feat(sdk): add workspace-wide conclusions list/get

This commit is contained in:
ajspig 2026-08-18 12:49:44 -04:00
parent 94ace5e3f5
commit 5913c7893c
9 changed files with 676 additions and 179 deletions

View File

@ -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",

View File

@ -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()

View File

@ -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.

View File

@ -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.

View File

@ -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: {} })

View File

@ -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.
*/

View File

@ -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<ConclusionResponse> {
// 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<PageResponse<ConclusionResponse>> {
// 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<void>
readonly workspaceId: string
constructor(
http: HonchoHTTPClient,
workspaceId: string,
ensureWorkspace: () => Promise<void> = async () => undefined
) {
this._http = http
this.workspaceId = workspaceId
this._ensureWorkspace = ensureWorkspace
}
private async _list(params: {
filters?: Record<string, unknown>
page?: number
size?: number
reverse?: boolean
}): Promise<PageResponse<ConclusionResponse>> {
await this._ensureWorkspace()
return this._http.post<PageResponse<ConclusionResponse>>(
`/${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<string, unknown>
reverse?: boolean
}): Promise<Page<Conclusion, ConclusionResponse>> {
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<PageResponse<ConclusionResponse>> => {
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<Conclusion> {
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<Conclusion[]> {
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}')`
}
}

View File

@ -7,6 +7,7 @@ export {
Conclusion,
type ConclusionCreateParams,
ConclusionScope,
WorkspaceConclusions,
} from './conclusions'
// HTTP infrastructure
export {

View File

@ -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)