From c2d8cf3a724a1584e5748c0a127aac8e68a4f2a5 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:48:29 -0400 Subject: [PATCH] Scopes SDK Changes (#1030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: scopes SDK surface and session allowlist on session context Exposes the Scopes v1 facade in both SDKs, which until now was reachable only by hand-rolled HTTP, and closes the Phase 1 gap where the session allowlist never landed on the context route. SDKs (DEV-2001, folds in DEV-1996) New Scope class in both SDKs — addSessions / removeSession / sessions / status — plus honcho.scope() and honcho.scopes() entry points, a `scopes` option on session creation, and `scope` + `sessions` read options on chat, chatStream, representation, and session.context. `scope` on workspace search. Python covers sync and .aio equally. `sessions` is sugar, not a new wire field: on the recall endpoints it goes out as the constrained `filters: {session_id: [...]}` body, never as a key of its own. Kept separate from the `filters` parameter on the list/search methods on purpose — that one is the full filter DSL, whereas the recall endpoints accept a single key and 422 on anything else, so one name for two grammars would be a trap. Server (DEV-2357) `GET /sessions/{id}/context` accepts a `sessions` allowlist confining the target's representation. Two deliberate choices worth review: - Sent as a repeated query parameter rather than the `filters` body the issue specced. The route is a GET and `session_id` is the only supported key, so a JSON blob in a query string buys nothing. - The peer card is omitted under an allowlist. Cards key on (workspace, observer, observed) with no session dimension, so they cannot be narrowed; returning one would leak exactly what the allowlist exists to exclude. Same reasoning as ALLOWLIST_SAFE_LEVELS. `scope` needs no carve-out — it swaps the observer to the scope peer, so the card read is the scope's own. `extract_session_allowlist` now delegates to a shared `normalize_session_allowlist`, so the cap, id charset, and must_include rule have one implementation across both entry points. Existing error messages are unchanged. Also in here - ConclusionScope renamed to ConclusionsView in both SDKs. "Scope" now means a named set of sessions, which that class is not — it is a view over one observer/observed pair. ConclusionScope kept as a deprecated alias; the package-level import path only. - The TS HTTP client comma-joined array query params, so any list-valued parameter arrived as one malformed entry. Fixed at buildURL rather than the call site. Not in this PR: the "How Scopes Work" docs guide and the Groudon dashboard tab (both DEV-2001), and CHANGELOG entries for Phases 2a-2c, which are still merged-but-unrecorded. Verified: ruff, basedpyright, tsc --noEmit, biome all clean. New unit tests cover the SDK option translation and the Scope client, but the context route's own behavior — the 422s, the 401 membership gate, the dropped peer card — has no test yet; the analogous chat/representation cases in tests/test_session_allowlist.py are the place for it. Refs DEV-2001, DEV-1996, DEV-2357 Co-Authored-By: Claude Opus 5 (1M context) Exposes the Scopes v1 facade in both SDKs, which until now was reachable only by hand-rolled HTTP, and closes the Phase 1 gap where the session allowlist never landed on the context route. SDKs (DEV-2001, folds in DEV-1996) New Scope class in both SDKs — addSessions / removeSession / sessions / status — plus honcho.scope() and honcho.scopes() entry points, a `scopes` option on session creation, and `scope` + `sessions` read options on chat, chatStream, representation, and session.context. `scope` on workspace search. Python covers sync and .aio equally. `sessions` is sugar, not a new wire field: on the recall endpoints it goes out as the constrained `filters: {session_id: [...]}` body, never as a key of its own. Kept separate from the `filters` parameter on the list/search methods on purpose — that one is the full filter DSL, whereas the recall endpoints accept a single key and 422 on anything else, so one name for two grammars would be a trap. Server (DEV-2357) `GET /sessions/{id}/context` accepts a `sessions` allowlist confining the target's representation. Two deliberate choices worth review: - Sent as a repeated query parameter rather than the `filters` body the issue specced. The route is a GET and `session_id` is the only supported key, so a JSON blob in a query string buys nothing. - The peer card is omitted under an allowlist. Cards key on (workspace, observer, observed) with no session dimension, so they cannot be narrowed; returning one would leak exactly what the allowlist exists to exclude. Same reasoning as ALLOWLIST_SAFE_LEVELS. `scope` needs no carve-out — it swaps the observer to the scope peer, so the card read is the scope's own. `extract_session_allowlist` now delegates to a shared `normalize_session_allowlist`, so the cap, id charset, and must_include rule have one implementation across both entry points. Existing error messages are unchanged. Also in here - ConclusionScope renamed to ConclusionsView in both SDKs. "Scope" now means a named set of sessions, which that class is not — it is a view over one observer/observed pair. ConclusionScope kept as a deprecated alias; the package-level import path only. - The TS HTTP client comma-joined array query params, so any list-valued parameter arrived as one malformed entry. Fixed at buildURL rather than the call site * fix(scopes): close peer-card leak under limit_to_session, harden SDK inputs Addresses review findings on the scopes work. All four were verified by reproducing them, not by reading. Peer card no longer leaks under any allowlist The card was dropped when `sessions` was set but returned when `limit_to_session=true` produced the identical allowlist, so a control meant to fail closed was defeated by swapping one query parameter. It is now gated on the effective allowlist, computed once and shared by the representation call and the card read — the duplicated inline conditional is what let the two drift apart. `POST /peers/{id}/chat` still injects an unscoped card under an allowlist (src/dialectic/chat.py fetches it on peer_card.use alone, with no reference to session_allowlist). Left alone deliberately: that is a behavior change to the shipped dialectic and Scope validation messages survive the option union ScopeOptionSchema is a union, and Zod collapses a failing union into one `invalid_union` / "Invalid input" issue, burying the branch errors. Every invalid scope on chat/representation reported "Invalid input" and told the caller nothing — including the reserved-prefix case the check order exists to surface. The rules are now a plain function applied after the union resolves, so the specific message reaches the caller for bad charset, reserved prefix, empty and over-cap lists alike. Empty scope no longer fails open `session.context({scope: ''})` and `honcho.search(q, {scope: ''})` used truthiness checks, so an invalid scope was dropped and the call returned *unscoped* results. Both now test against undefined so the value reaches the schema. Session IDs validated before reaching a URL path `scope.removeSession('valid-session?typo')` addressed `valid-session` with a stray query string: the wrong session removed, and reconciliation run against it. Both SDKs now validate the charset first. Python's `add_sessions` was unvalidated too — harmless in a JSON body, but leaving one path checked and its sibling unchecked is how this recurs. Also - Corrected the `limit_to_session` description: it claimed "only used if search_query is provided", but the allowlist reaches _query_documents_recent unconditionally. - Corrected the documented 1,000-session cap on `sessions`, which is unreachable via repeated query params — the request line exceeds h11's 16 KB and nginx's 8 KB defaults at a few hundred entries, giving an opaque 414/431 instead of a 422. - Removed a dead route builder. Tests: 7 new TypeScript cases and 2 new Python classes covering all four findings. The TypeScript unit suite passes 146/146. The context route itself is still unexercised — the card gate and the 401 membership check remain verified by reading only. Refs DEV-2001, DEV-1996, DEV-2357, DEV-2201 Co-Authored-By: Claude Opus 5 (1M context) * fix(sdk): align the conclusions-view error message across both SDKs The ConclusionScope -> ConclusionsView rename updated the identifier but not the prose inside the thrown message, which the rename pattern (\bConclusionScope\b) does not match. TypeScript ended up throwing "managed by this conclusions view" while Python still threw "managed by this conclusion scope" — the same error, different text per SDK. Three server-backed conclusions.test.ts cases assert that message by regex and failed under `pytest -k typescript`. The four equivalent Python assertions were passing, because they matched Python's unchanged string — so fixing only the TypeScript tests would have made the suite green with the divergence still in place. Brings Python's message, comments and docstring in line with TypeScript, and updates the assertions in both suites. `grep -ri 'conclusion scope'` is now empty. Verified: 64 passed across tests/sdk_typescript/, tests/sdk/test_conclusions.py and tests/sdk/test_scope_options.py — the last of which had never been run. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- sdks/python/src/honcho/__init__.py | 33 +- sdks/python/src/honcho/aio.py | 341 +++++++++++++-- sdks/python/src/honcho/api_types.py | 39 ++ sdks/python/src/honcho/base.py | 17 + sdks/python/src/honcho/client.py | 124 +++++- sdks/python/src/honcho/conclusions.py | 40 +- sdks/python/src/honcho/http/routes.py | 25 ++ sdks/python/src/honcho/peer.py | 75 +++- sdks/python/src/honcho/scope.py | 233 +++++++++++ sdks/python/src/honcho/session.py | 29 +- sdks/python/src/honcho/utils/__init__.py | 16 + sdks/python/src/honcho/utils/resolve.py | 8 +- sdks/python/src/honcho/utils/scopes.py | 296 +++++++++++++ sdks/typescript/__tests__/conclusions.test.ts | 24 +- sdks/typescript/__tests__/http-client.test.ts | 33 ++ sdks/typescript/__tests__/peer.test.ts | 4 +- sdks/typescript/__tests__/scope.unit.test.ts | 389 ++++++++++++++++++ sdks/typescript/src/client.ts | 145 +++++++ sdks/typescript/src/conclusions.ts | 34 +- sdks/typescript/src/http/client.ts | 36 +- sdks/typescript/src/index.ts | 16 +- sdks/typescript/src/peer.ts | 68 ++- sdks/typescript/src/scope.ts | 278 +++++++++++++ sdks/typescript/src/session.ts | 32 +- sdks/typescript/src/types/api.ts | 22 + sdks/typescript/src/validation.ts | 246 +++++++++++ src/deriver/queue_manager.py | 3 +- src/routers/sessions.py | 99 ++++- src/utils/filter.py | 42 +- tests/sdk/test_conclusions.py | 18 +- tests/sdk/test_scope_options.py | 236 +++++++++++ 31 files changed, 2841 insertions(+), 160 deletions(-) create mode 100644 sdks/python/src/honcho/scope.py create mode 100644 sdks/python/src/honcho/utils/scopes.py create mode 100644 sdks/typescript/__tests__/scope.unit.test.ts create mode 100644 sdks/typescript/src/scope.ts create mode 100644 tests/sdk/test_scope_options.py diff --git a/sdks/python/src/honcho/__init__.py b/sdks/python/src/honcho/__init__.py index 239f35ef..a07bf35f 100644 --- a/sdks/python/src/honcho/__init__.py +++ b/sdks/python/src/honcho/__init__.py @@ -41,11 +41,16 @@ from importlib.metadata import PackageNotFoundError, version from pathlib import Path import re -from .aio import ConclusionScopeAio, HonchoAio, PeerAio, SessionAio -from .api_types import MessageCreateParams -from .base import PeerBase, SessionBase +from .aio import ConclusionsViewAio, HonchoAio, PeerAio, ScopeAio, SessionAio +from .api_types import ( + MessageCreateParams, + ScopeBackfillJob, + ScopeResponse, + ScopeStatusResponse, +) +from .base import PeerBase, ScopeBase, SessionBase from .client import Honcho -from .conclusions import Conclusion, ConclusionScope +from .conclusions import Conclusion, ConclusionsView from .http.exceptions import ( APIError, AuthenticationError, @@ -63,6 +68,7 @@ from .http.exceptions import ( from .message import Message from .pagination import AsyncPage, SyncPage from .peer import Peer +from .scope import Scope from .session import Session from .session_context import SessionContext, SessionSummaries, Summary from .types import ( @@ -70,6 +76,12 @@ from .types import ( DialecticStreamResponse, ) +# Deprecated aliases. "Scope" now means a named set of sessions (see `Scope`), +# which these are not — they are views over one observer/observed pair. Kept for +# one more minor version. +ConclusionScope = ConclusionsView +ConclusionScopeAio = ConclusionsViewAio + def _detect_version() -> str: try: @@ -95,23 +107,32 @@ __all__ = [ "Honcho", # Domain classes "Conclusion", - "ConclusionScope", + "ConclusionsView", "Message", "MessageCreateParams", "Peer", + "Scope", "Session", # Aio views (for type hints) - "ConclusionScopeAio", + "ConclusionsViewAio", "HonchoAio", "PeerAio", + "ScopeAio", "SessionAio", # Base classes "PeerBase", + "ScopeBase", "SessionBase", # Response types + "ScopeBackfillJob", + "ScopeResponse", + "ScopeStatusResponse", "SessionContext", "SessionSummaries", "Summary", + # Deprecated aliases + "ConclusionScope", + "ConclusionScopeAio", # Pagination "AsyncPage", "SyncPage", diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py index 51797c72..29c0f445 100644 --- a/sdks/python/src/honcho/aio.py +++ b/sdks/python/src/honcho/aio.py @@ -3,7 +3,7 @@ This module provides async accessor classes that wrap the main SDK classes and provide async versions of all operations. Access via the `.aio` property -on Honcho, Peer, Session, and ConclusionScope instances. +on Honcho, Peer, Session, and ConclusionsView instances. Example: ```python @@ -24,7 +24,7 @@ from __future__ import annotations import json import logging import warnings -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Sequence from datetime import datetime from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload @@ -40,15 +40,18 @@ from .api_types import ( PeerResponse, QueueStatusResponse, RepresentationResponse, + ScopeBackfillJob, + ScopeResponse, + ScopeStatusResponse, SessionConfiguration, SessionPeerConfig, SessionResponse, WorkspaceConfiguration, WorkspaceResponse, ) -from .base import PeerBase, SessionBase +from .base import PeerBase, ScopeBase, SessionBase from .conclusions import ( - _SCOPE_RESERVED, + _VIEW_RESERVED, Conclusion, _reject_reserved_filter_keys, ) @@ -64,14 +67,20 @@ from .utils import ( parse_sse_astream, prepare_file_for_upload, resolve_id, + resolve_scope_membership, + resolve_scope_session, + scope_context_fields, + scope_recall_fields, + validate_scope_id, ) if TYPE_CHECKING: from .client import Honcho - from .conclusions import ConclusionScope + from .conclusions import ConclusionsView from .conclusions import ConclusionCreateParams from .peer import Peer, TResponseFormat, serialize_response_format +from .scope import Scope from .session import Session logger = logging.getLogger(__name__) @@ -79,8 +88,9 @@ logger = logging.getLogger(__name__) __all__ = [ "HonchoAio", "PeerAio", + "ScopeAio", "SessionAio", - "ConclusionScopeAio", + "ConclusionsViewAio", ] @@ -267,6 +277,7 @@ class HonchoAio(AsyncMetadataConfigMixin): | list[tuple[PeerBase | str, SessionPeerConfig]] | list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]] | None = None, + scopes: Sequence[str | ScopeBase] | None = None, ) -> Session: """ Get or create a session with the given ID asynchronously. @@ -278,6 +289,11 @@ class HonchoAio(AsyncMetadataConfigMixin): peers: Optional peers to attach to the session at creation. Accepts the same shape as Session.add_peers (peer ID string, Peer object, list of either, or tuples with SessionPeerConfig). + scopes: Optional scopes this session should join, as IDs or Scope + objects. Each scope is created if it does not exist yet. Attaching + at creation avoids the asynchronous backfill a later + ``scope.add_sessions()`` triggers, since there is no history to + copy. Returns: A Session object with cached values from the API response. @@ -290,6 +306,8 @@ class HonchoAio(AsyncMetadataConfigMixin): body["configuration"] = configuration.model_dump(exclude_none=True) if peers is not None: body["peers"] = normalize_peers_to_dict(peers) + if scopes is not None: + body["scopes"] = [validate_scope_id(resolve_id(scope)) for scope in scopes] data = await self._honcho._async_http_client.post( routes.sessions(self._honcho.workspace_id), body=body @@ -358,6 +376,88 @@ class HonchoAio(AsyncMetadataConfigMixin): return AsyncPage(data, SessionResponse, transform, fetch_next) + async def scope( + self, + id: str, # noqa: A002 + *, + metadata: dict[str, object] | None = None, + ) -> Scope: + """ + Get or create a scope with the given ID asynchronously. + + A scope is a named set of sessions that acts as a visibility boundary: + recall performed through the scope sees only what happened in its sessions, + while the underlying peer keeps its single unified representation of + everything. + + Args: + id: Unprefixed scope name, unique within the workspace. + metadata: Optional metadata dictionary to associate with this scope. + + Returns: + A Scope object for managing membership. + + Raises: + ValueError: If the scope ID is invalid. + """ + validate_scope_id(id) + await self._honcho._ensure_workspace_async() + body: dict[str, Any] = {"id": id} + if metadata is not None: + body["metadata"] = metadata + + data = await self._honcho._async_http_client.post( + routes.scopes(self._honcho.workspace_id), body=body + ) + scope_data = ScopeResponse.model_validate(data) + return Scope( + id, + self._honcho, + metadata=scope_data.metadata, + created_at=scope_data.created_at, + ) + + async def scopes( + self, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, + ) -> AsyncPage[ScopeResponse, Scope]: + """ + Get all scopes in the current workspace asynchronously. + + Args: + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, reverses the default ordering. Default: False. + """ + await self._honcho._ensure_workspace_async() + + async def fetch(next_page: int) -> dict[str, Any]: + query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + query["reverse"] = "true" + return await self._honcho._async_http_client.post( + routes.scopes_list(self._honcho.workspace_id), query=query + ) + + def transform(scope: ScopeResponse) -> Scope: + """Convert a scope API response into a Scope SDK object.""" + return Scope( + scope.id, + self._honcho, + metadata=scope.metadata, + created_at=scope.created_at, + ) + + async def fetch_next(next_page: int) -> AsyncPage[ScopeResponse, Scope]: + return AsyncPage( + await fetch(next_page), ScopeResponse, transform, fetch_next + ) + + return AsyncPage(await fetch(page), ScopeResponse, transform, fetch_next) + async def workspaces( self, filters: dict[str, object] | None = None, @@ -409,12 +509,27 @@ class HonchoAio(AsyncMetadataConfigMixin): limit: int = Field( default=10, ge=1, le=100, description="Number of results to return" ), + *, + scope: str | ScopeBase | None = None, ) -> list[Message]: - """Search for messages in the current workspace asynchronously.""" + """Search for messages in the current workspace asynchronously. + + Args: + query: The search query to use + filters: Filters to scope the search. + limit: Number of results to return (1-100, default: 10) + scope: Optional scope (ID or Scope object) restricting the search to + that scope's member sessions. Mutually exclusive with a + ``session_id`` filter. A scope with no member sessions matches + nothing rather than everything. + """ await self._honcho._ensure_workspace_async() + body: dict[str, Any] = {"query": query, "filters": filters, "limit": limit} + if scope is not None: + body["scope"] = validate_scope_id(resolve_id(scope)) data = await self._honcho._async_http_client.post( routes.workspace_search(self._honcho.workspace_id), - body={"query": query, "filters": filters, "limit": limit}, + body=body, ) return [ Message.from_api_response(MessageResponse.model_validate(item)) @@ -584,6 +699,8 @@ class PeerAio(AsyncMetadataConfigMixin): *, target: str | PeerBase | None = None, session: str | SessionBase | None = None, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[TResponseFormat], @@ -596,6 +713,8 @@ class PeerAio(AsyncMetadataConfigMixin): *, target: str | PeerBase | None = None, session: str | SessionBase | None = None, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: dict[str, Any] | None = None, @@ -608,6 +727,8 @@ class PeerAio(AsyncMetadataConfigMixin): *, target: str | PeerBase | None = None, session: str | SessionBase | None = None, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[BaseModel] | dict[str, Any] | None = None, @@ -623,6 +744,11 @@ class PeerAio(AsyncMetadataConfigMixin): resolved_session_id = resolve_id(session) body: dict[str, Any] = {"query": query, "stream": False} + body.update( + scope_recall_fields( + scope=scope, sessions=sessions, session_id=resolved_session_id + ) + ) if target_id: body["target"] = target_id if resolved_session_id: @@ -651,6 +777,8 @@ class PeerAio(AsyncMetadataConfigMixin): *, target: str | PeerBase | None = None, session: str | SessionBase | None = None, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[BaseModel] | dict[str, Any] | None = None, @@ -666,6 +794,11 @@ class PeerAio(AsyncMetadataConfigMixin): resolved_session_id = resolve_id(session) body: dict[str, Any] = {"query": query, "stream": True} + body.update( + scope_recall_fields( + scope=scope, sessions=sessions, session_id=resolved_session_id + ) + ) if target_id: body["target"] = target_id if resolved_session_id: @@ -826,13 +959,22 @@ class PeerAio(AsyncMetadataConfigMixin): search_max_distance: float | None = Field(None, ge=0.0, le=1.0), include_most_frequent: bool | None = None, max_conclusions: int | None = Field(None, ge=1, le=100), + *, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, ) -> str: - """Get a subset of the representation of the peer asynchronously.""" + """Get a subset of the representation of the peer asynchronously. + + See Peer.representation for parameter details, including the depth caveat + on ``sessions``. + """ await self._peer._honcho._ensure_workspace_async() session_id = resolve_id(session) target_id = resolve_id(target) - body: dict[str, Any] = {} + body: dict[str, Any] = scope_recall_fields( + scope=scope, sessions=sessions, session_id=session_id + ) if session_id: body["session_id"] = session_id if target_id: @@ -1192,6 +1334,14 @@ class SessionAio(AsyncMetadataConfigMixin): None, description="A peer ID to get context from the perspective of.", ), + scope: str | ScopeBase | None = Field( + None, + description="A scope to use as the perspective source instead of a peer.", + ), + sessions: Sequence[str | SessionBase] | None = Field( + None, + description="An allowlist of sessions confining `peer_target`'s representation to that set. This session must be one of them.", + ), limit_to_session: bool = Field( False, description="Whether to limit the representation to this session only.", @@ -1219,7 +1369,11 @@ class SessionAio(AsyncMetadataConfigMixin): description="Maximum number of conclusions to include in the representation.", ), ) -> SessionContext: - """Get optimized context for this session asynchronously.""" + """Get optimized context for this session asynchronously. + + See Session.context for parameter details, including the depth caveat on + ``sessions``. + """ await self._session._honcho._ensure_workspace_async() if peer_target is None and peer_perspective is not None: raise ValueError( @@ -1238,6 +1392,13 @@ class SessionAio(AsyncMetadataConfigMixin): query: dict[str, Any] = { "summary": summary, "limit_to_session": limit_to_session, + **scope_context_fields( + scope=scope, + sessions=sessions, + peer_target=peer_target, + peer_perspective=peer_perspective, + limit_to_session=limit_to_session, + ), } if tokens is not None: query["tokens"] = tokens @@ -1488,19 +1649,19 @@ class SessionAio(AsyncMetadataConfigMixin): return Message.from_api_response(MessageResponse.model_validate(data)) -class ConclusionScopeAio: +class ConclusionsViewAio: """ - Async view of a ConclusionScope. + Async view of a ConclusionsView. - Access via `scope.aio`. Provides async versions of all ConclusionScope methods. - Shares state with the parent ConclusionScope instance. + Access via `view.aio`. Provides async versions of all ConclusionsView methods. + Shares state with the parent ConclusionsView instance. """ - __slots__: ClassVar[tuple[str, ...]] = ("_scope",) - _scope: "ConclusionScope" + __slots__: ClassVar[tuple[str, ...]] = ("_view",) + _view: "ConclusionsView" - def __init__(self, scope: "ConclusionScope") -> None: - self._scope = scope + def __init__(self, view: "ConclusionsView") -> None: + self._view = view async def list( self, @@ -1520,13 +1681,13 @@ class ConclusionScopeAio: https://honcho.dev/docs/v3/documentation/features/advanced/using-filters """ _reject_reserved_filter_keys( - filters, _SCOPE_RESERVED + ("session", "session_id") + filters, _VIEW_RESERVED + ("session", "session_id") ) - await self._scope._honcho._ensure_workspace_async() + await self._view._honcho._ensure_workspace_async() resolved_session_id = resolve_id(session) filters = { - "observer_id": self._scope.observer, - "observed_id": self._scope.observed, + "observer_id": self._view.observer, + "observed_id": self._view.observed, **({"session_id": resolved_session_id} if resolved_session_id else {}), **(filters or {}), } @@ -1534,8 +1695,8 @@ class ConclusionScopeAio: query: dict[str, Any] = {"page": page, "size": size} if reverse: query["reverse"] = "true" - data = await self._scope._honcho._async_http_client.post( - routes.conclusions_list(self._scope.workspace_id), + data = await self._view._honcho._async_http_client.post( + routes.conclusions_list(self._view.workspace_id), body={"filters": filters}, query=query, ) @@ -1549,8 +1710,8 @@ class ConclusionScopeAio: next_query: dict[str, Any] = {"page": next_page, "size": size} if reverse: next_query["reverse"] = "true" - next_data = await self._scope._honcho._async_http_client.post( - routes.conclusions_list(self._scope.workspace_id), + next_data = await self._view._honcho._async_http_client.post( + routes.conclusions_list(self._view.workspace_id), body={"filters": filters}, query=next_query, ) @@ -1575,11 +1736,11 @@ class ConclusionScopeAio: filters: Optional dictionary of additional filter criteria, merged with this scope's observer/observed (e.g. ``{"level": "deductive"}``). """ - _reject_reserved_filter_keys(filters, _SCOPE_RESERVED) - await self._scope._honcho._ensure_workspace_async() + _reject_reserved_filter_keys(filters, _VIEW_RESERVED) + await self._view._honcho._ensure_workspace_async() filters = { - "observer_id": self._scope.observer, - "observed_id": self._scope.observed, + "observer_id": self._view.observer, + "observed_id": self._view.observed, **(filters or {}), } @@ -1591,8 +1752,8 @@ class ConclusionScopeAio: if distance is not None: body["distance"] = distance - data = await self._scope._honcho._async_http_client.post( - routes.conclusions_query(self._scope.workspace_id), + data = await self._view._honcho._async_http_client.post( + routes.conclusions_query(self._view.workspace_id), body=body, ) return [ @@ -1602,9 +1763,9 @@ class ConclusionScopeAio: async def delete(self, conclusion_id: str) -> None: """Delete a conclusion by ID asynchronously.""" - await self._scope._honcho._ensure_workspace_async() - await self._scope._honcho._async_http_client.delete( - routes.conclusion(self._scope.workspace_id, conclusion_id) + await self._view._honcho._ensure_workspace_async() + await self._view._honcho._async_http_client.delete( + routes.conclusion(self._view.workspace_id, conclusion_id) ) async def create( @@ -1612,15 +1773,15 @@ class ConclusionScopeAio: conclusions: list[ConclusionCreateParams | dict[str, Any]], ) -> list[Conclusion]: """Create conclusions in this scope asynchronously.""" - await self._scope._honcho._ensure_workspace_async() + await self._view._honcho._ensure_workspace_async() def build_conclusion_payload( item: ConclusionCreateParams | dict[str, Any], ) -> dict[str, Any]: """Build a single conclusion create payload.""" payload: dict[str, Any] = { - "observer_id": self._scope.observer, - "observed_id": self._scope.observed, + "observer_id": self._view.observer, + "observed_id": self._view.observed, } if isinstance(item, ConclusionCreateParams): payload["content"] = item.content @@ -1636,8 +1797,8 @@ class ConclusionScopeAio: conclusion_params = [build_conclusion_payload(c) for c in conclusions] - data = await self._scope._honcho._async_http_client.post( - routes.conclusions(self._scope.workspace_id), + data = await self._view._honcho._async_http_client.post( + routes.conclusions(self._view.workspace_id), body={"conclusions": conclusion_params}, ) return [ @@ -1654,8 +1815,8 @@ class ConclusionScopeAio: max_conclusions: int | None = None, ) -> str: """Get the computed representation for this scope asynchronously.""" - await self._scope._honcho._ensure_workspace_async() - body: dict[str, Any] = {"target": self._scope.observed} + await self._view._honcho._ensure_workspace_async() + body: dict[str, Any] = {"target": self._view.observed} if search_query is not None: body["search_query"] = search_query if search_top_k is not None: @@ -1667,9 +1828,99 @@ class ConclusionScopeAio: if max_conclusions is not None: body["max_conclusions"] = max_conclusions - data = await self._scope._honcho._async_http_client.post( - routes.peer_representation(self._scope.workspace_id, self._scope.observer), + data = await self._view._honcho._async_http_client.post( + routes.peer_representation(self._view.workspace_id, self._view.observer), body=body, ) response = RepresentationResponse.model_validate(data) return response.representation + + +class ScopeAio: + """ + Async view of a Scope. + + Access via `scope.aio`. Provides async versions of all Scope methods. + Shares state with the parent Scope instance. + """ + + __slots__: ClassVar[tuple[str, ...]] = ("_scope",) + _scope: "Scope" + + def __init__(self, scope: "Scope") -> None: + """Create an async view backed by a sync Scope.""" + self._scope = scope + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def add_sessions(self, sessions: Sequence[str | SessionBase]) -> None: + """Add sessions to this scope asynchronously. + + See Scope.add_sessions for details, including the asynchronous backfill + that sessions with existing messages trigger. + """ + session_ids = resolve_scope_membership(sessions) + await self._scope._honcho._ensure_workspace_async() + await self._scope._honcho._async_http_client.post( + routes.scope_sessions(self._scope.workspace_id, self._scope.id), + body={"session_ids": session_ids}, + ) + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def remove_session(self, session: str | SessionBase) -> None: + """Remove a session from this scope asynchronously. + + See Scope.remove_session for details on the asynchronous reconciliation. + """ + await self._scope._honcho._ensure_workspace_async() + await self._scope._honcho._async_http_client.delete( + routes.scope_session( + self._scope.workspace_id, self._scope.id, resolve_scope_session(session) + ) + ) + + async def sessions( + self, + page: int = 1, + size: int = 50, + *, + reverse: bool = False, + ) -> AsyncPage[SessionResponse, Session]: + """Get the sessions that are members of this scope asynchronously.""" + await self._scope._honcho._ensure_workspace_async() + + async def fetch(next_page: int) -> dict[str, Any]: + query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + query["reverse"] = "true" + return await self._scope._honcho._async_http_client.post( + routes.scope_sessions_list(self._scope.workspace_id, self._scope.id), + query=query, + ) + + def transform(response: SessionResponse) -> Session: + return Session( + response.id, + self._scope._honcho, + metadata=response.metadata, + configuration=response.configuration, + created_at=response.created_at, + is_active=response.is_active, + ) + + async def fetch_next(next_page: int) -> AsyncPage[SessionResponse, Session]: + return AsyncPage( + await fetch(next_page), SessionResponse, transform, fetch_next + ) + + return AsyncPage(await fetch(page), SessionResponse, transform, fetch_next) + + async def status(self) -> dict[str, ScopeBackfillJob]: + """Get the backfill/reconciliation progress for this scope asynchronously. + + See Scope.status for details. + """ + await self._scope._honcho._ensure_workspace_async() + data = await self._scope._honcho._async_http_client.get( + routes.scope_status(self._scope.workspace_id, self._scope.id) + ) + return ScopeStatusResponse.model_validate(data).backfill_status diff --git a/sdks/python/src/honcho/api_types.py b/sdks/python/src/honcho/api_types.py index f626c5a6..23692f2f 100644 --- a/sdks/python/src/honcho/api_types.py +++ b/sdks/python/src/honcho/api_types.py @@ -276,6 +276,7 @@ class SessionCreateParams(BaseModel): metadata: dict[str, Any] | None = None peers: dict[str, SessionPeerConfig] | None = None configuration: SessionConfiguration | None = None + scopes: list[str] | None = None class SessionUpdateParams(BaseModel): @@ -295,6 +296,44 @@ class SessionListParams(BaseModel): filters: dict[str, Any] | None = None +# ============================================================================== +# Scope Types +# ============================================================================== + + +class ScopeResponse(BaseModel): + """Scope API response.""" + + model_config = ConfigDict(populate_by_name=True) # pyright: ignore[reportUnannotatedClassAttribute] + + id: str + metadata: dict[str, Any] = Field(default_factory=dict) + created_at: datetime.datetime + + +class ScopeBackfillJob(BaseModel): + """Backfill job state for one session in a scope. + + ``docs_copied`` is present only once the backfill for that session completes. + """ + + model_config = ConfigDict(extra="ignore") # pyright: ignore[reportUnannotatedClassAttribute] + + state: Literal["pending", "completed", "failed"] + updated_at: datetime.datetime + docs_copied: int | None = None + + +class ScopeStatusResponse(BaseModel): + """Scope backfill/reconciliation status API response. + + ``backfill_status`` is keyed by session ID and only contains sessions that + have had a backfill enqueued. + """ + + backfill_status: dict[str, ScopeBackfillJob] = Field(default_factory=dict) + + # ============================================================================== # Summary Types # ============================================================================== diff --git a/sdks/python/src/honcho/base.py b/sdks/python/src/honcho/base.py index a27a64ad..fb80b121 100644 --- a/sdks/python/src/honcho/base.py +++ b/sdks/python/src/honcho/base.py @@ -43,3 +43,20 @@ class SessionBase(BaseModel): workspace_id: str = Field( ..., min_length=1, description="Workspace ID for scoping operations" ) + + +class ScopeBase(BaseModel): + """Base class for Scope objects (sync and async variants). + + Use this type in method signatures to accept either a scope ID string or any + Scope object. + + Attributes: + id: Unprefixed scope name, unique within the workspace + workspace_id: Workspace ID for scoping operations + """ + + id: str = Field(..., min_length=1, description="Unprefixed name of this scope") + workspace_id: str = Field( + ..., min_length=1, description="Workspace ID for scoping operations" + ) diff --git a/sdks/python/src/honcho/client.py b/sdks/python/src/honcho/client.py index 06a2887c..1527792e 100644 --- a/sdks/python/src/honcho/client.py +++ b/sdks/python/src/honcho/client.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging import os -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import Any, Literal import httpx @@ -16,20 +16,22 @@ from .api_types import ( PeerConfig, PeerResponse, QueueStatusResponse, + ScopeResponse, SessionConfiguration, SessionPeerConfig, SessionResponse, WorkspaceConfiguration, WorkspaceResponse, ) -from .base import PeerBase, SessionBase +from .base import PeerBase, ScopeBase, SessionBase from .http import AsyncHonchoHTTPClient, HonchoHTTPClient, routes from .message import Message from .mixins import MetadataConfigMixin from .pagination import SyncPage from .peer import Peer +from .scope import Scope from .session import Session -from .utils import normalize_peers_to_dict, resolve_id +from .utils import normalize_peers_to_dict, resolve_id, validate_scope_id logger = logging.getLogger(__name__) @@ -419,6 +421,10 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul None, description="Optional peers to attach to the session at creation. Accepts the same shape as Session.add_peers.", ), + scopes: Sequence[str | ScopeBase] | None = Field( + None, + description="Optional scopes this session should join. Each scope is created if it does not exist yet.", + ), ) -> Session: """ Get or create a session with the given ID. @@ -433,6 +439,11 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul peers: Optional peers to attach to the session at creation. Accepts the same shape as Session.add_peers (peer ID string, Peer object, list of either, or tuples with SessionPeerConfig). + scopes: Optional scopes this session should join, as IDs or Scope + objects. Each scope is created if it does not exist yet. Attaching + at creation avoids the asynchronous backfill a later + ``scope.add_sessions()`` triggers, since there is no history to + copy. Returns: A Session object with cached metadata, configuration, created_at, and is_active. @@ -445,6 +456,8 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul body["configuration"] = configuration.model_dump(exclude_none=True) if peers is not None: body["peers"] = normalize_peers_to_dict(peers) + if scopes is not None: + body["scopes"] = [validate_scope_id(resolve_id(scope)) for scope in scopes] data = self._http.post(routes.sessions(self.workspace_id), body=body) session_data = SessionResponse.model_validate(data) @@ -514,6 +527,97 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul return SyncPage(data, SessionResponse, transform, fetch_next) + @validate_call + def scope( + self, + id: str = Field( # noqa: A002 + ..., min_length=1, description="Unprefixed name for the scope" + ), + *, + metadata: dict[str, object] | None = Field( + None, + description="Optional metadata dictionary to associate with this scope.", + ), + ) -> Scope: + """ + Get or create a scope with the given ID. + + A scope is a named set of sessions that acts as a visibility boundary: + recall performed through the scope sees only what happened in its sessions, + while the underlying peer keeps its single unified representation of + everything. + + Args: + id: Unprefixed scope name, unique within the workspace. + metadata: Optional metadata dictionary to associate with this scope. + + Returns: + A Scope object for managing membership. + + Raises: + ValueError: If the scope ID is invalid. + + Example: + ```python + therapy = honcho.scope("therapy") + therapy.add_sessions([session_1, session_2]) + ``` + """ + validate_scope_id(id) + self._ensure_workspace() + body: dict[str, Any] = {"id": id} + if metadata is not None: + body["metadata"] = metadata + + data = self._http.post(routes.scopes(self.workspace_id), body=body) + scope_data = ScopeResponse.model_validate(data) + return Scope( + id, + self, + metadata=scope_data.metadata, + created_at=scope_data.created_at, + ) + + def scopes( + self, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, + ) -> SyncPage[ScopeResponse, Scope]: + """ + Get all scopes in the current workspace. + + Args: + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, reverses the default ordering. Default: False. + + Returns: + A SyncPage of Scope objects representing all scopes in the workspace. + """ + self._ensure_workspace() + + def fetch(next_page: int) -> dict[str, Any]: + query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + query["reverse"] = "true" + return self._http.post(routes.scopes_list(self.workspace_id), query=query) + + def transform(scope: ScopeResponse) -> Scope: + """Convert a scope API response into a Scope SDK object.""" + return Scope( + scope.id, + self, + metadata=scope.metadata, + created_at=scope.created_at, + ) + + def fetch_next(next_page: int) -> SyncPage[ScopeResponse, Scope]: + return SyncPage(fetch(next_page), ScopeResponse, transform, fetch_next) + + return SyncPage(fetch(page), ScopeResponse, transform, fetch_next) + def workspaces( self, filters: dict[str, object] | None = None, @@ -592,6 +696,11 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul limit: int = Field( default=10, ge=1, le=100, description="Number of results to return" ), + *, + scope: str | ScopeBase | None = Field( + None, + description="Optional scope restricting the search to its member sessions", + ), ) -> list[Message]: """ Search for messages in the current workspace. @@ -602,15 +711,22 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul query: The search query to use filters: Filters to scope the search. See [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters). limit: Number of results to return (1-100, default: 10) + scope: Optional scope (ID or Scope object) restricting the search to + that scope's member sessions. Mutually exclusive with a + ``session_id`` filter. A scope with no member sessions matches + nothing rather than everything. Returns: A list of Message objects representing the search results. Returns an empty list if no messages are found. """ self._ensure_workspace() + body: dict[str, Any] = {"query": query, "filters": filters, "limit": limit} + if scope is not None: + body["scope"] = validate_scope_id(resolve_id(scope)) data = self._http.post( routes.workspace_search(self.workspace_id), - body={"query": query, "filters": filters, "limit": limit}, + body=body, ) return [ Message.from_api_response(MessageResponse.model_validate(item)) diff --git a/sdks/python/src/honcho/conclusions.py b/sdks/python/src/honcho/conclusions.py index 708cc3ed..c9f6a6a9 100644 --- a/sdks/python/src/honcho/conclusions.py +++ b/sdks/python/src/honcho/conclusions.py @@ -15,28 +15,28 @@ from .pagination import SyncPage from .utils import resolve_id if TYPE_CHECKING: - from .aio import ConclusionScopeAio + from .aio import ConclusionsViewAio from .client import Honcho __all__ = [ "Conclusion", - "ConclusionScope", + "ConclusionsView", "ConclusionCreateParams", ] -# Filter keys that define a conclusion scope (the observer/observed peer pair). -# They are set from the scope itself, so a caller must not pass them in `filters`. -_SCOPE_RESERVED = ("observer", "observed", "observer_id", "observed_id") +# Filter keys that define a conclusions view (the observer/observed peer pair). +# They are set from the view itself, so a caller must not pass them in `filters`. +_VIEW_RESERVED = ("observer", "observed", "observer_id", "observed_id") def _reject_reserved_filter_keys( filters: dict[str, Any] | None, reserved: tuple[str, ...] ) -> None: - """Raise if ``filters`` contains keys managed by the conclusion scope. + """Raise if ``filters`` contains keys managed by the conclusions view. The observer/observed peer pair (and, on ``list``, the session) is fixed by - the scope, so letting a user filter override it would silently return data - from a different scope than requested. Fail loud instead. + the view, so letting a user filter override it would silently return data + from a different pair than requested. Fail loud instead. """ if not filters: return @@ -48,7 +48,7 @@ def _reject_reserved_filter_keys( if "session" in reserved or "session_id" in reserved: guidance += "; use the session= parameter to filter by session" raise ValueError( - f"Filter key(s) {clash} are managed by this conclusion scope and " + f"Filter key(s) {clash} are managed by this conclusions view and " + f"cannot be passed in filters. {guidance}." ) @@ -126,7 +126,7 @@ class Conclusion: return self.content -class ConclusionScope: +class ConclusionsView: """ Scoped access to conclusions for a specific observer/observed relationship. @@ -165,7 +165,7 @@ class ConclusionScope: observed: str, ): """ - Initialize a ConclusionScope. + Initialize a ConclusionsView. Args: honcho: The Honcho client instance @@ -179,12 +179,12 @@ class ConclusionScope: self.observed = observed @property - def aio(self) -> "ConclusionScopeAio": + def aio(self) -> "ConclusionsViewAio": """ - Access async versions of all ConclusionScope methods. + Access async versions of all ConclusionsView methods. - Returns a ConclusionScopeAio view that provides async versions of all methods - while sharing state with this ConclusionScope instance. + Returns a ConclusionsViewAio view that provides async versions of all methods + while sharing state with this ConclusionsView instance. Example: ```python @@ -194,9 +194,9 @@ class ConclusionScope: ``` """ # Import here to avoid circular import (aio.py imports from this module) - from .aio import ConclusionScopeAio + from .aio import ConclusionsViewAio - return ConclusionScopeAio(self) + return ConclusionsViewAio(self) def list( self, @@ -226,7 +226,7 @@ class ConclusionScope: Paginated response containing Conclusion objects """ _reject_reserved_filter_keys( - filters, _SCOPE_RESERVED + ("session", "session_id") + filters, _VIEW_RESERVED + ("session", "session_id") ) self._honcho._ensure_workspace() resolved_session_id = resolve_id(session) @@ -288,7 +288,7 @@ class ConclusionScope: Returns: List of matching Conclusion objects """ - _reject_reserved_filter_keys(filters, _SCOPE_RESERVED) + _reject_reserved_filter_keys(filters, _VIEW_RESERVED) self._honcho._ensure_workspace() filters = { "observer_id": self.observer, @@ -443,6 +443,6 @@ class ConclusionScope: def __repr__(self) -> str: return ( - f"ConclusionScope(workspace_id={self.workspace_id!r}, " + f"ConclusionsView(workspace_id={self.workspace_id!r}, " f"observer={self.observer!r}, observed={self.observed!r})" ) diff --git a/sdks/python/src/honcho/http/routes.py b/sdks/python/src/honcho/http/routes.py index 3fdbd677..8f9ee2fb 100644 --- a/sdks/python/src/honcho/http/routes.py +++ b/sdks/python/src/honcho/http/routes.py @@ -102,6 +102,31 @@ def session_peer_config(workspace_id: str, session_id: str, peer_id: str) -> str return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config" +# Scope routes +def scopes(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/scopes" + + +def scopes_list(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/list" + + +def scope_sessions(workspace_id: str, scope_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/sessions" + + +def scope_sessions_list(workspace_id: str, scope_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/sessions/list" + + +def scope_session(workspace_id: str, scope_id: str, session_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/sessions/{session_id}" + + +def scope_status(workspace_id: str, scope_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/status" + + # Message routes def messages(workspace_id: str, session_id: str) -> str: return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/messages" diff --git a/sdks/python/src/honcho/peer.py b/sdks/python/src/honcho/peer.py index e004db6a..38edf269 100644 --- a/sdks/python/src/honcho/peer.py +++ b/sdks/python/src/honcho/peer.py @@ -6,7 +6,7 @@ from __future__ import annotations import datetime import logging import warnings -from collections.abc import Generator +from collections.abc import Generator, Sequence from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call @@ -22,14 +22,14 @@ from .api_types import ( SessionConfiguration, SessionResponse, ) -from .base import PeerBase, SessionBase -from .conclusions import ConclusionScope +from .base import PeerBase, ScopeBase, SessionBase +from .conclusions import ConclusionsView from .http import routes from .message import Message from .mixins import MetadataConfigMixin from .pagination import SyncPage from .types import DialecticStreamResponse -from .utils import parse_datetime, parse_sse_stream, resolve_id +from .utils import parse_datetime, parse_sse_stream, resolve_id, scope_recall_fields if TYPE_CHECKING: from .aio import PeerAio @@ -241,6 +241,8 @@ class Peer(PeerBase, MetadataConfigMixin): *, target: str | PeerBase | None = None, session: str | SessionBase | None = None, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[TResponseFormat], @@ -253,6 +255,8 @@ class Peer(PeerBase, MetadataConfigMixin): *, target: str | PeerBase | None = None, session: str | SessionBase | None = None, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: dict[str, Any] | None = None, @@ -265,6 +269,8 @@ class Peer(PeerBase, MetadataConfigMixin): *, target: str | PeerBase | None = None, session: str | SessionBase | None = None, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[BaseModel] | dict[str, Any] | None = None, @@ -285,6 +291,19 @@ class Peer(PeerBase, MetadataConfigMixin): session: Optional session to scope the query to. If provided, only information from that session is considered. Can be a session ID string or a Session object. + scope: Optional scope(s) to confine the query to. A single scope answers + from that scope's own view of the target, including the + higher-order conclusions reasoned within it. A sequence of scopes + restricts recall to the union of their member sessions, which — + like ``sessions`` — yields only directly-stated conclusions. + Mutually exclusive with ``session`` and ``sessions``, and requires + a workspace-level key. + sessions: Optional allowlist of sessions to confine the query to, for + one-off questions spanning a handful of sessions. Recall is + limited to conclusions stated directly in those sessions: + conclusions produced by reasoning across sessions are excluded, + because their provenance cannot be proven to sit inside the + allowlist. Reach for a named ``scope`` when you need that depth. reasoning_level: Optional reasoning level for the query: "minimal", "low", "medium", "high", or "max". Defaults to "low" if not provided. response_format: Optional structure for the answer. Pass a Pydantic @@ -296,12 +315,20 @@ class Peer(PeerBase, MetadataConfigMixin): Response string containing the answer (a JSON string when a schema dict was given), a parsed model instance when a Pydantic model class was given, or None if no relevant information. + + Raises: + ValueError: If ``scope`` is combined with ``session`` or ``sessions``. """ self._honcho._ensure_workspace() target_id = resolve_id(target) resolved_session_id = resolve_id(session) body: dict[str, Any] = {"query": query, "stream": False} + body.update( + scope_recall_fields( + scope=scope, sessions=sessions, session_id=resolved_session_id + ) + ) if target_id: body["target"] = target_id if resolved_session_id: @@ -330,6 +357,8 @@ class Peer(PeerBase, MetadataConfigMixin): *, target: str | PeerBase | None = None, session: str | SessionBase | None = None, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[BaseModel] | dict[str, Any] | None = None, @@ -350,6 +379,9 @@ class Peer(PeerBase, MetadataConfigMixin): session: Optional session to scope the query to. If provided, only information from that session is considered. Can be a session ID string or a Session object. + scope: Optional scope(s) to confine the query to. See :meth:`chat`. + sessions: Optional allowlist of sessions to confine the query to. See + :meth:`chat` for the depth caveat. reasoning_level: Optional reasoning level for the query: "minimal", "low", "medium", "high", or "max". Defaults to "low" if not provided. response_format: Optional structure for the answer: a Pydantic model @@ -360,12 +392,20 @@ class Peer(PeerBase, MetadataConfigMixin): Returns: DialecticStreamResponse object that can be iterated over and provides final response + + Raises: + ValueError: If ``scope`` is combined with ``session`` or ``sessions``. """ self._honcho._ensure_workspace() target_id = resolve_id(target) resolved_session_id = resolve_id(session) body: dict[str, Any] = {"query": query, "stream": True} + body.update( + scope_recall_fields( + scope=scope, sessions=sessions, session_id=resolved_session_id + ) + ) if target_id: body["target"] = target_id if resolved_session_id: @@ -633,6 +673,9 @@ class Peer(PeerBase, MetadataConfigMixin): search_max_distance: float | None = Field(None, ge=0.0, le=1.0), include_most_frequent: bool | None = None, max_conclusions: int | None = Field(None, ge=1, le=100), + *, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, ) -> str: """ Get a subset of the representation of the peer. @@ -646,10 +689,18 @@ class Peer(PeerBase, MetadataConfigMixin): search_max_distance: Maximum semantic distance for search results (0.0-1.0) include_most_frequent: Whether to include the most frequent conclusions max_conclusions: Maximum number of conclusions to include + scope: Optional scope(s) confining the representation. See + :meth:`chat`. Mutually exclusive with ``session`` and ``sessions``. + sessions: Optional allowlist of sessions confining the representation to + directly-stated conclusions from those sessions. See + :meth:`chat` for the depth caveat. Returns: A Representation string + Raises: + ValueError: If ``scope`` is combined with ``session`` or ``sessions``. + Example: ```python # Get global representation @@ -671,7 +722,9 @@ class Peer(PeerBase, MetadataConfigMixin): session_id = resolve_id(session) target_id = resolve_id(target) - body: dict[str, Any] = {} + body: dict[str, Any] = scope_recall_fields( + scope=scope, sessions=sessions, session_id=session_id + ) if session_id: body["session_id"] = session_id if target_id: @@ -764,7 +817,7 @@ class Peer(PeerBase, MetadataConfigMixin): return PeerContextResponse.model_validate(data) @property - def conclusions(self) -> ConclusionScope: + def conclusions(self) -> ConclusionsView: """ Access this peer's self-conclusions (where observer == observed == self). @@ -772,7 +825,7 @@ class Peer(PeerBase, MetadataConfigMixin): has made about themselves. Use this for self-conclusion scenarios. Returns: - A ConclusionScope scoped to this peer's self-conclusions + A ConclusionsView scoped to this peer's self-conclusions Example: ```python @@ -786,9 +839,9 @@ class Peer(PeerBase, MetadataConfigMixin): peer.conclusions.delete("obs-123") ``` """ - return ConclusionScope(self._honcho, self.workspace_id, self.id, self.id) + return ConclusionsView(self._honcho, self.workspace_id, self.id, self.id) - def conclusions_of(self, target: str | PeerBase) -> ConclusionScope: + def conclusions_of(self, target: str | PeerBase) -> ConclusionsView: """ Access conclusions this peer has made about another peer. @@ -799,7 +852,7 @@ class Peer(PeerBase, MetadataConfigMixin): target: The target peer (either a Peer object or peer ID string) Returns: - A ConclusionScope scoped to this peer's conclusions of the target + A ConclusionsView scoped to this peer's conclusions of the target Example: ```python @@ -817,7 +870,7 @@ class Peer(PeerBase, MetadataConfigMixin): ``` """ target_id = target.id if isinstance(target, PeerBase) else target - return ConclusionScope(self._honcho, self.workspace_id, self.id, target_id) + return ConclusionsView(self._honcho, self.workspace_id, self.id, target_id) def __repr__(self) -> str: """ diff --git a/sdks/python/src/honcho/scope.py b/sdks/python/src/honcho/scope.py new file mode 100644 index 00000000..14a5752b --- /dev/null +++ b/sdks/python/src/honcho/scope.py @@ -0,0 +1,233 @@ +# pyright: reportPrivateUsage=false +"""Sync Scope class for Honcho SDK.""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from pydantic import ConfigDict, PrivateAttr, validate_call + +from .api_types import ScopeBackfillJob, ScopeStatusResponse, SessionResponse +from .base import ScopeBase, SessionBase +from .http import routes +from .pagination import SyncPage +from .session import Session +from .utils import resolve_scope_membership, resolve_scope_session + +if TYPE_CHECKING: + from .aio import ScopeAio + from .client import Honcho + +logger = logging.getLogger(__name__) + +__all__ = ["Scope"] + + +class Scope(ScopeBase): + """ + Represents a scope in Honcho. + + A scope is a named set of sessions that acts as a visibility boundary. Recall + performed through a scope sees only what happened in that scope's sessions, + while the underlying peer keeps its single unified representation across + everything it has ever participated in. + + Membership changes are applied asynchronously: adding a session that already + has messages copies its existing conclusions into the scope, and removing one + reconciles them back out. Poll :meth:`status` to watch that settle. + + Attributes: + id: Unprefixed scope name, unique within the workspace + workspace_id: Workspace ID for scoping operations + metadata: Cached metadata for this scope. May be stale if not recently + fetched. + created_at: When this scope was created, if known + + Example: + ```python + therapy = honcho.scope("therapy") + therapy.add_sessions([session_1, session_2]) + + # Ask a question answered only from the therapy sessions + answer = user.chat("What is stressing them out?", scope="therapy") + ``` + """ + + _metadata: dict[str, Any] | None = PrivateAttr(default=None) + _created_at: datetime | None = PrivateAttr(default=None) + _honcho: "Honcho" = PrivateAttr() + + @property + def metadata(self) -> dict[str, Any] | None: + """Cached metadata for this scope. May be stale if not recently fetched.""" + return self._metadata + + @property + def created_at(self) -> datetime | None: + """When this scope was created. Only available if fetched from the API.""" + return self._created_at + + def __init__( + self, + scope_id: str, + honcho: "Honcho", + *, + metadata: dict[str, Any] | None = None, + created_at: datetime | None = None, + ) -> None: + """ + Initialize a new Scope. + + **Do not call this directly — use** ``honcho.scope()``. + + Args: + scope_id: Unprefixed scope name, unique within the workspace + honcho: Honcho client instance + metadata: Cached metadata, if already fetched + created_at: Creation timestamp, if already fetched + """ + super().__init__( + id=scope_id, + workspace_id=honcho.workspace_id, + ) + self._honcho = honcho + self._metadata = metadata + self._created_at = created_at + + @property + def aio(self) -> "ScopeAio": + """ + Access async versions of all Scope methods. + + Returns a ScopeAio view that provides async versions of all methods while + sharing state with this Scope instance. + + Example: + ```python + await scope.aio.add_sessions(["session-1"]) + status = await scope.aio.status() + ``` + """ + # Import here to avoid circular import (aio.py imports this module) + from .aio import ScopeAio + + return ScopeAio(self) + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + def add_sessions(self, sessions: Sequence[str | SessionBase]) -> None: + """ + Add sessions to this scope. + + Every named session must already exist. Adding a session that is already + a member is a no-op. + + Sessions that already hold messages are backfilled into the scope + asynchronously, so recall through this scope may not reflect their history + immediately — poll :meth:`status` to watch that complete. + + Args: + sessions: Sessions to add, as ID strings or Session objects. At most + 100 per call, matching the server's limit; split larger membership + changes into separate calls so a failure names the batch that + failed. + + Raises: + ValueError: If no sessions are given, or more than 100. + """ + session_ids = resolve_scope_membership(sessions) + self._honcho._ensure_workspace() + self._honcho._http.post( + routes.scope_sessions(self.workspace_id, self.id), + body={"session_ids": session_ids}, + ) + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + def remove_session(self, session: str | SessionBase) -> None: + """ + Remove a session from this scope. + + Conclusions copied or derived while the session was a member are + reconciled out asynchronously, and the scope's peer card is rebuilt from + whatever evidence remains. Poll :meth:`status` to watch that settle. + + Args: + session: Session to remove, as an ID string or a Session object + """ + self._honcho._ensure_workspace() + self._honcho._http.delete( + routes.scope_session( + self.workspace_id, self.id, resolve_scope_session(session) + ) + ) + + def sessions( + self, + page: int = 1, + size: int = 50, + *, + reverse: bool = False, + ) -> SyncPage[SessionResponse, Session]: + """ + Get the sessions that are members of this scope. + + Ordered by how long each session has been a member — longest-standing + first, or most recently added first when ``reverse`` is True. + + Args: + page: Page number (1-indexed) + size: Number of results per page + reverse: If True, reverses the default ordering. Default: False. + + Returns: + Paginated response containing Session objects + """ + self._honcho._ensure_workspace() + + def fetch(next_page: int) -> dict[str, Any]: + query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + query["reverse"] = "true" + return self._honcho._http.post( + routes.scope_sessions_list(self.workspace_id, self.id), + query=query, + ) + + def transform(response: SessionResponse) -> Session: + return Session( + response.id, + self._honcho, + metadata=response.metadata, + configuration=response.configuration, + created_at=response.created_at, + is_active=response.is_active, + ) + + def fetch_next(next_page: int) -> SyncPage[SessionResponse, Session]: + return SyncPage(fetch(next_page), SessionResponse, transform, fetch_next) + + return SyncPage(fetch(page), SessionResponse, transform, fetch_next) + + def status(self) -> dict[str, ScopeBackfillJob]: + """ + Get the backfill/reconciliation progress for this scope. + + Use this after a membership change to tell "the scope knows nothing about + that session yet" apart from "the scope has caught up and there is + genuinely nothing to recall". + + Returns: + Per-session backfill state, keyed by session ID. Only sessions that + have had a backfill enqueued appear; an empty dict means none have. + """ + self._honcho._ensure_workspace() + data = self._honcho._http.get(routes.scope_status(self.workspace_id, self.id)) + return ScopeStatusResponse.model_validate(data).backfill_status + + def __repr__(self) -> str: + return f"Scope(id={self.id!r}, workspace_id={self.workspace_id!r})" + + def __str__(self) -> str: + return self.id diff --git a/sdks/python/src/honcho/session.py b/sdks/python/src/honcho/session.py index 3b1b0f14..2f1838c9 100644 --- a/sdks/python/src/honcho/session.py +++ b/sdks/python/src/honcho/session.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import logging +from collections.abc import Sequence from datetime import datetime from typing import TYPE_CHECKING, Any @@ -20,7 +21,7 @@ from .api_types import ( SessionPeerConfig, SessionResponse, ) -from .base import PeerBase, SessionBase +from .base import PeerBase, ScopeBase, SessionBase from .http import routes from .message import Message from .mixins import MetadataConfigMixin @@ -32,6 +33,7 @@ from .utils import ( normalize_peers_to_dict, prepare_file_for_upload, resolve_id, + scope_context_fields, ) if TYPE_CHECKING: @@ -574,6 +576,14 @@ class Session(SessionBase, MetadataConfigMixin): None, description="A peer ID to get context *from the perspective of*. If given, response will attempt to include representation and card from the perspective of `peer_perspective`. Must be provided with `peer_target`.", ), + scope: str | ScopeBase | None = Field( + None, + description="A scope to use as the perspective source instead of a peer: `peer_target`'s representation and card are read from what that scope observed. Must be provided with `peer_target`; mutually exclusive with `peer_perspective`. Requires a workspace-level key.", + ), + sessions: Sequence[str | SessionBase] | None = Field( + None, + description="An allowlist of sessions confining `peer_target`'s representation to that set. This session must be one of them. Mutually exclusive with `scope` and `limit_to_session`.", + ), limit_to_session: bool = Field( False, description="Whether to limit the representation to this session only. If True, only conclusions from this session will be included.", @@ -616,6 +626,11 @@ class Session(SessionBase, MetadataConfigMixin): peer_target: A peer ID to get context for. search_query: A query string for semantic search. peer_perspective: A peer ID to get context from the perspective of. + scope: A scope to read `peer_target`'s representation and card from. + sessions: An allowlist of sessions confining `peer_target`'s + representation. Recall is limited to conclusions stated directly in + those sessions, and the peer card is omitted, since neither derived + conclusions nor cards carry provable per-session provenance. limit_to_session: Whether to limit the representation to this session only. search_top_k: Number of semantically relevant facts to return. search_max_distance: Maximum semantic distance for search results. @@ -627,6 +642,11 @@ class Session(SessionBase, MetadataConfigMixin): summary, if available, that maximizes conversational context while respecting the token limit + Raises: + ValueError: If `peer_target` is missing when required, or if `scope`, + `sessions`, `peer_perspective`, and `limit_to_session` are combined + in ways the server rejects. + Note: Token counting is performed using tiktoken. For models using different tokenizers, you may need to adjust the token limit accordingly. @@ -650,6 +670,13 @@ class Session(SessionBase, MetadataConfigMixin): query: dict[str, Any] = { "summary": summary, "limit_to_session": limit_to_session, + **scope_context_fields( + scope=scope, + sessions=sessions, + peer_target=peer_target, + peer_perspective=peer_perspective, + limit_to_session=limit_to_session, + ), } if tokens is not None: query["tokens"] = tokens diff --git a/sdks/python/src/honcho/utils/__init__.py b/sdks/python/src/honcho/utils/__init__.py index aac9221b..90810763 100644 --- a/sdks/python/src/honcho/utils/__init__.py +++ b/sdks/python/src/honcho/utils/__init__.py @@ -6,6 +6,15 @@ from .datetime import datetime_to_iso, parse_datetime from .file_upload import normalize_file_input, prepare_file_for_upload from .peers import normalize_peers_to_dict from .resolve import resolve_id +from .scopes import ( + resolve_scope_membership, + resolve_scope_option, + resolve_scope_session, + resolve_session_allowlist, + scope_context_fields, + scope_recall_fields, + validate_scope_id, +) from .sse import SSEStreamParser, parse_sse_astream, parse_sse_chunk, parse_sse_stream __all__ = [ @@ -19,4 +28,11 @@ __all__ = [ "parse_sse_stream", "prepare_file_for_upload", "resolve_id", + "resolve_scope_membership", + "resolve_scope_option", + "resolve_scope_session", + "resolve_session_allowlist", + "scope_context_fields", + "scope_recall_fields", + "validate_scope_id", ] diff --git a/sdks/python/src/honcho/utils/resolve.py b/sdks/python/src/honcho/utils/resolve.py index db70fc01..30eaf811 100644 --- a/sdks/python/src/honcho/utils/resolve.py +++ b/sdks/python/src/honcho/utils/resolve.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, overload if TYPE_CHECKING: - from ..base import PeerBase, SessionBase + from ..base import PeerBase, ScopeBase, SessionBase @overload @@ -17,12 +17,12 @@ def resolve_id(obj: str) -> str: ... @overload -def resolve_id(obj: "PeerBase | SessionBase") -> str: ... +def resolve_id(obj: "PeerBase | SessionBase | ScopeBase") -> str: ... -def resolve_id(obj: "str | PeerBase | SessionBase | None") -> str | None: +def resolve_id(obj: "str | PeerBase | SessionBase | ScopeBase | None") -> str | None: """ - Resolve an ID from a string, PeerBase, SessionBase, or None. + Resolve an ID from a string, PeerBase, SessionBase, ScopeBase, or None. This utility function extracts the ID from an object that may be: - A string (returned as-is) diff --git a/sdks/python/src/honcho/utils/scopes.py b/sdks/python/src/honcho/utils/scopes.py new file mode 100644 index 00000000..4a0f6ef8 --- /dev/null +++ b/sdks/python/src/honcho/utils/scopes.py @@ -0,0 +1,296 @@ +"""Scope and session-allowlist option handling for the Honcho Python SDK. + +The ``scope`` and ``sessions`` options appear on several read surfaces (chat, +representation, session context, search). Their validation and their wire +translation live here so those surfaces cannot drift apart — the server enforces +the same exclusions with a 422, and this raises before the round trip. +""" + +from __future__ import annotations + +import re +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from .resolve import resolve_id + +if TYPE_CHECKING: + from ..base import ScopeBase, SessionBase + +__all__ = [ + "MAX_SCOPES_PER_OPTION", + "MAX_SESSIONS_PER_ADD", + "MAX_SESSION_ALLOWLIST_ENTRIES", + "resolve_scope_membership", + "resolve_scope_option", + "resolve_scope_session", + "resolve_session_allowlist", + "scope_context_fields", + "scope_recall_fields", + "validate_scope_id", +] + +# Scope IDs are stored server-side as peer names with this prefix prepended, so +# they must leave room for it within the 512-character peer name limit. +_SCOPE_PEER_PREFIX = "scope." +MAX_SCOPE_ID_LENGTH = 512 - len(_SCOPE_PEER_PREFIX) + +MAX_SCOPES_PER_OPTION = 100 +MAX_SESSION_ALLOWLIST_ENTRIES = 1000 +# The server accepts at most this many sessions per membership call. +MAX_SESSIONS_PER_ADD = 100 + +_RESOURCE_NAME_PATTERN = r"^[a-zA-Z0-9_-]+$" + + +def validate_scope_id(value: str) -> str: + """Validate an unprefixed scope ID. + + Args: + value: The scope ID as the caller supplied it. + + Returns: + The validated scope ID, unchanged. + + Raises: + ValueError: If the ID is empty, too long, carries the reserved prefix, + or contains characters outside the resource-name charset. + """ + if not 1 <= len(value) <= MAX_SCOPE_ID_LENGTH: + raise ValueError( + f"Scope ID must be between 1 and {MAX_SCOPE_ID_LENGTH} characters" + ) + # Checked before the charset: the reserved prefix contains '.', which is + # itself outside the charset, so a charset-first check would report the + # charset instead of the real mistake for a double-prefixed ID. + if value.startswith(_SCOPE_PEER_PREFIX): + raise ValueError( + f"Scope ID must not start with the reserved prefix '{_SCOPE_PEER_PREFIX}' (scope IDs are unprefixed)" + ) + if not re.fullmatch(_RESOURCE_NAME_PATTERN, value): + raise ValueError(f"Scope ID must match pattern {_RESOURCE_NAME_PATTERN}") + return value + + +def resolve_scope_option( + scope: "str | ScopeBase | Sequence[str | ScopeBase]", +) -> str | list[str]: + """Resolve the ``scope`` read option to its wire value. + + A single scope stays a string; a sequence becomes a list of IDs. The two + shapes mean different things to the server — one scope reads that scope's own + view, a list restricts recall to the union of their member sessions — so the + distinction is preserved rather than normalized away. + + Args: + scope: One scope (ID or ``Scope``) or a sequence of them. + + Returns: + A single validated scope ID, or a list of them. + + Raises: + ValueError: On an empty sequence, an over-cap sequence, or an invalid ID. + """ + # ``str`` is itself a Sequence, so both single-scope forms — an ID and a + # ``Scope`` — are taken first; whatever remains is the list form. + if isinstance(scope, str) or not isinstance(scope, Sequence): + return validate_scope_id(resolve_id(scope)) + + ids = [validate_scope_id(resolve_id(entry)) for entry in scope] + if not ids: + # An empty list would resolve to an empty allowlist server-side and + # silently recall nothing, which is never the intent. + raise ValueError("scope must name at least one scope") + if len(ids) > MAX_SCOPES_PER_OPTION: + raise ValueError(f"scope can name at most {MAX_SCOPES_PER_OPTION} scopes") + return ids + + +def resolve_session_allowlist( + sessions: "Sequence[str | SessionBase]", +) -> list[str]: + """Resolve the ``sessions`` allowlist option to a list of session IDs. + + Args: + sessions: Sessions to allow, as IDs or ``Session`` objects. + + Returns: + The session IDs, in the order given. + + Raises: + ValueError: On an empty list or one over the server's cap. + """ + ids = [resolve_id(entry) for entry in sessions] + if not ids: + # The server treats an empty allowlist as fail-closed (recalls nothing), + # so an empty list here is a caller mistake rather than a query. + raise ValueError("sessions must name at least one session") + if len(ids) > MAX_SESSION_ALLOWLIST_ENTRIES: + raise ValueError( + f"sessions can name at most {MAX_SESSION_ALLOWLIST_ENTRIES} sessions" + ) + return ids + + +def _validate_session_id(value: str) -> str: + """Validate a session ID against the charset the server accepts. + + Args: + value: The session ID as the caller supplied it. + + Returns: + The validated session ID, unchanged. + + Raises: + ValueError: If the ID is empty or contains characters outside the + resource-name charset. + """ + if not value: + raise ValueError("Session ID must be a non-empty string") + if not re.fullmatch(_RESOURCE_NAME_PATTERN, value): + raise ValueError(f"Session ID must match pattern {_RESOURCE_NAME_PATTERN}") + return value + + +def resolve_scope_session(session: "str | SessionBase") -> str: + """Resolve and validate a single session ID for a scope membership change. + + Validated rather than passed through because this ID is interpolated into a + request *path*: an unvalidated value silently changes which resource the + request addresses. ``valid-session?typo`` would target ``valid-session`` + with a stray query string, removing the wrong session from the scope and + triggering reconciliation against it. + + Args: + session: The session, as an ID or a ``Session`` object. + + Returns: + The validated session ID. + + Raises: + ValueError: If the ID is empty or malformed. + """ + return _validate_session_id(resolve_id(session)) + + +def resolve_scope_membership( + sessions: "Sequence[str | SessionBase]", +) -> list[str]: + """Resolve a scope membership change to a list of session IDs. + + Capped at the server's per-call limit rather than silently chunking, so a + rejected batch is the batch the caller passed. + + Args: + sessions: Sessions to add, as IDs or ``Session`` objects. + + Returns: + The session IDs, in the order given. + + Raises: + ValueError: On an empty list, one over the server's per-call cap, or a + malformed session ID. + """ + ids = [_validate_session_id(resolve_id(session)) for session in sessions] + if not ids: + raise ValueError("At least one session must be given") + if len(ids) > MAX_SESSIONS_PER_ADD: + raise ValueError( + f"At most {MAX_SESSIONS_PER_ADD} sessions can be added per call" + ) + return ids + + +def scope_context_fields( + *, + scope: "str | ScopeBase | None", + sessions: "Sequence[str | SessionBase] | None", + peer_target: str | None, + peer_perspective: str | None, + limit_to_session: bool, +) -> dict[str, Any]: + """Build the query fields for ``scope``/``sessions`` on the context route. + + Unlike the recall endpoints, session context takes these as query parameters + — ``sessions`` is sent as a repeated parameter, not as a ``filters`` body. + + Only a single scope is accepted: a scope is the *perspective source* for the + target's representation and card, which is one observer, so a list has no + meaning here. + + Args: + scope: The ``scope`` option, if given. + sessions: The ``sessions`` allowlist option, if given. + peer_target: The observed peer. Required by either option, since both only + reach the representation and there is none without a target. + peer_perspective: The observing peer, if given — a scope replaces it. + limit_to_session: Whether recall is already pinned to this session alone. + + Returns: + The fields to merge into the query. Empty when neither option is set. + + Raises: + ValueError: If either option is combined with something it contradicts, or + used without ``peer_target``. + """ + # A scope already determines what the context can see, and limit_to_session + # already pins recall to this session alone, so combining them with a + # perspective or an allowlist is a contradiction rather than a narrowing. + # Raised here so the caller does not pay a round trip for a 422. + if sessions is not None: + if scope is not None: + raise ValueError("`sessions` and `scope` are mutually exclusive") + if limit_to_session: + raise ValueError("`sessions` and `limit_to_session` are mutually exclusive") + if peer_target is None: + raise ValueError( + "You must provide a `peer_target` when `sessions` is provided" + ) + return {"sessions": resolve_session_allowlist(sessions)} + + if scope is None: + return {} + + if peer_perspective is not None: + raise ValueError("`scope` and `peer_perspective` are mutually exclusive") + if peer_target is None: + raise ValueError("You must provide a `peer_target` when `scope` is provided") + return {"scope": validate_scope_id(resolve_id(scope))} + + +def scope_recall_fields( + *, + scope: "str | ScopeBase | Sequence[str | ScopeBase] | None", + sessions: "Sequence[str | SessionBase] | None", + session_id: str | None = None, +) -> dict[str, Any]: + """Build the request-body fields for the ``scope``/``sessions`` options. + + ``sessions`` is sugar: it goes on the wire as the constrained + ``filters: {"session_id": [...]}`` body the recall endpoints accept, never as + a field of its own, which the server would reject as an unknown key. + + Args: + scope: The ``scope`` option, if given. + sessions: The ``sessions`` allowlist option, if given. + session_id: A single session already set on the request, if any — a scope + already determines what can be seen, so the two conflict. + + Returns: + The fields to merge into the request body. Empty when neither option is + set. + + Raises: + ValueError: If ``scope`` is combined with ``sessions`` or ``session_id``, + or if either option is itself invalid. + """ + if scope is None: + if sessions is None: + return {} + return {"filters": {"session_id": resolve_session_allowlist(sessions)}} + + if sessions is not None: + raise ValueError("`scope` and `sessions` are mutually exclusive") + if session_id is not None: + raise ValueError("`scope` and `session` are mutually exclusive") + return {"scope": resolve_scope_option(scope)} diff --git a/sdks/typescript/__tests__/conclusions.test.ts b/sdks/typescript/__tests__/conclusions.test.ts index 7716d3ac..7beadc34 100644 --- a/sdks/typescript/__tests__/conclusions.test.ts +++ b/sdks/typescript/__tests__/conclusions.test.ts @@ -1,7 +1,7 @@ /** * Conclusions Tests * - * Tests for Conclusion operations via ConclusionScope. + * Tests for Conclusion operations via ConclusionsView. * * Endpoints covered: * - POST /v3/workspaces/:workspaceId/conclusions (create conclusions) @@ -11,7 +11,7 @@ */ import { describe, test, expect, beforeAll, afterAll } from 'bun:test' -import { Honcho, Conclusion, ConclusionScope } from '../src' +import { Honcho, Conclusion, ConclusionsView } from '../src' import { createTestClient, requireServer } from './setup' import { assertConclusionShape } from './helpers' @@ -31,16 +31,16 @@ describe('Conclusions', () => { }) // =========================================================================== - // ConclusionScope Access + // ConclusionsView Access // =========================================================================== - describe('ConclusionScope access', () => { + describe('ConclusionsView access', () => { test('peer.conclusions returns self-scope', async () => { const peer = await client.peer('self-scope-peer') const scope = peer.conclusions - expect(scope).toBeInstanceOf(ConclusionScope) + expect(scope).toBeInstanceOf(ConclusionsView) expect(scope.observer).toBe(peer.id) expect(scope.observed).toBe(peer.id) expect(scope.workspaceId).toBe(client.workspaceId) @@ -289,7 +289,7 @@ describe('Conclusions', () => { for (const key of ['observer', 'observed', 'observer_id', 'observed_id']) { await expect( peer.conclusions.list({ filters: { [key]: 'someone-else' } }) - ).rejects.toThrow(/managed by this conclusion scope/) + ).rejects.toThrow(/managed by this conclusions view/) } }) @@ -298,10 +298,10 @@ describe('Conclusions', () => { await expect( peer.conclusions.list({ filters: { session_id: 'sess' } }) - ).rejects.toThrow(/managed by this conclusion scope/) + ).rejects.toThrow(/managed by this conclusions view/) await expect( peer.conclusions.list({ filters: { session: 'sess' } }) - ).rejects.toThrow(/managed by this conclusion scope/) + ).rejects.toThrow(/managed by this conclusions view/) }) test('query rejects observer/observed scope keys in filters', async () => { @@ -310,7 +310,7 @@ describe('Conclusions', () => { for (const key of ['observer', 'observed', 'observer_id', 'observed_id']) { await expect( peer.conclusions.query('q', 10, undefined, { [key]: 'someone-else' }) - ).rejects.toThrow(/managed by this conclusion scope/) + ).rejects.toThrow(/managed by this conclusions view/) } }) @@ -464,16 +464,16 @@ describe('Conclusions', () => { }) // =========================================================================== - // ConclusionScope toString + // ConclusionsView toString // =========================================================================== - describe('ConclusionScope toString', () => { + describe('ConclusionsView toString', () => { test('returns readable format', async () => { const peer = await client.peer('scope-tostring-peer') const str = peer.conclusions.toString() - expect(str).toContain('ConclusionScope') + expect(str).toContain('ConclusionsView') expect(str).toContain(peer.id) expect(str).toContain(client.workspaceId) }) diff --git a/sdks/typescript/__tests__/http-client.test.ts b/sdks/typescript/__tests__/http-client.test.ts index 06d80230..878869b9 100644 --- a/sdks/typescript/__tests__/http-client.test.ts +++ b/sdks/typescript/__tests__/http-client.test.ts @@ -237,6 +237,39 @@ describe('URL building', () => { expect(url.searchParams.get('present')).toBe('value') expect(url.searchParams.has('missing')).toBe(false) }) + + test('sends array query parameters as repeated params, not comma-joined', async () => { + let capturedURL = '' + globalThis.fetch = async (url) => { + capturedURL = url.toString() + return mockResponse({ ok: true }) + } + + await client.get('/v1/test', { + query: { sessions: ['session-a', 'session-b'] }, + }) + + const url = new URL(capturedURL) + // The API reads list-valued params as ?k=a&k=b. A comma-joined single value + // would arrive as one malformed entry. + expect(url.searchParams.getAll('sessions')).toEqual([ + 'session-a', + 'session-b', + ]) + }) + + test('an empty array query parameter contributes nothing', async () => { + let capturedURL = '' + globalThis.fetch = async (url) => { + capturedURL = url.toString() + return mockResponse({ ok: true }) + } + + await client.get('/v1/test', { query: { sessions: [] } }) + + const url = new URL(capturedURL) + expect(url.searchParams.has('sessions')).toBe(false) + }) }) // ============================================================================= diff --git a/sdks/typescript/__tests__/peer.test.ts b/sdks/typescript/__tests__/peer.test.ts index fca85910..cde6a0ad 100644 --- a/sdks/typescript/__tests__/peer.test.ts +++ b/sdks/typescript/__tests__/peer.test.ts @@ -671,7 +671,7 @@ describe('Peer', () => { // =========================================================================== describe('Conclusion scope access', () => { - test('conclusions property returns ConclusionScope for self', async () => { + test('conclusions property returns ConclusionsView for self', async () => { const peer = await client.peer('self-conclusions-peer') const scope = peer.conclusions @@ -681,7 +681,7 @@ describe('Peer', () => { expect(scope.workspaceId).toBe(client.workspaceId) }) - test('conclusionsOf returns ConclusionScope for target', async () => { + test('conclusionsOf returns ConclusionsView for target', async () => { const observer = await client.peer('obs-conclusions-peer') const target = await client.peer('target-conclusions-peer') diff --git a/sdks/typescript/__tests__/scope.unit.test.ts b/sdks/typescript/__tests__/scope.unit.test.ts new file mode 100644 index 00000000..b4b9ebab --- /dev/null +++ b/sdks/typescript/__tests__/scope.unit.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, test } from 'bun:test' +import { ZodError } from 'zod' +import type { HonchoHTTPClient } from '../src/http/client' +import { Peer } from '../src/peer' +import { Scope } from '../src/scope' +import { Session } from '../src/session' +import type { ScopeStatusResponse } from '../src/types/api' + +/** + * Capture the body of the single request a call makes, so the wire shape the + * server actually receives is asserted rather than the SDK's own options. + */ +function capturingHttp(response: unknown): { + http: HonchoHTTPClient + body: () => Record | undefined + query: () => Record | undefined + path: () => string | undefined +} { + let capturedBody: Record | undefined + let capturedQuery: Record | undefined + let capturedPath: string | undefined + const http = { + post: async ( + path: string, + options?: { body?: Record } + ) => { + capturedPath = path + capturedBody = options?.body + return response + }, + get: async ( + path: string, + options?: { query?: Record } + ) => { + capturedPath = path + capturedQuery = options?.query + return response + }, + delete: async (path: string) => { + capturedPath = path + return undefined + }, + } as unknown as HonchoHTTPClient + return { + http, + body: () => capturedBody, + query: () => capturedQuery, + path: () => capturedPath, + } +} + +describe('sessions allowlist sugar', () => { + test('chat sends `sessions` as a session_id filter, not a bare field', async () => { + const { http, body } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await peer.chat('what happened?', { + sessions: ['session-a', new Session('session-b', 'workspace-1', http)], + }) + + expect(body()).toMatchObject({ + filters: { session_id: ['session-a', 'session-b'] }, + }) + // The sugar must not leak through as its own wire field — the server would + // reject an unknown key. + expect(body()).not.toHaveProperty('sessions') + }) + + test('representation sends `sessions` as a session_id filter', async () => { + const { http, body } = capturingHttp({ representation: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await peer.representation({ sessions: ['session-a'] }) + + expect(body()).toMatchObject({ filters: { session_id: ['session-a'] } }) + }) + + test('an empty allowlist is rejected rather than silently recalling nothing', async () => { + const { http } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await expect(peer.chat('q', { sessions: [] })).rejects.toBeInstanceOf( + ZodError + ) + }) +}) + +describe('scope read option', () => { + test('a single scope passes through as `scope`', async () => { + const { http, body } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await peer.chat('q', { scope: 'therapy' }) + + expect(body()).toMatchObject({ scope: 'therapy' }) + }) + + test('a Scope object resolves to its id', async () => { + const { http, body } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await peer.chat('q', { scope: new Scope('therapy', 'workspace-1', http) }) + + expect(body()).toMatchObject({ scope: 'therapy' }) + }) + + test('a list of scopes passes through as a list', async () => { + const { http, body } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await peer.chat('q', { scope: ['therapy', 'work'] }) + + expect(body()).toMatchObject({ scope: ['therapy', 'work'] }) + }) + + test('scope and sessions are rejected together', async () => { + const { http } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await expect( + peer.chat('q', { scope: 'therapy', sessions: ['session-a'] }) + ).rejects.toBeInstanceOf(ZodError) + }) + + test('scope and a single session are rejected together', async () => { + const { http } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await expect( + peer.chat('q', { scope: 'therapy', session: 'session-a' }) + ).rejects.toBeInstanceOf(ZodError) + }) +}) + +describe('scope id validation', () => { + test('a prefixed name reports the reserved prefix, not the charset', async () => { + const { http } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + // 'scope.therapy' fails both rules; the prefix message is the useful one, + // so it must be the only one raised. + const error = await peer + .chat('q', { scope: 'scope.therapy' }) + .then(() => undefined) + .catch((err: unknown) => err as ZodError) + + expect(error).toBeInstanceOf(ZodError) + const messages = (error as ZodError).issues.map((issue) => issue.message) + expect(messages.some((m) => m.includes('reserved prefix'))).toBe(true) + expect(messages.some((m) => m.includes('may only contain'))).toBe(false) + }) + + test('a name with illegal characters is rejected', async () => { + const { http } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await expect(peer.chat('q', { scope: 'my scope' })).rejects.toBeInstanceOf( + ZodError + ) + }) + + test('the specific message survives the scope option union', async () => { + // ScopeOptionSchema is a union. Zod collapses a failing union into a single + // `invalid_union` / "Invalid input" issue and buries the branch errors, so + // the rules are applied after the union resolves. Without that, every bad + // scope reports "Invalid input" and the caller learns nothing. + for (const [input, expected] of [ + ['scope.therapy', 'reserved prefix'], + ['my scope', 'may only contain'], + ['', 'non-empty'], + ['a'.repeat(507), 'at most 506'], + ] as const) { + const { http } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + const error = (await peer + .chat('q', { scope: input }) + .then(() => undefined) + .catch((err: unknown) => err)) as ZodError + + expect(error).toBeInstanceOf(ZodError) + const messages = error.issues.map((i) => i.message).join(' | ') + expect(messages).toContain(expected) + expect(messages).not.toContain('Invalid input') + } + }) + + test('list-form messages also survive the union', async () => { + const { http } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + for (const [input, expected] of [ + [[], 'at least one scope'], + [['ok', 'scope.bad'], 'reserved prefix'], + [Array.from({ length: 101 }, (_, i) => `s${i}`), 'at most 100 scopes'], + ] as const) { + const error = (await peer + .chat('q', { scope: input as string[] }) + .then(() => undefined) + .catch((err: unknown) => err)) as ZodError + + expect(error.issues.map((i) => i.message).join(' | ')).toContain(expected) + } + }) +}) + +describe('empty-string options fail closed', () => { + test("session.context rejects scope: '' instead of returning unscoped context", async () => { + const { http, query } = capturingHttp({ + id: 'session-a', + messages: [], + summary: null, + peer_representation: null, + peer_card: null, + }) + const session = new Session('session-a', 'workspace-1', http) + + // A truthiness check here would drop the option and silently return the + // unscoped context — the opposite of what an invalid scope should do. + await expect( + session.context({ peerTarget: 'user', scope: '' }) + ).rejects.toBeInstanceOf(ZodError) + expect(query()).toBeUndefined() + }) +}) + +describe('scope membership ids are validated before reaching a URL', () => { + test('removeSession rejects an id that would alter the request path', async () => { + const { http, path } = capturingHttp(undefined) + const scope = new Scope('therapy', 'workspace-1', http) + + // `valid-session?typo` would address `valid-session` with a stray query + // string, removing the wrong session and reconciling against it. + await expect( + scope.removeSession('valid-session?typo') + ).rejects.toBeInstanceOf(ZodError) + expect(path()).toBeUndefined() + }) + + test('addSessions rejects the same shape', async () => { + const { http, body } = capturingHttp(undefined) + const scope = new Scope('therapy', 'workspace-1', http) + + await expect( + scope.addSessions(['ok-session', 'valid-session?typo']) + ).rejects.toBeInstanceOf(ZodError) + expect(body()).toBeUndefined() + }) +}) + +describe('session.context scoping', () => { + const contextResponse = { + id: 'session-a', + messages: [], + summary: null, + peer_representation: null, + peer_card: null, + } + + test('scope and sessions are sent as their own query params', async () => { + const { http, query } = capturingHttp(contextResponse) + const session = new Session('session-a', 'workspace-1', http) + + await session.context({ + peerTarget: 'user', + sessions: ['session-a', 'session-b'], + }) + + // An array here relies on the HTTP client emitting repeated params; see + // http-client.test.ts. + expect(query()).toMatchObject({ sessions: ['session-a', 'session-b'] }) + }) + + test('sessions without peerTarget is rejected, not silently ignored', async () => { + const { http } = capturingHttp(contextResponse) + const session = new Session('session-a', 'workspace-1', http) + + await expect( + session.context({ sessions: ['session-a'] }) + ).rejects.toBeInstanceOf(ZodError) + }) + + test('sessions and scope are rejected together', async () => { + const { http } = capturingHttp(contextResponse) + const session = new Session('session-a', 'workspace-1', http) + + await expect( + session.context({ + peerTarget: 'user', + scope: 'therapy', + sessions: ['session-a'], + }) + ).rejects.toBeInstanceOf(ZodError) + }) + + test('sessions and limitToSession are rejected together', async () => { + const { http } = capturingHttp(contextResponse) + const session = new Session('session-a', 'workspace-1', http) + + await expect( + session.context({ + peerTarget: 'user', + limitToSession: true, + sessions: ['session-a'], + }) + ).rejects.toBeInstanceOf(ZodError) + }) + + test('scope and peerPerspective are rejected together', async () => { + const { http } = capturingHttp(contextResponse) + const session = new Session('session-a', 'workspace-1', http) + + await expect( + session.context({ + peerTarget: 'user', + peerPerspective: 'assistant', + scope: 'therapy', + }) + ).rejects.toBeInstanceOf(ZodError) + }) +}) + +describe('Scope membership and status', () => { + test('addSessions posts session_ids and resolves Session objects', async () => { + const { http, body, path } = capturingHttp(undefined) + const scope = new Scope('therapy', 'workspace-1', http) + + await scope.addSessions([ + 'session-a', + new Session('session-b', 'workspace-1', http), + ]) + + expect(path()).toBe('/v3/workspaces/workspace-1/scopes/therapy/sessions') + expect(body()).toEqual({ session_ids: ['session-a', 'session-b'] }) + }) + + test('addSessions rejects a batch over the server limit instead of chunking', async () => { + const { http } = capturingHttp(undefined) + const scope = new Scope('therapy', 'workspace-1', http) + + const tooMany = Array.from({ length: 101 }, (_, i) => `session-${i}`) + + await expect(scope.addSessions(tooMany)).rejects.toBeInstanceOf(ZodError) + }) + + test('removeSession targets the session subpath', async () => { + const { http, path } = capturingHttp(undefined) + const scope = new Scope('therapy', 'workspace-1', http) + + await scope.removeSession(new Session('session-b', 'workspace-1', http)) + + expect(path()).toBe( + '/v3/workspaces/workspace-1/scopes/therapy/sessions/session-b' + ) + }) + + test('status maps snake_case job fields to camelCase', async () => { + const response: ScopeStatusResponse = { + backfill_status: { + 'session-a': { + state: 'completed', + updated_at: '2024-01-01T00:00:00Z', + docs_copied: 12, + }, + 'session-b': { state: 'pending', updated_at: '2024-01-02T00:00:00Z' }, + }, + } + const { http } = capturingHttp(response) + const scope = new Scope('therapy', 'workspace-1', http) + + const status = await scope.status() + + expect(status.backfillStatus['session-a']).toEqual({ + state: 'completed', + updatedAt: '2024-01-01T00:00:00Z', + docsCopied: 12, + }) + expect(status.backfillStatus['session-b']?.docsCopied).toBeUndefined() + }) + + test('status on a scope with no backfill is an empty map, not a throw', async () => { + // The server omits the key entirely when nothing was ever enqueued. + const { http } = capturingHttp({} as ScopeStatusResponse) + const scope = new Scope('therapy', 'workspace-1', http) + + const status = await scope.status() + + expect(status.backfillStatus).toEqual({}) + }) +}) diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index db8e237a..2b66b9c2 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -3,6 +3,7 @@ import { HonchoHTTPClient } from './http/client' import { Message } from './message' import { Page } from './pagination' import { Peer } from './peer' +import { Scope } from './scope' import { Session } from './session' import type { MessageResponse, @@ -11,6 +12,7 @@ import type { QueueStatus, QueueStatusParams, QueueStatusResponse, + ScopeResponse, SessionResponse, WorkspaceResponse, } from './types/api' @@ -32,12 +34,14 @@ import { peerConfigFromApi, peerConfigToApi, type QueueStatusOptions, + ScopeIdSchema, SearchQuerySchema, type SessionConfig, SessionConfigSchema, SessionIdSchema, type SessionMetadata, SessionMetadataSchema, + SessionScopesSchema, sessionConfigFromApi, sessionConfigToApi, type WorkspaceConfig, @@ -254,6 +258,7 @@ export class Honcho { params: { query: string filters?: Record + scope?: string limit?: number } ): Promise { @@ -314,6 +319,39 @@ export class Honcho { ) } + private async _getOrCreateScope( + workspaceId: string, + params: { + id: string + metadata?: Record + } + ): Promise { + return this._http.post( + `/${API_VERSION}/workspaces/${workspaceId}/scopes`, + { body: params } + ) + } + + private async _listScopes( + workspaceId: string, + params?: { + page?: number + size?: number + reverse?: boolean + } + ): Promise> { + return this._http.post>( + `/${API_VERSION}/workspaces/${workspaceId}/scopes/list`, + { + query: { + page: params?.page, + size: params?.size, + reverse: params?.reverse ? 'true' : undefined, + }, + } + ) + } + private async _listSessions( workspaceId: string, params?: { @@ -346,6 +384,7 @@ export class Honcho { string, { observe_me?: boolean | null; observe_others?: boolean | null } > + scopes?: string[] } ): Promise { return this._http.post( @@ -356,6 +395,7 @@ export class Honcho { metadata: params.metadata, configuration: sessionConfigToApi(params.configuration), peers: params.peers, + scopes: params.scopes, }, } ) @@ -504,6 +544,10 @@ export class Honcho { * @param options.peers - Optional peers to attach to the session at creation. * Accepts the same shape as `session.addPeers()` (peer ID strings, * Peer objects, arrays of either, or a record with per-peer config). + * @param options.scopes - Optional scopes this session should join. Each scope is + * created if it does not exist yet. Attaching at creation avoids the + * asynchronous backfill that a later `scope.addSessions()` triggers, + * since there is no history to copy. * @returns Promise resolving to a Session object that can be used to add peers, * send messages, and manage conversation context * @throws Error if the session ID is empty or invalid @@ -514,6 +558,7 @@ export class Honcho { metadata?: SessionMetadata configuration?: SessionConfig peers?: PeerAddition + scopes?: (string | Scope)[] } ): Promise { await this._ensureWorkspace() @@ -528,12 +573,17 @@ export class Honcho { options?.peers !== undefined ? PeerAdditionToApiSchema.parse(options.peers) : undefined + const validatedScopes = + options?.scopes !== undefined + ? SessionScopesSchema.parse(options.scopes.map(resolveId)) + : undefined const sessionData = await this._getOrCreateSession(this.workspaceId, { id: validatedId, configuration: validatedConfiguration, metadata: validatedMetadata, peers: validatedPeers, + scopes: validatedScopes, }) return new Session( validatedId, @@ -547,6 +597,90 @@ export class Honcho { ) } + /** + * Get or create a scope with the given ID. + * + * A scope is a named set of sessions that acts as a visibility boundary: recall + * performed through the scope sees only what happened in its sessions, while the + * underlying peer keeps its single unified representation of everything. + * + * @param id - Unprefixed scope name, unique within the workspace + * @param options.metadata - Optional metadata to associate with this scope + * @returns Promise resolving to a Scope object for managing membership + * @throws Error if the scope ID is empty or invalid, or if a peer already occupies + * the scope's reserved internal name + * + * @example + * ```typescript + * const therapy = await honcho.scope('therapy') + * await therapy.addSessions([session1, session2]) + * ``` + */ + async scope( + id: string, + options?: { + metadata?: Record + } + ): Promise { + await this._ensureWorkspace() + const validatedId = ScopeIdSchema.parse(id) + + const scopeData = await this._getOrCreateScope(this.workspaceId, { + id: validatedId, + metadata: options?.metadata, + }) + return new Scope( + validatedId, + this.workspaceId, + this._http, + scopeData.metadata ?? undefined, + () => this._ensureWorkspace(), + scopeData.created_at + ) + } + + /** + * Get all scopes in the current workspace. + * + * @param options - Pagination options: `page`, `size`, and `reverse` + * @returns Promise resolving to a Page of Scope objects. Returns an empty page if + * no scopes exist + */ + async scopes(options?: { + page?: number + size?: number + reverse?: boolean + }): Promise> { + await this._ensureWorkspace() + const reverse = options?.reverse + const scopesPage = await this._listScopes(this.workspaceId, { + page: options?.page, + size: options?.size, + reverse, + }) + + const fetchNextPage = async ( + page: number, + size: number + ): Promise> => { + return this._listScopes(this.workspaceId, { page, size, reverse }) + } + + return new Page( + scopesPage, + (scope) => + new Scope( + scope.id, + this.workspaceId, + this._http, + scope.metadata ?? undefined, + () => this._ensureWorkspace(), + scope.created_at + ), + fetchNextPage + ) + } + /** * Get all sessions in the current workspace. * @@ -775,6 +909,9 @@ export class Honcho { * * @param query - The search query to use * @param filters - Optional filters to scope the search. See [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters). + * @param options.scope - Optional scope to restrict the search to that scope's member + * sessions. Mutually exclusive with a `session_id` filter. A scope + * with no member sessions matches nothing rather than everything. * @param limit - Number of results to return (1-100, default: 10). * @returns Promise resolving to an array of Message objects representing the search results. * Returns an empty array if no messages are found. @@ -784,6 +921,7 @@ export class Honcho { query: string, options?: { filters?: Filters + scope?: string | Scope limit?: number } ): Promise { @@ -792,12 +930,19 @@ export class Honcho { const validatedFilters = options?.filters ? FilterSchema.parse(options.filters) : undefined + // Checked against undefined, not truthiness: `scope: ''` is invalid, and + // dropping it silently would diverge from the Python SDK, which rejects it. + const validatedScope = + options?.scope !== undefined + ? ScopeIdSchema.parse(resolveId(options.scope)) + : undefined const validatedLimit = options?.limit ? LimitSchema.parse(options.limit) : undefined const response = await this._searchWorkspace(this.workspaceId, { query: validatedQuery, filters: validatedFilters, + scope: validatedScope, limit: validatedLimit, }) return response.map(Message.fromApiResponse) diff --git a/sdks/typescript/src/conclusions.ts b/sdks/typescript/src/conclusions.ts index c9add4e2..44f5e7e1 100644 --- a/sdks/typescript/src/conclusions.ts +++ b/sdks/typescript/src/conclusions.ts @@ -12,10 +12,10 @@ import type { import { normalizeSearchQuery, RepresentationOptionsSchema } from './validation' /** - * Filter keys that define a conclusion scope (the observer/observed peer pair). - * They are set from the scope itself, so a caller must not pass them in `filters`. + * Filter keys that define a conclusions view (the observer/observed peer pair). + * They are set from the view itself, so a caller must not pass them in `filters`. */ -const SCOPE_RESERVED_KEYS = [ +const VIEW_RESERVED_KEYS = [ 'observer', 'observed', 'observer_id', @@ -23,11 +23,11 @@ const SCOPE_RESERVED_KEYS = [ ] /** - * Throw if `filters` contains keys managed by the conclusion scope. + * Throw if `filters` contains keys managed by the conclusions view. * * The observer/observed peer pair (and, on `list`, the session) is fixed by the - * scope, so letting a user filter override it would silently return data from a - * different scope than requested. Fail loud instead. + * view, so letting a user filter override it would silently return data from a + * different pair than requested. Fail loud instead. */ function rejectReservedFilterKeys( filters: Record | undefined, @@ -42,7 +42,7 @@ function rejectReservedFilterKeys( guidance += '; use the session option to filter by session' } throw new Error( - `Filter key(s) ${clash.join(', ')} are managed by this conclusion scope ` + + `Filter key(s) ${clash.join(', ')} are managed by this conclusions view ` + `and cannot be passed in filters. ${guidance}.` ) } @@ -120,7 +120,7 @@ export class Conclusion { /** * Scoped access to conclusions for a specific observer/observed relationship. */ -export class ConclusionScope { +export class ConclusionsView { private _http: HonchoHTTPClient private _ensureWorkspace: () => Promise readonly workspaceId: string @@ -223,14 +223,14 @@ export class ConclusionScope { // =========================================================================== /** - * List conclusions in this scope. + * List conclusions in this view. * * @param options - Optional configuration for the list request * @param options.page - Page number (1-indexed, default: 1) * @param options.size - Number of items per page (default: 50) * @param options.session - Optional session (ID string or Session object) to filter by * @param options.filters - Optional additional filter criteria, merged with - * this scope's observer/observed (and session, if given). Supports the same + * this view's observer/observed (and session, if given). Supports the same * operators as other list endpoints — e.g. `{ level: 'explicit' }` to get * only conclusions extracted directly from messages (i.e. not derived during * dreaming). See @@ -245,7 +245,7 @@ export class ConclusionScope { reverse?: boolean }): Promise> { rejectReservedFilterKeys(options?.filters, [ - ...SCOPE_RESERVED_KEYS, + ...VIEW_RESERVED_KEYS, 'session', 'session_id', ]) @@ -284,13 +284,13 @@ export class ConclusionScope { } /** - * Semantic search for conclusions in this scope. + * Semantic search for conclusions in this view. * * @param query - The search query string * @param topK - Maximum number of results to return (default: 10) * @param distance - Maximum cosine distance threshold (0.0-1.0) * @param filters - Optional additional filter criteria, merged with this - * scope's observer/observed. Supports the same operators as the list + * view's observer/observed. Supports the same operators as the list * endpoint — e.g. `{ level: 'deductive' }` to search only conclusions * derived during dreaming. See * https://honcho.dev/docs/v3/documentation/features/advanced/using-filters @@ -301,7 +301,7 @@ export class ConclusionScope { distance?: number, filters?: Record ): Promise { - rejectReservedFilterKeys(filters, SCOPE_RESERVED_KEYS) + rejectReservedFilterKeys(filters, VIEW_RESERVED_KEYS) const response = await this._query({ query, top_k: topK, @@ -324,7 +324,7 @@ export class ConclusionScope { } /** - * Create conclusions in this scope. + * Create conclusions in this view. */ async create( conclusions: ConclusionCreateParams | ConclusionCreateParams[] @@ -351,7 +351,7 @@ export class ConclusionScope { } /** - * Get the computed representation for this scope. + * Get the computed representation for this view. */ async representation(options?: RepresentationOptions): Promise { const searchQuery = normalizeSearchQuery(options?.searchQuery) @@ -375,6 +375,6 @@ export class ConclusionScope { } toString(): string { - return `ConclusionScope(workspaceId='${this.workspaceId}', observer='${this.observer}', observed='${this.observed}')` + return `ConclusionsView(workspaceId='${this.workspaceId}', observer='${this.observer}', observed='${this.observed}')` } } diff --git a/sdks/typescript/src/http/client.ts b/sdks/typescript/src/http/client.ts index e22480d2..fd5e6379 100644 --- a/sdks/typescript/src/http/client.ts +++ b/sdks/typescript/src/http/client.ts @@ -6,18 +6,27 @@ import { TimeoutError, } from './errors' +/** + * Query parameters for a request. An array value is sent as repeated + * parameters (`?k=a&k=b`), which is how the API reads list-valued parameters. + */ +export type QueryParams = Record< + string, + string | number | boolean | readonly (string | number | boolean)[] | undefined +> + export interface HonchoHTTPClientConfig { baseURL: string apiKey?: string timeout?: number maxRetries?: number defaultHeaders?: Record - defaultQuery?: Record + defaultQuery?: QueryParams } export interface RequestOptions { body?: unknown - query?: Record + query?: QueryParams headers?: Record timeout?: number signal?: AbortSignal @@ -37,7 +46,7 @@ export class HonchoHTTPClient { readonly timeout: number readonly maxRetries: number readonly defaultHeaders: Record - readonly defaultQuery?: Record + readonly defaultQuery?: QueryParams constructor(config: HonchoHTTPClientConfig) { // Remove trailing slash from baseURL @@ -273,21 +282,28 @@ export class HonchoHTTPClient { return JSON.parse(text) as T } - private buildURL( - path: string, - query?: Record - ): string { + private buildURL(path: string, query?: QueryParams): string { const url = new URL(path, this.baseURL) - const mergedQuery: Record = { + const mergedQuery: QueryParams = { ...(this.defaultQuery ?? {}), ...(query ?? {}), } for (const [key, value] of Object.entries(mergedQuery)) { - if (value !== undefined) { - url.searchParams.set(key, String(value)) + if (value === undefined) { + continue } + if (Array.isArray(value)) { + // Repeated params, not a comma-joined value: the API reads list-valued + // query parameters as `?k=a&k=b`, and String([a, b]) would arrive as a + // single malformed entry. + for (const entry of value) { + url.searchParams.append(key, String(entry)) + } + continue + } + url.searchParams.set(key, String(value)) } return url.toString() diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 90bff9f2..24ba57b4 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -6,7 +6,13 @@ export { Honcho } from './client' export { Conclusion, type ConclusionCreateParams, - ConclusionScope, + /** + * @deprecated Renamed to `ConclusionsView`. "Scope" now means a named set of + * sessions (see `Scope`), which this class is not — it is a view over one + * observer/observed pair. Kept as an alias for one more minor version. + */ + ConclusionsView as ConclusionScope, + ConclusionsView, } from './conclusions' // HTTP infrastructure export { @@ -30,6 +36,11 @@ export { export { Message, type MessageInput } from './message' export { Page } from './pagination' export { Peer, PeerContext } from './peer' +export { + Scope, + type ScopeBackfillState, + type ScopeStatus, +} from './scope' export { Session } from './session' export { SessionContext, @@ -50,6 +61,9 @@ export type { QueueStatus, QueueStatusResponse, RepresentationOptions, + ScopeBackfillJob, + ScopeResponse, + ScopeStatusResponse, SessionContextResponse, SessionQueueStatus, SessionResponse, diff --git a/sdks/typescript/src/peer.ts b/sdks/typescript/src/peer.ts index adb67cb2..9dab1658 100644 --- a/sdks/typescript/src/peer.ts +++ b/sdks/typescript/src/peer.ts @@ -1,6 +1,6 @@ import { ZodType, z } from 'zod' import { API_VERSION } from './api-version' -import { ConclusionScope } from './conclusions' +import { ConclusionsView } from './conclusions' import type { HonchoHTTPClient } from './http/client' import { createDialecticStream, @@ -8,6 +8,9 @@ import { } from './http/streaming' import { Message, type MessageInput } from './message' import { Page } from './pagination' +// Type-only: scope.ts imports Session, which imports Peer. Importing the type +// keeps that cycle out of the emitted JS. +import type { Scope } from './scope' import { Session } from './session' import type { MessageResponse, @@ -40,6 +43,7 @@ import { peerConfigToApi, RepresentationOptionsSchema, SearchQuerySchema, + scopeRecallFields, sessionConfigFromApi, } from './validation' @@ -251,6 +255,8 @@ export class Peer { stream?: boolean target?: string session_id?: string + scope?: string | string[] + filters?: Record reasoning_level?: string response_format?: Record }): Promise { @@ -265,6 +271,8 @@ export class Peer { query: string target?: string session_id?: string + scope?: string | string[] + filters?: Record reasoning_level?: string response_format?: Record }): Promise { @@ -295,6 +303,8 @@ export class Peer { private async _getRepresentation(params: { session_id?: string + scope?: string | string[] + filters?: Record target?: string search_query?: string search_top_k?: number @@ -365,6 +375,18 @@ export class Peer { * @param options.session - Optional session to scope the query to. If provided, only * information from that session is considered. Can be a session * ID string or a Session object. + * @param options.scope - Optional scope(s) to confine the query to. A single scope answers + * from that scope's own view of the target, including the higher-order + * conclusions reasoned within it. A list of scopes restricts recall to + * the union of their member sessions, which — like `sessions` — yields + * only directly-stated conclusions. Mutually exclusive with `session` + * and `sessions`, and requires a workspace-level key. + * @param options.sessions - Optional allowlist of sessions to confine the query to, for + * one-off questions that span a handful of sessions. Recall is + * limited to conclusions stated directly in those sessions: + * conclusions produced by reasoning across sessions are excluded, + * because their provenance cannot be proven to sit inside the + * allowlist. Reach for a named `scope` when you need that depth. * @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low", "medium", * "high", or "max". Defaults to "low" if not provided. * @returns Promise resolving to the response string, or null if no relevant information @@ -379,6 +401,16 @@ export class Peer { * target: otherPeer, * reasoningLevel: 'high' * }) + * + * // Answer only from a named scope + * const response = await peer.chat('What is stressing them out?', { + * scope: 'therapy', + * }) + * + * // Answer only from an ad-hoc set of sessions + * const response = await peer.chat('What did we decide?', { + * sessions: [session1, session2], + * }) * ``` */ async chat( @@ -386,6 +418,8 @@ export class Peer { options: { target?: string | Peer session?: string | Session + scope?: string | Scope | (string | Scope)[] + sessions?: (string | Session)[] reasoningLevel?: string responseFormat: ZodType } @@ -395,6 +429,8 @@ export class Peer { options?: { target?: string | Peer session?: string | Session + scope?: string | Scope | (string | Scope)[] + sessions?: (string | Session)[] reasoningLevel?: string responseFormat?: Record } @@ -404,6 +440,8 @@ export class Peer { options?: { target?: string | Peer session?: string | Session + scope?: string | Scope | (string | Scope)[] + sessions?: (string | Session)[] reasoningLevel?: string responseFormat?: ZodType | Record } @@ -423,6 +461,8 @@ export class Peer { query, target: targetId, session: resolvedSessionId, + scope: options?.scope, + sessions: options?.sessions, reasoningLevel: options?.reasoningLevel, responseFormat: options?.responseFormat, }) @@ -437,6 +477,7 @@ export class Peer { stream: false, target: chatParams.target, session_id: chatParams.session, + ...scopeRecallFields(chatParams), reasoning_level: chatParams.reasoningLevel, response_format: Peer.toResponseFormatSchema(options?.responseFormat), }) @@ -465,6 +506,9 @@ export class Peer { * @param options.session - Optional session to scope the query to. If provided, only * information from that session is considered. Can be a session * ID string or a Session object. + * @param options.scope - Optional scope(s) to confine the query to. See {@link Peer.chat}. + * @param options.sessions - Optional allowlist of sessions to confine the query to. + * See {@link Peer.chat} for the depth caveat. * @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low", "medium", * "high", or "max". Defaults to "low" if not provided. * @returns Promise resolving to a DialecticStreamResponse that can be iterated over @@ -489,6 +533,8 @@ export class Peer { options?: { target?: string | Peer session?: string | Session + scope?: string | Scope | (string | Scope)[] + sessions?: (string | Session)[] reasoningLevel?: string responseFormat?: ZodType | Record } @@ -508,6 +554,8 @@ export class Peer { query, target: targetId, session: resolvedSessionId, + scope: options?.scope, + sessions: options?.sessions, reasoningLevel: options?.reasoningLevel, responseFormat: options?.responseFormat, }) @@ -516,6 +564,7 @@ export class Peer { query: chatParams.query, target: chatParams.target, session_id: chatParams.session, + ...scopeRecallFields(chatParams), reasoning_level: chatParams.reasoningLevel, response_format: Peer.toResponseFormatSchema(options?.responseFormat), }) @@ -846,6 +895,8 @@ export class Peer { */ async representation(options?: { session?: string | Session + scope?: string | Scope | (string | Scope)[] + sessions?: (string | Session)[] target?: string | Peer searchQuery?: string | Message searchTopK?: number @@ -856,6 +907,8 @@ export class Peer { const searchQuery = normalizeSearchQuery(options?.searchQuery) const getRepresentationParams = PeerGetRepresentationParamsSchema.parse({ session: options?.session, + scope: options?.scope, + sessions: options?.sessions, target: options?.target, options: { searchQuery, @@ -878,6 +931,7 @@ export class Peer { const response = await this._getRepresentation({ session_id: sessionId, + ...scopeRecallFields(getRepresentationParams), target: targetId, search_query: searchQuery, search_top_k: getRepresentationParams.options?.searchTopK, @@ -964,7 +1018,7 @@ export class Peer { * This property provides a convenient way to access conclusions that this peer * has made about themselves. Use this for self-conclusion scenarios. * - * @returns A ConclusionScope scoped to this peer's self-conclusions + * @returns A ConclusionsView scoped to this peer's self-conclusions * * @example * ```typescript @@ -978,8 +1032,8 @@ export class Peer { * await peer.conclusions.delete('obs-123') * ``` */ - get conclusions(): ConclusionScope { - return new ConclusionScope( + get conclusions(): ConclusionsView { + return new ConclusionsView( this._http, this.workspaceId, this.id, @@ -995,7 +1049,7 @@ export class Peer { * observer and the target is the observed peer. * * @param target - The target peer (either a Peer object or peer ID string) - * @returns A ConclusionScope scoped to this peer's conclusions of the target + * @returns A ConclusionsView scoped to this peer's conclusions of the target * * @example * ```typescript @@ -1012,9 +1066,9 @@ export class Peer { * const rep = await bobConclusions.representation() * ``` */ - conclusionsOf(target: string | Peer): ConclusionScope { + conclusionsOf(target: string | Peer): ConclusionsView { const targetId = typeof target === 'string' ? target : target.id - return new ConclusionScope( + return new ConclusionsView( this._http, this.workspaceId, this.id, diff --git a/sdks/typescript/src/scope.ts b/sdks/typescript/src/scope.ts new file mode 100644 index 00000000..5a1114fe --- /dev/null +++ b/sdks/typescript/src/scope.ts @@ -0,0 +1,278 @@ +import { API_VERSION } from './api-version' +import type { HonchoHTTPClient } from './http/client' +import { Page } from './pagination' +import { Session } from './session' +import type { + PageResponse, + ScopeStatusResponse, + SessionResponse, +} from './types/api' +import { resolveId } from './utils' +import { + ScopeSessionsSchema, + SessionIdSchema, + sessionConfigFromApi, +} from './validation' + +/** + * Backfill job state for one session in a scope. + */ +export interface ScopeBackfillState { + state: 'pending' | 'completed' | 'failed' + updatedAt: string + /** + * Number of documents copied into the scope. Present only once the backfill + * for this session completes. + */ + docsCopied?: number +} + +/** + * Backfill/reconciliation progress for a scope, keyed by session ID. + * + * Only sessions that have had a backfill enqueued appear. A scope whose + * sessions were all empty when added has an empty `backfillStatus`. + */ +export interface ScopeStatus { + backfillStatus: Record +} + +/** + * Represents a scope in the Honcho system. + * + * A scope is a named set of sessions that acts as a visibility boundary. Recall + * performed through a scope sees only what happened in that scope's sessions, + * while the underlying peer keeps its single unified representation across + * everything it has ever participated in. + * + * Membership changes are applied asynchronously: adding a session that already + * has messages copies its existing conclusions into the scope, and removing one + * reconciles them back out. Poll {@link Scope.status} to watch that settle. + * + * @example + * ```typescript + * const therapy = await honcho.scope('therapy') + * await therapy.addSessions([session1, session2]) + * + * // Ask a question answered only from the therapy sessions + * const answer = await user.chat('What is stressing them out?', { + * scope: 'therapy', + * }) + * ``` + */ +export class Scope { + /** + * Unique identifier for this scope, without the server-side `scope.` prefix. + */ + readonly id: string + /** + * Workspace ID for scoping operations. + */ + readonly workspaceId: string + private _http: HonchoHTTPClient + private _metadata?: Record + private _createdAt?: string + private _ensureWorkspace: () => Promise + + /** + * Cached metadata for this scope. May be stale if the scope was not recently + * fetched from the API. + */ + get metadata(): Record | undefined { + return this._metadata + } + + /** + * Timestamp when this scope was created. Only available if fetched from the API. + */ + get createdAt(): string | undefined { + return this._createdAt + } + + /** + * Initialize a new Scope. **Do not call this directly, use the client.scope() method instead.** + * + * @param id - Unprefixed scope name, unique within the workspace + * @param workspaceId - Workspace ID for scoping operations + * @param http - Reference to the HTTP client instance + * @param metadata - Optional metadata to initialize the cached value + * @param ensureWorkspace - Callback that guarantees the workspace exists + * @param createdAt - Creation timestamp, if already fetched + */ + constructor( + id: string, + workspaceId: string, + http: HonchoHTTPClient, + metadata?: Record, + ensureWorkspace: () => Promise = async () => undefined, + createdAt?: string + ) { + this.id = id + this.workspaceId = workspaceId + this._http = http + this._metadata = metadata + this._ensureWorkspace = ensureWorkspace + this._createdAt = createdAt + } + + // =========================================================================== + // Private API Methods + // =========================================================================== + + private get _basePath(): string { + return `/${API_VERSION}/workspaces/${this.workspaceId}/scopes/${this.id}` + } + + private async _addSessions(sessionIds: string[]): Promise { + await this._ensureWorkspace() + await this._http.post(`${this._basePath}/sessions`, { + body: { session_ids: sessionIds }, + }) + } + + private async _removeSession(sessionId: string): Promise { + await this._ensureWorkspace() + await this._http.delete(`${this._basePath}/sessions/${sessionId}`) + } + + private async _listSessions(params?: { + page?: number + size?: number + reverse?: boolean + }): Promise> { + await this._ensureWorkspace() + return this._http.post>( + `${this._basePath}/sessions/list`, + { + query: { + page: params?.page, + size: params?.size, + reverse: params?.reverse ? 'true' : undefined, + }, + } + ) + } + + private async _getStatus(): Promise { + await this._ensureWorkspace() + return this._http.get(`${this._basePath}/status`) + } + + // =========================================================================== + // Public API Methods + // =========================================================================== + + /** + * Add sessions to this scope. + * + * Every named session must already exist. Adding a session that is already a + * member is a no-op. + * + * Sessions that already hold messages are backfilled into the scope + * asynchronously, so recall through this scope may not reflect their history + * immediately — poll {@link Scope.status} to watch that complete. + * + * @param sessions - Sessions to add, as ID strings or Session objects. At most + * 100 per call, matching the server's limit; split larger + * membership changes into separate calls so a failure names + * the batch that failed. + */ + async addSessions(sessions: (string | Session)[]): Promise { + const sessionIds = ScopeSessionsSchema.parse(sessions.map(resolveId)) + await this._addSessions(sessionIds) + } + + /** + * Remove a session from this scope. + * + * Conclusions copied or derived while the session was a member are + * reconciled out asynchronously, and the scope's peer card is rebuilt from + * whatever evidence remains. Poll {@link Scope.status} to watch that settle. + * + * @param session - Session to remove, as an ID string or a Session object + * @throws If the session ID is malformed + */ + async removeSession(session: string | Session): Promise { + // Validated because this ID is interpolated into a request *path*: an + // unvalidated value silently changes which resource the request addresses. + // `valid-session?typo` would target `valid-session` with a stray query + // string, removing the wrong session and reconciling against it. + await this._removeSession(SessionIdSchema.parse(resolveId(session))) + } + + /** + * Get the sessions that are members of this scope. + * + * Ordered by how long each session has been a member — longest-standing + * first, or most recently added first when `reverse` is true. + * + * @param options - Pagination options: `page`, `size`, and `reverse` + * @returns Promise resolving to a paginated list of member Sessions + */ + async sessions(options?: { + page?: number + size?: number + reverse?: boolean + }): Promise> { + const reverse = options?.reverse + const sessionsPage = await this._listSessions({ + page: options?.page, + size: options?.size, + reverse, + }) + + const fetchNextPage = async ( + page: number, + size: number + ): Promise> => { + return this._listSessions({ page, size, reverse }) + } + + return new Page( + sessionsPage, + (session) => + new Session( + session.id, + this.workspaceId, + this._http, + session.metadata ?? undefined, + sessionConfigFromApi(session.configuration) ?? undefined, + () => this._ensureWorkspace(), + session.created_at, + session.is_active + ), + fetchNextPage + ) + } + + /** + * Get the backfill/reconciliation progress for this scope. + * + * Use this after a membership change to tell "the scope knows nothing about + * that session yet" apart from "the scope has caught up and there is genuinely + * nothing to recall". + * + * @returns Promise resolving to per-session backfill state + */ + async status(): Promise { + const response = await this._getStatus() + return { + backfillStatus: Object.fromEntries( + Object.entries(response.backfill_status ?? {}).map( + ([sessionId, job]) => [ + sessionId, + { + state: job.state, + updatedAt: job.updated_at, + docsCopied: job.docs_copied, + }, + ] + ) + ), + } + } + + toString(): string { + return `Scope(id='${this.id}', workspaceId='${this.workspaceId}')` + } +} diff --git a/sdks/typescript/src/session.ts b/sdks/typescript/src/session.ts index 0f9cf4aa..ed6140a6 100644 --- a/sdks/typescript/src/session.ts +++ b/sdks/typescript/src/session.ts @@ -3,6 +3,9 @@ import type { HonchoHTTPClient } from './http/client' import { Message } from './message' import { Page } from './pagination' import { Peer } from './peer' +// Type-only: scope.ts imports this module. Importing the type keeps that cycle +// out of the emitted JS. +import type { Scope } from './scope' import { SessionContext, SessionSummaries } from './session_context' import type { MessageResponse, @@ -17,7 +20,7 @@ import type { SessionResponse, SessionSummariesResponse, } from './types/api' -import { transformQueueStatus } from './utils' +import { resolveId, transformQueueStatus } from './utils' import { ContextParamsSchema, FileUploadSchema, @@ -220,6 +223,8 @@ export class Session { search_query?: string peer_target?: string peer_perspective?: string + scope?: string + sessions?: string[] limit_to_session?: boolean search_top_k?: number search_max_distance?: number @@ -752,6 +757,17 @@ export class Session { * @param options.tokens - Target token count for the context window * @param options.peerTarget - The peer to get representation for * @param options.peerPerspective - The peer whose perspective to use for representation + * @param options.scope - A scope to use as the perspective source instead of a peer: the + * target's representation and card are read from what that scope + * observed. Requires `peerTarget`, is mutually exclusive with + * `peerPerspective`, and requires a workspace-level key. + * @param options.sessions - Allowlist of sessions confining the target's representation + * to that set. This session must be one of them. Recall is + * limited to conclusions stated directly in those sessions, and + * the peer card is omitted, since neither derived conclusions + * nor cards carry provable per-session provenance. Mutually + * exclusive with `scope` and `limitToSession`; requires + * `peerTarget`. * @param options.limitToSession - Whether to limit representation to this session only * @param options.representationOptions - Options for representation retrieval (searchQuery, searchTopK, etc.) * @returns Promise resolving to a SessionContext with messages, summary, and representation @@ -764,6 +780,12 @@ export class Session { * peerTarget: user * }) * + * // Build the context from what a scope observed + * const ctx = await session.context({ + * peerTarget: user, + * scope: 'therapy', + * }) + * * // Convert to OpenAI format * const messages = ctx.toOpenAI(assistant) * ``` @@ -773,6 +795,8 @@ export class Session { tokens?: number peerTarget?: string | Peer peerPerspective?: string | Peer + scope?: string | Scope + sessions?: (string | Session)[] limitToSession?: boolean representationOptions?: RepresentationOptions }): Promise { @@ -795,6 +819,10 @@ export class Session { tokens: opts.tokens, peerTarget: peerTargetId, peerPerspective: peerPerspectiveId, + // Checked against undefined, not truthiness: `scope: ''` must reach the + // schema and be rejected, not be dropped into an unscoped context. + scope: opts.scope !== undefined ? resolveId(opts.scope) : undefined, + sessions: opts.sessions, limitToSession: opts.limitToSession, representationOptions: opts.representationOptions ? { @@ -810,6 +838,8 @@ export class Session { search_query: searchQuery, peer_target: contextParams.peerTarget, peer_perspective: contextParams.peerPerspective, + scope: contextParams.scope, + sessions: contextParams.sessions, limit_to_session: contextParams.limitToSession, search_top_k: contextParams.representationOptions?.searchTopK, search_max_distance: diff --git a/sdks/typescript/src/types/api.ts b/sdks/typescript/src/types/api.ts index dbe4378d..10ed6037 100644 --- a/sdks/typescript/src/types/api.ts +++ b/sdks/typescript/src/types/api.ts @@ -133,6 +133,28 @@ export interface SessionCreateParams { metadata?: Record configuration?: SessionConfigApi peers?: Record + scopes?: string[] +} + +export interface ScopeResponse { + id: string + metadata: Record + created_at: string +} + +/** + * Per-session backfill job state for a scope. + * + * `docs_copied` is present only once a backfill completes. + */ +export interface ScopeBackfillJob { + state: 'pending' | 'completed' | 'failed' + updated_at: string + docs_copied?: number +} + +export interface ScopeStatusResponse { + backfill_status: Record } export interface SessionUpdateParams { diff --git a/sdks/typescript/src/validation.ts b/sdks/typescript/src/validation.ts index 48255833..832690e2 100644 --- a/sdks/typescript/src/validation.ts +++ b/sdks/typescript/src/validation.ts @@ -159,6 +159,149 @@ export const SessionIdSchema = z */ const SessionIdObjectSchema = z.object({ id: SessionIdSchema }) +/** + * Reserved peer-name prefix the server uses to store a scope. + */ +const SCOPE_PEER_PREFIX = 'scope.' + +/** + * Scope IDs are stored as peer names with the reserved prefix prepended, so + * they must leave room for it within the 512-character peer name limit. + */ +const SCOPE_ID_MAX_LENGTH = 512 - SCOPE_PEER_PREFIX.length + +/** + * The scope ID rules, as a plain function rather than only a schema. + * + * Zod reports a failing union as a single `invalid_union` / "Invalid input" + * issue and buries the branch errors, so a schema alone cannot carry these + * messages out of `ScopeOptionSchema`. Keeping the rules callable lets both the + * bare schema and the union surface the same specific message. + * + * @returns The problems found, or an empty array when the ID is valid. + */ +function scopeIdIssues(value: string): string[] { + if (value.length < 1) { + return ['Scope ID must be a non-empty string'] + } + if (value.length > SCOPE_ID_MAX_LENGTH) { + return [`Scope ID can be at most ${SCOPE_ID_MAX_LENGTH} characters`] + } + // Checked before the charset: the reserved prefix contains '.', which is + // itself outside the charset, so a charset-first check would report the + // charset instead of the real mistake for a double-prefixed name. + if (value.startsWith(SCOPE_PEER_PREFIX)) { + return [ + `Scope ID must not start with the reserved prefix '${SCOPE_PEER_PREFIX}' (scope IDs are unprefixed)`, + ] + } + if (!/^[a-zA-Z0-9_-]+$/.test(value)) { + return [ + 'Scope ID may only contain letters, numbers, underscores, and hyphens', + ] + } + return [] +} + +/** + * Add every scope ID problem in `values` as a top-level issue. + */ +function addScopeIdIssues(values: string[], ctx: z.RefinementCtx): void { + for (const value of values) { + for (const message of scopeIdIssues(value)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message }) + } + } +} + +/** + * Schema for scope ID validation. + * + * Scope IDs are unprefixed — the `scope.` prefix is a server-side storage + * detail and never appears on the wire. + */ +export const ScopeIdSchema = z.string().superRefine((val, ctx) => { + addScopeIdIssues([val], ctx) +}) + +/** + * Shape-only branch for the `scope` option: an ID string, or an object carrying + * one (so a `Scope` instance is accepted). The ID itself is validated after the + * union resolves — see `ScopeOptionSchema`. + */ +const ScopeIdLikeSchema = z.union([z.string(), z.object({ id: z.string() })]) + +/** + * Schema for the `scope` read option: one scope, or a bounded list of them. + * + * A single scope reads that scope's own view. A list restricts recall to the + * union of the scopes' member sessions. An empty list is rejected rather than + * resolved to an empty allowlist, which would silently recall nothing. + * + * The union discriminates shape only; IDs and list bounds are checked after the + * transform so their messages are not swallowed as `invalid_union`. + */ +export const ScopeOptionSchema = z + .union([ScopeIdLikeSchema, z.array(ScopeIdLikeSchema)]) + .transform((val) => + Array.isArray(val) + ? val.map((entry) => (typeof entry === 'string' ? entry : entry.id)) + : typeof val === 'string' + ? val + : val.id + ) + .superRefine((resolved, ctx) => { + if (Array.isArray(resolved)) { + if (resolved.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'scope must name at least one scope', + }) + } + if (resolved.length > 100) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'scope can name at most 100 scopes', + }) + } + } + addScopeIdIssues(Array.isArray(resolved) ? resolved : [resolved], ctx) + }) + +/** + * Schema for a scope membership change: the sessions to add to a scope. + * + * Capped at 100 to match the server rather than silently chunking, so a + * rejected batch is the batch the caller passed. + */ +export const ScopeSessionsSchema = z + .array(SessionIdSchema) + .min(1, 'At least one session must be given') + .max(100, 'At most 100 sessions can be added per call') + +/** + * Schema for the `scopes` option on session creation: the scopes a new session + * should join. + */ +export const SessionScopesSchema = z + .array(ScopeIdSchema) + .min(1, 'scopes must name at least one scope') + .max(100, 'scopes can name at most 100 scopes') + +/** + * Schema for the `sessions` allowlist option — sugar for the wire-level + * `filters: { session_id: [...] }`. + * + * Capped at 1,000 entries to match the server. An empty list is rejected: the + * server treats an empty allowlist as fail-closed (recalls nothing), which is + * never what a caller passing `sessions: []` intends. + */ +export const SessionAllowlistSchema = z + .array(z.union([SessionIdSchema, SessionIdObjectSchema])) + .min(1, 'sessions must name at least one session') + .max(1000, 'sessions can name at most 1000 sessions') + .transform((vals) => vals.map((v) => (typeof v === 'string' ? v : v.id))) + /** * Schema for session peer configuration. */ @@ -291,6 +434,58 @@ export function normalizeListOptions( return { filters: input as Filters } as T } +/** + * Translate validated `scope` / `sessions` options into their wire fields. + * + * `sessions` is sugar: it goes out as the constrained + * `filters: { session_id: [...] }` body the recall endpoints accept, never as a + * field of its own — the server rejects unknown keys with a 422. Shared by chat, + * chatStream, and representation so the three cannot drift apart. + * + * Purely a translation; the schemas have already rejected the invalid + * combinations by the time this runs. + */ +export function scopeRecallFields(options: { + scope?: string | string[] + sessions?: string[] +}): { scope?: string | string[]; filters?: Record } { + return { + scope: options.scope, + filters: options.sessions ? { session_id: options.sessions } : undefined, + } +} + +/** + * Add issues for the `scope` exclusions the server enforces with a 422. + * + * A scope already determines what a query can see, so combining it with a + * session allowlist or a single session is a contradiction rather than a + * narrowing. Shared by the chat, representation, and context schemas so the + * three surfaces cannot drift apart. + */ +function scopeExclusivityIssues( + data: { scope?: unknown; sessions?: unknown; session?: unknown }, + ctx: z.RefinementCtx +): void { + if (data.scope === undefined) { + return + } + if (data.sessions !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'scope and sessions are mutually exclusive', + path: ['sessions'], + }) + } + if (data.session !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'scope and session are mutually exclusive', + path: ['session'], + }) + } +} + /** * Schema for chat query parameters. */ @@ -309,6 +504,8 @@ export const ChatQuerySchema = z .transform((val) => val ? (typeof val === 'string' ? val : val.id) : undefined ), + scope: ScopeOptionSchema.optional(), + sessions: SessionAllowlistSchema.optional(), reasoningLevel: z .enum(['minimal', 'low', 'medium', 'high', 'max']) .optional(), @@ -319,6 +516,7 @@ export const ChatQuerySchema = z .optional(), }) .strict() + .superRefine(scopeExclusivityIssues) /** * Schema for representation options. @@ -356,11 +554,40 @@ export const ContextParamsSchema = z tokens: z.int('Token limit must be an integer').optional(), peerTarget: PeerIdSchema.optional(), peerPerspective: PeerIdSchema.optional(), + // Only a single scope is accepted here: the context route uses a scope as + // the *perspective source* for the target's representation and card, which + // is one observer. A list of scopes has no meaning for that. + scope: ScopeIdSchema.optional(), + sessions: SessionAllowlistSchema.optional(), limitToSession: z.boolean().optional(), representationOptions: RepresentationOptionsSchema.optional(), }) .strict() .superRefine((data, ctx) => { + if (data.sessions && !data.peerTarget) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'peerTarget is required when sessions is provided', + path: ['sessions'], + }) + } + + if (data.sessions && data.scope) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'sessions and scope are mutually exclusive', + path: ['sessions'], + }) + } + + if (data.sessions && data.limitToSession) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'sessions and limitToSession are mutually exclusive', + path: ['sessions'], + }) + } + if (data.representationOptions?.searchQuery && !data.peerTarget) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -376,6 +603,22 @@ export const ContextParamsSchema = z path: ['peerPerspective'], }) } + + if (data.scope && !data.peerTarget) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'peerTarget is required when scope is provided', + path: ['scope'], + }) + } + + if (data.scope && data.peerPerspective) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'scope and peerPerspective are mutually exclusive', + path: ['scope'], + }) + } }) /** @@ -437,10 +680,13 @@ export const GetRepresentationParamsSchema = z export const PeerGetRepresentationParamsSchema = z .object({ session: z.union([SessionIdSchema, SessionIdObjectSchema]).optional(), + scope: ScopeOptionSchema.optional(), + sessions: SessionAllowlistSchema.optional(), target: z.union([PeerIdSchema, PeerIdObjectSchema]).optional(), options: RepresentationOptionsSchema.optional(), }) .strict() + .superRefine(scopeExclusivityIssues) /** * Schema for peer card target parameter. diff --git a/src/deriver/queue_manager.py b/src/deriver/queue_manager.py index e4c8124b..1c19c131 100644 --- a/src/deriver/queue_manager.py +++ b/src/deriver/queue_manager.py @@ -478,8 +478,7 @@ class QueueManager: @staticmethod def _is_tenant_work(work_unit_keys: Iterable[str]) -> bool: - """True if any claimed work unit is real tenant work, not housekeeping. - """ + """True if any claimed work unit is real tenant work, not housekeeping.""" for key in work_unit_keys: try: if parse_work_unit_key(key).task_type != "reconciler": diff --git a/src/routers/sessions.py b/src/routers/sessions.py index 10b769c4..f0d5b056 100644 --- a/src/routers/sessions.py +++ b/src/routers/sessions.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src import config, crud, schemas from src.cache.client import safe_cache_delete +from src.crud.message import get_peer_session_names from src.crud.session import session_cache_key from src.dependencies import db, read_db from src.deriver.enqueue import enqueue_deletion @@ -23,6 +24,7 @@ from src.exceptions import ( from src.security import JWTParams, require_auth from src.telemetry.events import EmbeddingCallPurpose, GetContextEvent, emit from src.utils import summarizer +from src.utils.filter import normalize_session_allowlist from src.utils.representation import Representation from src.utils.search import search from src.utils.tokens import estimate_tokens @@ -714,9 +716,27 @@ async def get_session_context( 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.", ), + sessions: list[str] | None = Query( + None, + description=( + "Optional allowlist of session IDs confining the representation of " + "`peer_target` to those sessions. This session must be one of them. " + "Recall is restricted to conclusions stated directly in the allowed " + "sessions — conclusions synthesized across sessions are excluded, " + "since their provenance cannot be proven to sit inside the allowlist " + "— and the peer card is omitted for the same reason. Mutually " + "exclusive with `scope` and `limit_to_session`. A peer-scoped key " + "must be an active member of every session named. The 1,000-session " + "cap shared with the recall endpoints applies but is not reachable " + "here: these are repeated query parameters, so a long list exceeds " + "the request-line limit of the server or any proxy in front of it " + "(a 414/431, not a 422) at a few hundred entries. Use a named " + "`scope` for large or reusable session sets." + ), + ), 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)", + description="Whether to limit the representation to the session (as opposed to everything known about the target peer). Narrows recall the same way `sessions` does, so the same restrictions apply: explicit-only conclusions, and the peer card is omitted because it carries no per-session provenance.", ), search_top_k: int | None = Query( None, @@ -801,6 +821,47 @@ async def get_session_context( "`scope` requires a workspace- or admin-level key" ) + # The session allowlist confines the representation to a set of sessions this + # one belongs to. `scope` already determines what can be seen and + # `limit_to_session` already pins the set to this session alone, so both are + # contradictions rather than further narrowings — refused rather than given a + # silent precedence order. + session_allowlist: list[str] | None = None + if sessions is not None: + if scope is not None: + raise ValidationException("`sessions` and `scope` are mutually exclusive") + if limit_to_session: + raise ValidationException( + "`sessions` and `limit_to_session` are mutually exclusive" + ) + if not peer_target: + # The allowlist only reaches the representation, and there is no + # representation without a target. Refused rather than accepted and + # silently ignored, which would read as a scoped context. + raise ValidationException( + "peer_target must be provided if sessions is provided" + ) + # `must_include` keeps the allowlist from contradicting the route's own + # session: this session's messages and summary are always part of the + # response, so an allowlist excluding it would describe a context that + # cannot be assembled. + session_allowlist = normalize_session_allowlist( + sessions, field="sessions", must_include=session_id + ) + # A peer-scoped key may only name sessions its peer belongs to. Mirrors + # the chat route's gate (see routers/peers.py), including `active_only`, + # so both answer the same question for a peer that has left a session. + # Reuses the handler's session rather than opening its own: this is a + # DB-only read and the handler already holds a connection. + if jwt_params.p is not None: + member_sessions = set( + await get_peer_session_names( + db, workspace_id, jwt_params.p, active_only=True + ) + ) + if not set(session_allowlist) <= member_sessions: + raise AuthenticationException("JWT not permissioned for this resource") + if not peer_target: # No representation or card needed summary, messages = await _get_session_context_task( @@ -865,6 +926,17 @@ async def get_session_context( ): embedding = await embedding_client.embed(search_query) + # The allowlist recall must respect, whichever way the caller expressed it. + # `sessions` and `limit_to_session` are mutually exclusive (422 above), so at + # most one of these is set. `session_allowlist` is never an empty list here — + # `must_include=session_id` guarantees at least this session — so the + # None-check is the only distinction that matters. + effective_allowlist = ( + session_allowlist + if session_allowlist is not None + else ([session_id] if limit_to_session else None) + ) + # Sequential calls on shared DB session representation = await _get_working_representation_task( db, @@ -872,15 +944,34 @@ async def get_session_context( search_query, observer=observer, observed=observed, - session_allowlist=[session_id] if limit_to_session else None, + session_allowlist=effective_allowlist, search_top_k=search_top_k, search_max_distance=search_max_distance, include_most_derived=include_most_frequent, max_observations=max_conclusions, embedding=embedding, ) - card = await _get_peer_card_task( - db, workspace_id, observer=observer, observed=observed + # A peer card is keyed by (workspace, observer, observed) with no session + # dimension (crud/peer_card.py), so it is synthesized from everything the + # observer has ever seen and cannot be narrowed to an allowlist. Returning it + # would leak exactly what the allowlist exists to exclude, so it is dropped — + # the same fail-closed reasoning that limits allowlisted conclusion recall to + # ALLOWLIST_SAFE_LEVELS. + # + # Gated on the *effective* allowlist, not on `sessions` alone: + # `limit_to_session=true` narrows recall identically, so carving out only the + # newer parameter would leave a control that one parameter swap defeats. + # `scope` needs no carve-out at all — it swaps the observer to the scope peer + # above, so the card read below is the scope's own. + # + # POST /peers/{id}/chat still injects an unscoped card under an allowlist + # (src/dialectic/chat.py) — tracked in DEV-2201, not fixed here. + card = ( + None + if effective_allowlist is not None + else await _get_peer_card_task( + db, workspace_id, observer=observer, observed=observed + ) ) short_summary, long_summary = await _get_both_summaries_task( db, workspace_id, session_id diff --git a/src/utils/filter.py b/src/utils/filter.py index 1155d408..22d648af 100644 --- a/src/utils/filter.py +++ b/src/utils/filter.py @@ -279,16 +279,50 @@ def extract_session_allowlist( 'filters.session_id must be a session id, a list of session ids, or {"in": [...]}' ) + return normalize_session_allowlist( + entries, field="filters.session_id", must_include=must_include + ) + + +def normalize_session_allowlist( + entries: Sequence[Any], + *, + field: str, + must_include: str | None = None, +) -> list[str]: + """Validate and de-duplicate a session allowlist. + + Shared by every route-level entry point that accepts one — the ``filters`` + body on the recall endpoints and the ``sessions`` query parameter on session + context — so the cap, the id charset, and the ``must_include`` rule cannot + drift apart between them. Only the parameter *name* in error messages + differs, which is what ``field`` supplies. + + Args: + entries: Raw allowlist entries as the caller supplied them. + field: Caller-facing parameter name, used in error messages. + must_include: A session id that must appear in the allowlist — used by + routes that also carry a session of their own, so the two can't + contradict each other. + + Returns: + The allowlist, de-duplicated, in first-seen order. An empty input yields + an empty list so downstream consumers fail closed. + + Raises: + FilterError: On an over-cap list, a malformed session id, or a + ``must_include`` session missing from the allowlist. + """ if len(entries) > MAX_SESSION_ALLOWLIST_ENTRIES: raise FilterError( - f"filters.session_id supports at most {MAX_SESSION_ALLOWLIST_ENTRIES} sessions per request" + f"{field} supports at most {MAX_SESSION_ALLOWLIST_ENTRIES} sessions per request" ) allowlist: list[str] = [] seen: set[str] = set() for entry in entries: if not isinstance(entry, str) or not entry: - raise FilterError("filters.session_id entries must be non-empty strings") + raise FilterError(f"{field} entries must be non-empty strings") # Only names a session could actually have. The allowlist reaches # queries three ways — direct `IN`, the filter DSL, and a Python # membership test — and they don't agree on a value like "*", which the @@ -298,14 +332,14 @@ def extract_session_allowlist( # {"in": [...]}) which never included wildcards. if not re.fullmatch(RESOURCE_NAME_PATTERN, entry): raise FilterError( - f"Invalid session id in filters.session_id: {entry!r}. Session ids match {RESOURCE_NAME_PATTERN}" + f"Invalid session id in {field}: {entry!r}. Session ids match {RESOURCE_NAME_PATTERN}" ) if entry not in seen: seen.add(entry) allowlist.append(entry) if must_include is not None and must_include not in seen: - raise FilterError("session_id must be included in filters.session_id") + raise FilterError(f"session_id must be included in {field}") return allowlist diff --git a/tests/sdk/test_conclusions.py b/tests/sdk/test_conclusions.py index 9c2cdbc0..314717dc 100644 --- a/tests/sdk/test_conclusions.py +++ b/tests/sdk/test_conclusions.py @@ -6,7 +6,7 @@ from sdks.python.src.honcho.client import Honcho from sdks.python.src.honcho.conclusions import ( Conclusion, ConclusionCreateParams, - ConclusionScope, + ConclusionsView, ) @@ -34,7 +34,7 @@ async def test_observation_create_single( # Get observation scope for observer -> target obs_scope = observer.conclusions_of(target) - assert isinstance(obs_scope, ConclusionScope) + assert isinstance(obs_scope, ConclusionsView) # Create a single observation created = await obs_scope.aio.create( @@ -68,7 +68,7 @@ async def test_observation_create_single( # Get observation scope for observer -> target obs_scope = observer.conclusions_of(target) - assert isinstance(obs_scope, ConclusionScope) + assert isinstance(obs_scope, ConclusionsView) # Create a single observation created = obs_scope.create( @@ -422,7 +422,7 @@ async def test_self_observation_create( # Get self-observation scope obs_scope = peer.conclusions - assert isinstance(obs_scope, ConclusionScope) + assert isinstance(obs_scope, ConclusionsView) assert obs_scope.observer == peer.id assert obs_scope.observed == peer.id @@ -443,7 +443,7 @@ async def test_self_observation_create( # Get self-observation scope obs_scope = peer.conclusions - assert isinstance(obs_scope, ConclusionScope) + assert isinstance(obs_scope, ConclusionsView) assert obs_scope.observer == peer.id assert obs_scope.observed == peer.id @@ -796,7 +796,7 @@ async def test_list_rejects_reserved_scope_filter_keys( target = await honcho_client.aio.peer(id="test-obs-reserved-list-target") obs_scope = observer.conclusions_of(target) for key in reserved: - with pytest.raises(ValueError, match="managed by this conclusion scope"): + with pytest.raises(ValueError, match="managed by this conclusions view"): await obs_scope.aio.list(filters={key: "someone-else"}) # A non-reserved filter (level) is allowed through. await obs_scope.aio.list(filters={"level": "explicit"}) @@ -805,7 +805,7 @@ async def test_list_rejects_reserved_scope_filter_keys( target = honcho_client.peer(id="test-obs-reserved-list-target") obs_scope = observer.conclusions_of(target) for key in reserved: - with pytest.raises(ValueError, match="managed by this conclusion scope"): + with pytest.raises(ValueError, match="managed by this conclusions view"): obs_scope.list(filters={key: "someone-else"}) obs_scope.list(filters={"level": "explicit"}) @@ -827,7 +827,7 @@ async def test_query_rejects_reserved_scope_filter_keys( target = await honcho_client.aio.peer(id="test-obs-reserved-query-target") obs_scope = observer.conclusions_of(target) for key in reserved: - with pytest.raises(ValueError, match="managed by this conclusion scope"): + with pytest.raises(ValueError, match="managed by this conclusions view"): await obs_scope.aio.query("q", filters={key: "someone-else"}) # session_id is a normal filter for query (no dedicated param) — allowed. await obs_scope.aio.query("q", filters={"session_id": "some-session"}) @@ -836,6 +836,6 @@ async def test_query_rejects_reserved_scope_filter_keys( target = honcho_client.peer(id="test-obs-reserved-query-target") obs_scope = observer.conclusions_of(target) for key in reserved: - with pytest.raises(ValueError, match="managed by this conclusion scope"): + with pytest.raises(ValueError, match="managed by this conclusions view"): obs_scope.query("q", filters={key: "someone-else"}) obs_scope.query("q", filters={"session_id": "some-session"}) diff --git a/tests/sdk/test_scope_options.py b/tests/sdk/test_scope_options.py new file mode 100644 index 00000000..fddfe51d --- /dev/null +++ b/tests/sdk/test_scope_options.py @@ -0,0 +1,236 @@ +"""Unit tests for the SDK's scope / session-allowlist option handling. + +Pure logic — no server, no database. These pin the wire translation the server +expects, so a rename or a shape change fails here rather than as a 422 at runtime. +""" + +import sys +from pathlib import Path + +import pytest + +# Add the SDK src to the path to allow imports +sdk_src_path = Path(__file__).parent.parent.parent / "sdks" / "python" / "src" +sys.path.insert(0, str(sdk_src_path)) + +from sdks.python.src.honcho.utils.scopes import ( # noqa: E402 + MAX_SCOPES_PER_OPTION, + MAX_SESSION_ALLOWLIST_ENTRIES, + MAX_SESSIONS_PER_ADD, + resolve_scope_membership, + resolve_scope_option, + resolve_scope_session, + scope_context_fields, + scope_recall_fields, + validate_scope_id, +) + + +def context_fields(**overrides: object) -> dict[str, object]: + """Call scope_context_fields with the neutral defaults filled in.""" + kwargs: dict[str, object] = { + "scope": None, + "sessions": None, + "peer_target": "user", + "peer_perspective": None, + "limit_to_session": False, + } + kwargs.update(overrides) + return scope_context_fields(**kwargs) # pyright: ignore[reportArgumentType] + + +class TestValidateScopeId: + def test_accepts_a_plain_name(self): + assert validate_scope_id("therapy") == "therapy" + + def test_rejects_the_reserved_prefix_by_name(self): + # 'scope.therapy' violates both the prefix rule and the charset. The + # prefix message is the actionable one, so it must be the one raised. + with pytest.raises(ValueError, match="reserved prefix"): + validate_scope_id("scope.therapy") + + def test_rejects_characters_outside_the_charset(self): + with pytest.raises(ValueError, match="must match pattern"): + validate_scope_id("my scope") + + def test_rejects_empty(self): + with pytest.raises(ValueError, match="between 1 and"): + validate_scope_id("") + + def test_rejects_a_name_that_leaves_no_room_for_the_prefix(self): + # 512 - len("scope.") is the ceiling: the server stores the name prefixed + # into a 512-character peer name. + with pytest.raises(ValueError, match="between 1 and"): + validate_scope_id("a" * 507) + + +class TestResolveScopeOption: + def test_a_single_scope_stays_a_string(self): + # The shapes are not interchangeable to the server: one scope reads that + # scope's own view, a list restricts to the union of member sessions. + assert resolve_scope_option("therapy") == "therapy" + + def test_a_sequence_becomes_a_list(self): + assert resolve_scope_option(["therapy", "work"]) == ["therapy", "work"] + + def test_rejects_an_empty_sequence(self): + with pytest.raises(ValueError, match="at least one scope"): + resolve_scope_option([]) + + def test_rejects_an_over_cap_sequence(self): + with pytest.raises(ValueError, match="at most"): + resolve_scope_option([f"s{i}" for i in range(MAX_SCOPES_PER_OPTION + 1)]) + + +class TestScopeRecallFields: + def test_neither_option_contributes_nothing(self): + assert scope_recall_fields(scope=None, sessions=None) == {} + + def test_sessions_becomes_a_session_id_filter(self): + # `sessions` is sugar. It must never reach the wire as its own key — + # the server rejects unknown keys with a 422. + fields = scope_recall_fields(scope=None, sessions=["a", "b"]) + assert fields == {"filters": {"session_id": ["a", "b"]}} + assert "sessions" not in fields + + def test_scope_passes_through_under_its_own_key(self): + assert scope_recall_fields(scope="therapy", sessions=None) == { + "scope": "therapy" + } + + def test_scope_and_sessions_are_mutually_exclusive(self): + with pytest.raises(ValueError, match="mutually exclusive"): + scope_recall_fields(scope="therapy", sessions=["a"]) + + def test_scope_and_a_single_session_are_mutually_exclusive(self): + with pytest.raises(ValueError, match="mutually exclusive"): + scope_recall_fields(scope="therapy", sessions=None, session_id="a") + + def test_sessions_composes_with_a_single_session(self): + # Unlike `scope`, an allowlist may accompany a session_id — the server + # only requires that the session be inside the allowlist. + assert scope_recall_fields(scope=None, sessions=["a", "b"], session_id="a") == { + "filters": {"session_id": ["a", "b"]} + } + + def test_rejects_an_empty_allowlist(self): + # An empty allowlist is fail-closed server-side (recalls nothing), which + # is never what `sessions=[]` intends. + with pytest.raises(ValueError, match="at least one session"): + scope_recall_fields(scope=None, sessions=[]) + + def test_rejects_an_over_cap_allowlist(self): + with pytest.raises(ValueError, match="at most"): + scope_recall_fields( + scope=None, + sessions=[f"s{i}" for i in range(MAX_SESSION_ALLOWLIST_ENTRIES + 1)], + ) + + def test_resolves_objects_with_an_id(self): + class FakeSession: + id: str = "session-a" + + fields = scope_recall_fields(scope=None, sessions=[FakeSession()]) # pyright: ignore[reportArgumentType] + assert fields == {"filters": {"session_id": ["session-a"]}} + + +class TestScopeContextFields: + """The context route takes these as query params, not as a `filters` body.""" + + def test_neither_option_contributes_nothing(self): + assert context_fields() == {} + + def test_scope_passes_through(self): + assert context_fields(scope="therapy") == {"scope": "therapy"} + + def test_sessions_stays_a_plain_list(self): + # Not wrapped in `filters` — this route reads a repeated query parameter. + assert context_fields(sessions=["a", "b"]) == {"sessions": ["a", "b"]} + + def test_scope_and_peer_perspective_are_mutually_exclusive(self): + with pytest.raises(ValueError, match="mutually exclusive"): + context_fields(scope="therapy", peer_perspective="assistant") + + def test_scope_and_sessions_are_mutually_exclusive(self): + with pytest.raises(ValueError, match="mutually exclusive"): + context_fields(scope="therapy", sessions=["a"]) + + def test_sessions_and_limit_to_session_are_mutually_exclusive(self): + with pytest.raises(ValueError, match="mutually exclusive"): + context_fields(sessions=["a"], limit_to_session=True) + + @pytest.mark.parametrize("option", [{"scope": "therapy"}, {"sessions": ["a"]}]) + def test_either_option_requires_a_peer_target(self, option: dict[str, object]): + # Both only reach the representation, and there is none without a target. + # Refused rather than accepted and silently ignored. + with pytest.raises(ValueError, match="peer_target"): + context_fields(peer_target=None, **option) + + def test_limit_to_session_alone_is_untouched(self): + # The neutral case must not start emitting a scope/sessions key. + assert context_fields(limit_to_session=True) == {} + + +class TestResolveScopeMembership: + def test_resolves_ids_and_objects_in_order(self): + class FakeSession: + id: str = "session-b" + + assert resolve_scope_membership(["session-a", FakeSession()]) == [ # pyright: ignore[reportArgumentType] + "session-a", + "session-b", + ] + + def test_rejects_empty(self): + with pytest.raises(ValueError, match="At least one session"): + resolve_scope_membership([]) + + def test_rejects_over_the_per_call_cap_rather_than_chunking(self): + with pytest.raises(ValueError, match="At most"): + resolve_scope_membership([f"s{i}" for i in range(MAX_SESSIONS_PER_ADD + 1)]) + + def test_rejects_a_malformed_id(self): + with pytest.raises(ValueError, match="must match pattern"): + resolve_scope_membership(["ok-session", "valid-session?typo"]) + + +class TestResolveScopeSession: + """Guards the ID that gets interpolated into a scope membership URL path.""" + + def test_resolves_a_plain_id(self): + assert resolve_scope_session("session-a") == "session-a" + + def test_resolves_an_object(self): + class FakeSession: + id: str = "session-a" + + assert resolve_scope_session(FakeSession()) == "session-a" # pyright: ignore[reportArgumentType] + + @pytest.mark.parametrize( + "malformed", + [ + "valid-session?typo", # would address `valid-session` + a query string + "valid-session/../other", # would climb the path + "valid session", + "", + ], + ) + def test_rejects_ids_that_would_alter_the_request_path(self, malformed: str): + # This value lands in a DELETE path. An unvalidated id silently changes + # which session is removed, and removal triggers reconciliation against + # whatever it hits. + with pytest.raises(ValueError, match="Session ID"): + resolve_scope_session(malformed) + + +def test_deprecated_conclusion_scope_aliases_still_resolve(): + """The rename keeps working for callers on the old name.""" + from sdks.python.src.honcho import ( + ConclusionScope, + ConclusionScopeAio, + ConclusionsView, + ConclusionsViewAio, + ) + + assert ConclusionScope is ConclusionsView + assert ConclusionScopeAio is ConclusionsViewAio