From 14538cfc906c1d209983f69c3a703485b452f3c4 Mon Sep 17 00:00:00 2001 From: ajspig <46900795+ajspig@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:48:01 -0400 Subject: [PATCH] Abigail/conclusions level filter (#851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(conclusions): expose reasoning level + allow filtering by level The `level` of a conclusion (explicit / deductive / inductive / contradiction) was filterable server-side but stripped from the `Conclusion` response and not surfaced in either SDK. This adds it end-to-end so callers can list explicit-only ("not dreamed on") conclusions without dropping to raw HTTP. - api: add `level` to the Conclusion response schema - python sdk: `ConclusionLevel` type, `level` on Conclusion/response, `level=` kwarg on ConclusionScope.list() and the async variant - ts sdk: `ConclusionLevel` type, `level` on Conclusion/response, `level` option on list() - tests: assert level is exposed; add level-filter list test Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(conclusions): use generic filters= on list() instead of level= kwarg Match the documented SDK convention (peers/sessions/messages all take a generic `filters` dict passed through to the same dynamic server-side filter logic) instead of a one-off `level=` kwarg. `level` filtering now works as `list(filters={"level": "explicit"})` alongside any other supported filter/operator. The `level` field on the Conclusion response (added in the previous commit) is kept — it's still not otherwise returned by the API. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(conclusions): allow filtering by level on query() in py + ts SDKs The branch's level-filter work exposed `filters=` on `list()` but left `query()` (semantic search) hardcoding `{observer, observed}`, so callers could filter the list endpoint by reasoning level but not semantic search — asymmetric in both SDKs. - Python: add keyword-only `filters` to `ConclusionScope.query` and `ConclusionScopeAio.query`, merged over the scope's observer/observed. - TypeScript: add optional `filters` arg to `ConclusionScope.query`, mirroring the existing `list()` change. The server `/conclusions/query` endpoint already honors filters in the body (verified against production), so this is purely SDK surface parity. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(filters): document filtering conclusions by reasoning level The using-filters page covered workspaces/peers/sessions/messages but not conclusions. Add a "Filtering Conclusions" section showing level-based filtering on both list() and query(), including the common "explicit only" (exclude dream-derived) case and the in[deductive,inductive] inverse. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(conclusions): simplify filter merge to a single dict spread Replace the merged_filters + if-block pattern in list()/query() (py sync, aio, ts) with a single dict spread that layers the caller's filters over the scope's observer/observed (and session). No behavior change — same merge order (caller wins) — just less code. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(conclusions): reject scope-managed keys in SDK conclusion filters The generic filters= argument on ConclusionScope.list()/query() spread user-supplied filters last, so a stray observer/observed/session key silently overrode the scope and returned data from a different peer pair. Add a fail-loud guard in both the Python and TypeScript SDKs that rejects scope-managed filter keys with a clear error, directing callers to peer.conclusions / conclusions_of(target) and the session= parameter. session_id remains a valid filter on query() (which has no dedicated session parameter). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> --- .../features/advanced/using-filters.mdx | 59 ++++++++++++ sdks/python/src/honcho/aio.py | 41 +++++++-- sdks/python/src/honcho/api_types.py | 5 + sdks/python/src/honcho/conclusions.py | 64 ++++++++++++- sdks/typescript/__tests__/conclusions.test.ts | 55 +++++++++++ sdks/typescript/src/conclusions.ts | 92 ++++++++++++++++--- sdks/typescript/src/index.ts | 1 + sdks/typescript/src/types/api.ts | 11 +++ src/schemas/api.py | 9 ++ tests/routes/test_conclusions.py | 75 +++++++++++++++ tests/sdk/test_conclusions.py | 70 ++++++++++++++ 11 files changed, 458 insertions(+), 24 deletions(-) diff --git a/docs/v3/documentation/features/advanced/using-filters.mdx b/docs/v3/documentation/features/advanced/using-filters.mdx index 306312ce..2a015c03 100644 --- a/docs/v3/documentation/features/advanced/using-filters.mdx +++ b/docs/v3/documentation/features/advanced/using-filters.mdx @@ -614,6 +614,65 @@ messages = session.messages(filters={ ``` +### Filtering Conclusions + +Conclusions are scoped to an observer/observed peer pair (accessed via +`peer.conclusions` for self-conclusions or `peer.conclusions_of(target)` for +conclusions about another peer). The observer and observed are filled in +automatically by the scope, so the `filters` you pass add to them. + +The most useful conclusion-specific field is `level`, the reasoning level: + +- `explicit` — extracted directly from messages +- `deductive` / `inductive` / `contradiction` — derived later during dreaming + +A common request is to surface only the directly-stated facts and exclude +anything inferred during dreaming — filter `level` to `explicit`: + + +```python Python +# Only conclusions extracted directly from messages (exclude dream-derived) +explicit = peer.conclusions.list(filters={"level": "explicit"}) + +# Only dream-derived conclusions +derived = peer.conclusions.list(filters={"level": {"in": ["deductive", "inductive"]}}) + +# Same filtering on semantic search +results = peer.conclusions.query( + "food preferences", + filters={"level": "deductive"}, +) + +# Conclusions about another peer, explicit only +bob_explicit = peer.conclusions_of("bob").list(filters={"level": "explicit"}) +``` + +```typescript TypeScript +(async () => { + // Only conclusions extracted directly from messages (exclude dream-derived) + const explicit = await peer.conclusions.list({ filters: { level: "explicit" } }); + + // Only dream-derived conclusions + const derived = await peer.conclusions.list({ + filters: { level: { in: ["deductive", "inductive"] } } + }); + + // Same filtering on semantic search (query, topK, distance, filters) + const results = await peer.conclusions.query( + "food preferences", + 10, + undefined, + { level: "deductive" } + ); + + // Conclusions about another peer, explicit only + const bobExplicit = await peer.conclusionsOf("bob").list({ + filters: { level: "explicit" } + }); +})(); +``` + + ## Error Handling Handle filter errors gracefully: diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py index 9ff27a12..2cf39ab1 100644 --- a/sdks/python/src/honcho/aio.py +++ b/sdks/python/src/honcho/aio.py @@ -47,7 +47,11 @@ from .api_types import ( WorkspaceResponse, ) from .base import PeerBase, SessionBase -from .conclusions import Conclusion +from .conclusions import ( + _SCOPE_RESERVED, + Conclusion, + _reject_reserved_filter_keys, +) from .http import routes from .message import Message from .mixins import AsyncMetadataConfigMixin @@ -1460,17 +1464,28 @@ class ConclusionScopeAio: size: int = 50, session: str | SessionBase | None = None, *, + filters: dict[str, Any] | None = None, reverse: bool = False, ) -> AsyncPage[ConclusionResponse, Conclusion]: - """List conclusions in this scope asynchronously.""" + """List conclusions in this scope asynchronously. + + Pass ``filters`` to add criteria merged with this scope's + observer/observed (and session, if given) — e.g. + ``{"level": "explicit"}`` to get only conclusions extracted directly + from messages (i.e. not derived during dreaming). See + https://honcho.dev/docs/v3/documentation/features/advanced/using-filters + """ + _reject_reserved_filter_keys( + filters, _SCOPE_RESERVED + ("session", "session_id") + ) await self._scope._honcho._ensure_workspace_async() resolved_session_id = resolve_id(session) - filters: dict[str, Any] = { + filters = { "observer_id": self._scope.observer, "observed_id": self._scope.observed, + **({"session_id": resolved_session_id} if resolved_session_id else {}), + **(filters or {}), } - if resolved_session_id: - filters["session_id"] = resolved_session_id query: dict[str, Any] = {"page": page, "size": size} if reverse: @@ -1504,12 +1519,24 @@ class ConclusionScopeAio: query: str, top_k: int = 10, distance: float | None = None, + *, + filters: dict[str, Any] | None = None, ) -> list[Conclusion]: - """Semantic search for conclusions asynchronously.""" + """Semantic search for conclusions asynchronously. + + Args: + query: The search query string + top_k: Maximum number of results to return + distance: Maximum cosine distance threshold (0.0-1.0) + 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() - filters: dict[str, Any] = { + filters = { "observer_id": self._scope.observer, "observed_id": self._scope.observed, + **(filters or {}), } body: dict[str, Any] = { diff --git a/sdks/python/src/honcho/api_types.py b/sdks/python/src/honcho/api_types.py index 897a86fe..64ee7b65 100644 --- a/sdks/python/src/honcho/api_types.py +++ b/sdks/python/src/honcho/api_types.py @@ -10,6 +10,10 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field +# Reasoning level of a conclusion. "explicit" conclusions are extracted directly +# from messages; the others are derived during dreaming. +ConclusionLevel = Literal["explicit", "deductive", "inductive", "contradiction"] + # ============================================================================== # Configuration Types # ============================================================================== @@ -414,6 +418,7 @@ class ConclusionResponse(BaseModel): observer_id: str observed_id: str session_id: str | None = None + level: ConclusionLevel = "explicit" created_at: datetime.datetime diff --git a/sdks/python/src/honcho/conclusions.py b/sdks/python/src/honcho/conclusions.py index e76b1900..708cc3ed 100644 --- a/sdks/python/src/honcho/conclusions.py +++ b/sdks/python/src/honcho/conclusions.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any from pydantic import BaseModel -from .api_types import ConclusionResponse, RepresentationResponse +from .api_types import ConclusionLevel, ConclusionResponse, RepresentationResponse from .base import SessionBase from .http import routes from .pagination import SyncPage @@ -24,6 +24,34 @@ __all__ = [ "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") + + +def _reject_reserved_filter_keys( + filters: dict[str, Any] | None, reserved: tuple[str, ...] +) -> None: + """Raise if ``filters`` contains keys managed by the conclusion scope. + + 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. + """ + if not filters: + return + clash = sorted(k for k in reserved if k in filters) + if clash: + guidance = ( + "Choose the peer pair via peer.conclusions / peer.conclusions_of(target)" + ) + 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"cannot be passed in filters. {guidance}." + ) + class ConclusionCreateParams(BaseModel): content: str @@ -43,6 +71,9 @@ class Conclusion: observer_id: The peer ID who made this conclusion observed_id: The peer ID this conclusion is about session_id: The session this conclusion relates to + level: Reasoning level ("explicit", "deductive", "inductive", + "contradiction"). "explicit" conclusions are extracted directly + from messages; the others are derived during dreaming. created_at: Timestamp for when the conclusion was created """ @@ -51,6 +82,7 @@ class Conclusion: observer_id: str observed_id: str session_id: str | None = None + level: ConclusionLevel = "explicit" created_at: datetime.datetime def __init__( @@ -61,12 +93,14 @@ class Conclusion: observed_id: str, session_id: str | None, created_at: datetime.datetime, + level: ConclusionLevel = "explicit", ) -> None: self.id = id self.content = content self.observer_id = observer_id self.observed_id = observed_id self.session_id = session_id + self.level = level self.created_at = created_at @classmethod @@ -78,6 +112,7 @@ class Conclusion: observer_id=data.observer_id, observed_id=data.observed_id, session_id=data.session_id, + level=data.level, created_at=data.created_at, ) @@ -169,6 +204,7 @@ class ConclusionScope: size: int = 50, session: str | SessionBase | None = None, *, + filters: dict[str, Any] | None = None, reverse: bool = False, ) -> SyncPage[ConclusionResponse, Conclusion]: """ @@ -178,19 +214,28 @@ class ConclusionScope: page: Page number (1-indexed) size: Number of results per page session: Optional session (ID string or Session object) to filter by + filters: Optional dictionary of additional filter criteria, merged + with this scope'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 + https://honcho.dev/docs/v3/documentation/features/advanced/using-filters reverse: If True, reverses the default ordering. Default: False. Returns: Paginated response containing Conclusion objects """ + _reject_reserved_filter_keys( + filters, _SCOPE_RESERVED + ("session", "session_id") + ) self._honcho._ensure_workspace() resolved_session_id = resolve_id(session) - filters: dict[str, Any] = { + filters = { "observer_id": self.observer, "observed_id": self.observed, + **({"session_id": resolved_session_id} if resolved_session_id else {}), + **(filters or {}), } - if resolved_session_id: - filters["session_id"] = resolved_session_id query: dict[str, Any] = {"page": page, "size": size} if reverse: @@ -224,6 +269,8 @@ class ConclusionScope: query: str, top_k: int = 10, distance: float | None = None, + *, + filters: dict[str, Any] | None = None, ) -> list[Conclusion]: """ Semantic search for conclusions in this scope. @@ -232,14 +279,21 @@ class ConclusionScope: query: The search query string top_k: Maximum number of results to return distance: Maximum cosine distance threshold (0.0-1.0) + filters: Optional dictionary of additional filter criteria, merged + with this scope'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 Returns: List of matching Conclusion objects """ + _reject_reserved_filter_keys(filters, _SCOPE_RESERVED) self._honcho._ensure_workspace() - filters: dict[str, Any] = { + filters = { "observer_id": self.observer, "observed_id": self.observed, + **(filters or {}), } body: dict[str, Any] = { diff --git a/sdks/typescript/__tests__/conclusions.test.ts b/sdks/typescript/__tests__/conclusions.test.ts index 8e7a6a09..7716d3ac 100644 --- a/sdks/typescript/__tests__/conclusions.test.ts +++ b/sdks/typescript/__tests__/conclusions.test.ts @@ -278,6 +278,61 @@ describe('Conclusions', () => { }) }) + // =========================================================================== + // Scope-reserved filter guard + // =========================================================================== + + describe('reserved filter keys', () => { + test('list rejects observer/observed scope keys in filters', async () => { + const peer = await client.peer('reserved-list-peer', { metadata: {} }) + + 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/) + } + }) + + test('list rejects session keys in filters (use the session option)', async () => { + const peer = await client.peer('reserved-list-session-peer', { metadata: {} }) + + await expect( + peer.conclusions.list({ filters: { session_id: 'sess' } }) + ).rejects.toThrow(/managed by this conclusion scope/) + await expect( + peer.conclusions.list({ filters: { session: 'sess' } }) + ).rejects.toThrow(/managed by this conclusion scope/) + }) + + test('query rejects observer/observed scope keys in filters', async () => { + const peer = await client.peer('reserved-query-peer', { metadata: {} }) + + 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/) + } + }) + + test('query allows session_id in filters (no dedicated session param)', async () => { + const peer = await client.peer('reserved-query-session-peer', { metadata: {} }) + + // Should not throw the reserved-key guard; session_id is a normal filter + // for query. The call may return no matches, which is fine. + await expect( + peer.conclusions.query('q', 10, undefined, { session_id: 'sess' }) + ).resolves.toBeDefined() + }) + + test('non-reserved filters (level) still work on list', async () => { + const peer = await client.peer('reserved-allowed-peer', { metadata: {} }) + + await expect( + peer.conclusions.list({ filters: { level: 'explicit' } }) + ).resolves.toBeDefined() + }) + }) + // =========================================================================== // Conclusion Deletion (DELETE /conclusions/:id) // =========================================================================== diff --git a/sdks/typescript/src/conclusions.ts b/sdks/typescript/src/conclusions.ts index 7c854b57..c9add4e2 100644 --- a/sdks/typescript/src/conclusions.ts +++ b/sdks/typescript/src/conclusions.ts @@ -3,6 +3,7 @@ import type { HonchoHTTPClient } from './http/client' import { Page } from './pagination' import type { Session } from './session' import type { + ConclusionLevel, ConclusionResponse, PageResponse, RepresentationOptions, @@ -10,6 +11,43 @@ import type { } from './types/api' 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 SCOPE_RESERVED_KEYS = [ + 'observer', + 'observed', + 'observer_id', + 'observed_id', +] + +/** + * Throw if `filters` contains keys managed by the conclusion scope. + * + * 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. + */ +function rejectReservedFilterKeys( + filters: Record | undefined, + reserved: string[] +): void { + if (!filters) return + const clash = reserved.filter((k) => k in filters).sort() + if (clash.length > 0) { + let guidance = + 'Choose the peer pair via peer.conclusions / peer.conclusionsOf(target)' + if (reserved.includes('session') || reserved.includes('session_id')) { + guidance += '; use the session option to filter by session' + } + throw new Error( + `Filter key(s) ${clash.join(', ')} are managed by this conclusion scope ` + + `and cannot be passed in filters. ${guidance}.` + ) + } +} + /** * Parameters for creating a conclusion. */ @@ -32,6 +70,12 @@ export class Conclusion { readonly observerId: string readonly observedId: string readonly sessionId: string | null + /** + * Reasoning level: 'explicit' conclusions are extracted directly from + * messages; 'deductive'/'inductive'/'contradiction' are derived during + * dreaming. + */ + readonly level: ConclusionLevel readonly createdAt: string constructor( @@ -40,13 +84,15 @@ export class Conclusion { observerId: string, observedId: string, sessionId: string | null, - createdAt: string + createdAt: string, + level: ConclusionLevel = 'explicit' ) { this.id = id this.content = content this.observerId = observerId this.observedId = observedId this.sessionId = sessionId + this.level = level this.createdAt = createdAt } @@ -57,7 +103,8 @@ export class Conclusion { data.observer_id, data.observed_id, data.session_id, - data.created_at + data.created_at, + data.level ) } @@ -182,14 +229,26 @@ export class ConclusionScope { * @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 + * operators as other list endpoints — e.g. `{ level: 'explicit' }` to get + * only conclusions extracted directly from messages (i.e. not derived during + * dreaming). See + * https://honcho.dev/docs/v3/documentation/features/advanced/using-filters * @returns Promise resolving to a Page of Conclusion objects */ async list(options?: { page?: number size?: number session?: string | Session + filters?: Record reverse?: boolean }): Promise> { + rejectReservedFilterKeys(options?.filters, [ + ...SCOPE_RESERVED_KEYS, + 'session', + 'session_id', + ]) const resolvedSessionId = options?.session ? typeof options.session === 'string' ? options.session @@ -198,9 +257,8 @@ export class ConclusionScope { const filters: Record = { observer_id: this.observer, observed_id: this.observed, - } - if (resolvedSessionId) { - filters.session_id = resolvedSessionId + ...(resolvedSessionId ? { session_id: resolvedSessionId } : {}), + ...options?.filters, } const reverse = options?.reverse @@ -227,22 +285,32 @@ export class ConclusionScope { /** * Semantic search for conclusions in this scope. + * + * @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 + * endpoint — e.g. `{ level: 'deductive' }` to search only conclusions + * derived during dreaming. See + * https://honcho.dev/docs/v3/documentation/features/advanced/using-filters */ async query( query: string, topK: number = 10, - distance?: number + distance?: number, + filters?: Record ): Promise { - const filters: Record = { - observer_id: this.observer, - observed_id: this.observed, - } - + rejectReservedFilterKeys(filters, SCOPE_RESERVED_KEYS) const response = await this._query({ query, top_k: topK, distance, - filters, + filters: { + observer_id: this.observer, + observed_id: this.observed, + ...filters, + }, }) return (response ?? []).map((item) => Conclusion.fromApiResponse(item)) diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index f6b11d26..90bff9f2 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -40,6 +40,7 @@ export { // API types (snake_case, for advanced usage) export type { + ConclusionLevel, ConclusionQueryParams, ConclusionResponse, MessageResponse, diff --git a/sdks/typescript/src/types/api.ts b/sdks/typescript/src/types/api.ts index 65c6d88c..dda25ad1 100644 --- a/sdks/typescript/src/types/api.ts +++ b/sdks/typescript/src/types/api.ts @@ -242,12 +242,23 @@ export interface MessageSearchParams { // Conclusion Types // ============================================================================= +/** + * Reasoning level of a conclusion. "explicit" conclusions are extracted + * directly from messages; the others are derived during dreaming. + */ +export type ConclusionLevel = + | 'explicit' + | 'deductive' + | 'inductive' + | 'contradiction' + export interface ConclusionResponse { id: string content: string observer_id: string observed_id: string session_id: string | null + level: ConclusionLevel created_at: string } diff --git a/src/schemas/api.py b/src/schemas/api.py index 4863d99a..a276c4b7 100644 --- a/src/schemas/api.py +++ b/src/schemas/api.py @@ -29,6 +29,7 @@ from src.schemas.configuration import ( SessionPeerConfig, WorkspaceConfiguration, ) +from src.utils.types import DocumentLevel # --------------------------------------------------------------------------- # Metadata validation helpers @@ -446,6 +447,14 @@ class Conclusion(BaseModel): serialization_alias="observed_id", ) session_name: str | None = Field(default=None, serialization_alias="session_id") + level: DocumentLevel = Field( + default="explicit", + description=( + "Reasoning level of the conclusion: 'explicit' (directly extracted " + "from messages) or 'deductive'/'inductive'/'contradiction' (derived " + "during dreaming)." + ), + ) created_at: datetime.datetime model_config = ConfigDict( # pyright: ignore diff --git a/tests/routes/test_conclusions.py b/tests/routes/test_conclusions.py index a2ef56a0..77cb23fb 100644 --- a/tests/routes/test_conclusions.py +++ b/tests/routes/test_conclusions.py @@ -737,6 +737,7 @@ class TestConclusionRoutes: assert conclusion["observer_id"] == doc.observer assert conclusion["observed_id"] == doc.observed assert conclusion["session_id"] == doc.session_name + assert conclusion["level"] == "explicit" assert "created_at" in conclusion # Verify internal fields are NOT exposed @@ -744,6 +745,80 @@ class TestConclusionRoutes: assert "internal_metadata" not in conclusion assert "collection" not in conclusion + @pytest.mark.asyncio + async def test_list_conclusions_filter_by_level( + self, + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + ): + """Filtering by `level` returns only conclusions at that reasoning level. + + `level="explicit"` is the "not dreamed on" view — it excludes the + deductive/inductive conclusions produced during dreaming. + """ + test_workspace, test_peer = sample_data + + test_peer2 = models.Peer( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add(test_peer2) + await db_session.flush() + + test_session = models.Session( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add(test_session) + await db_session.commit() + + await self._create_collection( + db_session, test_workspace.name, test_peer.name, test_peer2.name + ) + + # Two explicit, one deductive, one inductive + levels = ["explicit", "explicit", "deductive", "inductive"] + for i, level in enumerate(levels): + db_session.add( + models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content=f"{level} conclusion {i}", + embedding=[0.1] * 1536, + session_name=test_session.name, + level=level, + ) + ) + await db_session.commit() + + # No level filter -> all four + all_resp = client.post( + f"/v3/workspaces/{test_workspace.name}/conclusions/list", + json={"filters": {"session_id": test_session.name}}, + ) + assert all_resp.status_code == 200 + assert all_resp.json()["total"] == 4 + + # level="explicit" -> only the two non-dreamed conclusions + explicit_resp = client.post( + f"/v3/workspaces/{test_workspace.name}/conclusions/list", + json={"filters": {"session_id": test_session.name, "level": "explicit"}}, + ) + assert explicit_resp.status_code == 200 + explicit_data = explicit_resp.json() + assert explicit_data["total"] == 2 + assert all(item["level"] == "explicit" for item in explicit_data["items"]) + + # level="deductive" -> only the one deductive conclusion + deductive_resp = client.post( + f"/v3/workspaces/{test_workspace.name}/conclusions/list", + json={"filters": {"session_id": test_session.name, "level": "deductive"}}, + ) + assert deductive_resp.status_code == 200 + deductive_data = deductive_resp.json() + assert deductive_data["total"] == 1 + assert deductive_data["items"][0]["level"] == "deductive" + @pytest.mark.asyncio async def test_create_conclusion_success( self, diff --git a/tests/sdk/test_conclusions.py b/tests/sdk/test_conclusions.py index 33331de3..9c2cdbc0 100644 --- a/tests/sdk/test_conclusions.py +++ b/tests/sdk/test_conclusions.py @@ -769,3 +769,73 @@ async def test_observation_create_mixed_session_and_sessionless( assert session_obs.session_id == session.id assert global_obs.session_id is None + + +@pytest.mark.asyncio +async def test_list_rejects_reserved_scope_filter_keys( + client_fixture: tuple[Honcho, str], +): + """`list` rejects observer/observed/session filter keys managed by the scope. + + These keys are fixed by the scope (observer/observed) or by the dedicated + ``session=`` parameter, so passing them in ``filters`` would silently return + data from a different scope. The guard raises before any HTTP call. + """ + honcho_client, client_type = client_fixture + reserved = [ + "observer", + "observed", + "observer_id", + "observed_id", + "session_id", + "session", + ] + + if client_type == "async": + observer = await honcho_client.aio.peer(id="test-obs-reserved-list-observer") + 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"): + 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"}) + else: + observer = honcho_client.peer(id="test-obs-reserved-list-observer") + 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"): + obs_scope.list(filters={key: "someone-else"}) + obs_scope.list(filters={"level": "explicit"}) + + +@pytest.mark.asyncio +async def test_query_rejects_reserved_scope_filter_keys( + client_fixture: tuple[Honcho, str], +): + """`query` rejects observer/observed filter keys but allows session_id. + + Unlike ``list``, ``query`` has no dedicated session parameter, so + ``session_id`` remains a normal filter and must NOT be rejected. + """ + honcho_client, client_type = client_fixture + reserved = ["observer", "observed", "observer_id", "observed_id"] + + if client_type == "async": + observer = await honcho_client.aio.peer(id="test-obs-reserved-query-observer") + 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"): + 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"}) + else: + observer = honcho_client.peer(id="test-obs-reserved-query-observer") + 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"): + obs_scope.query("q", filters={key: "someone-else"}) + obs_scope.query("q", filters={"session_id": "some-session"})