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