Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/v3/documentation/features/advanced/using-filters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,65 @@ messages = session.messages(filters={
```
</CodeGroup>

### 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`:

<CodeGroup>
```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" }
});
})();
```
</CodeGroup>

## Error Handling

Handle filter errors gracefully:
Expand Down
41 changes: 34 additions & 7 deletions sdks/python/src/honcho/aio.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@
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
Expand Down Expand Up @@ -1460,17 +1464,28 @@ async def list(
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:
Expand Down Expand Up @@ -1504,12 +1519,24 @@ async def query(
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] = {
Expand Down
5 changes: 5 additions & 0 deletions sdks/python/src/honcho/api_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

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
# ==============================================================================
Expand Down Expand Up @@ -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


Expand Down
64 changes: 59 additions & 5 deletions sdks/python/src/honcho/conclusions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

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
Expand All @@ -24,6 +24,34 @@
"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
Expand All @@ -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
"""

Expand All @@ -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__(
Expand All @@ -61,12 +93,14 @@ def __init__(
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
Expand All @@ -78,6 +112,7 @@ def from_api_response(cls, data: ConclusionResponse) -> "Conclusion":
observer_id=data.observer_id,
observed_id=data.observed_id,
session_id=data.session_id,
level=data.level,
created_at=data.created_at,
)

Expand Down Expand Up @@ -169,6 +204,7 @@ def list(
size: int = 50,
session: str | SessionBase | None = None,
*,
filters: dict[str, Any] | None = None,
reverse: bool = False,
) -> SyncPage[ConclusionResponse, Conclusion]:
"""
Expand All @@ -178,19 +214,28 @@ def list(
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:
Expand Down Expand Up @@ -224,6 +269,8 @@ def query(
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.
Expand All @@ -232,14 +279,21 @@ def query(
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] = {
Expand Down
55 changes: 55 additions & 0 deletions sdks/typescript/__tests__/conclusions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ===========================================================================
Expand Down
Loading
Loading