diff --git a/sdks/python/src/honcho/__init__.py b/sdks/python/src/honcho/__init__.py index 239f35ef..031567a4 100644 --- a/sdks/python/src/honcho/__init__.py +++ b/sdks/python/src/honcho/__init__.py @@ -41,11 +41,17 @@ from importlib.metadata import PackageNotFoundError, version from pathlib import Path import re -from .aio import ConclusionScopeAio, HonchoAio, PeerAio, SessionAio +from .aio import ( + ConclusionScopeAio, + HonchoAio, + PeerAio, + SessionAio, + WorkspaceConclusionsAio, +) from .api_types import MessageCreateParams from .base import PeerBase, SessionBase from .client import Honcho -from .conclusions import Conclusion, ConclusionScope +from .conclusions import Conclusion, ConclusionScope, WorkspaceConclusions from .http.exceptions import ( APIError, AuthenticationError, @@ -96,12 +102,14 @@ __all__ = [ # Domain classes "Conclusion", "ConclusionScope", + "WorkspaceConclusions", "Message", "MessageCreateParams", "Peer", "Session", # Aio views (for type hints) "ConclusionScopeAio", + "WorkspaceConclusionsAio", "HonchoAio", "PeerAio", "SessionAio", diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py index 8fae9301..c70bf56b 100644 --- a/sdks/python/src/honcho/aio.py +++ b/sdks/python/src/honcho/aio.py @@ -48,6 +48,7 @@ from .api_types import ( ) from .base import PeerBase, SessionBase from .conclusions import ( + _LIST_PAGE_CAP, _SCOPE_RESERVED, Conclusion, _reject_reserved_filter_keys, @@ -68,7 +69,7 @@ from .utils import ( if TYPE_CHECKING: from .client import Honcho - from .conclusions import ConclusionScope + from .conclusions import ConclusionScope, WorkspaceConclusions from .conclusions import ConclusionCreateParams from .peer import Peer, TResponseFormat, serialize_response_format @@ -81,6 +82,7 @@ __all__ = [ "PeerAio", "SessionAio", "ConclusionScopeAio", + "WorkspaceConclusionsAio", ] @@ -447,6 +449,11 @@ class HonchoAio(AsyncMetadataConfigMixin): ) return QueueStatusResponse.model_validate(data) + @property + def conclusions(self) -> "WorkspaceConclusionsAio": + """Workspace-wide conclusions (no observer/observed pair implied).""" + return self._honcho.conclusions.aio + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) async def schedule_dream( self, @@ -1488,6 +1495,118 @@ class SessionAio(AsyncMetadataConfigMixin): return Message.from_api_response(MessageResponse.model_validate(data)) +async def _aget_conclusion( + honcho: "Honcho", + *, + filters: dict[str, Any], +) -> Conclusion: + await honcho._ensure_workspace_async() + data = await honcho._async_http_client.post( + routes.conclusions_list(honcho.workspace_id), + body={"filters": filters}, + query={"page": 1, "size": 1}, + ) + items = data.get("items", []) + if not items: + raise NotFoundError("Conclusion not found") + return Conclusion.from_api_response(ConclusionResponse.model_validate(items[0])) + + +async def _aget_many_conclusions( + honcho: "Honcho", + conclusion_ids: list[str], + extra_filters: dict[str, Any] | None = None, +) -> list[Conclusion]: + if not conclusion_ids: + return [] + await honcho._ensure_workspace_async() + conclusions: list[Conclusion] = [] + for start in range(0, len(conclusion_ids), _LIST_PAGE_CAP): + chunk = conclusion_ids[start : start + _LIST_PAGE_CAP] + filters: dict[str, Any] = {"id": {"in": chunk}, **(extra_filters or {})} + data = await honcho._async_http_client.post( + routes.conclusions_list(honcho.workspace_id), + body={"filters": filters}, + query={"page": 1, "size": len(chunk)}, + ) + conclusions.extend( + Conclusion.from_api_response(ConclusionResponse.model_validate(item)) + for item in data.get("items", []) + ) + return conclusions + + +async def _alist_conclusions( + honcho: "Honcho", + filters: dict[str, Any] | None, + *, + page: int, + size: int, + reverse: bool, +) -> AsyncPage[ConclusionResponse, Conclusion]: + await honcho._ensure_workspace_async() + body: dict[str, Any] | None = {"filters": filters} if filters else None + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" + data = await honcho._async_http_client.post( + routes.conclusions_list(honcho.workspace_id), + body=body, + query=query, + ) + + def transform(response: ConclusionResponse) -> Conclusion: + return Conclusion.from_api_response(response) + + async def fetch_next( + next_page: int, + ) -> AsyncPage[ConclusionResponse, Conclusion]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" + next_data = await honcho._async_http_client.post( + routes.conclusions_list(honcho.workspace_id), + body=body, + query=next_query, + ) + return AsyncPage(next_data, ConclusionResponse, transform, fetch_next) + + return AsyncPage(data, ConclusionResponse, transform, fetch_next) + + +class WorkspaceConclusionsAio: + """Async view of workspace-wide conclusions. Access via ``honcho.aio.conclusions``.""" + + __slots__: ClassVar[tuple[str, ...]] = ("_workspace",) + _workspace: "WorkspaceConclusions" + + def __init__(self, workspace: "WorkspaceConclusions") -> None: + self._workspace = workspace + + async def list( + self, + page: int = 1, + size: int = 50, + *, + filters: dict[str, Any] | None = None, + reverse: bool = False, + ) -> AsyncPage[ConclusionResponse, Conclusion]: + """List conclusions in this workspace asynchronously.""" + return await _alist_conclusions( + self._workspace._honcho, filters, page=page, size=size, reverse=reverse + ) + + async def get(self, conclusion_id: str) -> Conclusion: + """Get a single conclusion by ID, anywhere in the workspace.""" + return await _aget_conclusion( + self._workspace._honcho, filters={"id": conclusion_id} + ) + + async def get_many(self, conclusion_ids: list[str]) -> list[Conclusion]: + """Get multiple conclusions by ID. Missing IDs are omitted.""" + return await _aget_many_conclusions(self._workspace._honcho, conclusion_ids) + + class ConclusionScopeAio: """ Async view of a ConclusionScope. @@ -1522,7 +1641,6 @@ class ConclusionScopeAio: _reject_reserved_filter_keys( filters, _SCOPE_RESERVED + ("session", "session_id") ) - await self._scope._honcho._ensure_workspace_async() resolved_session_id = resolve_id(session) filters = { "observer_id": self._scope.observer, @@ -1530,34 +1648,10 @@ class ConclusionScopeAio: **({"session_id": resolved_session_id} if resolved_session_id else {}), **(filters or {}), } - - 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), - body={"filters": filters}, - query=query, + return await _alist_conclusions( + self._scope._honcho, filters, page=page, size=size, reverse=reverse ) - def transform(response: ConclusionResponse) -> Conclusion: - return Conclusion.from_api_response(response) - - async def fetch_next( - next_page: int, - ) -> AsyncPage[ConclusionResponse, Conclusion]: - 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), - body={"filters": filters}, - query=next_query, - ) - return AsyncPage(next_data, ConclusionResponse, transform, fetch_next) - - return AsyncPage(data, ConclusionResponse, transform, fetch_next) - async def query( self, query: str, @@ -1610,18 +1704,18 @@ class ConclusionScopeAio: (`source_ids`, `times_derived`) Raises: - NotFoundError: If no conclusion with the given ID exists + NotFoundError: If no conclusion with the given ID exists in this + observer/observed pair. Use ``honcho.aio.conclusions.get`` for a + workspace-wide lookup. """ - await self._scope._honcho._ensure_workspace_async() - data = await self._scope._honcho._async_http_client.post( - routes.conclusions_list(self._scope.workspace_id), - body={"filters": {"id": conclusion_id}}, - query={"page": 1, "size": 1}, + return await _aget_conclusion( + self._scope._honcho, + filters={ + "id": conclusion_id, + "observer_id": self._scope.observer, + "observed_id": self._scope.observed, + }, ) - items = data.get("items", []) - if not items: - raise NotFoundError("Conclusion not found") - return Conclusion.from_api_response(ConclusionResponse.model_validate(items[0])) async def get_many(self, conclusion_ids: list[str]) -> list[Conclusion]: """Get multiple conclusions by ID in a single call asynchronously. @@ -1635,22 +1729,14 @@ class ConclusionScopeAio: omitted, so the result may be shorter than the input (order is not guaranteed to match the input either). """ - if not conclusion_ids: - return [] - await self._scope._honcho._ensure_workspace_async() - conclusions: list[Conclusion] = [] - for start in range(0, len(conclusion_ids), 100): - chunk = conclusion_ids[start : start + 100] - data = await self._scope._honcho._async_http_client.post( - routes.conclusions_list(self._scope.workspace_id), - body={"filters": {"id": {"in": chunk}}}, - query={"page": 1, "size": len(chunk)}, - ) - conclusions.extend( - Conclusion.from_api_response(ConclusionResponse.model_validate(item)) - for item in data.get("items", []) - ) - return conclusions + return await _aget_many_conclusions( + self._scope._honcho, + conclusion_ids, + extra_filters={ + "observer_id": self._scope.observer, + "observed_id": self._scope.observed, + }, + ) async def derived( self, @@ -1672,42 +1758,25 @@ class ConclusionScopeAio: size: Number of results per page. Default: 50. reverse: If True, reverses the default newest-first ordering. - Equivalent to ``list`` with a ``parent_id`` filter; an unknown - ``conclusion_id`` yields an empty page rather than an error. + Equivalent to ``list`` with a ``parent_id`` filter, restricted to this + observer/observed pair. An unknown ``conclusion_id`` yields an empty + page rather than an error. Returns: Paginated response containing Conclusion objects """ - await self._scope._honcho._ensure_workspace_async() - body: dict[str, Any] = {"filters": {"parent_id": conclusion_id}} - - def build_query(page_num: int) -> dict[str, Any]: - query: dict[str, Any] = {"page": page_num, "size": size} - if reverse: - query["reverse"] = "true" - return query - - data = await self._scope._honcho._async_http_client.post( - routes.conclusions_list(self._scope.workspace_id), - body=body, - query=build_query(page), + return await _alist_conclusions( + self._scope._honcho, + { + "parent_id": conclusion_id, + "observer_id": self._scope.observer, + "observed_id": self._scope.observed, + }, + page=page, + size=size, + reverse=reverse, ) - def transform(response: ConclusionResponse) -> Conclusion: - return Conclusion.from_api_response(response) - - async def fetch_next( - next_page: int, - ) -> AsyncPage[ConclusionResponse, Conclusion]: - next_data = await self._scope._honcho._async_http_client.post( - routes.conclusions_list(self._scope.workspace_id), - body=body, - query=build_query(next_page), - ) - return AsyncPage(next_data, ConclusionResponse, transform, fetch_next) - - return AsyncPage(data, ConclusionResponse, transform, fetch_next) - async def delete(self, conclusion_id: str) -> None: """Delete a conclusion by ID asynchronously.""" await self._scope._honcho._ensure_workspace_async() diff --git a/sdks/python/src/honcho/client.py b/sdks/python/src/honcho/client.py index 06a2887c..8b241b60 100644 --- a/sdks/python/src/honcho/client.py +++ b/sdks/python/src/honcho/client.py @@ -23,6 +23,7 @@ from .api_types import ( WorkspaceResponse, ) from .base import PeerBase, SessionBase +from .conclusions import WorkspaceConclusions from .http import AsyncHonchoHTTPClient, HonchoHTTPClient, routes from .message import Message from .mixins import MetadataConfigMixin @@ -689,6 +690,23 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul }, ) + @property + def conclusions(self) -> WorkspaceConclusions: + """Workspace-wide conclusions. No observer/observed pair is implied. + + Use this to list or look up conclusions across the workspace. Pair- + scoped create/query/delete stay on ``peer.conclusions``. + + Example: + ```python + honcho.conclusions.list() + honcho.conclusions.list(filters={"observed_id": "alice"}) + honcho.conclusions.list(filters={"session_id": session.id}) + honcho.conclusions.get(conclusion_id) + ``` + """ + return WorkspaceConclusions(self) + def __repr__(self) -> str: """ Return a string representation of the Honcho client. diff --git a/sdks/python/src/honcho/conclusions.py b/sdks/python/src/honcho/conclusions.py index 078689b3..d3ec4620 100644 --- a/sdks/python/src/honcho/conclusions.py +++ b/sdks/python/src/honcho/conclusions.py @@ -15,15 +15,18 @@ from .pagination import SyncPage from .utils import resolve_id if TYPE_CHECKING: - from .aio import ConclusionScopeAio + from .aio import ConclusionScopeAio, WorkspaceConclusionsAio from .client import Honcho __all__ = [ "Conclusion", "ConclusionScope", "ConclusionCreateParams", + "WorkspaceConclusions", ] +_LIST_PAGE_CAP = 100 + # 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") @@ -53,6 +56,89 @@ def _reject_reserved_filter_keys( ) +def _list_query(page: int, size: int, reverse: bool) -> dict[str, Any]: + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" + return query + + +def _conclusion_from_item(item: Any) -> Conclusion: + return Conclusion.from_api_response(ConclusionResponse.model_validate(item)) + + +def _get_conclusion( + honcho: "Honcho", + *, + filters: dict[str, Any], +) -> Conclusion: + honcho._ensure_workspace() + data = honcho._http.post( + routes.conclusions_list(honcho.workspace_id), + body={"filters": filters}, + query={"page": 1, "size": 1}, + ) + items = data.get("items", []) + if not items: + raise NotFoundError("Conclusion not found") + return _conclusion_from_item(items[0]) + + +def _get_many_conclusions( + honcho: "Honcho", + conclusion_ids: list[str], + extra_filters: dict[str, Any] | None = None, +) -> list[Conclusion]: + if not conclusion_ids: + return [] + honcho._ensure_workspace() + conclusions: list[Conclusion] = [] + for start in range(0, len(conclusion_ids), _LIST_PAGE_CAP): + chunk = conclusion_ids[start : start + _LIST_PAGE_CAP] + filters: dict[str, Any] = {"id": {"in": chunk}, **(extra_filters or {})} + data = honcho._http.post( + routes.conclusions_list(honcho.workspace_id), + body={"filters": filters}, + query={"page": 1, "size": len(chunk)}, + ) + conclusions.extend( + _conclusion_from_item(item) for item in data.get("items", []) + ) + return conclusions + + +def _list_conclusions( + honcho: "Honcho", + filters: dict[str, Any] | None, + *, + page: int, + size: int, + reverse: bool, +) -> SyncPage[ConclusionResponse, Conclusion]: + honcho._ensure_workspace() + body: dict[str, Any] | None = {"filters": filters} if filters else None + data = honcho._http.post( + routes.conclusions_list(honcho.workspace_id), + body=body, + query=_list_query(page, size, reverse), + ) + + def transform(response: ConclusionResponse) -> Conclusion: + return Conclusion.from_api_response(response) + + def fetch_next( + next_page: int, + ) -> SyncPage[ConclusionResponse, Conclusion]: + next_data = honcho._http.post( + routes.conclusions_list(honcho.workspace_id), + body=body, + query=_list_query(next_page, size, reverse), + ) + return SyncPage(next_data, ConclusionResponse, transform, fetch_next) + + return SyncPage(data, ConclusionResponse, transform, fetch_next) + + class ConclusionCreateParams(BaseModel): content: str session_id: str | None = None @@ -139,6 +225,66 @@ class Conclusion: return self.content +class WorkspaceConclusions: + """Workspace-wide conclusion access. No observer/observed pair is implied. + + Use this to list or look up conclusions across the workspace, then filter + down to a peer or session. Pair-scoped create/query/delete stay on + ``peer.conclusions`` / ``peer.conclusions_of(target)``. + + Example: + ```python + honcho.conclusions.list() # whole workspace + honcho.conclusions.list(filters={"observed_id": "alice"}) + honcho.conclusions.list(filters={"session_id": session.id}) + honcho.conclusions.get(conclusion_id) + honcho.conclusions.get_many(node.source_ids or []) + ``` + """ + + _honcho: "Honcho" + workspace_id: str + + def __init__(self, honcho: "Honcho") -> None: + self._honcho = honcho + self.workspace_id = honcho.workspace_id + + @property + def aio(self) -> "WorkspaceConclusionsAio": + from .aio import WorkspaceConclusionsAio + + return WorkspaceConclusionsAio(self) + + def list( + self, + page: int = 1, + size: int = 50, + *, + filters: dict[str, Any] | None = None, + reverse: bool = False, + ) -> SyncPage[ConclusionResponse, Conclusion]: + """List conclusions in this workspace. + + Unlike ``peer.conclusions.list``, no observer/observed pair is injected. + Pass ``filters`` to narrow the view — e.g. ``{"observed_id": "alice"}`` + or ``{"session_id": "..."}``. + """ + return _list_conclusions( + self._honcho, filters, page=page, size=size, reverse=reverse + ) + + def get(self, conclusion_id: str) -> Conclusion: + """Get a single conclusion by ID, anywhere in the workspace.""" + return _get_conclusion(self._honcho, filters={"id": conclusion_id}) + + def get_many(self, conclusion_ids: list[str]) -> list[Conclusion]: + """Get multiple conclusions by ID. Missing IDs are omitted.""" + return _get_many_conclusions(self._honcho, conclusion_ids) + + def __repr__(self) -> str: + return f"WorkspaceConclusions(workspace_id={self.workspace_id!r})" + + class ConclusionScope: """ Scoped access to conclusions for a specific observer/observed relationship. @@ -243,7 +389,6 @@ class ConclusionScope: _reject_reserved_filter_keys( filters, _SCOPE_RESERVED + ("session", "session_id") ) - self._honcho._ensure_workspace() resolved_session_id = resolve_id(session) filters = { "observer_id": self.observer, @@ -251,34 +396,10 @@ class ConclusionScope: **({"session_id": resolved_session_id} if resolved_session_id else {}), **(filters or {}), } - - query: dict[str, Any] = {"page": page, "size": size} - if reverse: - query["reverse"] = "true" - data = self._honcho._http.post( - routes.conclusions_list(self.workspace_id), - body={"filters": filters}, - query=query, + return _list_conclusions( + self._honcho, filters, page=page, size=size, reverse=reverse ) - def transform(response: ConclusionResponse) -> Conclusion: - return Conclusion.from_api_response(response) - - def fetch_next( - next_page: int, - ) -> SyncPage[ConclusionResponse, Conclusion]: - next_query: dict[str, Any] = {"page": next_page, "size": size} - if reverse: - next_query["reverse"] = "true" - next_data = self._honcho._http.post( - routes.conclusions_list(self.workspace_id), - body={"filters": filters}, - query=next_query, - ) - return SyncPage(next_data, ConclusionResponse, transform, fetch_next) - - return SyncPage(data, ConclusionResponse, transform, fetch_next) - def query( self, query: str, @@ -342,18 +463,18 @@ class ConclusionScope: (`source_ids`, `times_derived`) Raises: - NotFoundError: If no conclusion with the given ID exists + NotFoundError: If no conclusion with the given ID exists in this + observer/observed pair. Use ``honcho.conclusions.get`` for a + workspace-wide lookup. """ - self._honcho._ensure_workspace() - data = self._honcho._http.post( - routes.conclusions_list(self.workspace_id), - body={"filters": {"id": conclusion_id}}, - query={"page": 1, "size": 1}, + return _get_conclusion( + self._honcho, + filters={ + "id": conclusion_id, + "observer_id": self.observer, + "observed_id": self.observed, + }, ) - items = data.get("items", []) - if not items: - raise NotFoundError("Conclusion not found") - return Conclusion.from_api_response(ConclusionResponse.model_validate(items[0])) def get_many(self, conclusion_ids: list[str]) -> list[Conclusion]: """ @@ -371,22 +492,14 @@ class ConclusionScope: omitted, so the result may be shorter than the input (order is not guaranteed to match the input either). """ - if not conclusion_ids: - return [] - self._honcho._ensure_workspace() - conclusions: list[Conclusion] = [] - for start in range(0, len(conclusion_ids), 100): - chunk = conclusion_ids[start : start + 100] - data = self._honcho._http.post( - routes.conclusions_list(self.workspace_id), - body={"filters": {"id": {"in": chunk}}}, - query={"page": 1, "size": len(chunk)}, - ) - conclusions.extend( - Conclusion.from_api_response(ConclusionResponse.model_validate(item)) - for item in data.get("items", []) - ) - return conclusions + return _get_many_conclusions( + self._honcho, + conclusion_ids, + extra_filters={ + "observer_id": self.observer, + "observed_id": self.observed, + }, + ) def derived( self, @@ -407,42 +520,25 @@ class ConclusionScope: size: Number of results per page. Default: 50. reverse: If True, reverses the default newest-first ordering. - Equivalent to ``list`` with a ``parent_id`` filter; an unknown - ``conclusion_id`` yields an empty page rather than an error. + Equivalent to ``list`` with a ``parent_id`` filter, restricted to this + observer/observed pair. An unknown ``conclusion_id`` yields an empty + page rather than an error. Returns: Paginated response containing Conclusion objects """ - self._honcho._ensure_workspace() - body: dict[str, Any] = {"filters": {"parent_id": conclusion_id}} - - def build_query(page_num: int) -> dict[str, Any]: - query: dict[str, Any] = {"page": page_num, "size": size} - if reverse: - query["reverse"] = "true" - return query - - data = self._honcho._http.post( - routes.conclusions_list(self.workspace_id), - body=body, - query=build_query(page), + return _list_conclusions( + self._honcho, + { + "parent_id": conclusion_id, + "observer_id": self.observer, + "observed_id": self.observed, + }, + page=page, + size=size, + reverse=reverse, ) - def transform(response: ConclusionResponse) -> Conclusion: - return Conclusion.from_api_response(response) - - def fetch_next( - next_page: int, - ) -> SyncPage[ConclusionResponse, Conclusion]: - next_data = self._honcho._http.post( - routes.conclusions_list(self.workspace_id), - body=body, - query=build_query(next_page), - ) - return SyncPage(next_data, ConclusionResponse, transform, fetch_next) - - return SyncPage(data, ConclusionResponse, transform, fetch_next) - def delete(self, conclusion_id: str) -> None: """ Delete a conclusion by ID. diff --git a/sdks/typescript/__tests__/conclusions.test.ts b/sdks/typescript/__tests__/conclusions.test.ts index c351dde9..c0a7d67b 100644 --- a/sdks/typescript/__tests__/conclusions.test.ts +++ b/sdks/typescript/__tests__/conclusions.test.ts @@ -11,7 +11,8 @@ */ import { describe, test, expect, beforeAll, afterAll } from 'bun:test' -import { Honcho, Conclusion, ConclusionScope } from '../src' +import { Honcho, Conclusion, ConclusionScope, WorkspaceConclusions } from '../src' +import { NotFoundError } from '../src/http/errors' import { createTestClient, requireServer } from './setup' import { assertConclusionShape } from './helpers' @@ -400,6 +401,61 @@ describe('Conclusions', () => { // Derived Conclusions (list + parent_id filter) // =========================================================================== + describe('honcho.conclusions (workspace-wide)', () => { + test('lists and fetches across observer/observed pairs', async () => { + const alice = await client.peer('ws-conc-alice', { metadata: {} }) + const bob = await client.peer('ws-conc-bob', { metadata: {} }) + const session = await client.session('ws-conc-session', { metadata: {} }) + + const [aliceSelf] = await alice.conclusions.create({ + content: 'Alice self conclusion', + sessionId: session, + }) + const [aboutBob] = await alice.conclusionsOf(bob).create({ + content: 'Alice about Bob', + sessionId: session, + }) + + expect(client.conclusions).toBeInstanceOf(WorkspaceConclusions) + + const page = await client.conclusions.list({ size: 100 }) + const ids = new Set(page.items.map((c) => c.id)) + expect(ids.has(aliceSelf.id)).toBe(true) + expect(ids.has(aboutBob.id)).toBe(true) + + const aboutBobOnly = await client.conclusions.list({ + filters: { observed_id: bob.id }, + size: 100, + }) + expect(aboutBobOnly.items.every((c) => c.observedId === bob.id)).toBe(true) + expect(new Set(aboutBobOnly.items.map((c) => c.id)).has(aboutBob.id)).toBe( + true + ) + + const sessionPage = await client.conclusions.list({ + filters: { session_id: session.id }, + size: 100, + }) + const sessionIds = new Set(sessionPage.items.map((c) => c.id)) + expect(sessionIds.has(aliceSelf.id)).toBe(true) + expect(sessionIds.has(aboutBob.id)).toBe(true) + + const fetched = await client.conclusions.get(aboutBob.id) + expect(fetched.id).toBe(aboutBob.id) + expect(fetched.observerId).toBe(alice.id) + expect(fetched.observedId).toBe(bob.id) + + const batch = await client.conclusions.getMany([aliceSelf.id, aboutBob.id]) + expect(new Set(batch.map((c) => c.id))).toEqual( + new Set([aliceSelf.id, aboutBob.id]) + ) + + await expect(alice.conclusions.get(aboutBob.id)).rejects.toBeInstanceOf( + NotFoundError + ) + }) + }) + describe('derived() via parent_id filter', () => { test('leaf conclusion has no derived conclusions', async () => { const peer = await client.peer('derived-conclusion-peer', { metadata: {} }) diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index db8e237a..194b429e 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -1,4 +1,5 @@ import { API_VERSION } from './api-version' +import { WorkspaceConclusions } from './conclusions' import { HonchoHTTPClient } from './http/client' import { Message } from './message' import { Page } from './pagination' @@ -121,6 +122,18 @@ export class Honcho { return this._http } + /** + * Workspace-wide conclusions. No observer/observed pair is implied. + * + * Use this to list or look up conclusions across the workspace. Pair- + * scoped create/query/delete stay on `peer.conclusions`. + */ + get conclusions(): WorkspaceConclusions { + return new WorkspaceConclusions(this._http, this.workspaceId, () => + this._ensureWorkspace() + ) + } + /** * Get the base URL for the API. */ diff --git a/sdks/typescript/src/conclusions.ts b/sdks/typescript/src/conclusions.ts index 39fc39f3..69502041 100644 --- a/sdks/typescript/src/conclusions.ts +++ b/sdks/typescript/src/conclusions.ts @@ -16,6 +16,8 @@ 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`. */ +const LIST_PAGE_CAP = 100 + const SCOPE_RESERVED_KEYS = [ 'observer', 'observed', @@ -209,9 +211,13 @@ export class ConclusionScope { } private async _get(conclusionId: string): Promise { - // Equivalent to list with an `id` filter; there is no single-GET endpoint. + // Equivalent to list with an `id` filter, restricted to this pair. const response = await this._list({ - filters: { id: conclusionId }, + filters: { + id: conclusionId, + observer_id: this.observer, + observed_id: this.observed, + }, page: 1, size: 1, }) @@ -230,10 +236,13 @@ export class ConclusionScope { reverse?: boolean } ): Promise> { - // Equivalent to list with a parent_id filter; an unknown conclusionId - // yields an empty page rather than an error. + // Equivalent to list with a parent_id filter, restricted to this pair. return this._list({ - filters: { parent_id: conclusionId }, + filters: { + parent_id: conclusionId, + observer_id: this.observer, + observed_id: this.observed, + }, page: params.page, size: params.size, reverse: params.reverse, @@ -391,10 +400,14 @@ export class ConclusionScope { if (conclusionIds.length === 0) return [] const conclusions: Conclusion[] = [] // The list endpoint caps page size at 100 - for (let start = 0; start < conclusionIds.length; start += 100) { - const chunk = conclusionIds.slice(start, start + 100) + for (let start = 0; start < conclusionIds.length; start += LIST_PAGE_CAP) { + const chunk = conclusionIds.slice(start, start + LIST_PAGE_CAP) const response = await this._list({ - filters: { id: { in: chunk } }, + filters: { + id: { in: chunk }, + observer_id: this.observer, + observed_id: this.observed, + }, page: 1, size: chunk.length, }) @@ -510,3 +523,124 @@ export class ConclusionScope { return `ConclusionScope(workspaceId='${this.workspaceId}', observer='${this.observer}', observed='${this.observed}')` } } + +/** + * Workspace-wide conclusion access. No observer/observed pair is implied. + * + * Use this to list or look up conclusions across the workspace, then filter + * down to a peer or session. Pair-scoped create/query/delete stay on + * `peer.conclusions` / `peer.conclusionsOf(target)`. + */ +export class WorkspaceConclusions { + private _http: HonchoHTTPClient + private _ensureWorkspace: () => Promise + readonly workspaceId: string + + constructor( + http: HonchoHTTPClient, + workspaceId: string, + ensureWorkspace: () => Promise = async () => undefined + ) { + this._http = http + this.workspaceId = workspaceId + this._ensureWorkspace = ensureWorkspace + } + + private async _list(params: { + filters?: Record + page?: number + size?: number + reverse?: boolean + }): Promise> { + await this._ensureWorkspace() + return this._http.post>( + `/${API_VERSION}/workspaces/${this.workspaceId}/conclusions/list`, + { + body: params.filters ? { filters: params.filters } : undefined, + query: { + page: params.page, + size: params.size, + reverse: params.reverse ? 'true' : undefined, + }, + } + ) + } + + /** + * List conclusions in this workspace. + * + * Unlike `peer.conclusions.list`, no observer/observed pair is injected. + * Pass `filters` to narrow the view — e.g. `{ observed_id: 'alice' }` or + * `{ session_id: '...' }`. + */ + async list(options?: { + page?: number + size?: number + filters?: Record + reverse?: boolean + }): Promise> { + const filters = options?.filters + const reverse = options?.reverse + const response = await this._list({ + filters, + page: options?.page ?? 1, + size: options?.size ?? 50, + reverse, + }) + + const fetchNextPage = async ( + page: number, + size: number + ): Promise> => { + return this._list({ filters, page, size, reverse }) + } + + return new Page( + response, + (item) => Conclusion.fromApiResponse(item), + fetchNextPage + ) + } + + /** + * Get a single conclusion by ID, anywhere in the workspace. + */ + async get(conclusionId: string): Promise { + const response = await this._list({ + filters: { id: conclusionId }, + page: 1, + size: 1, + }) + const item = response.items?.[0] + if (!item) { + throw new NotFoundError('Conclusion not found') + } + return Conclusion.fromApiResponse(item) + } + + /** + * Get multiple conclusions by ID. Missing IDs are omitted. + */ + async getMany(conclusionIds: string[]): Promise { + if (conclusionIds.length === 0) return [] + const conclusions: Conclusion[] = [] + for (let start = 0; start < conclusionIds.length; start += LIST_PAGE_CAP) { + const chunk = conclusionIds.slice(start, start + LIST_PAGE_CAP) + const response = await this._list({ + filters: { id: { in: chunk } }, + page: 1, + size: chunk.length, + }) + conclusions.push( + ...(response.items ?? []).map((item) => + Conclusion.fromApiResponse(item) + ) + ) + } + return conclusions + } + + toString(): string { + return `WorkspaceConclusions(workspaceId='${this.workspaceId}')` + } +} diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 90bff9f2..64cceb86 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -7,6 +7,7 @@ export { Conclusion, type ConclusionCreateParams, ConclusionScope, + WorkspaceConclusions, } from './conclusions' // HTTP infrastructure export { diff --git a/tests/sdk/test_conclusions.py b/tests/sdk/test_conclusions.py index 0765b15d..0ce77338 100644 --- a/tests/sdk/test_conclusions.py +++ b/tests/sdk/test_conclusions.py @@ -7,7 +7,9 @@ from sdks.python.src.honcho.conclusions import ( Conclusion, ConclusionCreateParams, ConclusionScope, + WorkspaceConclusions, ) +from sdks.python.src.honcho.http import NotFoundError @pytest.mark.asyncio @@ -1023,3 +1025,103 @@ async def test_query_rejects_reserved_scope_filter_keys( with pytest.raises(ValueError, match="managed by this conclusion scope"): obs_scope.query("q", filters={key: "someone-else"}) obs_scope.query("q", filters={"session_id": "some-session"}) + + +@pytest.mark.asyncio +async def test_workspace_conclusions_list_and_get( + client_fixture: tuple[Honcho, str], +): + """honcho.conclusions lists and fetches across observer/observed pairs.""" + honcho_client, client_type = client_fixture + + if client_type == "async": + alice = await honcho_client.aio.peer(id="test-ws-conc-alice") + bob = await honcho_client.aio.peer(id="test-ws-conc-bob") + session = await honcho_client.aio.session(id="test-ws-conc-session") + await session.aio.add_messages( + [alice.message("hi from alice"), bob.message("hi from bob")] + ) + alice_conc = ( + await alice.conclusions.aio.create( + [{"content": "Alice self conclusion", "session_id": session.id}] + ) + )[0] + about_bob = ( + await alice.conclusions_of(bob).aio.create( + [{"content": "Alice about Bob", "session_id": session.id}] + ) + )[0] + + assert isinstance(honcho_client.conclusions, WorkspaceConclusions) + + page = await honcho_client.aio.conclusions.list(size=100) + ids = {c.id for c in page.items} + assert alice_conc.id in ids + assert about_bob.id in ids + + about_bob_only = await honcho_client.aio.conclusions.list( + filters={"observed_id": bob.id}, size=100 + ) + assert {c.id for c in about_bob_only.items} >= {about_bob.id} + assert all(c.observed_id == bob.id for c in about_bob_only.items) + + session_page = await honcho_client.aio.conclusions.list( + filters={"session_id": session.id}, size=100 + ) + assert {c.id for c in session_page.items} >= {alice_conc.id, about_bob.id} + + fetched = await honcho_client.aio.conclusions.get(about_bob.id) + assert fetched.id == about_bob.id + assert fetched.observer_id == alice.id + assert fetched.observed_id == bob.id + + batch = await honcho_client.aio.conclusions.get_many( + [alice_conc.id, about_bob.id] + ) + assert {c.id for c in batch} == {alice_conc.id, about_bob.id} + + # Scoped get cannot see a conclusion from a different pair. + with pytest.raises(NotFoundError): + await alice.conclusions.aio.get(about_bob.id) + else: + alice = honcho_client.peer(id="test-ws-conc-alice") + bob = honcho_client.peer(id="test-ws-conc-bob") + session = honcho_client.session(id="test-ws-conc-session") + session.add_messages( + [alice.message("hi from alice"), bob.message("hi from bob")] + ) + alice_conc = alice.conclusions.create( + [{"content": "Alice self conclusion", "session_id": session.id}] + )[0] + about_bob = alice.conclusions_of(bob).create( + [{"content": "Alice about Bob", "session_id": session.id}] + )[0] + + assert isinstance(honcho_client.conclusions, WorkspaceConclusions) + + page = honcho_client.conclusions.list(size=100) + ids = {c.id for c in page.items} + assert alice_conc.id in ids + assert about_bob.id in ids + + about_bob_only = honcho_client.conclusions.list( + filters={"observed_id": bob.id}, size=100 + ) + assert {c.id for c in about_bob_only.items} >= {about_bob.id} + assert all(c.observed_id == bob.id for c in about_bob_only.items) + + session_page = honcho_client.conclusions.list( + filters={"session_id": session.id}, size=100 + ) + assert {c.id for c in session_page.items} >= {alice_conc.id, about_bob.id} + + fetched = honcho_client.conclusions.get(about_bob.id) + assert fetched.id == about_bob.id + assert fetched.observer_id == alice.id + assert fetched.observed_id == bob.id + + batch = honcho_client.conclusions.get_many([alice_conc.id, about_bob.id]) + assert {c.id for c in batch} == {alice_conc.id, about_bob.id} + + with pytest.raises(NotFoundError): + alice.conclusions.get(about_bob.id)