Merge remote-tracking branch 'origin/main' into aakash/issue-templates

This commit is contained in:
Aakash Kattelu 2026-08-20 12:17:16 -04:00
commit f180de322f
71 changed files with 4789 additions and 223 deletions

5
.gitattributes vendored Normal file
View File

@ -0,0 +1,5 @@
# Shell entrypoints are executed with sh/dash inside the container image. A
# Windows checkout with core.autocrlf=true rewrites them to CRLF, and dash
# then aborts with "set: Illegal option" because the carriage return becomes
# part of the "-e" flag argument (docker/entrypoint.sh).
*.sh text eol=lf

13
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,13 @@
## Description
<!-- 2-3 sentences about what problem this PR solves and how -->
## Proofs
<!-- Add screenshots, logs, files as a proof that this change works -->
## Checklist
- [ ] This PR is correlated to an existing issue, and I understand it will be closed if that issue does not have the `maintainer-approved` label.
<!-- Fixes #XXX -->

View File

@ -187,6 +187,9 @@ The Dreamer is an orchestrated multi-specialist system that runs during schedule
- **LLM subsystem** (`src/llm/`): provider-agnostic `honcho_llm_call()`. Backends in `src/llm/backends/` (`anthropic.py`, `gemini.py`, `openai.py`). Includes prompt caching (`caching.py`), structured output (`structured_output.py`), tool loop (`tool_loop.py`), history adapters for cross-provider message formats, and a model registry. Per-retry provider selection is pinned via an `AttemptPlan` so stream-final retries don't bounce back to primary after the tool loop has settled on fallback.
- **Per-agent model config**: each agent has its own `MODEL_CONFIG` in `src/config.py` with fallback chains (see `ConfiguredModelSettings`, `FallbackModelSettings`).
- **Telemetry**: cloudevents in `src/telemetry/events/` cover API routes, dialectic, dream, deletion, reconciliation, representation, and per-call LLM accounting (`llm.py` — `LLMCallCompletedEvent` fires once per provider hit with full cost-attribution context). High-volume events are sampled deterministically per `run_id` via `TelemetrySettings.HIGH_VOLUME_SAMPLE_RATE`.
- **Prometheus metrics** (`src/telemetry/prometheus/`): every metric carries a `namespace` label and every recorder is fail-soft (a metrics error never propagates into a request or a worker loop). Counter children with a *bounded* label domain are zero-initialized per process at startup — `initialize_bounded_metrics(instance_type=...)`, called from the `src/main.py` lifespan (`api`) and `src/deriver/__main__.py` (`deriver`) — so an absent series means a broken scrape rather than "nothing happened". Two consequences worth knowing before touching telemetry:
- **Adding a `BaseEvent` subclass requires adding its `_event_type` to `ALL_EVENT_TYPES`** in `src/telemetry/events/__init__.py` (and to `HIGH_VOLUME_EVENT_TYPES` if `_volume_class == "high_volume"`). Enforced by the drift guards in `tests/telemetry/test_metric_zero_init.py`, which assert set-equality against the discovered subclasses.
- **A service-wide, non-additive gauge must be refreshed by every replica on its own timer**, and aggregated with `max()`/`avg()`, never `sum()`. `message_embeddings_pending` is the example: it reports a DB-global count, so it is driven from `ReconcilerScheduler._scheduler_loop` (runs on all replicas) rather than from the work-unit-deduped reconciliation cycle — otherwise, combined with the zero-init, every replica that never won the work unit would export a confident permanent `0`.
### Project Structure

View File

@ -77,6 +77,8 @@ model = "text-embedding-3-small"
# Optional provider request input cap. Useful for OpenAI-compatible embedding
# APIs with smaller limits, such as DashScope text-embedding-v4.
# max_batch_size = 10
# Optional client HTTP timeout in seconds (OpenAI + Gemini).
# timeout = 90.0
# Optional module-level endpoint overrides
# [embedding.model_config.overrides]

View File

@ -267,6 +267,7 @@ EMBEDDING_MAX_TOKENS_PER_REQUEST=300000
EMBEDDING_MODEL_CONFIG__TRANSPORT=openai # openai, gemini
EMBEDDING_MODEL_CONFIG__MODEL=text-embedding-3-small
EMBEDDING_MODEL_CONFIG__MAX_BATCH_SIZE=10 # optional per-request input cap
EMBEDDING_MODEL_CONFIG__TIMEOUT=90.0 # optional client HTTP timeout (seconds)
# Optional endpoint overrides
EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=http://localhost:8000/v1
@ -279,6 +280,13 @@ document a per-request limit. Set it when an OpenAI-compatible embedding
provider accepts fewer inputs per request, such as DashScope
`text-embedding-v4` with a limit of 10.
`EMBEDDING_MODEL_CONFIG__TIMEOUT` is an optional client HTTP timeout in
seconds. OpenAI-compatible transports receive it as the SDK `timeout` kwarg
(omitted when unset, so the SDK default applies). Gemini converts it to
milliseconds on `http_options.timeout`, and keeps its existing 10-minute
default when unset. The value is validated at config load the same way as
LLM `provider_params.timeout` (positive, finite number of seconds).
Forwarding `dimensions=` to OpenAI-compatible providers is controlled by `EMBEDDING_MODEL_CONFIG__DIMENSIONS_MODE`:
- `auto` (default): forwards `dimensions=` when **the operator has explicitly set `EMBEDDING_VECTOR_DIMENSIONS`** — provenance, not value — and the configured model is not on the known-rejecting list (currently `text-embedding-ada-002`). Explicit `EMBEDDING_VECTOR_DIMENSIONS=1536` *does* trigger the forward; this is how `text-embedding-3-large` truncation to 1536 is expressed. Deployments that leave the setting unset get their existing behavior (`dimensions=` is not forwarded).

View File

@ -41,11 +41,16 @@ from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
import re
from .aio import ConclusionScopeAio, HonchoAio, PeerAio, SessionAio
from .api_types import MessageCreateParams
from .base import PeerBase, SessionBase
from .aio import ConclusionsViewAio, HonchoAio, PeerAio, ScopeAio, SessionAio
from .api_types import (
MessageCreateParams,
ScopeBackfillJob,
ScopeResponse,
ScopeStatusResponse,
)
from .base import PeerBase, ScopeBase, SessionBase
from .client import Honcho
from .conclusions import Conclusion, ConclusionScope
from .conclusions import Conclusion, ConclusionsView
from .http.exceptions import (
APIError,
AuthenticationError,
@ -63,6 +68,7 @@ from .http.exceptions import (
from .message import Message
from .pagination import AsyncPage, SyncPage
from .peer import Peer
from .scope import Scope
from .session import Session
from .session_context import SessionContext, SessionSummaries, Summary
from .types import (
@ -70,6 +76,12 @@ from .types import (
DialecticStreamResponse,
)
# Deprecated aliases. "Scope" now means a named set of sessions (see `Scope`),
# which these are not — they are views over one observer/observed pair. Kept for
# one more minor version.
ConclusionScope = ConclusionsView
ConclusionScopeAio = ConclusionsViewAio
def _detect_version() -> str:
try:
@ -95,23 +107,32 @@ __all__ = [
"Honcho",
# Domain classes
"Conclusion",
"ConclusionScope",
"ConclusionsView",
"Message",
"MessageCreateParams",
"Peer",
"Scope",
"Session",
# Aio views (for type hints)
"ConclusionScopeAio",
"ConclusionsViewAio",
"HonchoAio",
"PeerAio",
"ScopeAio",
"SessionAio",
# Base classes
"PeerBase",
"ScopeBase",
"SessionBase",
# Response types
"ScopeBackfillJob",
"ScopeResponse",
"ScopeStatusResponse",
"SessionContext",
"SessionSummaries",
"Summary",
# Deprecated aliases
"ConclusionScope",
"ConclusionScopeAio",
# Pagination
"AsyncPage",
"SyncPage",

View File

@ -3,7 +3,7 @@
This module provides async accessor classes that wrap the main SDK classes
and provide async versions of all operations. Access via the `.aio` property
on Honcho, Peer, Session, and ConclusionScope instances.
on Honcho, Peer, Session, and ConclusionsView instances.
Example:
```python
@ -24,7 +24,7 @@ from __future__ import annotations
import json
import logging
import warnings
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Sequence
from datetime import datetime
from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload
@ -40,15 +40,18 @@ from .api_types import (
PeerResponse,
QueueStatusResponse,
RepresentationResponse,
ScopeBackfillJob,
ScopeResponse,
ScopeStatusResponse,
SessionConfiguration,
SessionPeerConfig,
SessionResponse,
WorkspaceConfiguration,
WorkspaceResponse,
)
from .base import PeerBase, SessionBase
from .base import PeerBase, ScopeBase, SessionBase
from .conclusions import (
_SCOPE_RESERVED,
_VIEW_RESERVED,
Conclusion,
_reject_reserved_filter_keys,
)
@ -64,14 +67,20 @@ from .utils import (
parse_sse_astream,
prepare_file_for_upload,
resolve_id,
resolve_scope_membership,
resolve_scope_session,
scope_context_fields,
scope_recall_fields,
validate_scope_id,
)
if TYPE_CHECKING:
from .client import Honcho
from .conclusions import ConclusionScope
from .conclusions import ConclusionsView
from .conclusions import ConclusionCreateParams
from .peer import Peer, TResponseFormat, serialize_response_format
from .scope import Scope
from .session import Session
logger = logging.getLogger(__name__)
@ -79,8 +88,9 @@ logger = logging.getLogger(__name__)
__all__ = [
"HonchoAio",
"PeerAio",
"ScopeAio",
"SessionAio",
"ConclusionScopeAio",
"ConclusionsViewAio",
]
@ -267,6 +277,7 @@ class HonchoAio(AsyncMetadataConfigMixin):
| list[tuple[PeerBase | str, SessionPeerConfig]]
| list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]]
| None = None,
scopes: Sequence[str | ScopeBase] | None = None,
) -> Session:
"""
Get or create a session with the given ID asynchronously.
@ -278,6 +289,11 @@ class HonchoAio(AsyncMetadataConfigMixin):
peers: Optional peers to attach to the session at creation. Accepts the
same shape as Session.add_peers (peer ID string, Peer object, list
of either, or tuples with SessionPeerConfig).
scopes: Optional scopes this session should join, as IDs or Scope
objects. Each scope is created if it does not exist yet. Attaching
at creation avoids the asynchronous backfill a later
``scope.add_sessions()`` triggers, since there is no history to
copy.
Returns:
A Session object with cached values from the API response.
@ -290,6 +306,8 @@ class HonchoAio(AsyncMetadataConfigMixin):
body["configuration"] = configuration.model_dump(exclude_none=True)
if peers is not None:
body["peers"] = normalize_peers_to_dict(peers)
if scopes is not None:
body["scopes"] = [validate_scope_id(resolve_id(scope)) for scope in scopes]
data = await self._honcho._async_http_client.post(
routes.sessions(self._honcho.workspace_id), body=body
@ -358,6 +376,88 @@ class HonchoAio(AsyncMetadataConfigMixin):
return AsyncPage(data, SessionResponse, transform, fetch_next)
async def scope(
self,
id: str, # noqa: A002
*,
metadata: dict[str, object] | None = None,
) -> Scope:
"""
Get or create a scope with the given ID asynchronously.
A scope is a named set of sessions that acts as a visibility boundary:
recall performed through the scope sees only what happened in its sessions,
while the underlying peer keeps its single unified representation of
everything.
Args:
id: Unprefixed scope name, unique within the workspace.
metadata: Optional metadata dictionary to associate with this scope.
Returns:
A Scope object for managing membership.
Raises:
ValueError: If the scope ID is invalid.
"""
validate_scope_id(id)
await self._honcho._ensure_workspace_async()
body: dict[str, Any] = {"id": id}
if metadata is not None:
body["metadata"] = metadata
data = await self._honcho._async_http_client.post(
routes.scopes(self._honcho.workspace_id), body=body
)
scope_data = ScopeResponse.model_validate(data)
return Scope(
id,
self._honcho,
metadata=scope_data.metadata,
created_at=scope_data.created_at,
)
async def scopes(
self,
*,
page: int = 1,
size: int = 50,
reverse: bool = False,
) -> AsyncPage[ScopeResponse, Scope]:
"""
Get all scopes in the current workspace asynchronously.
Args:
page: Page number (1-indexed). Default: 1.
size: Number of items per page. Default: 50.
reverse: If True, reverses the default ordering. Default: False.
"""
await self._honcho._ensure_workspace_async()
async def fetch(next_page: int) -> dict[str, Any]:
query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
query["reverse"] = "true"
return await self._honcho._async_http_client.post(
routes.scopes_list(self._honcho.workspace_id), query=query
)
def transform(scope: ScopeResponse) -> Scope:
"""Convert a scope API response into a Scope SDK object."""
return Scope(
scope.id,
self._honcho,
metadata=scope.metadata,
created_at=scope.created_at,
)
async def fetch_next(next_page: int) -> AsyncPage[ScopeResponse, Scope]:
return AsyncPage(
await fetch(next_page), ScopeResponse, transform, fetch_next
)
return AsyncPage(await fetch(page), ScopeResponse, transform, fetch_next)
async def workspaces(
self,
filters: dict[str, object] | None = None,
@ -409,12 +509,27 @@ class HonchoAio(AsyncMetadataConfigMixin):
limit: int = Field(
default=10, ge=1, le=100, description="Number of results to return"
),
*,
scope: str | ScopeBase | None = None,
) -> list[Message]:
"""Search for messages in the current workspace asynchronously."""
"""Search for messages in the current workspace asynchronously.
Args:
query: The search query to use
filters: Filters to scope the search.
limit: Number of results to return (1-100, default: 10)
scope: Optional scope (ID or Scope object) restricting the search to
that scope's member sessions. Mutually exclusive with a
``session_id`` filter. A scope with no member sessions matches
nothing rather than everything.
"""
await self._honcho._ensure_workspace_async()
body: dict[str, Any] = {"query": query, "filters": filters, "limit": limit}
if scope is not None:
body["scope"] = validate_scope_id(resolve_id(scope))
data = await self._honcho._async_http_client.post(
routes.workspace_search(self._honcho.workspace_id),
body={"query": query, "filters": filters, "limit": limit},
body=body,
)
return [
Message.from_api_response(MessageResponse.model_validate(item))
@ -584,6 +699,8 @@ class PeerAio(AsyncMetadataConfigMixin):
*,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
sessions: Sequence[str | SessionBase] | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[TResponseFormat],
@ -596,6 +713,8 @@ class PeerAio(AsyncMetadataConfigMixin):
*,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
sessions: Sequence[str | SessionBase] | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: dict[str, Any] | None = None,
@ -608,6 +727,8 @@ class PeerAio(AsyncMetadataConfigMixin):
*,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
sessions: Sequence[str | SessionBase] | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[BaseModel] | dict[str, Any] | None = None,
@ -623,6 +744,11 @@ class PeerAio(AsyncMetadataConfigMixin):
resolved_session_id = resolve_id(session)
body: dict[str, Any] = {"query": query, "stream": False}
body.update(
scope_recall_fields(
scope=scope, sessions=sessions, session_id=resolved_session_id
)
)
if target_id:
body["target"] = target_id
if resolved_session_id:
@ -651,6 +777,8 @@ class PeerAio(AsyncMetadataConfigMixin):
*,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
sessions: Sequence[str | SessionBase] | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[BaseModel] | dict[str, Any] | None = None,
@ -666,6 +794,11 @@ class PeerAio(AsyncMetadataConfigMixin):
resolved_session_id = resolve_id(session)
body: dict[str, Any] = {"query": query, "stream": True}
body.update(
scope_recall_fields(
scope=scope, sessions=sessions, session_id=resolved_session_id
)
)
if target_id:
body["target"] = target_id
if resolved_session_id:
@ -826,13 +959,22 @@ class PeerAio(AsyncMetadataConfigMixin):
search_max_distance: float | None = Field(None, ge=0.0, le=1.0),
include_most_frequent: bool | None = None,
max_conclusions: int | None = Field(None, ge=1, le=100),
*,
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
sessions: Sequence[str | SessionBase] | None = None,
) -> str:
"""Get a subset of the representation of the peer asynchronously."""
"""Get a subset of the representation of the peer asynchronously.
See Peer.representation for parameter details, including the depth caveat
on ``sessions``.
"""
await self._peer._honcho._ensure_workspace_async()
session_id = resolve_id(session)
target_id = resolve_id(target)
body: dict[str, Any] = {}
body: dict[str, Any] = scope_recall_fields(
scope=scope, sessions=sessions, session_id=session_id
)
if session_id:
body["session_id"] = session_id
if target_id:
@ -1192,6 +1334,14 @@ class SessionAio(AsyncMetadataConfigMixin):
None,
description="A peer ID to get context from the perspective of.",
),
scope: str | ScopeBase | None = Field(
None,
description="A scope to use as the perspective source instead of a peer.",
),
sessions: Sequence[str | SessionBase] | None = Field(
None,
description="An allowlist of sessions confining `peer_target`'s representation to that set. This session must be one of them.",
),
limit_to_session: bool = Field(
False,
description="Whether to limit the representation to this session only.",
@ -1219,7 +1369,11 @@ class SessionAio(AsyncMetadataConfigMixin):
description="Maximum number of conclusions to include in the representation.",
),
) -> SessionContext:
"""Get optimized context for this session asynchronously."""
"""Get optimized context for this session asynchronously.
See Session.context for parameter details, including the depth caveat on
``sessions``.
"""
await self._session._honcho._ensure_workspace_async()
if peer_target is None and peer_perspective is not None:
raise ValueError(
@ -1238,6 +1392,13 @@ class SessionAio(AsyncMetadataConfigMixin):
query: dict[str, Any] = {
"summary": summary,
"limit_to_session": limit_to_session,
**scope_context_fields(
scope=scope,
sessions=sessions,
peer_target=peer_target,
peer_perspective=peer_perspective,
limit_to_session=limit_to_session,
),
}
if tokens is not None:
query["tokens"] = tokens
@ -1488,19 +1649,19 @@ class SessionAio(AsyncMetadataConfigMixin):
return Message.from_api_response(MessageResponse.model_validate(data))
class ConclusionScopeAio:
class ConclusionsViewAio:
"""
Async view of a ConclusionScope.
Async view of a ConclusionsView.
Access via `scope.aio`. Provides async versions of all ConclusionScope methods.
Shares state with the parent ConclusionScope instance.
Access via `view.aio`. Provides async versions of all ConclusionsView methods.
Shares state with the parent ConclusionsView instance.
"""
__slots__: ClassVar[tuple[str, ...]] = ("_scope",)
_scope: "ConclusionScope"
__slots__: ClassVar[tuple[str, ...]] = ("_view",)
_view: "ConclusionsView"
def __init__(self, scope: "ConclusionScope") -> None:
self._scope = scope
def __init__(self, view: "ConclusionsView") -> None:
self._view = view
async def list(
self,
@ -1520,13 +1681,13 @@ class ConclusionScopeAio:
https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
"""
_reject_reserved_filter_keys(
filters, _SCOPE_RESERVED + ("session", "session_id")
filters, _VIEW_RESERVED + ("session", "session_id")
)
await self._scope._honcho._ensure_workspace_async()
await self._view._honcho._ensure_workspace_async()
resolved_session_id = resolve_id(session)
filters = {
"observer_id": self._scope.observer,
"observed_id": self._scope.observed,
"observer_id": self._view.observer,
"observed_id": self._view.observed,
**({"session_id": resolved_session_id} if resolved_session_id else {}),
**(filters or {}),
}
@ -1534,8 +1695,8 @@ class ConclusionScopeAio:
query: dict[str, Any] = {"page": page, "size": size}
if reverse:
query["reverse"] = "true"
data = await self._scope._honcho._async_http_client.post(
routes.conclusions_list(self._scope.workspace_id),
data = await self._view._honcho._async_http_client.post(
routes.conclusions_list(self._view.workspace_id),
body={"filters": filters},
query=query,
)
@ -1549,8 +1710,8 @@ class ConclusionScopeAio:
next_query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
next_query["reverse"] = "true"
next_data = await self._scope._honcho._async_http_client.post(
routes.conclusions_list(self._scope.workspace_id),
next_data = await self._view._honcho._async_http_client.post(
routes.conclusions_list(self._view.workspace_id),
body={"filters": filters},
query=next_query,
)
@ -1575,11 +1736,11 @@ class ConclusionScopeAio:
filters: Optional dictionary of additional filter criteria, merged
with this scope's observer/observed (e.g. ``{"level": "deductive"}``).
"""
_reject_reserved_filter_keys(filters, _SCOPE_RESERVED)
await self._scope._honcho._ensure_workspace_async()
_reject_reserved_filter_keys(filters, _VIEW_RESERVED)
await self._view._honcho._ensure_workspace_async()
filters = {
"observer_id": self._scope.observer,
"observed_id": self._scope.observed,
"observer_id": self._view.observer,
"observed_id": self._view.observed,
**(filters or {}),
}
@ -1591,8 +1752,8 @@ class ConclusionScopeAio:
if distance is not None:
body["distance"] = distance
data = await self._scope._honcho._async_http_client.post(
routes.conclusions_query(self._scope.workspace_id),
data = await self._view._honcho._async_http_client.post(
routes.conclusions_query(self._view.workspace_id),
body=body,
)
return [
@ -1602,9 +1763,9 @@ class ConclusionScopeAio:
async def delete(self, conclusion_id: str) -> None:
"""Delete a conclusion by ID asynchronously."""
await self._scope._honcho._ensure_workspace_async()
await self._scope._honcho._async_http_client.delete(
routes.conclusion(self._scope.workspace_id, conclusion_id)
await self._view._honcho._ensure_workspace_async()
await self._view._honcho._async_http_client.delete(
routes.conclusion(self._view.workspace_id, conclusion_id)
)
async def create(
@ -1612,15 +1773,15 @@ class ConclusionScopeAio:
conclusions: list[ConclusionCreateParams | dict[str, Any]],
) -> list[Conclusion]:
"""Create conclusions in this scope asynchronously."""
await self._scope._honcho._ensure_workspace_async()
await self._view._honcho._ensure_workspace_async()
def build_conclusion_payload(
item: ConclusionCreateParams | dict[str, Any],
) -> dict[str, Any]:
"""Build a single conclusion create payload."""
payload: dict[str, Any] = {
"observer_id": self._scope.observer,
"observed_id": self._scope.observed,
"observer_id": self._view.observer,
"observed_id": self._view.observed,
}
if isinstance(item, ConclusionCreateParams):
payload["content"] = item.content
@ -1636,8 +1797,8 @@ class ConclusionScopeAio:
conclusion_params = [build_conclusion_payload(c) for c in conclusions]
data = await self._scope._honcho._async_http_client.post(
routes.conclusions(self._scope.workspace_id),
data = await self._view._honcho._async_http_client.post(
routes.conclusions(self._view.workspace_id),
body={"conclusions": conclusion_params},
)
return [
@ -1654,8 +1815,8 @@ class ConclusionScopeAio:
max_conclusions: int | None = None,
) -> str:
"""Get the computed representation for this scope asynchronously."""
await self._scope._honcho._ensure_workspace_async()
body: dict[str, Any] = {"target": self._scope.observed}
await self._view._honcho._ensure_workspace_async()
body: dict[str, Any] = {"target": self._view.observed}
if search_query is not None:
body["search_query"] = search_query
if search_top_k is not None:
@ -1667,9 +1828,99 @@ class ConclusionScopeAio:
if max_conclusions is not None:
body["max_conclusions"] = max_conclusions
data = await self._scope._honcho._async_http_client.post(
routes.peer_representation(self._scope.workspace_id, self._scope.observer),
data = await self._view._honcho._async_http_client.post(
routes.peer_representation(self._view.workspace_id, self._view.observer),
body=body,
)
response = RepresentationResponse.model_validate(data)
return response.representation
class ScopeAio:
"""
Async view of a Scope.
Access via `scope.aio`. Provides async versions of all Scope methods.
Shares state with the parent Scope instance.
"""
__slots__: ClassVar[tuple[str, ...]] = ("_scope",)
_scope: "Scope"
def __init__(self, scope: "Scope") -> None:
"""Create an async view backed by a sync Scope."""
self._scope = scope
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def add_sessions(self, sessions: Sequence[str | SessionBase]) -> None:
"""Add sessions to this scope asynchronously.
See Scope.add_sessions for details, including the asynchronous backfill
that sessions with existing messages trigger.
"""
session_ids = resolve_scope_membership(sessions)
await self._scope._honcho._ensure_workspace_async()
await self._scope._honcho._async_http_client.post(
routes.scope_sessions(self._scope.workspace_id, self._scope.id),
body={"session_ids": session_ids},
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def remove_session(self, session: str | SessionBase) -> None:
"""Remove a session from this scope asynchronously.
See Scope.remove_session for details on the asynchronous reconciliation.
"""
await self._scope._honcho._ensure_workspace_async()
await self._scope._honcho._async_http_client.delete(
routes.scope_session(
self._scope.workspace_id, self._scope.id, resolve_scope_session(session)
)
)
async def sessions(
self,
page: int = 1,
size: int = 50,
*,
reverse: bool = False,
) -> AsyncPage[SessionResponse, Session]:
"""Get the sessions that are members of this scope asynchronously."""
await self._scope._honcho._ensure_workspace_async()
async def fetch(next_page: int) -> dict[str, Any]:
query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
query["reverse"] = "true"
return await self._scope._honcho._async_http_client.post(
routes.scope_sessions_list(self._scope.workspace_id, self._scope.id),
query=query,
)
def transform(response: SessionResponse) -> Session:
return Session(
response.id,
self._scope._honcho,
metadata=response.metadata,
configuration=response.configuration,
created_at=response.created_at,
is_active=response.is_active,
)
async def fetch_next(next_page: int) -> AsyncPage[SessionResponse, Session]:
return AsyncPage(
await fetch(next_page), SessionResponse, transform, fetch_next
)
return AsyncPage(await fetch(page), SessionResponse, transform, fetch_next)
async def status(self) -> dict[str, ScopeBackfillJob]:
"""Get the backfill/reconciliation progress for this scope asynchronously.
See Scope.status for details.
"""
await self._scope._honcho._ensure_workspace_async()
data = await self._scope._honcho._async_http_client.get(
routes.scope_status(self._scope.workspace_id, self._scope.id)
)
return ScopeStatusResponse.model_validate(data).backfill_status

View File

@ -276,6 +276,7 @@ class SessionCreateParams(BaseModel):
metadata: dict[str, Any] | None = None
peers: dict[str, SessionPeerConfig] | None = None
configuration: SessionConfiguration | None = None
scopes: list[str] | None = None
class SessionUpdateParams(BaseModel):
@ -295,6 +296,44 @@ class SessionListParams(BaseModel):
filters: dict[str, Any] | None = None
# ==============================================================================
# Scope Types
# ==============================================================================
class ScopeResponse(BaseModel):
"""Scope API response."""
model_config = ConfigDict(populate_by_name=True) # pyright: ignore[reportUnannotatedClassAttribute]
id: str
metadata: dict[str, Any] = Field(default_factory=dict)
created_at: datetime.datetime
class ScopeBackfillJob(BaseModel):
"""Backfill job state for one session in a scope.
``docs_copied`` is present only once the backfill for that session completes.
"""
model_config = ConfigDict(extra="ignore") # pyright: ignore[reportUnannotatedClassAttribute]
state: Literal["pending", "completed", "failed"]
updated_at: datetime.datetime
docs_copied: int | None = None
class ScopeStatusResponse(BaseModel):
"""Scope backfill/reconciliation status API response.
``backfill_status`` is keyed by session ID and only contains sessions that
have had a backfill enqueued.
"""
backfill_status: dict[str, ScopeBackfillJob] = Field(default_factory=dict)
# ==============================================================================
# Summary Types
# ==============================================================================

View File

@ -43,3 +43,20 @@ class SessionBase(BaseModel):
workspace_id: str = Field(
..., min_length=1, description="Workspace ID for scoping operations"
)
class ScopeBase(BaseModel):
"""Base class for Scope objects (sync and async variants).
Use this type in method signatures to accept either a scope ID string or any
Scope object.
Attributes:
id: Unprefixed scope name, unique within the workspace
workspace_id: Workspace ID for scoping operations
"""
id: str = Field(..., min_length=1, description="Unprefixed name of this scope")
workspace_id: str = Field(
..., min_length=1, description="Workspace ID for scoping operations"
)

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import logging
import os
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from typing import Any, Literal
import httpx
@ -16,20 +16,22 @@ from .api_types import (
PeerConfig,
PeerResponse,
QueueStatusResponse,
ScopeResponse,
SessionConfiguration,
SessionPeerConfig,
SessionResponse,
WorkspaceConfiguration,
WorkspaceResponse,
)
from .base import PeerBase, SessionBase
from .base import PeerBase, ScopeBase, SessionBase
from .http import AsyncHonchoHTTPClient, HonchoHTTPClient, routes
from .message import Message
from .mixins import MetadataConfigMixin
from .pagination import SyncPage
from .peer import Peer
from .scope import Scope
from .session import Session
from .utils import normalize_peers_to_dict, resolve_id
from .utils import normalize_peers_to_dict, resolve_id, validate_scope_id
logger = logging.getLogger(__name__)
@ -419,6 +421,10 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
None,
description="Optional peers to attach to the session at creation. Accepts the same shape as Session.add_peers.",
),
scopes: Sequence[str | ScopeBase] | None = Field(
None,
description="Optional scopes this session should join. Each scope is created if it does not exist yet.",
),
) -> Session:
"""
Get or create a session with the given ID.
@ -433,6 +439,11 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
peers: Optional peers to attach to the session at creation. Accepts the
same shape as Session.add_peers (peer ID string, Peer object, list
of either, or tuples with SessionPeerConfig).
scopes: Optional scopes this session should join, as IDs or Scope
objects. Each scope is created if it does not exist yet. Attaching
at creation avoids the asynchronous backfill a later
``scope.add_sessions()`` triggers, since there is no history to
copy.
Returns:
A Session object with cached metadata, configuration, created_at, and is_active.
@ -445,6 +456,8 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
body["configuration"] = configuration.model_dump(exclude_none=True)
if peers is not None:
body["peers"] = normalize_peers_to_dict(peers)
if scopes is not None:
body["scopes"] = [validate_scope_id(resolve_id(scope)) for scope in scopes]
data = self._http.post(routes.sessions(self.workspace_id), body=body)
session_data = SessionResponse.model_validate(data)
@ -514,6 +527,97 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
return SyncPage(data, SessionResponse, transform, fetch_next)
@validate_call
def scope(
self,
id: str = Field( # noqa: A002
..., min_length=1, description="Unprefixed name for the scope"
),
*,
metadata: dict[str, object] | None = Field(
None,
description="Optional metadata dictionary to associate with this scope.",
),
) -> Scope:
"""
Get or create a scope with the given ID.
A scope is a named set of sessions that acts as a visibility boundary:
recall performed through the scope sees only what happened in its sessions,
while the underlying peer keeps its single unified representation of
everything.
Args:
id: Unprefixed scope name, unique within the workspace.
metadata: Optional metadata dictionary to associate with this scope.
Returns:
A Scope object for managing membership.
Raises:
ValueError: If the scope ID is invalid.
Example:
```python
therapy = honcho.scope("therapy")
therapy.add_sessions([session_1, session_2])
```
"""
validate_scope_id(id)
self._ensure_workspace()
body: dict[str, Any] = {"id": id}
if metadata is not None:
body["metadata"] = metadata
data = self._http.post(routes.scopes(self.workspace_id), body=body)
scope_data = ScopeResponse.model_validate(data)
return Scope(
id,
self,
metadata=scope_data.metadata,
created_at=scope_data.created_at,
)
def scopes(
self,
*,
page: int = 1,
size: int = 50,
reverse: bool = False,
) -> SyncPage[ScopeResponse, Scope]:
"""
Get all scopes in the current workspace.
Args:
page: Page number (1-indexed). Default: 1.
size: Number of items per page. Default: 50.
reverse: If True, reverses the default ordering. Default: False.
Returns:
A SyncPage of Scope objects representing all scopes in the workspace.
"""
self._ensure_workspace()
def fetch(next_page: int) -> dict[str, Any]:
query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
query["reverse"] = "true"
return self._http.post(routes.scopes_list(self.workspace_id), query=query)
def transform(scope: ScopeResponse) -> Scope:
"""Convert a scope API response into a Scope SDK object."""
return Scope(
scope.id,
self,
metadata=scope.metadata,
created_at=scope.created_at,
)
def fetch_next(next_page: int) -> SyncPage[ScopeResponse, Scope]:
return SyncPage(fetch(next_page), ScopeResponse, transform, fetch_next)
return SyncPage(fetch(page), ScopeResponse, transform, fetch_next)
def workspaces(
self,
filters: dict[str, object] | None = None,
@ -592,6 +696,11 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
limit: int = Field(
default=10, ge=1, le=100, description="Number of results to return"
),
*,
scope: str | ScopeBase | None = Field(
None,
description="Optional scope restricting the search to its member sessions",
),
) -> list[Message]:
"""
Search for messages in the current workspace.
@ -602,15 +711,22 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
query: The search query to use
filters: Filters to scope the search. See [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters).
limit: Number of results to return (1-100, default: 10)
scope: Optional scope (ID or Scope object) restricting the search to
that scope's member sessions. Mutually exclusive with a
``session_id`` filter. A scope with no member sessions matches
nothing rather than everything.
Returns:
A list of Message objects representing the search results.
Returns an empty list if no messages are found.
"""
self._ensure_workspace()
body: dict[str, Any] = {"query": query, "filters": filters, "limit": limit}
if scope is not None:
body["scope"] = validate_scope_id(resolve_id(scope))
data = self._http.post(
routes.workspace_search(self.workspace_id),
body={"query": query, "filters": filters, "limit": limit},
body=body,
)
return [
Message.from_api_response(MessageResponse.model_validate(item))

View File

@ -15,28 +15,28 @@ from .pagination import SyncPage
from .utils import resolve_id
if TYPE_CHECKING:
from .aio import ConclusionScopeAio
from .aio import ConclusionsViewAio
from .client import Honcho
__all__ = [
"Conclusion",
"ConclusionScope",
"ConclusionsView",
"ConclusionCreateParams",
]
# Filter keys that define a conclusion scope (the observer/observed peer pair).
# They are set from the scope itself, so a caller must not pass them in `filters`.
_SCOPE_RESERVED = ("observer", "observed", "observer_id", "observed_id")
# Filter keys that define a conclusions view (the observer/observed peer pair).
# They are set from the view itself, so a caller must not pass them in `filters`.
_VIEW_RESERVED = ("observer", "observed", "observer_id", "observed_id")
def _reject_reserved_filter_keys(
filters: dict[str, Any] | None, reserved: tuple[str, ...]
) -> None:
"""Raise if ``filters`` contains keys managed by the conclusion scope.
"""Raise if ``filters`` contains keys managed by the conclusions view.
The observer/observed peer pair (and, on ``list``, the session) is fixed by
the scope, so letting a user filter override it would silently return data
from a different scope than requested. Fail loud instead.
the view, so letting a user filter override it would silently return data
from a different pair than requested. Fail loud instead.
"""
if not filters:
return
@ -48,7 +48,7 @@ def _reject_reserved_filter_keys(
if "session" in reserved or "session_id" in reserved:
guidance += "; use the session= parameter to filter by session"
raise ValueError(
f"Filter key(s) {clash} are managed by this conclusion scope and "
f"Filter key(s) {clash} are managed by this conclusions view and "
+ f"cannot be passed in filters. {guidance}."
)
@ -126,7 +126,7 @@ class Conclusion:
return self.content
class ConclusionScope:
class ConclusionsView:
"""
Scoped access to conclusions for a specific observer/observed relationship.
@ -165,7 +165,7 @@ class ConclusionScope:
observed: str,
):
"""
Initialize a ConclusionScope.
Initialize a ConclusionsView.
Args:
honcho: The Honcho client instance
@ -179,12 +179,12 @@ class ConclusionScope:
self.observed = observed
@property
def aio(self) -> "ConclusionScopeAio":
def aio(self) -> "ConclusionsViewAio":
"""
Access async versions of all ConclusionScope methods.
Access async versions of all ConclusionsView methods.
Returns a ConclusionScopeAio view that provides async versions of all methods
while sharing state with this ConclusionScope instance.
Returns a ConclusionsViewAio view that provides async versions of all methods
while sharing state with this ConclusionsView instance.
Example:
```python
@ -194,9 +194,9 @@ class ConclusionScope:
```
"""
# Import here to avoid circular import (aio.py imports from this module)
from .aio import ConclusionScopeAio
from .aio import ConclusionsViewAio
return ConclusionScopeAio(self)
return ConclusionsViewAio(self)
def list(
self,
@ -226,7 +226,7 @@ class ConclusionScope:
Paginated response containing Conclusion objects
"""
_reject_reserved_filter_keys(
filters, _SCOPE_RESERVED + ("session", "session_id")
filters, _VIEW_RESERVED + ("session", "session_id")
)
self._honcho._ensure_workspace()
resolved_session_id = resolve_id(session)
@ -288,7 +288,7 @@ class ConclusionScope:
Returns:
List of matching Conclusion objects
"""
_reject_reserved_filter_keys(filters, _SCOPE_RESERVED)
_reject_reserved_filter_keys(filters, _VIEW_RESERVED)
self._honcho._ensure_workspace()
filters = {
"observer_id": self.observer,
@ -443,6 +443,6 @@ class ConclusionScope:
def __repr__(self) -> str:
return (
f"ConclusionScope(workspace_id={self.workspace_id!r}, "
f"ConclusionsView(workspace_id={self.workspace_id!r}, "
f"observer={self.observer!r}, observed={self.observed!r})"
)

View File

@ -102,6 +102,31 @@ def session_peer_config(workspace_id: str, session_id: str, peer_id: str) -> str
return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config"
# Scope routes
def scopes(workspace_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes"
def scopes_list(workspace_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/list"
def scope_sessions(workspace_id: str, scope_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/sessions"
def scope_sessions_list(workspace_id: str, scope_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/sessions/list"
def scope_session(workspace_id: str, scope_id: str, session_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/sessions/{session_id}"
def scope_status(workspace_id: str, scope_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/status"
# Message routes
def messages(workspace_id: str, session_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/messages"

View File

@ -6,7 +6,7 @@ from __future__ import annotations
import datetime
import logging
import warnings
from collections.abc import Generator
from collections.abc import Generator, Sequence
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
@ -22,14 +22,14 @@ from .api_types import (
SessionConfiguration,
SessionResponse,
)
from .base import PeerBase, SessionBase
from .conclusions import ConclusionScope
from .base import PeerBase, ScopeBase, SessionBase
from .conclusions import ConclusionsView
from .http import routes
from .message import Message
from .mixins import MetadataConfigMixin
from .pagination import SyncPage
from .types import DialecticStreamResponse
from .utils import parse_datetime, parse_sse_stream, resolve_id
from .utils import parse_datetime, parse_sse_stream, resolve_id, scope_recall_fields
if TYPE_CHECKING:
from .aio import PeerAio
@ -241,6 +241,8 @@ class Peer(PeerBase, MetadataConfigMixin):
*,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
sessions: Sequence[str | SessionBase] | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[TResponseFormat],
@ -253,6 +255,8 @@ class Peer(PeerBase, MetadataConfigMixin):
*,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
sessions: Sequence[str | SessionBase] | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: dict[str, Any] | None = None,
@ -265,6 +269,8 @@ class Peer(PeerBase, MetadataConfigMixin):
*,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
sessions: Sequence[str | SessionBase] | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[BaseModel] | dict[str, Any] | None = None,
@ -285,6 +291,19 @@ class Peer(PeerBase, MetadataConfigMixin):
session: Optional session to scope the query to. If provided, only
information from that session is considered. Can be a session
ID string or a Session object.
scope: Optional scope(s) to confine the query to. A single scope answers
from that scope's own view of the target, including the
higher-order conclusions reasoned within it. A sequence of scopes
restricts recall to the union of their member sessions, which
like ``sessions`` yields only directly-stated conclusions.
Mutually exclusive with ``session`` and ``sessions``, and requires
a workspace-level key.
sessions: Optional allowlist of sessions to confine the query to, for
one-off questions spanning a handful of sessions. Recall is
limited to conclusions stated directly in those sessions:
conclusions produced by reasoning across sessions are excluded,
because their provenance cannot be proven to sit inside the
allowlist. Reach for a named ``scope`` when you need that depth.
reasoning_level: Optional reasoning level for the query: "minimal", "low", "medium",
"high", or "max". Defaults to "low" if not provided.
response_format: Optional structure for the answer. Pass a Pydantic
@ -296,12 +315,20 @@ class Peer(PeerBase, MetadataConfigMixin):
Response string containing the answer (a JSON string when a schema
dict was given), a parsed model instance when a Pydantic model class
was given, or None if no relevant information.
Raises:
ValueError: If ``scope`` is combined with ``session`` or ``sessions``.
"""
self._honcho._ensure_workspace()
target_id = resolve_id(target)
resolved_session_id = resolve_id(session)
body: dict[str, Any] = {"query": query, "stream": False}
body.update(
scope_recall_fields(
scope=scope, sessions=sessions, session_id=resolved_session_id
)
)
if target_id:
body["target"] = target_id
if resolved_session_id:
@ -330,6 +357,8 @@ class Peer(PeerBase, MetadataConfigMixin):
*,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
sessions: Sequence[str | SessionBase] | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[BaseModel] | dict[str, Any] | None = None,
@ -350,6 +379,9 @@ class Peer(PeerBase, MetadataConfigMixin):
session: Optional session to scope the query to. If provided, only
information from that session is considered. Can be a session
ID string or a Session object.
scope: Optional scope(s) to confine the query to. See :meth:`chat`.
sessions: Optional allowlist of sessions to confine the query to. See
:meth:`chat` for the depth caveat.
reasoning_level: Optional reasoning level for the query: "minimal", "low", "medium",
"high", or "max". Defaults to "low" if not provided.
response_format: Optional structure for the answer: a Pydantic model
@ -360,12 +392,20 @@ class Peer(PeerBase, MetadataConfigMixin):
Returns:
DialecticStreamResponse object that can be iterated over and provides final response
Raises:
ValueError: If ``scope`` is combined with ``session`` or ``sessions``.
"""
self._honcho._ensure_workspace()
target_id = resolve_id(target)
resolved_session_id = resolve_id(session)
body: dict[str, Any] = {"query": query, "stream": True}
body.update(
scope_recall_fields(
scope=scope, sessions=sessions, session_id=resolved_session_id
)
)
if target_id:
body["target"] = target_id
if resolved_session_id:
@ -633,6 +673,9 @@ class Peer(PeerBase, MetadataConfigMixin):
search_max_distance: float | None = Field(None, ge=0.0, le=1.0),
include_most_frequent: bool | None = None,
max_conclusions: int | None = Field(None, ge=1, le=100),
*,
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
sessions: Sequence[str | SessionBase] | None = None,
) -> str:
"""
Get a subset of the representation of the peer.
@ -646,10 +689,18 @@ class Peer(PeerBase, MetadataConfigMixin):
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_frequent: Whether to include the most frequent conclusions
max_conclusions: Maximum number of conclusions to include
scope: Optional scope(s) confining the representation. See
:meth:`chat`. Mutually exclusive with ``session`` and ``sessions``.
sessions: Optional allowlist of sessions confining the representation to
directly-stated conclusions from those sessions. See
:meth:`chat` for the depth caveat.
Returns:
A Representation string
Raises:
ValueError: If ``scope`` is combined with ``session`` or ``sessions``.
Example:
```python
# Get global representation
@ -671,7 +722,9 @@ class Peer(PeerBase, MetadataConfigMixin):
session_id = resolve_id(session)
target_id = resolve_id(target)
body: dict[str, Any] = {}
body: dict[str, Any] = scope_recall_fields(
scope=scope, sessions=sessions, session_id=session_id
)
if session_id:
body["session_id"] = session_id
if target_id:
@ -764,7 +817,7 @@ class Peer(PeerBase, MetadataConfigMixin):
return PeerContextResponse.model_validate(data)
@property
def conclusions(self) -> ConclusionScope:
def conclusions(self) -> ConclusionsView:
"""
Access this peer's self-conclusions (where observer == observed == self).
@ -772,7 +825,7 @@ class Peer(PeerBase, MetadataConfigMixin):
has made about themselves. Use this for self-conclusion scenarios.
Returns:
A ConclusionScope scoped to this peer's self-conclusions
A ConclusionsView scoped to this peer's self-conclusions
Example:
```python
@ -786,9 +839,9 @@ class Peer(PeerBase, MetadataConfigMixin):
peer.conclusions.delete("obs-123")
```
"""
return ConclusionScope(self._honcho, self.workspace_id, self.id, self.id)
return ConclusionsView(self._honcho, self.workspace_id, self.id, self.id)
def conclusions_of(self, target: str | PeerBase) -> ConclusionScope:
def conclusions_of(self, target: str | PeerBase) -> ConclusionsView:
"""
Access conclusions this peer has made about another peer.
@ -799,7 +852,7 @@ class Peer(PeerBase, MetadataConfigMixin):
target: The target peer (either a Peer object or peer ID string)
Returns:
A ConclusionScope scoped to this peer's conclusions of the target
A ConclusionsView scoped to this peer's conclusions of the target
Example:
```python
@ -817,7 +870,7 @@ class Peer(PeerBase, MetadataConfigMixin):
```
"""
target_id = target.id if isinstance(target, PeerBase) else target
return ConclusionScope(self._honcho, self.workspace_id, self.id, target_id)
return ConclusionsView(self._honcho, self.workspace_id, self.id, target_id)
def __repr__(self) -> str:
"""

View File

@ -0,0 +1,233 @@
# pyright: reportPrivateUsage=false
"""Sync Scope class for Honcho SDK."""
from __future__ import annotations
import logging
from collections.abc import Sequence
from datetime import datetime
from typing import TYPE_CHECKING, Any
from pydantic import ConfigDict, PrivateAttr, validate_call
from .api_types import ScopeBackfillJob, ScopeStatusResponse, SessionResponse
from .base import ScopeBase, SessionBase
from .http import routes
from .pagination import SyncPage
from .session import Session
from .utils import resolve_scope_membership, resolve_scope_session
if TYPE_CHECKING:
from .aio import ScopeAio
from .client import Honcho
logger = logging.getLogger(__name__)
__all__ = ["Scope"]
class Scope(ScopeBase):
"""
Represents a scope in Honcho.
A scope is a named set of sessions that acts as a visibility boundary. Recall
performed through a scope sees only what happened in that scope's sessions,
while the underlying peer keeps its single unified representation across
everything it has ever participated in.
Membership changes are applied asynchronously: adding a session that already
has messages copies its existing conclusions into the scope, and removing one
reconciles them back out. Poll :meth:`status` to watch that settle.
Attributes:
id: Unprefixed scope name, unique within the workspace
workspace_id: Workspace ID for scoping operations
metadata: Cached metadata for this scope. May be stale if not recently
fetched.
created_at: When this scope was created, if known
Example:
```python
therapy = honcho.scope("therapy")
therapy.add_sessions([session_1, session_2])
# Ask a question answered only from the therapy sessions
answer = user.chat("What is stressing them out?", scope="therapy")
```
"""
_metadata: dict[str, Any] | None = PrivateAttr(default=None)
_created_at: datetime | None = PrivateAttr(default=None)
_honcho: "Honcho" = PrivateAttr()
@property
def metadata(self) -> dict[str, Any] | None:
"""Cached metadata for this scope. May be stale if not recently fetched."""
return self._metadata
@property
def created_at(self) -> datetime | None:
"""When this scope was created. Only available if fetched from the API."""
return self._created_at
def __init__(
self,
scope_id: str,
honcho: "Honcho",
*,
metadata: dict[str, Any] | None = None,
created_at: datetime | None = None,
) -> None:
"""
Initialize a new Scope.
**Do not call this directly use** ``honcho.scope()``.
Args:
scope_id: Unprefixed scope name, unique within the workspace
honcho: Honcho client instance
metadata: Cached metadata, if already fetched
created_at: Creation timestamp, if already fetched
"""
super().__init__(
id=scope_id,
workspace_id=honcho.workspace_id,
)
self._honcho = honcho
self._metadata = metadata
self._created_at = created_at
@property
def aio(self) -> "ScopeAio":
"""
Access async versions of all Scope methods.
Returns a ScopeAio view that provides async versions of all methods while
sharing state with this Scope instance.
Example:
```python
await scope.aio.add_sessions(["session-1"])
status = await scope.aio.status()
```
"""
# Import here to avoid circular import (aio.py imports this module)
from .aio import ScopeAio
return ScopeAio(self)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def add_sessions(self, sessions: Sequence[str | SessionBase]) -> None:
"""
Add sessions to this scope.
Every named session must already exist. Adding a session that is already
a member is a no-op.
Sessions that already hold messages are backfilled into the scope
asynchronously, so recall through this scope may not reflect their history
immediately poll :meth:`status` to watch that complete.
Args:
sessions: Sessions to add, as ID strings or Session objects. At most
100 per call, matching the server's limit; split larger membership
changes into separate calls so a failure names the batch that
failed.
Raises:
ValueError: If no sessions are given, or more than 100.
"""
session_ids = resolve_scope_membership(sessions)
self._honcho._ensure_workspace()
self._honcho._http.post(
routes.scope_sessions(self.workspace_id, self.id),
body={"session_ids": session_ids},
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def remove_session(self, session: str | SessionBase) -> None:
"""
Remove a session from this scope.
Conclusions copied or derived while the session was a member are
reconciled out asynchronously, and the scope's peer card is rebuilt from
whatever evidence remains. Poll :meth:`status` to watch that settle.
Args:
session: Session to remove, as an ID string or a Session object
"""
self._honcho._ensure_workspace()
self._honcho._http.delete(
routes.scope_session(
self.workspace_id, self.id, resolve_scope_session(session)
)
)
def sessions(
self,
page: int = 1,
size: int = 50,
*,
reverse: bool = False,
) -> SyncPage[SessionResponse, Session]:
"""
Get the sessions that are members of this scope.
Ordered by how long each session has been a member longest-standing
first, or most recently added first when ``reverse`` is True.
Args:
page: Page number (1-indexed)
size: Number of results per page
reverse: If True, reverses the default ordering. Default: False.
Returns:
Paginated response containing Session objects
"""
self._honcho._ensure_workspace()
def fetch(next_page: int) -> dict[str, Any]:
query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
query["reverse"] = "true"
return self._honcho._http.post(
routes.scope_sessions_list(self.workspace_id, self.id),
query=query,
)
def transform(response: SessionResponse) -> Session:
return Session(
response.id,
self._honcho,
metadata=response.metadata,
configuration=response.configuration,
created_at=response.created_at,
is_active=response.is_active,
)
def fetch_next(next_page: int) -> SyncPage[SessionResponse, Session]:
return SyncPage(fetch(next_page), SessionResponse, transform, fetch_next)
return SyncPage(fetch(page), SessionResponse, transform, fetch_next)
def status(self) -> dict[str, ScopeBackfillJob]:
"""
Get the backfill/reconciliation progress for this scope.
Use this after a membership change to tell "the scope knows nothing about
that session yet" apart from "the scope has caught up and there is
genuinely nothing to recall".
Returns:
Per-session backfill state, keyed by session ID. Only sessions that
have had a backfill enqueued appear; an empty dict means none have.
"""
self._honcho._ensure_workspace()
data = self._honcho._http.get(routes.scope_status(self.workspace_id, self.id))
return ScopeStatusResponse.model_validate(data).backfill_status
def __repr__(self) -> str:
return f"Scope(id={self.id!r}, workspace_id={self.workspace_id!r})"
def __str__(self) -> str:
return self.id

View File

@ -5,6 +5,7 @@ from __future__ import annotations
import json
import logging
from collections.abc import Sequence
from datetime import datetime
from typing import TYPE_CHECKING, Any
@ -20,7 +21,7 @@ from .api_types import (
SessionPeerConfig,
SessionResponse,
)
from .base import PeerBase, SessionBase
from .base import PeerBase, ScopeBase, SessionBase
from .http import routes
from .message import Message
from .mixins import MetadataConfigMixin
@ -32,6 +33,7 @@ from .utils import (
normalize_peers_to_dict,
prepare_file_for_upload,
resolve_id,
scope_context_fields,
)
if TYPE_CHECKING:
@ -574,6 +576,14 @@ class Session(SessionBase, MetadataConfigMixin):
None,
description="A peer ID to get context *from the perspective of*. If given, response will attempt to include representation and card from the perspective of `peer_perspective`. Must be provided with `peer_target`.",
),
scope: str | ScopeBase | None = Field(
None,
description="A scope to use as the perspective source instead of a peer: `peer_target`'s representation and card are read from what that scope observed. Must be provided with `peer_target`; mutually exclusive with `peer_perspective`. Requires a workspace-level key.",
),
sessions: Sequence[str | SessionBase] | None = Field(
None,
description="An allowlist of sessions confining `peer_target`'s representation to that set. This session must be one of them. Mutually exclusive with `scope` and `limit_to_session`.",
),
limit_to_session: bool = Field(
False,
description="Whether to limit the representation to this session only. If True, only conclusions from this session will be included.",
@ -616,6 +626,11 @@ class Session(SessionBase, MetadataConfigMixin):
peer_target: A peer ID to get context for.
search_query: A query string for semantic search.
peer_perspective: A peer ID to get context from the perspective of.
scope: A scope to read `peer_target`'s representation and card from.
sessions: An allowlist of sessions confining `peer_target`'s
representation. Recall is limited to conclusions stated directly in
those sessions, and the peer card is omitted, since neither derived
conclusions nor cards carry provable per-session provenance.
limit_to_session: Whether to limit the representation to this session only.
search_top_k: Number of semantically relevant facts to return.
search_max_distance: Maximum semantic distance for search results.
@ -627,6 +642,11 @@ class Session(SessionBase, MetadataConfigMixin):
summary, if available, that maximizes conversational context while
respecting the token limit
Raises:
ValueError: If `peer_target` is missing when required, or if `scope`,
`sessions`, `peer_perspective`, and `limit_to_session` are combined
in ways the server rejects.
Note:
Token counting is performed using tiktoken. For models using different
tokenizers, you may need to adjust the token limit accordingly.
@ -650,6 +670,13 @@ class Session(SessionBase, MetadataConfigMixin):
query: dict[str, Any] = {
"summary": summary,
"limit_to_session": limit_to_session,
**scope_context_fields(
scope=scope,
sessions=sessions,
peer_target=peer_target,
peer_perspective=peer_perspective,
limit_to_session=limit_to_session,
),
}
if tokens is not None:
query["tokens"] = tokens

View File

@ -6,6 +6,15 @@ from .datetime import datetime_to_iso, parse_datetime
from .file_upload import normalize_file_input, prepare_file_for_upload
from .peers import normalize_peers_to_dict
from .resolve import resolve_id
from .scopes import (
resolve_scope_membership,
resolve_scope_option,
resolve_scope_session,
resolve_session_allowlist,
scope_context_fields,
scope_recall_fields,
validate_scope_id,
)
from .sse import SSEStreamParser, parse_sse_astream, parse_sse_chunk, parse_sse_stream
__all__ = [
@ -19,4 +28,11 @@ __all__ = [
"parse_sse_stream",
"prepare_file_for_upload",
"resolve_id",
"resolve_scope_membership",
"resolve_scope_option",
"resolve_scope_session",
"resolve_session_allowlist",
"scope_context_fields",
"scope_recall_fields",
"validate_scope_id",
]

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, overload
if TYPE_CHECKING:
from ..base import PeerBase, SessionBase
from ..base import PeerBase, ScopeBase, SessionBase
@overload
@ -17,12 +17,12 @@ def resolve_id(obj: str) -> str: ...
@overload
def resolve_id(obj: "PeerBase | SessionBase") -> str: ...
def resolve_id(obj: "PeerBase | SessionBase | ScopeBase") -> str: ...
def resolve_id(obj: "str | PeerBase | SessionBase | None") -> str | None:
def resolve_id(obj: "str | PeerBase | SessionBase | ScopeBase | None") -> str | None:
"""
Resolve an ID from a string, PeerBase, SessionBase, or None.
Resolve an ID from a string, PeerBase, SessionBase, ScopeBase, or None.
This utility function extracts the ID from an object that may be:
- A string (returned as-is)

View File

@ -0,0 +1,296 @@
"""Scope and session-allowlist option handling for the Honcho Python SDK.
The ``scope`` and ``sessions`` options appear on several read surfaces (chat,
representation, session context, search). Their validation and their wire
translation live here so those surfaces cannot drift apart the server enforces
the same exclusions with a 422, and this raises before the round trip.
"""
from __future__ import annotations
import re
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any
from .resolve import resolve_id
if TYPE_CHECKING:
from ..base import ScopeBase, SessionBase
__all__ = [
"MAX_SCOPES_PER_OPTION",
"MAX_SESSIONS_PER_ADD",
"MAX_SESSION_ALLOWLIST_ENTRIES",
"resolve_scope_membership",
"resolve_scope_option",
"resolve_scope_session",
"resolve_session_allowlist",
"scope_context_fields",
"scope_recall_fields",
"validate_scope_id",
]
# Scope IDs are stored server-side as peer names with this prefix prepended, so
# they must leave room for it within the 512-character peer name limit.
_SCOPE_PEER_PREFIX = "scope."
MAX_SCOPE_ID_LENGTH = 512 - len(_SCOPE_PEER_PREFIX)
MAX_SCOPES_PER_OPTION = 100
MAX_SESSION_ALLOWLIST_ENTRIES = 1000
# The server accepts at most this many sessions per membership call.
MAX_SESSIONS_PER_ADD = 100
_RESOURCE_NAME_PATTERN = r"^[a-zA-Z0-9_-]+$"
def validate_scope_id(value: str) -> str:
"""Validate an unprefixed scope ID.
Args:
value: The scope ID as the caller supplied it.
Returns:
The validated scope ID, unchanged.
Raises:
ValueError: If the ID is empty, too long, carries the reserved prefix,
or contains characters outside the resource-name charset.
"""
if not 1 <= len(value) <= MAX_SCOPE_ID_LENGTH:
raise ValueError(
f"Scope ID must be between 1 and {MAX_SCOPE_ID_LENGTH} characters"
)
# Checked before the charset: the reserved prefix contains '.', which is
# itself outside the charset, so a charset-first check would report the
# charset instead of the real mistake for a double-prefixed ID.
if value.startswith(_SCOPE_PEER_PREFIX):
raise ValueError(
f"Scope ID must not start with the reserved prefix '{_SCOPE_PEER_PREFIX}' (scope IDs are unprefixed)"
)
if not re.fullmatch(_RESOURCE_NAME_PATTERN, value):
raise ValueError(f"Scope ID must match pattern {_RESOURCE_NAME_PATTERN}")
return value
def resolve_scope_option(
scope: "str | ScopeBase | Sequence[str | ScopeBase]",
) -> str | list[str]:
"""Resolve the ``scope`` read option to its wire value.
A single scope stays a string; a sequence becomes a list of IDs. The two
shapes mean different things to the server one scope reads that scope's own
view, a list restricts recall to the union of their member sessions so the
distinction is preserved rather than normalized away.
Args:
scope: One scope (ID or ``Scope``) or a sequence of them.
Returns:
A single validated scope ID, or a list of them.
Raises:
ValueError: On an empty sequence, an over-cap sequence, or an invalid ID.
"""
# ``str`` is itself a Sequence, so both single-scope forms — an ID and a
# ``Scope`` — are taken first; whatever remains is the list form.
if isinstance(scope, str) or not isinstance(scope, Sequence):
return validate_scope_id(resolve_id(scope))
ids = [validate_scope_id(resolve_id(entry)) for entry in scope]
if not ids:
# An empty list would resolve to an empty allowlist server-side and
# silently recall nothing, which is never the intent.
raise ValueError("scope must name at least one scope")
if len(ids) > MAX_SCOPES_PER_OPTION:
raise ValueError(f"scope can name at most {MAX_SCOPES_PER_OPTION} scopes")
return ids
def resolve_session_allowlist(
sessions: "Sequence[str | SessionBase]",
) -> list[str]:
"""Resolve the ``sessions`` allowlist option to a list of session IDs.
Args:
sessions: Sessions to allow, as IDs or ``Session`` objects.
Returns:
The session IDs, in the order given.
Raises:
ValueError: On an empty list or one over the server's cap.
"""
ids = [resolve_id(entry) for entry in sessions]
if not ids:
# The server treats an empty allowlist as fail-closed (recalls nothing),
# so an empty list here is a caller mistake rather than a query.
raise ValueError("sessions must name at least one session")
if len(ids) > MAX_SESSION_ALLOWLIST_ENTRIES:
raise ValueError(
f"sessions can name at most {MAX_SESSION_ALLOWLIST_ENTRIES} sessions"
)
return ids
def _validate_session_id(value: str) -> str:
"""Validate a session ID against the charset the server accepts.
Args:
value: The session ID as the caller supplied it.
Returns:
The validated session ID, unchanged.
Raises:
ValueError: If the ID is empty or contains characters outside the
resource-name charset.
"""
if not value:
raise ValueError("Session ID must be a non-empty string")
if not re.fullmatch(_RESOURCE_NAME_PATTERN, value):
raise ValueError(f"Session ID must match pattern {_RESOURCE_NAME_PATTERN}")
return value
def resolve_scope_session(session: "str | SessionBase") -> str:
"""Resolve and validate a single session ID for a scope membership change.
Validated rather than passed through because this ID is interpolated into a
request *path*: an unvalidated value silently changes which resource the
request addresses. ``valid-session?typo`` would target ``valid-session``
with a stray query string, removing the wrong session from the scope and
triggering reconciliation against it.
Args:
session: The session, as an ID or a ``Session`` object.
Returns:
The validated session ID.
Raises:
ValueError: If the ID is empty or malformed.
"""
return _validate_session_id(resolve_id(session))
def resolve_scope_membership(
sessions: "Sequence[str | SessionBase]",
) -> list[str]:
"""Resolve a scope membership change to a list of session IDs.
Capped at the server's per-call limit rather than silently chunking, so a
rejected batch is the batch the caller passed.
Args:
sessions: Sessions to add, as IDs or ``Session`` objects.
Returns:
The session IDs, in the order given.
Raises:
ValueError: On an empty list, one over the server's per-call cap, or a
malformed session ID.
"""
ids = [_validate_session_id(resolve_id(session)) for session in sessions]
if not ids:
raise ValueError("At least one session must be given")
if len(ids) > MAX_SESSIONS_PER_ADD:
raise ValueError(
f"At most {MAX_SESSIONS_PER_ADD} sessions can be added per call"
)
return ids
def scope_context_fields(
*,
scope: "str | ScopeBase | None",
sessions: "Sequence[str | SessionBase] | None",
peer_target: str | None,
peer_perspective: str | None,
limit_to_session: bool,
) -> dict[str, Any]:
"""Build the query fields for ``scope``/``sessions`` on the context route.
Unlike the recall endpoints, session context takes these as query parameters
``sessions`` is sent as a repeated parameter, not as a ``filters`` body.
Only a single scope is accepted: a scope is the *perspective source* for the
target's representation and card, which is one observer, so a list has no
meaning here.
Args:
scope: The ``scope`` option, if given.
sessions: The ``sessions`` allowlist option, if given.
peer_target: The observed peer. Required by either option, since both only
reach the representation and there is none without a target.
peer_perspective: The observing peer, if given a scope replaces it.
limit_to_session: Whether recall is already pinned to this session alone.
Returns:
The fields to merge into the query. Empty when neither option is set.
Raises:
ValueError: If either option is combined with something it contradicts, or
used without ``peer_target``.
"""
# A scope already determines what the context can see, and limit_to_session
# already pins recall to this session alone, so combining them with a
# perspective or an allowlist is a contradiction rather than a narrowing.
# Raised here so the caller does not pay a round trip for a 422.
if sessions is not None:
if scope is not None:
raise ValueError("`sessions` and `scope` are mutually exclusive")
if limit_to_session:
raise ValueError("`sessions` and `limit_to_session` are mutually exclusive")
if peer_target is None:
raise ValueError(
"You must provide a `peer_target` when `sessions` is provided"
)
return {"sessions": resolve_session_allowlist(sessions)}
if scope is None:
return {}
if peer_perspective is not None:
raise ValueError("`scope` and `peer_perspective` are mutually exclusive")
if peer_target is None:
raise ValueError("You must provide a `peer_target` when `scope` is provided")
return {"scope": validate_scope_id(resolve_id(scope))}
def scope_recall_fields(
*,
scope: "str | ScopeBase | Sequence[str | ScopeBase] | None",
sessions: "Sequence[str | SessionBase] | None",
session_id: str | None = None,
) -> dict[str, Any]:
"""Build the request-body fields for the ``scope``/``sessions`` options.
``sessions`` is sugar: it goes on the wire as the constrained
``filters: {"session_id": [...]}`` body the recall endpoints accept, never as
a field of its own, which the server would reject as an unknown key.
Args:
scope: The ``scope`` option, if given.
sessions: The ``sessions`` allowlist option, if given.
session_id: A single session already set on the request, if any a scope
already determines what can be seen, so the two conflict.
Returns:
The fields to merge into the request body. Empty when neither option is
set.
Raises:
ValueError: If ``scope`` is combined with ``sessions`` or ``session_id``,
or if either option is itself invalid.
"""
if scope is None:
if sessions is None:
return {}
return {"filters": {"session_id": resolve_session_allowlist(sessions)}}
if sessions is not None:
raise ValueError("`scope` and `sessions` are mutually exclusive")
if session_id is not None:
raise ValueError("`scope` and `session` are mutually exclusive")
return {"scope": resolve_scope_option(scope)}

View File

@ -1,7 +1,7 @@
/**
* Conclusions Tests
*
* Tests for Conclusion operations via ConclusionScope.
* Tests for Conclusion operations via ConclusionsView.
*
* Endpoints covered:
* - POST /v3/workspaces/:workspaceId/conclusions (create conclusions)
@ -11,7 +11,7 @@
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
import { Honcho, Conclusion, ConclusionScope } from '../src'
import { Honcho, Conclusion, ConclusionsView } from '../src'
import { createTestClient, requireServer } from './setup'
import { assertConclusionShape } from './helpers'
@ -31,16 +31,16 @@ describe('Conclusions', () => {
})
// ===========================================================================
// ConclusionScope Access
// ConclusionsView Access
// ===========================================================================
describe('ConclusionScope access', () => {
describe('ConclusionsView access', () => {
test('peer.conclusions returns self-scope', async () => {
const peer = await client.peer('self-scope-peer')
const scope = peer.conclusions
expect(scope).toBeInstanceOf(ConclusionScope)
expect(scope).toBeInstanceOf(ConclusionsView)
expect(scope.observer).toBe(peer.id)
expect(scope.observed).toBe(peer.id)
expect(scope.workspaceId).toBe(client.workspaceId)
@ -289,7 +289,7 @@ describe('Conclusions', () => {
for (const key of ['observer', 'observed', 'observer_id', 'observed_id']) {
await expect(
peer.conclusions.list({ filters: { [key]: 'someone-else' } })
).rejects.toThrow(/managed by this conclusion scope/)
).rejects.toThrow(/managed by this conclusions view/)
}
})
@ -298,10 +298,10 @@ describe('Conclusions', () => {
await expect(
peer.conclusions.list({ filters: { session_id: 'sess' } })
).rejects.toThrow(/managed by this conclusion scope/)
).rejects.toThrow(/managed by this conclusions view/)
await expect(
peer.conclusions.list({ filters: { session: 'sess' } })
).rejects.toThrow(/managed by this conclusion scope/)
).rejects.toThrow(/managed by this conclusions view/)
})
test('query rejects observer/observed scope keys in filters', async () => {
@ -310,7 +310,7 @@ describe('Conclusions', () => {
for (const key of ['observer', 'observed', 'observer_id', 'observed_id']) {
await expect(
peer.conclusions.query('q', 10, undefined, { [key]: 'someone-else' })
).rejects.toThrow(/managed by this conclusion scope/)
).rejects.toThrow(/managed by this conclusions view/)
}
})
@ -464,16 +464,16 @@ describe('Conclusions', () => {
})
// ===========================================================================
// ConclusionScope toString
// ConclusionsView toString
// ===========================================================================
describe('ConclusionScope toString', () => {
describe('ConclusionsView toString', () => {
test('returns readable format', async () => {
const peer = await client.peer('scope-tostring-peer')
const str = peer.conclusions.toString()
expect(str).toContain('ConclusionScope')
expect(str).toContain('ConclusionsView')
expect(str).toContain(peer.id)
expect(str).toContain(client.workspaceId)
})

View File

@ -237,6 +237,39 @@ describe('URL building', () => {
expect(url.searchParams.get('present')).toBe('value')
expect(url.searchParams.has('missing')).toBe(false)
})
test('sends array query parameters as repeated params, not comma-joined', async () => {
let capturedURL = ''
globalThis.fetch = async (url) => {
capturedURL = url.toString()
return mockResponse({ ok: true })
}
await client.get('/v1/test', {
query: { sessions: ['session-a', 'session-b'] },
})
const url = new URL(capturedURL)
// The API reads list-valued params as ?k=a&k=b. A comma-joined single value
// would arrive as one malformed entry.
expect(url.searchParams.getAll('sessions')).toEqual([
'session-a',
'session-b',
])
})
test('an empty array query parameter contributes nothing', async () => {
let capturedURL = ''
globalThis.fetch = async (url) => {
capturedURL = url.toString()
return mockResponse({ ok: true })
}
await client.get('/v1/test', { query: { sessions: [] } })
const url = new URL(capturedURL)
expect(url.searchParams.has('sessions')).toBe(false)
})
})
// =============================================================================

View File

@ -671,7 +671,7 @@ describe('Peer', () => {
// ===========================================================================
describe('Conclusion scope access', () => {
test('conclusions property returns ConclusionScope for self', async () => {
test('conclusions property returns ConclusionsView for self', async () => {
const peer = await client.peer('self-conclusions-peer')
const scope = peer.conclusions
@ -681,7 +681,7 @@ describe('Peer', () => {
expect(scope.workspaceId).toBe(client.workspaceId)
})
test('conclusionsOf returns ConclusionScope for target', async () => {
test('conclusionsOf returns ConclusionsView for target', async () => {
const observer = await client.peer('obs-conclusions-peer')
const target = await client.peer('target-conclusions-peer')

View File

@ -0,0 +1,389 @@
import { describe, expect, test } from 'bun:test'
import { ZodError } from 'zod'
import type { HonchoHTTPClient } from '../src/http/client'
import { Peer } from '../src/peer'
import { Scope } from '../src/scope'
import { Session } from '../src/session'
import type { ScopeStatusResponse } from '../src/types/api'
/**
* Capture the body of the single request a call makes, so the wire shape the
* server actually receives is asserted rather than the SDK's own options.
*/
function capturingHttp(response: unknown): {
http: HonchoHTTPClient
body: () => Record<string, unknown> | undefined
query: () => Record<string, unknown> | undefined
path: () => string | undefined
} {
let capturedBody: Record<string, unknown> | undefined
let capturedQuery: Record<string, unknown> | undefined
let capturedPath: string | undefined
const http = {
post: async (
path: string,
options?: { body?: Record<string, unknown> }
) => {
capturedPath = path
capturedBody = options?.body
return response
},
get: async (
path: string,
options?: { query?: Record<string, unknown> }
) => {
capturedPath = path
capturedQuery = options?.query
return response
},
delete: async (path: string) => {
capturedPath = path
return undefined
},
} as unknown as HonchoHTTPClient
return {
http,
body: () => capturedBody,
query: () => capturedQuery,
path: () => capturedPath,
}
}
describe('sessions allowlist sugar', () => {
test('chat sends `sessions` as a session_id filter, not a bare field', async () => {
const { http, body } = capturingHttp({ content: 'ok' })
const peer = new Peer('alice', 'workspace-1', http)
await peer.chat('what happened?', {
sessions: ['session-a', new Session('session-b', 'workspace-1', http)],
})
expect(body()).toMatchObject({
filters: { session_id: ['session-a', 'session-b'] },
})
// The sugar must not leak through as its own wire field — the server would
// reject an unknown key.
expect(body()).not.toHaveProperty('sessions')
})
test('representation sends `sessions` as a session_id filter', async () => {
const { http, body } = capturingHttp({ representation: 'ok' })
const peer = new Peer('alice', 'workspace-1', http)
await peer.representation({ sessions: ['session-a'] })
expect(body()).toMatchObject({ filters: { session_id: ['session-a'] } })
})
test('an empty allowlist is rejected rather than silently recalling nothing', async () => {
const { http } = capturingHttp({ content: 'ok' })
const peer = new Peer('alice', 'workspace-1', http)
await expect(peer.chat('q', { sessions: [] })).rejects.toBeInstanceOf(
ZodError
)
})
})
describe('scope read option', () => {
test('a single scope passes through as `scope`', async () => {
const { http, body } = capturingHttp({ content: 'ok' })
const peer = new Peer('alice', 'workspace-1', http)
await peer.chat('q', { scope: 'therapy' })
expect(body()).toMatchObject({ scope: 'therapy' })
})
test('a Scope object resolves to its id', async () => {
const { http, body } = capturingHttp({ content: 'ok' })
const peer = new Peer('alice', 'workspace-1', http)
await peer.chat('q', { scope: new Scope('therapy', 'workspace-1', http) })
expect(body()).toMatchObject({ scope: 'therapy' })
})
test('a list of scopes passes through as a list', async () => {
const { http, body } = capturingHttp({ content: 'ok' })
const peer = new Peer('alice', 'workspace-1', http)
await peer.chat('q', { scope: ['therapy', 'work'] })
expect(body()).toMatchObject({ scope: ['therapy', 'work'] })
})
test('scope and sessions are rejected together', async () => {
const { http } = capturingHttp({ content: 'ok' })
const peer = new Peer('alice', 'workspace-1', http)
await expect(
peer.chat('q', { scope: 'therapy', sessions: ['session-a'] })
).rejects.toBeInstanceOf(ZodError)
})
test('scope and a single session are rejected together', async () => {
const { http } = capturingHttp({ content: 'ok' })
const peer = new Peer('alice', 'workspace-1', http)
await expect(
peer.chat('q', { scope: 'therapy', session: 'session-a' })
).rejects.toBeInstanceOf(ZodError)
})
})
describe('scope id validation', () => {
test('a prefixed name reports the reserved prefix, not the charset', async () => {
const { http } = capturingHttp({ content: 'ok' })
const peer = new Peer('alice', 'workspace-1', http)
// 'scope.therapy' fails both rules; the prefix message is the useful one,
// so it must be the only one raised.
const error = await peer
.chat('q', { scope: 'scope.therapy' })
.then(() => undefined)
.catch((err: unknown) => err as ZodError)
expect(error).toBeInstanceOf(ZodError)
const messages = (error as ZodError).issues.map((issue) => issue.message)
expect(messages.some((m) => m.includes('reserved prefix'))).toBe(true)
expect(messages.some((m) => m.includes('may only contain'))).toBe(false)
})
test('a name with illegal characters is rejected', async () => {
const { http } = capturingHttp({ content: 'ok' })
const peer = new Peer('alice', 'workspace-1', http)
await expect(peer.chat('q', { scope: 'my scope' })).rejects.toBeInstanceOf(
ZodError
)
})
test('the specific message survives the scope option union', async () => {
// ScopeOptionSchema is a union. Zod collapses a failing union into a single
// `invalid_union` / "Invalid input" issue and buries the branch errors, so
// the rules are applied after the union resolves. Without that, every bad
// scope reports "Invalid input" and the caller learns nothing.
for (const [input, expected] of [
['scope.therapy', 'reserved prefix'],
['my scope', 'may only contain'],
['', 'non-empty'],
['a'.repeat(507), 'at most 506'],
] as const) {
const { http } = capturingHttp({ content: 'ok' })
const peer = new Peer('alice', 'workspace-1', http)
const error = (await peer
.chat('q', { scope: input })
.then(() => undefined)
.catch((err: unknown) => err)) as ZodError
expect(error).toBeInstanceOf(ZodError)
const messages = error.issues.map((i) => i.message).join(' | ')
expect(messages).toContain(expected)
expect(messages).not.toContain('Invalid input')
}
})
test('list-form messages also survive the union', async () => {
const { http } = capturingHttp({ content: 'ok' })
const peer = new Peer('alice', 'workspace-1', http)
for (const [input, expected] of [
[[], 'at least one scope'],
[['ok', 'scope.bad'], 'reserved prefix'],
[Array.from({ length: 101 }, (_, i) => `s${i}`), 'at most 100 scopes'],
] as const) {
const error = (await peer
.chat('q', { scope: input as string[] })
.then(() => undefined)
.catch((err: unknown) => err)) as ZodError
expect(error.issues.map((i) => i.message).join(' | ')).toContain(expected)
}
})
})
describe('empty-string options fail closed', () => {
test("session.context rejects scope: '' instead of returning unscoped context", async () => {
const { http, query } = capturingHttp({
id: 'session-a',
messages: [],
summary: null,
peer_representation: null,
peer_card: null,
})
const session = new Session('session-a', 'workspace-1', http)
// A truthiness check here would drop the option and silently return the
// unscoped context — the opposite of what an invalid scope should do.
await expect(
session.context({ peerTarget: 'user', scope: '' })
).rejects.toBeInstanceOf(ZodError)
expect(query()).toBeUndefined()
})
})
describe('scope membership ids are validated before reaching a URL', () => {
test('removeSession rejects an id that would alter the request path', async () => {
const { http, path } = capturingHttp(undefined)
const scope = new Scope('therapy', 'workspace-1', http)
// `valid-session?typo` would address `valid-session` with a stray query
// string, removing the wrong session and reconciling against it.
await expect(
scope.removeSession('valid-session?typo')
).rejects.toBeInstanceOf(ZodError)
expect(path()).toBeUndefined()
})
test('addSessions rejects the same shape', async () => {
const { http, body } = capturingHttp(undefined)
const scope = new Scope('therapy', 'workspace-1', http)
await expect(
scope.addSessions(['ok-session', 'valid-session?typo'])
).rejects.toBeInstanceOf(ZodError)
expect(body()).toBeUndefined()
})
})
describe('session.context scoping', () => {
const contextResponse = {
id: 'session-a',
messages: [],
summary: null,
peer_representation: null,
peer_card: null,
}
test('scope and sessions are sent as their own query params', async () => {
const { http, query } = capturingHttp(contextResponse)
const session = new Session('session-a', 'workspace-1', http)
await session.context({
peerTarget: 'user',
sessions: ['session-a', 'session-b'],
})
// An array here relies on the HTTP client emitting repeated params; see
// http-client.test.ts.
expect(query()).toMatchObject({ sessions: ['session-a', 'session-b'] })
})
test('sessions without peerTarget is rejected, not silently ignored', async () => {
const { http } = capturingHttp(contextResponse)
const session = new Session('session-a', 'workspace-1', http)
await expect(
session.context({ sessions: ['session-a'] })
).rejects.toBeInstanceOf(ZodError)
})
test('sessions and scope are rejected together', async () => {
const { http } = capturingHttp(contextResponse)
const session = new Session('session-a', 'workspace-1', http)
await expect(
session.context({
peerTarget: 'user',
scope: 'therapy',
sessions: ['session-a'],
})
).rejects.toBeInstanceOf(ZodError)
})
test('sessions and limitToSession are rejected together', async () => {
const { http } = capturingHttp(contextResponse)
const session = new Session('session-a', 'workspace-1', http)
await expect(
session.context({
peerTarget: 'user',
limitToSession: true,
sessions: ['session-a'],
})
).rejects.toBeInstanceOf(ZodError)
})
test('scope and peerPerspective are rejected together', async () => {
const { http } = capturingHttp(contextResponse)
const session = new Session('session-a', 'workspace-1', http)
await expect(
session.context({
peerTarget: 'user',
peerPerspective: 'assistant',
scope: 'therapy',
})
).rejects.toBeInstanceOf(ZodError)
})
})
describe('Scope membership and status', () => {
test('addSessions posts session_ids and resolves Session objects', async () => {
const { http, body, path } = capturingHttp(undefined)
const scope = new Scope('therapy', 'workspace-1', http)
await scope.addSessions([
'session-a',
new Session('session-b', 'workspace-1', http),
])
expect(path()).toBe('/v3/workspaces/workspace-1/scopes/therapy/sessions')
expect(body()).toEqual({ session_ids: ['session-a', 'session-b'] })
})
test('addSessions rejects a batch over the server limit instead of chunking', async () => {
const { http } = capturingHttp(undefined)
const scope = new Scope('therapy', 'workspace-1', http)
const tooMany = Array.from({ length: 101 }, (_, i) => `session-${i}`)
await expect(scope.addSessions(tooMany)).rejects.toBeInstanceOf(ZodError)
})
test('removeSession targets the session subpath', async () => {
const { http, path } = capturingHttp(undefined)
const scope = new Scope('therapy', 'workspace-1', http)
await scope.removeSession(new Session('session-b', 'workspace-1', http))
expect(path()).toBe(
'/v3/workspaces/workspace-1/scopes/therapy/sessions/session-b'
)
})
test('status maps snake_case job fields to camelCase', async () => {
const response: ScopeStatusResponse = {
backfill_status: {
'session-a': {
state: 'completed',
updated_at: '2024-01-01T00:00:00Z',
docs_copied: 12,
},
'session-b': { state: 'pending', updated_at: '2024-01-02T00:00:00Z' },
},
}
const { http } = capturingHttp(response)
const scope = new Scope('therapy', 'workspace-1', http)
const status = await scope.status()
expect(status.backfillStatus['session-a']).toEqual({
state: 'completed',
updatedAt: '2024-01-01T00:00:00Z',
docsCopied: 12,
})
expect(status.backfillStatus['session-b']?.docsCopied).toBeUndefined()
})
test('status on a scope with no backfill is an empty map, not a throw', async () => {
// The server omits the key entirely when nothing was ever enqueued.
const { http } = capturingHttp({} as ScopeStatusResponse)
const scope = new Scope('therapy', 'workspace-1', http)
const status = await scope.status()
expect(status.backfillStatus).toEqual({})
})
})

View File

@ -3,6 +3,7 @@ import { HonchoHTTPClient } from './http/client'
import { Message } from './message'
import { Page } from './pagination'
import { Peer } from './peer'
import { Scope } from './scope'
import { Session } from './session'
import type {
MessageResponse,
@ -11,6 +12,7 @@ import type {
QueueStatus,
QueueStatusParams,
QueueStatusResponse,
ScopeResponse,
SessionResponse,
WorkspaceResponse,
} from './types/api'
@ -32,12 +34,14 @@ import {
peerConfigFromApi,
peerConfigToApi,
type QueueStatusOptions,
ScopeIdSchema,
SearchQuerySchema,
type SessionConfig,
SessionConfigSchema,
SessionIdSchema,
type SessionMetadata,
SessionMetadataSchema,
SessionScopesSchema,
sessionConfigFromApi,
sessionConfigToApi,
type WorkspaceConfig,
@ -254,6 +258,7 @@ export class Honcho {
params: {
query: string
filters?: Record<string, unknown>
scope?: string
limit?: number
}
): Promise<MessageResponse[]> {
@ -314,6 +319,39 @@ export class Honcho {
)
}
private async _getOrCreateScope(
workspaceId: string,
params: {
id: string
metadata?: Record<string, unknown>
}
): Promise<ScopeResponse> {
return this._http.post<ScopeResponse>(
`/${API_VERSION}/workspaces/${workspaceId}/scopes`,
{ body: params }
)
}
private async _listScopes(
workspaceId: string,
params?: {
page?: number
size?: number
reverse?: boolean
}
): Promise<PageResponse<ScopeResponse>> {
return this._http.post<PageResponse<ScopeResponse>>(
`/${API_VERSION}/workspaces/${workspaceId}/scopes/list`,
{
query: {
page: params?.page,
size: params?.size,
reverse: params?.reverse ? 'true' : undefined,
},
}
)
}
private async _listSessions(
workspaceId: string,
params?: {
@ -346,6 +384,7 @@ export class Honcho {
string,
{ observe_me?: boolean | null; observe_others?: boolean | null }
>
scopes?: string[]
}
): Promise<SessionResponse> {
return this._http.post<SessionResponse>(
@ -356,6 +395,7 @@ export class Honcho {
metadata: params.metadata,
configuration: sessionConfigToApi(params.configuration),
peers: params.peers,
scopes: params.scopes,
},
}
)
@ -504,6 +544,10 @@ export class Honcho {
* @param options.peers - Optional peers to attach to the session at creation.
* Accepts the same shape as `session.addPeers()` (peer ID strings,
* Peer objects, arrays of either, or a record with per-peer config).
* @param options.scopes - Optional scopes this session should join. Each scope is
* created if it does not exist yet. Attaching at creation avoids the
* asynchronous backfill that a later `scope.addSessions()` triggers,
* since there is no history to copy.
* @returns Promise resolving to a Session object that can be used to add peers,
* send messages, and manage conversation context
* @throws Error if the session ID is empty or invalid
@ -514,6 +558,7 @@ export class Honcho {
metadata?: SessionMetadata
configuration?: SessionConfig
peers?: PeerAddition
scopes?: (string | Scope)[]
}
): Promise<Session> {
await this._ensureWorkspace()
@ -528,12 +573,17 @@ export class Honcho {
options?.peers !== undefined
? PeerAdditionToApiSchema.parse(options.peers)
: undefined
const validatedScopes =
options?.scopes !== undefined
? SessionScopesSchema.parse(options.scopes.map(resolveId))
: undefined
const sessionData = await this._getOrCreateSession(this.workspaceId, {
id: validatedId,
configuration: validatedConfiguration,
metadata: validatedMetadata,
peers: validatedPeers,
scopes: validatedScopes,
})
return new Session(
validatedId,
@ -547,6 +597,90 @@ export class Honcho {
)
}
/**
* Get or create a scope with the given ID.
*
* A scope is a named set of sessions that acts as a visibility boundary: recall
* performed through the scope sees only what happened in its sessions, while the
* underlying peer keeps its single unified representation of everything.
*
* @param id - Unprefixed scope name, unique within the workspace
* @param options.metadata - Optional metadata to associate with this scope
* @returns Promise resolving to a Scope object for managing membership
* @throws Error if the scope ID is empty or invalid, or if a peer already occupies
* the scope's reserved internal name
*
* @example
* ```typescript
* const therapy = await honcho.scope('therapy')
* await therapy.addSessions([session1, session2])
* ```
*/
async scope(
id: string,
options?: {
metadata?: Record<string, unknown>
}
): Promise<Scope> {
await this._ensureWorkspace()
const validatedId = ScopeIdSchema.parse(id)
const scopeData = await this._getOrCreateScope(this.workspaceId, {
id: validatedId,
metadata: options?.metadata,
})
return new Scope(
validatedId,
this.workspaceId,
this._http,
scopeData.metadata ?? undefined,
() => this._ensureWorkspace(),
scopeData.created_at
)
}
/**
* Get all scopes in the current workspace.
*
* @param options - Pagination options: `page`, `size`, and `reverse`
* @returns Promise resolving to a Page of Scope objects. Returns an empty page if
* no scopes exist
*/
async scopes(options?: {
page?: number
size?: number
reverse?: boolean
}): Promise<Page<Scope, ScopeResponse>> {
await this._ensureWorkspace()
const reverse = options?.reverse
const scopesPage = await this._listScopes(this.workspaceId, {
page: options?.page,
size: options?.size,
reverse,
})
const fetchNextPage = async (
page: number,
size: number
): Promise<PageResponse<ScopeResponse>> => {
return this._listScopes(this.workspaceId, { page, size, reverse })
}
return new Page(
scopesPage,
(scope) =>
new Scope(
scope.id,
this.workspaceId,
this._http,
scope.metadata ?? undefined,
() => this._ensureWorkspace(),
scope.created_at
),
fetchNextPage
)
}
/**
* Get all sessions in the current workspace.
*
@ -775,6 +909,9 @@ export class Honcho {
*
* @param query - The search query to use
* @param filters - Optional filters to scope the search. See [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters).
* @param options.scope - Optional scope to restrict the search to that scope's member
* sessions. Mutually exclusive with a `session_id` filter. A scope
* with no member sessions matches nothing rather than everything.
* @param limit - Number of results to return (1-100, default: 10).
* @returns Promise resolving to an array of Message objects representing the search results.
* Returns an empty array if no messages are found.
@ -784,6 +921,7 @@ export class Honcho {
query: string,
options?: {
filters?: Filters
scope?: string | Scope
limit?: number
}
): Promise<Message[]> {
@ -792,12 +930,19 @@ export class Honcho {
const validatedFilters = options?.filters
? FilterSchema.parse(options.filters)
: undefined
// Checked against undefined, not truthiness: `scope: ''` is invalid, and
// dropping it silently would diverge from the Python SDK, which rejects it.
const validatedScope =
options?.scope !== undefined
? ScopeIdSchema.parse(resolveId(options.scope))
: undefined
const validatedLimit = options?.limit
? LimitSchema.parse(options.limit)
: undefined
const response = await this._searchWorkspace(this.workspaceId, {
query: validatedQuery,
filters: validatedFilters,
scope: validatedScope,
limit: validatedLimit,
})
return response.map(Message.fromApiResponse)

View File

@ -12,10 +12,10 @@ import type {
import { normalizeSearchQuery, RepresentationOptionsSchema } from './validation'
/**
* Filter keys that define a conclusion scope (the observer/observed peer pair).
* They are set from the scope itself, so a caller must not pass them in `filters`.
* Filter keys that define a conclusions view (the observer/observed peer pair).
* They are set from the view itself, so a caller must not pass them in `filters`.
*/
const SCOPE_RESERVED_KEYS = [
const VIEW_RESERVED_KEYS = [
'observer',
'observed',
'observer_id',
@ -23,11 +23,11 @@ const SCOPE_RESERVED_KEYS = [
]
/**
* Throw if `filters` contains keys managed by the conclusion scope.
* Throw if `filters` contains keys managed by the conclusions view.
*
* The observer/observed peer pair (and, on `list`, the session) is fixed by the
* scope, so letting a user filter override it would silently return data from a
* different scope than requested. Fail loud instead.
* view, so letting a user filter override it would silently return data from a
* different pair than requested. Fail loud instead.
*/
function rejectReservedFilterKeys(
filters: Record<string, unknown> | undefined,
@ -42,7 +42,7 @@ function rejectReservedFilterKeys(
guidance += '; use the session option to filter by session'
}
throw new Error(
`Filter key(s) ${clash.join(', ')} are managed by this conclusion scope ` +
`Filter key(s) ${clash.join(', ')} are managed by this conclusions view ` +
`and cannot be passed in filters. ${guidance}.`
)
}
@ -120,7 +120,7 @@ export class Conclusion {
/**
* Scoped access to conclusions for a specific observer/observed relationship.
*/
export class ConclusionScope {
export class ConclusionsView {
private _http: HonchoHTTPClient
private _ensureWorkspace: () => Promise<void>
readonly workspaceId: string
@ -223,14 +223,14 @@ export class ConclusionScope {
// ===========================================================================
/**
* List conclusions in this scope.
* List conclusions in this view.
*
* @param options - Optional configuration for the list request
* @param options.page - Page number (1-indexed, default: 1)
* @param options.size - Number of items per page (default: 50)
* @param options.session - Optional session (ID string or Session object) to filter by
* @param options.filters - Optional additional filter criteria, merged with
* this scope's observer/observed (and session, if given). Supports the same
* this view's observer/observed (and session, if given). Supports the same
* operators as other list endpoints e.g. `{ level: 'explicit' }` to get
* only conclusions extracted directly from messages (i.e. not derived during
* dreaming). See
@ -245,7 +245,7 @@ export class ConclusionScope {
reverse?: boolean
}): Promise<Page<Conclusion, ConclusionResponse>> {
rejectReservedFilterKeys(options?.filters, [
...SCOPE_RESERVED_KEYS,
...VIEW_RESERVED_KEYS,
'session',
'session_id',
])
@ -284,13 +284,13 @@ export class ConclusionScope {
}
/**
* Semantic search for conclusions in this scope.
* Semantic search for conclusions in this view.
*
* @param query - The search query string
* @param topK - Maximum number of results to return (default: 10)
* @param distance - Maximum cosine distance threshold (0.0-1.0)
* @param filters - Optional additional filter criteria, merged with this
* scope's observer/observed. Supports the same operators as the list
* view's observer/observed. Supports the same operators as the list
* endpoint e.g. `{ level: 'deductive' }` to search only conclusions
* derived during dreaming. See
* https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
@ -301,7 +301,7 @@ export class ConclusionScope {
distance?: number,
filters?: Record<string, unknown>
): Promise<Conclusion[]> {
rejectReservedFilterKeys(filters, SCOPE_RESERVED_KEYS)
rejectReservedFilterKeys(filters, VIEW_RESERVED_KEYS)
const response = await this._query({
query,
top_k: topK,
@ -324,7 +324,7 @@ export class ConclusionScope {
}
/**
* Create conclusions in this scope.
* Create conclusions in this view.
*/
async create(
conclusions: ConclusionCreateParams | ConclusionCreateParams[]
@ -351,7 +351,7 @@ export class ConclusionScope {
}
/**
* Get the computed representation for this scope.
* Get the computed representation for this view.
*/
async representation(options?: RepresentationOptions): Promise<string> {
const searchQuery = normalizeSearchQuery(options?.searchQuery)
@ -375,6 +375,6 @@ export class ConclusionScope {
}
toString(): string {
return `ConclusionScope(workspaceId='${this.workspaceId}', observer='${this.observer}', observed='${this.observed}')`
return `ConclusionsView(workspaceId='${this.workspaceId}', observer='${this.observer}', observed='${this.observed}')`
}
}

View File

@ -6,18 +6,27 @@ import {
TimeoutError,
} from './errors'
/**
* Query parameters for a request. An array value is sent as repeated
* parameters (`?k=a&k=b`), which is how the API reads list-valued parameters.
*/
export type QueryParams = Record<
string,
string | number | boolean | readonly (string | number | boolean)[] | undefined
>
export interface HonchoHTTPClientConfig {
baseURL: string
apiKey?: string
timeout?: number
maxRetries?: number
defaultHeaders?: Record<string, string>
defaultQuery?: Record<string, string | number | boolean | undefined>
defaultQuery?: QueryParams
}
export interface RequestOptions {
body?: unknown
query?: Record<string, string | number | boolean | undefined>
query?: QueryParams
headers?: Record<string, string>
timeout?: number
signal?: AbortSignal
@ -37,7 +46,7 @@ export class HonchoHTTPClient {
readonly timeout: number
readonly maxRetries: number
readonly defaultHeaders: Record<string, string>
readonly defaultQuery?: Record<string, string | number | boolean | undefined>
readonly defaultQuery?: QueryParams
constructor(config: HonchoHTTPClientConfig) {
// Remove trailing slash from baseURL
@ -273,21 +282,28 @@ export class HonchoHTTPClient {
return JSON.parse(text) as T
}
private buildURL(
path: string,
query?: Record<string, string | number | boolean | undefined>
): string {
private buildURL(path: string, query?: QueryParams): string {
const url = new URL(path, this.baseURL)
const mergedQuery: Record<string, string | number | boolean | undefined> = {
const mergedQuery: QueryParams = {
...(this.defaultQuery ?? {}),
...(query ?? {}),
}
for (const [key, value] of Object.entries(mergedQuery)) {
if (value !== undefined) {
url.searchParams.set(key, String(value))
if (value === undefined) {
continue
}
if (Array.isArray(value)) {
// Repeated params, not a comma-joined value: the API reads list-valued
// query parameters as `?k=a&k=b`, and String([a, b]) would arrive as a
// single malformed entry.
for (const entry of value) {
url.searchParams.append(key, String(entry))
}
continue
}
url.searchParams.set(key, String(value))
}
return url.toString()

View File

@ -6,7 +6,13 @@ export { Honcho } from './client'
export {
Conclusion,
type ConclusionCreateParams,
ConclusionScope,
/**
* @deprecated Renamed to `ConclusionsView`. "Scope" now means a named set of
* sessions (see `Scope`), which this class is not it is a view over one
* observer/observed pair. Kept as an alias for one more minor version.
*/
ConclusionsView as ConclusionScope,
ConclusionsView,
} from './conclusions'
// HTTP infrastructure
export {
@ -30,6 +36,11 @@ export {
export { Message, type MessageInput } from './message'
export { Page } from './pagination'
export { Peer, PeerContext } from './peer'
export {
Scope,
type ScopeBackfillState,
type ScopeStatus,
} from './scope'
export { Session } from './session'
export {
SessionContext,
@ -50,6 +61,9 @@ export type {
QueueStatus,
QueueStatusResponse,
RepresentationOptions,
ScopeBackfillJob,
ScopeResponse,
ScopeStatusResponse,
SessionContextResponse,
SessionQueueStatus,
SessionResponse,

View File

@ -1,6 +1,6 @@
import { ZodType, z } from 'zod'
import { API_VERSION } from './api-version'
import { ConclusionScope } from './conclusions'
import { ConclusionsView } from './conclusions'
import type { HonchoHTTPClient } from './http/client'
import {
createDialecticStream,
@ -8,6 +8,9 @@ import {
} from './http/streaming'
import { Message, type MessageInput } from './message'
import { Page } from './pagination'
// Type-only: scope.ts imports Session, which imports Peer. Importing the type
// keeps that cycle out of the emitted JS.
import type { Scope } from './scope'
import { Session } from './session'
import type {
MessageResponse,
@ -40,6 +43,7 @@ import {
peerConfigToApi,
RepresentationOptionsSchema,
SearchQuerySchema,
scopeRecallFields,
sessionConfigFromApi,
} from './validation'
@ -251,6 +255,8 @@ export class Peer {
stream?: boolean
target?: string
session_id?: string
scope?: string | string[]
filters?: Record<string, unknown>
reasoning_level?: string
response_format?: Record<string, unknown>
}): Promise<PeerChatResponse> {
@ -265,6 +271,8 @@ export class Peer {
query: string
target?: string
session_id?: string
scope?: string | string[]
filters?: Record<string, unknown>
reasoning_level?: string
response_format?: Record<string, unknown>
}): Promise<Response> {
@ -295,6 +303,8 @@ export class Peer {
private async _getRepresentation(params: {
session_id?: string
scope?: string | string[]
filters?: Record<string, unknown>
target?: string
search_query?: string
search_top_k?: number
@ -365,6 +375,18 @@ export class Peer {
* @param options.session - Optional session to scope the query to. If provided, only
* information from that session is considered. Can be a session
* ID string or a Session object.
* @param options.scope - Optional scope(s) to confine the query to. A single scope answers
* from that scope's own view of the target, including the higher-order
* conclusions reasoned within it. A list of scopes restricts recall to
* the union of their member sessions, which like `sessions` yields
* only directly-stated conclusions. Mutually exclusive with `session`
* and `sessions`, and requires a workspace-level key.
* @param options.sessions - Optional allowlist of sessions to confine the query to, for
* one-off questions that span a handful of sessions. Recall is
* limited to conclusions stated directly in those sessions:
* conclusions produced by reasoning across sessions are excluded,
* because their provenance cannot be proven to sit inside the
* allowlist. Reach for a named `scope` when you need that depth.
* @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low", "medium",
* "high", or "max". Defaults to "low" if not provided.
* @returns Promise resolving to the response string, or null if no relevant information
@ -379,6 +401,16 @@ export class Peer {
* target: otherPeer,
* reasoningLevel: 'high'
* })
*
* // Answer only from a named scope
* const response = await peer.chat('What is stressing them out?', {
* scope: 'therapy',
* })
*
* // Answer only from an ad-hoc set of sessions
* const response = await peer.chat('What did we decide?', {
* sessions: [session1, session2],
* })
* ```
*/
async chat<T>(
@ -386,6 +418,8 @@ export class Peer {
options: {
target?: string | Peer
session?: string | Session
scope?: string | Scope | (string | Scope)[]
sessions?: (string | Session)[]
reasoningLevel?: string
responseFormat: ZodType<T>
}
@ -395,6 +429,8 @@ export class Peer {
options?: {
target?: string | Peer
session?: string | Session
scope?: string | Scope | (string | Scope)[]
sessions?: (string | Session)[]
reasoningLevel?: string
responseFormat?: Record<string, unknown>
}
@ -404,6 +440,8 @@ export class Peer {
options?: {
target?: string | Peer
session?: string | Session
scope?: string | Scope | (string | Scope)[]
sessions?: (string | Session)[]
reasoningLevel?: string
responseFormat?: ZodType<T> | Record<string, unknown>
}
@ -423,6 +461,8 @@ export class Peer {
query,
target: targetId,
session: resolvedSessionId,
scope: options?.scope,
sessions: options?.sessions,
reasoningLevel: options?.reasoningLevel,
responseFormat: options?.responseFormat,
})
@ -437,6 +477,7 @@ export class Peer {
stream: false,
target: chatParams.target,
session_id: chatParams.session,
...scopeRecallFields(chatParams),
reasoning_level: chatParams.reasoningLevel,
response_format: Peer.toResponseFormatSchema(options?.responseFormat),
})
@ -465,6 +506,9 @@ export class Peer {
* @param options.session - Optional session to scope the query to. If provided, only
* information from that session is considered. Can be a session
* ID string or a Session object.
* @param options.scope - Optional scope(s) to confine the query to. See {@link Peer.chat}.
* @param options.sessions - Optional allowlist of sessions to confine the query to.
* See {@link Peer.chat} for the depth caveat.
* @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low", "medium",
* "high", or "max". Defaults to "low" if not provided.
* @returns Promise resolving to a DialecticStreamResponse that can be iterated over
@ -489,6 +533,8 @@ export class Peer {
options?: {
target?: string | Peer
session?: string | Session
scope?: string | Scope | (string | Scope)[]
sessions?: (string | Session)[]
reasoningLevel?: string
responseFormat?: ZodType | Record<string, unknown>
}
@ -508,6 +554,8 @@ export class Peer {
query,
target: targetId,
session: resolvedSessionId,
scope: options?.scope,
sessions: options?.sessions,
reasoningLevel: options?.reasoningLevel,
responseFormat: options?.responseFormat,
})
@ -516,6 +564,7 @@ export class Peer {
query: chatParams.query,
target: chatParams.target,
session_id: chatParams.session,
...scopeRecallFields(chatParams),
reasoning_level: chatParams.reasoningLevel,
response_format: Peer.toResponseFormatSchema(options?.responseFormat),
})
@ -846,6 +895,8 @@ export class Peer {
*/
async representation(options?: {
session?: string | Session
scope?: string | Scope | (string | Scope)[]
sessions?: (string | Session)[]
target?: string | Peer
searchQuery?: string | Message
searchTopK?: number
@ -856,6 +907,8 @@ export class Peer {
const searchQuery = normalizeSearchQuery(options?.searchQuery)
const getRepresentationParams = PeerGetRepresentationParamsSchema.parse({
session: options?.session,
scope: options?.scope,
sessions: options?.sessions,
target: options?.target,
options: {
searchQuery,
@ -878,6 +931,7 @@ export class Peer {
const response = await this._getRepresentation({
session_id: sessionId,
...scopeRecallFields(getRepresentationParams),
target: targetId,
search_query: searchQuery,
search_top_k: getRepresentationParams.options?.searchTopK,
@ -964,7 +1018,7 @@ export class Peer {
* This property provides a convenient way to access conclusions that this peer
* has made about themselves. Use this for self-conclusion scenarios.
*
* @returns A ConclusionScope scoped to this peer's self-conclusions
* @returns A ConclusionsView scoped to this peer's self-conclusions
*
* @example
* ```typescript
@ -978,8 +1032,8 @@ export class Peer {
* await peer.conclusions.delete('obs-123')
* ```
*/
get conclusions(): ConclusionScope {
return new ConclusionScope(
get conclusions(): ConclusionsView {
return new ConclusionsView(
this._http,
this.workspaceId,
this.id,
@ -995,7 +1049,7 @@ export class Peer {
* observer and the target is the observed peer.
*
* @param target - The target peer (either a Peer object or peer ID string)
* @returns A ConclusionScope scoped to this peer's conclusions of the target
* @returns A ConclusionsView scoped to this peer's conclusions of the target
*
* @example
* ```typescript
@ -1012,9 +1066,9 @@ export class Peer {
* const rep = await bobConclusions.representation()
* ```
*/
conclusionsOf(target: string | Peer): ConclusionScope {
conclusionsOf(target: string | Peer): ConclusionsView {
const targetId = typeof target === 'string' ? target : target.id
return new ConclusionScope(
return new ConclusionsView(
this._http,
this.workspaceId,
this.id,

View File

@ -0,0 +1,278 @@
import { API_VERSION } from './api-version'
import type { HonchoHTTPClient } from './http/client'
import { Page } from './pagination'
import { Session } from './session'
import type {
PageResponse,
ScopeStatusResponse,
SessionResponse,
} from './types/api'
import { resolveId } from './utils'
import {
ScopeSessionsSchema,
SessionIdSchema,
sessionConfigFromApi,
} from './validation'
/**
* Backfill job state for one session in a scope.
*/
export interface ScopeBackfillState {
state: 'pending' | 'completed' | 'failed'
updatedAt: string
/**
* Number of documents copied into the scope. Present only once the backfill
* for this session completes.
*/
docsCopied?: number
}
/**
* Backfill/reconciliation progress for a scope, keyed by session ID.
*
* Only sessions that have had a backfill enqueued appear. A scope whose
* sessions were all empty when added has an empty `backfillStatus`.
*/
export interface ScopeStatus {
backfillStatus: Record<string, ScopeBackfillState>
}
/**
* Represents a scope in the Honcho system.
*
* A scope is a named set of sessions that acts as a visibility boundary. Recall
* performed through a scope sees only what happened in that scope's sessions,
* while the underlying peer keeps its single unified representation across
* everything it has ever participated in.
*
* Membership changes are applied asynchronously: adding a session that already
* has messages copies its existing conclusions into the scope, and removing one
* reconciles them back out. Poll {@link Scope.status} to watch that settle.
*
* @example
* ```typescript
* const therapy = await honcho.scope('therapy')
* await therapy.addSessions([session1, session2])
*
* // Ask a question answered only from the therapy sessions
* const answer = await user.chat('What is stressing them out?', {
* scope: 'therapy',
* })
* ```
*/
export class Scope {
/**
* Unique identifier for this scope, without the server-side `scope.` prefix.
*/
readonly id: string
/**
* Workspace ID for scoping operations.
*/
readonly workspaceId: string
private _http: HonchoHTTPClient
private _metadata?: Record<string, unknown>
private _createdAt?: string
private _ensureWorkspace: () => Promise<void>
/**
* Cached metadata for this scope. May be stale if the scope was not recently
* fetched from the API.
*/
get metadata(): Record<string, unknown> | undefined {
return this._metadata
}
/**
* Timestamp when this scope was created. Only available if fetched from the API.
*/
get createdAt(): string | undefined {
return this._createdAt
}
/**
* Initialize a new Scope. **Do not call this directly, use the client.scope() method instead.**
*
* @param id - Unprefixed scope name, unique within the workspace
* @param workspaceId - Workspace ID for scoping operations
* @param http - Reference to the HTTP client instance
* @param metadata - Optional metadata to initialize the cached value
* @param ensureWorkspace - Callback that guarantees the workspace exists
* @param createdAt - Creation timestamp, if already fetched
*/
constructor(
id: string,
workspaceId: string,
http: HonchoHTTPClient,
metadata?: Record<string, unknown>,
ensureWorkspace: () => Promise<void> = async () => undefined,
createdAt?: string
) {
this.id = id
this.workspaceId = workspaceId
this._http = http
this._metadata = metadata
this._ensureWorkspace = ensureWorkspace
this._createdAt = createdAt
}
// ===========================================================================
// Private API Methods
// ===========================================================================
private get _basePath(): string {
return `/${API_VERSION}/workspaces/${this.workspaceId}/scopes/${this.id}`
}
private async _addSessions(sessionIds: string[]): Promise<void> {
await this._ensureWorkspace()
await this._http.post(`${this._basePath}/sessions`, {
body: { session_ids: sessionIds },
})
}
private async _removeSession(sessionId: string): Promise<void> {
await this._ensureWorkspace()
await this._http.delete(`${this._basePath}/sessions/${sessionId}`)
}
private async _listSessions(params?: {
page?: number
size?: number
reverse?: boolean
}): Promise<PageResponse<SessionResponse>> {
await this._ensureWorkspace()
return this._http.post<PageResponse<SessionResponse>>(
`${this._basePath}/sessions/list`,
{
query: {
page: params?.page,
size: params?.size,
reverse: params?.reverse ? 'true' : undefined,
},
}
)
}
private async _getStatus(): Promise<ScopeStatusResponse> {
await this._ensureWorkspace()
return this._http.get<ScopeStatusResponse>(`${this._basePath}/status`)
}
// ===========================================================================
// Public API Methods
// ===========================================================================
/**
* Add sessions to this scope.
*
* Every named session must already exist. Adding a session that is already a
* member is a no-op.
*
* Sessions that already hold messages are backfilled into the scope
* asynchronously, so recall through this scope may not reflect their history
* immediately poll {@link Scope.status} to watch that complete.
*
* @param sessions - Sessions to add, as ID strings or Session objects. At most
* 100 per call, matching the server's limit; split larger
* membership changes into separate calls so a failure names
* the batch that failed.
*/
async addSessions(sessions: (string | Session)[]): Promise<void> {
const sessionIds = ScopeSessionsSchema.parse(sessions.map(resolveId))
await this._addSessions(sessionIds)
}
/**
* Remove a session from this scope.
*
* Conclusions copied or derived while the session was a member are
* reconciled out asynchronously, and the scope's peer card is rebuilt from
* whatever evidence remains. Poll {@link Scope.status} to watch that settle.
*
* @param session - Session to remove, as an ID string or a Session object
* @throws If the session ID is malformed
*/
async removeSession(session: string | Session): Promise<void> {
// Validated because this ID is interpolated into a request *path*: an
// unvalidated value silently changes which resource the request addresses.
// `valid-session?typo` would target `valid-session` with a stray query
// string, removing the wrong session and reconciling against it.
await this._removeSession(SessionIdSchema.parse(resolveId(session)))
}
/**
* Get the sessions that are members of this scope.
*
* Ordered by how long each session has been a member longest-standing
* first, or most recently added first when `reverse` is true.
*
* @param options - Pagination options: `page`, `size`, and `reverse`
* @returns Promise resolving to a paginated list of member Sessions
*/
async sessions(options?: {
page?: number
size?: number
reverse?: boolean
}): Promise<Page<Session, SessionResponse>> {
const reverse = options?.reverse
const sessionsPage = await this._listSessions({
page: options?.page,
size: options?.size,
reverse,
})
const fetchNextPage = async (
page: number,
size: number
): Promise<PageResponse<SessionResponse>> => {
return this._listSessions({ page, size, reverse })
}
return new Page(
sessionsPage,
(session) =>
new Session(
session.id,
this.workspaceId,
this._http,
session.metadata ?? undefined,
sessionConfigFromApi(session.configuration) ?? undefined,
() => this._ensureWorkspace(),
session.created_at,
session.is_active
),
fetchNextPage
)
}
/**
* Get the backfill/reconciliation progress for this scope.
*
* Use this after a membership change to tell "the scope knows nothing about
* that session yet" apart from "the scope has caught up and there is genuinely
* nothing to recall".
*
* @returns Promise resolving to per-session backfill state
*/
async status(): Promise<ScopeStatus> {
const response = await this._getStatus()
return {
backfillStatus: Object.fromEntries(
Object.entries(response.backfill_status ?? {}).map(
([sessionId, job]) => [
sessionId,
{
state: job.state,
updatedAt: job.updated_at,
docsCopied: job.docs_copied,
},
]
)
),
}
}
toString(): string {
return `Scope(id='${this.id}', workspaceId='${this.workspaceId}')`
}
}

View File

@ -3,6 +3,9 @@ import type { HonchoHTTPClient } from './http/client'
import { Message } from './message'
import { Page } from './pagination'
import { Peer } from './peer'
// Type-only: scope.ts imports this module. Importing the type keeps that cycle
// out of the emitted JS.
import type { Scope } from './scope'
import { SessionContext, SessionSummaries } from './session_context'
import type {
MessageResponse,
@ -17,7 +20,7 @@ import type {
SessionResponse,
SessionSummariesResponse,
} from './types/api'
import { transformQueueStatus } from './utils'
import { resolveId, transformQueueStatus } from './utils'
import {
ContextParamsSchema,
FileUploadSchema,
@ -220,6 +223,8 @@ export class Session {
search_query?: string
peer_target?: string
peer_perspective?: string
scope?: string
sessions?: string[]
limit_to_session?: boolean
search_top_k?: number
search_max_distance?: number
@ -752,6 +757,17 @@ export class Session {
* @param options.tokens - Target token count for the context window
* @param options.peerTarget - The peer to get representation for
* @param options.peerPerspective - The peer whose perspective to use for representation
* @param options.scope - A scope to use as the perspective source instead of a peer: the
* target's representation and card are read from what that scope
* observed. Requires `peerTarget`, is mutually exclusive with
* `peerPerspective`, and requires a workspace-level key.
* @param options.sessions - Allowlist of sessions confining the target's representation
* to that set. This session must be one of them. Recall is
* limited to conclusions stated directly in those sessions, and
* the peer card is omitted, since neither derived conclusions
* nor cards carry provable per-session provenance. Mutually
* exclusive with `scope` and `limitToSession`; requires
* `peerTarget`.
* @param options.limitToSession - Whether to limit representation to this session only
* @param options.representationOptions - Options for representation retrieval (searchQuery, searchTopK, etc.)
* @returns Promise resolving to a SessionContext with messages, summary, and representation
@ -764,6 +780,12 @@ export class Session {
* peerTarget: user
* })
*
* // Build the context from what a scope observed
* const ctx = await session.context({
* peerTarget: user,
* scope: 'therapy',
* })
*
* // Convert to OpenAI format
* const messages = ctx.toOpenAI(assistant)
* ```
@ -773,6 +795,8 @@ export class Session {
tokens?: number
peerTarget?: string | Peer
peerPerspective?: string | Peer
scope?: string | Scope
sessions?: (string | Session)[]
limitToSession?: boolean
representationOptions?: RepresentationOptions
}): Promise<SessionContext> {
@ -795,6 +819,10 @@ export class Session {
tokens: opts.tokens,
peerTarget: peerTargetId,
peerPerspective: peerPerspectiveId,
// Checked against undefined, not truthiness: `scope: ''` must reach the
// schema and be rejected, not be dropped into an unscoped context.
scope: opts.scope !== undefined ? resolveId(opts.scope) : undefined,
sessions: opts.sessions,
limitToSession: opts.limitToSession,
representationOptions: opts.representationOptions
? {
@ -810,6 +838,8 @@ export class Session {
search_query: searchQuery,
peer_target: contextParams.peerTarget,
peer_perspective: contextParams.peerPerspective,
scope: contextParams.scope,
sessions: contextParams.sessions,
limit_to_session: contextParams.limitToSession,
search_top_k: contextParams.representationOptions?.searchTopK,
search_max_distance:

View File

@ -133,6 +133,28 @@ export interface SessionCreateParams {
metadata?: Record<string, unknown>
configuration?: SessionConfigApi
peers?: Record<string, SessionPeerConfigParams>
scopes?: string[]
}
export interface ScopeResponse {
id: string
metadata: Record<string, unknown>
created_at: string
}
/**
* Per-session backfill job state for a scope.
*
* `docs_copied` is present only once a backfill completes.
*/
export interface ScopeBackfillJob {
state: 'pending' | 'completed' | 'failed'
updated_at: string
docs_copied?: number
}
export interface ScopeStatusResponse {
backfill_status: Record<string, ScopeBackfillJob>
}
export interface SessionUpdateParams {

View File

@ -159,6 +159,149 @@ export const SessionIdSchema = z
*/
const SessionIdObjectSchema = z.object({ id: SessionIdSchema })
/**
* Reserved peer-name prefix the server uses to store a scope.
*/
const SCOPE_PEER_PREFIX = 'scope.'
/**
* Scope IDs are stored as peer names with the reserved prefix prepended, so
* they must leave room for it within the 512-character peer name limit.
*/
const SCOPE_ID_MAX_LENGTH = 512 - SCOPE_PEER_PREFIX.length
/**
* The scope ID rules, as a plain function rather than only a schema.
*
* Zod reports a failing union as a single `invalid_union` / "Invalid input"
* issue and buries the branch errors, so a schema alone cannot carry these
* messages out of `ScopeOptionSchema`. Keeping the rules callable lets both the
* bare schema and the union surface the same specific message.
*
* @returns The problems found, or an empty array when the ID is valid.
*/
function scopeIdIssues(value: string): string[] {
if (value.length < 1) {
return ['Scope ID must be a non-empty string']
}
if (value.length > SCOPE_ID_MAX_LENGTH) {
return [`Scope ID can be at most ${SCOPE_ID_MAX_LENGTH} characters`]
}
// Checked before the charset: the reserved prefix contains '.', which is
// itself outside the charset, so a charset-first check would report the
// charset instead of the real mistake for a double-prefixed name.
if (value.startsWith(SCOPE_PEER_PREFIX)) {
return [
`Scope ID must not start with the reserved prefix '${SCOPE_PEER_PREFIX}' (scope IDs are unprefixed)`,
]
}
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
return [
'Scope ID may only contain letters, numbers, underscores, and hyphens',
]
}
return []
}
/**
* Add every scope ID problem in `values` as a top-level issue.
*/
function addScopeIdIssues(values: string[], ctx: z.RefinementCtx): void {
for (const value of values) {
for (const message of scopeIdIssues(value)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message })
}
}
}
/**
* Schema for scope ID validation.
*
* Scope IDs are unprefixed the `scope.` prefix is a server-side storage
* detail and never appears on the wire.
*/
export const ScopeIdSchema = z.string().superRefine((val, ctx) => {
addScopeIdIssues([val], ctx)
})
/**
* Shape-only branch for the `scope` option: an ID string, or an object carrying
* one (so a `Scope` instance is accepted). The ID itself is validated after the
* union resolves see `ScopeOptionSchema`.
*/
const ScopeIdLikeSchema = z.union([z.string(), z.object({ id: z.string() })])
/**
* Schema for the `scope` read option: one scope, or a bounded list of them.
*
* A single scope reads that scope's own view. A list restricts recall to the
* union of the scopes' member sessions. An empty list is rejected rather than
* resolved to an empty allowlist, which would silently recall nothing.
*
* The union discriminates shape only; IDs and list bounds are checked after the
* transform so their messages are not swallowed as `invalid_union`.
*/
export const ScopeOptionSchema = z
.union([ScopeIdLikeSchema, z.array(ScopeIdLikeSchema)])
.transform((val) =>
Array.isArray(val)
? val.map((entry) => (typeof entry === 'string' ? entry : entry.id))
: typeof val === 'string'
? val
: val.id
)
.superRefine((resolved, ctx) => {
if (Array.isArray(resolved)) {
if (resolved.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'scope must name at least one scope',
})
}
if (resolved.length > 100) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'scope can name at most 100 scopes',
})
}
}
addScopeIdIssues(Array.isArray(resolved) ? resolved : [resolved], ctx)
})
/**
* Schema for a scope membership change: the sessions to add to a scope.
*
* Capped at 100 to match the server rather than silently chunking, so a
* rejected batch is the batch the caller passed.
*/
export const ScopeSessionsSchema = z
.array(SessionIdSchema)
.min(1, 'At least one session must be given')
.max(100, 'At most 100 sessions can be added per call')
/**
* Schema for the `scopes` option on session creation: the scopes a new session
* should join.
*/
export const SessionScopesSchema = z
.array(ScopeIdSchema)
.min(1, 'scopes must name at least one scope')
.max(100, 'scopes can name at most 100 scopes')
/**
* Schema for the `sessions` allowlist option sugar for the wire-level
* `filters: { session_id: [...] }`.
*
* Capped at 1,000 entries to match the server. An empty list is rejected: the
* server treats an empty allowlist as fail-closed (recalls nothing), which is
* never what a caller passing `sessions: []` intends.
*/
export const SessionAllowlistSchema = z
.array(z.union([SessionIdSchema, SessionIdObjectSchema]))
.min(1, 'sessions must name at least one session')
.max(1000, 'sessions can name at most 1000 sessions')
.transform((vals) => vals.map((v) => (typeof v === 'string' ? v : v.id)))
/**
* Schema for session peer configuration.
*/
@ -291,6 +434,58 @@ export function normalizeListOptions<T extends { filters?: Filters }>(
return { filters: input as Filters } as T
}
/**
* Translate validated `scope` / `sessions` options into their wire fields.
*
* `sessions` is sugar: it goes out as the constrained
* `filters: { session_id: [...] }` body the recall endpoints accept, never as a
* field of its own the server rejects unknown keys with a 422. Shared by chat,
* chatStream, and representation so the three cannot drift apart.
*
* Purely a translation; the schemas have already rejected the invalid
* combinations by the time this runs.
*/
export function scopeRecallFields(options: {
scope?: string | string[]
sessions?: string[]
}): { scope?: string | string[]; filters?: Record<string, unknown> } {
return {
scope: options.scope,
filters: options.sessions ? { session_id: options.sessions } : undefined,
}
}
/**
* Add issues for the `scope` exclusions the server enforces with a 422.
*
* A scope already determines what a query can see, so combining it with a
* session allowlist or a single session is a contradiction rather than a
* narrowing. Shared by the chat, representation, and context schemas so the
* three surfaces cannot drift apart.
*/
function scopeExclusivityIssues(
data: { scope?: unknown; sessions?: unknown; session?: unknown },
ctx: z.RefinementCtx
): void {
if (data.scope === undefined) {
return
}
if (data.sessions !== undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'scope and sessions are mutually exclusive',
path: ['sessions'],
})
}
if (data.session !== undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'scope and session are mutually exclusive',
path: ['session'],
})
}
}
/**
* Schema for chat query parameters.
*/
@ -309,6 +504,8 @@ export const ChatQuerySchema = z
.transform((val) =>
val ? (typeof val === 'string' ? val : val.id) : undefined
),
scope: ScopeOptionSchema.optional(),
sessions: SessionAllowlistSchema.optional(),
reasoningLevel: z
.enum(['minimal', 'low', 'medium', 'high', 'max'])
.optional(),
@ -319,6 +516,7 @@ export const ChatQuerySchema = z
.optional(),
})
.strict()
.superRefine(scopeExclusivityIssues)
/**
* Schema for representation options.
@ -356,11 +554,40 @@ export const ContextParamsSchema = z
tokens: z.int('Token limit must be an integer').optional(),
peerTarget: PeerIdSchema.optional(),
peerPerspective: PeerIdSchema.optional(),
// Only a single scope is accepted here: the context route uses a scope as
// the *perspective source* for the target's representation and card, which
// is one observer. A list of scopes has no meaning for that.
scope: ScopeIdSchema.optional(),
sessions: SessionAllowlistSchema.optional(),
limitToSession: z.boolean().optional(),
representationOptions: RepresentationOptionsSchema.optional(),
})
.strict()
.superRefine((data, ctx) => {
if (data.sessions && !data.peerTarget) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'peerTarget is required when sessions is provided',
path: ['sessions'],
})
}
if (data.sessions && data.scope) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'sessions and scope are mutually exclusive',
path: ['sessions'],
})
}
if (data.sessions && data.limitToSession) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'sessions and limitToSession are mutually exclusive',
path: ['sessions'],
})
}
if (data.representationOptions?.searchQuery && !data.peerTarget) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@ -376,6 +603,22 @@ export const ContextParamsSchema = z
path: ['peerPerspective'],
})
}
if (data.scope && !data.peerTarget) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'peerTarget is required when scope is provided',
path: ['scope'],
})
}
if (data.scope && data.peerPerspective) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'scope and peerPerspective are mutually exclusive',
path: ['scope'],
})
}
})
/**
@ -437,10 +680,13 @@ export const GetRepresentationParamsSchema = z
export const PeerGetRepresentationParamsSchema = z
.object({
session: z.union([SessionIdSchema, SessionIdObjectSchema]).optional(),
scope: ScopeOptionSchema.optional(),
sessions: SessionAllowlistSchema.optional(),
target: z.union([PeerIdSchema, PeerIdObjectSchema]).optional(),
options: RepresentationOptionsSchema.optional(),
})
.strict()
.superRefine(scopeExclusivityIssues)
/**
* Schema for peer card target parameter.

63
skills/pre-pr/SKILL.md Normal file
View File

@ -0,0 +1,63 @@
---
name: pre-pr
description: Prepare a Honcho change for a pull request to plastic-labs/honcho. Invoke before opening a PR, when drafting a PR body, when asked if a branch is PR-ready, or when filling the pull request template. Checks the linked issue, required tests and docs, then writes Description / Proofs / Fixes.
---
# Pre-PR checklist
Do this after the change works, before anyone opens the GitHub PR. Output is a filled template body — do not create the PR.
The template lives at `.github/pull_request_template.md`. Do not add extra sections.
## 1. Issue gate (hard stop)
A PR without a maintainer-approved issue will be closed.
```bash
gh issue view <N> --repo plastic-labs/honcho --json number,title,labels,state
```
Stop if any of these fail:
- no issue number, or the issue is not in `plastic-labs/honcho`
- issue is closed (unless this PR is explicitly reopening it)
- labels do not include `maintainer-approved`
Say which check failed. Do not draft a PR body around it.
## 2. Classify the diff
```bash
git diff main...HEAD --stat
```
Pick one primary kind: bug, feature, docs. Then decide layers:
| Surface touched | Required |
| --- | --- |
| `src/` (non-prompt) | unit tests under the matching `tests/` tree |
| deriver / dialectic / dreamer / LLM path | unit + consider live-llm (`tests/live_llm`) |
| queue, config hierarchy, multi-turn, SDK contract | unified (`uv run python -m tests.unified.run`) |
| `/v3` HTTP or deriver queue behavior | `/verify` skill (runtime, not just pytest) |
| public API, SDK exports, `config.toml` / settings, mintlify `docs/` | documentation in the matching file |
Skip a layer only with a one-line reason (e.g. "docs-only", "comment-only"). "When appropriate" is not a skip.
Invoke `/verify` when the runtime surface moved. Do not restate that skill here.
Lint/type before claiming tests are green: `uv run ruff check src/``uv run basedpyright` → the pytest command for the layer.
## 3. Proofs
Collect evidence that belongs in the PR, not in the commit:
- command + pass/fail for what you ran
- a log snippet, screenshot, or file path that shows the new behavior
- for bugs: the failing case before vs after, if you have it
If `/verify` ran, the proofs *are* that session's output. Do not invent green runs.
## 4. Write the body
Fill the description, proofs, checklist portion of the pull request description template.
Make sure to link the related github issue, otherwise the PR will be auto-closed.

View File

@ -2,7 +2,7 @@ import logging
import math
import os
from pathlib import Path
from typing import Annotated, Any, ClassVar, Literal, cast
from typing import Annotated, Any, ClassVar, Literal, cast, get_args
from urllib.parse import urlparse
import tomllib
@ -394,6 +394,15 @@ class ConfiguredEmbeddingModelSettings(BaseModel):
dimensions_mode: EmbeddingDimensionsMode = "auto"
encoding_format_mode: EmbeddingEncodingFormatMode = "auto"
max_batch_size: Annotated[int, Field(gt=0)] | None = None
# Client HTTP timeout in seconds. OpenAI receives seconds; Gemini converts to ms.
timeout: float | None = None
@field_validator("timeout", mode="before")
@classmethod
def _validate_timeout(cls, v: Any) -> float | None:
if v is None:
return None
return coerce_provider_timeout(v)
@model_validator(mode="before")
@classmethod
@ -431,6 +440,15 @@ class EmbeddingModelConfig(BaseModel):
api_key: str | None = None
base_url: str | None = None
max_batch_size: Annotated[int, Field(gt=0)] | None = None
# Client HTTP timeout in seconds. OpenAI receives seconds; Gemini converts to ms.
timeout: float | None = None
@field_validator("timeout", mode="before")
@classmethod
def _validate_timeout(cls, v: Any) -> float | None:
if v is None:
return None
return coerce_provider_timeout(v)
@model_validator(mode="before")
@classmethod
@ -556,6 +574,7 @@ def resolve_embedding_model_config(
api_key=api_key,
base_url=configured.overrides.base_url,
max_batch_size=configured.max_batch_size,
timeout=configured.timeout,
)
@ -979,15 +998,14 @@ class PeerCardSettings(HonchoSettings):
ENABLED: bool = True
# Reasoning levels for dialectic - defined here to avoid circular imports with schemas
# Reasoning levels for dialectic - defined here to avoid circular imports with schemas.
# region ai
# REASONING_LEVELS is derived from the Literal, not hand-listed: the annotation
# rejects an invalid member but not a MISSING one, so a hand-written copy could
# silently drop a level and still typecheck.
# endregion
ReasoningLevel = Literal["minimal", "low", "medium", "high", "max"]
REASONING_LEVELS: list[ReasoningLevel] = [
"minimal",
"low",
"medium",
"high",
"max",
]
REASONING_LEVELS: list[ReasoningLevel] = list(get_args(ReasoningLevel))
class DialecticLevelSettings(BaseModel):

View File

@ -984,7 +984,9 @@ async def create_observations(
# Generate embeddings in batch
contents = [obs.content for obs in observations]
try:
embeddings = await embedding_client.simple_batch_embed(contents)
embeddings = await embedding_client.simple_batch_embed(
contents, on_oversize="truncate"
)
except ValueError as e:
raise ValidationException(str(e)) from e

View File

@ -107,7 +107,7 @@ class RepresentationManager:
parent_category="representation",
):
embeddings = await embedding_client.simple_batch_embed(
observation_texts
observation_texts, on_oversize="truncate"
)
except ValueError as e:
raise exceptions.ValidationException(

View File

@ -10,6 +10,7 @@ from src.db import engine, register_db_query_instrumentation
from src.startup import validate_embedding_schema
from src.telemetry import (
initialize_telemetry_async,
prometheus_metrics,
register_db_pool_collector,
shutdown_telemetry,
)
@ -25,6 +26,12 @@ def start_metrics_server() -> None:
# Expose DB connection-pool stats for this deriver instance.
register_db_pool_collector("deriver")
register_db_query_instrumentation("deriver")
# region ai
# Zero-init bounded-label counters so a missing series signals a broken scrape,
# not "no events" — see initialize_bounded_metrics. No-op if metrics off.
# endregion
prometheus_metrics.initialize_bounded_metrics(instance_type="deriver")
logger.info("Prometheus metrics server started on port 9090")

View File

@ -7,6 +7,7 @@ from src import crud
from src.config import ConfiguredModelSettings, settings
from src.crud.representation import RepresentationManager
from src.dependencies import tracked_db
from src.exceptions import RepresentationSaveError
from src.llm import honcho_llm_call
from src.llm.types import LLMTelemetryContext
from src.models import Message
@ -196,6 +197,7 @@ async def process_representation_tasks_batch(
agg_representation_result = crud.CreateDocumentsResult()
successful_observer_count = 0
save_errors: list[tuple[str, Exception]] = []
if observations.is_empty() or not message_ids:
logger.warning(
"Deriver generated zero observations for messages %s:%s in %s/%s!",
@ -236,10 +238,11 @@ async def process_representation_tasks_batch(
representation_result.semantic_dup_replaced_count
)
successful_observer_count += 1
except Exception as e:
logger.error(
"Failed to save representation for observer %s: %s", observer, e
except Exception as e: # noqa: BLE001
logger.exception(
"Failed to save representation for observer %s", observer
)
save_errors.append((observer, e))
# Log metrics
overall_duration = (time.perf_counter() - overall_start) * 1000
@ -337,5 +340,16 @@ async def process_representation_tasks_batch(
exact_dup_in_batch_count=agg_representation_result.exact_dup_in_batch_count,
semantic_dup_rejected_count=agg_representation_result.semantic_dup_rejected_count,
semantic_dup_replaced_count=agg_representation_result.semantic_dup_replaced_count,
failed_observer_count=len(save_errors),
)
)
if save_errors and successful_observer_count == 0:
details = "; ".join(
f"{observer}: {exc.__class__.__name__}: {exc}"
for observer, exc in save_errors
)
raise RepresentationSaveError(
f"save_representation failed for all {len(save_errors)} observer(s): "
+ details
) from save_errors[0][1]

View File

@ -478,8 +478,7 @@ class QueueManager:
@staticmethod
def _is_tenant_work(work_unit_keys: Iterable[str]) -> bool:
"""True if any claimed work unit is real tenant work, not housekeeping.
"""
"""True if any claimed work unit is real tenant work, not housekeeping."""
for key in work_unit_keys:
try:
if parse_work_unit_key(key).task_type != "reconciler":

View File

@ -195,13 +195,13 @@ class _EmbeddingClient:
from google import genai
from google.genai import types as genai_types
# 10-minute HTTP timeout, in lockstep with the LLM registry's Gemini
# client (`src/llm/registry.py:_build_gemini_http_options`). Without
# this, a stalled Gemini embedding socket wedges the deriver worker
# exactly the way #785 describes for the LLM client.
# Default 10-minute HTTP timeout matches the LLM registry Gemini client.
timeout_ms = (
int(config.timeout * 1000) if config.timeout is not None else 600_000
)
http_options = genai_types.HttpOptions(
base_url=config.base_url,
timeout=600_000,
timeout=timeout_ms,
)
self.client: genai.Client | AsyncOpenAI = genai.Client(
api_key=config.api_key,
@ -216,10 +216,14 @@ class _EmbeddingClient:
raise ValueError("OpenAI API key is required")
from openai import AsyncOpenAI
self.client = AsyncOpenAI(
api_key=config.api_key,
base_url=config.base_url,
)
# Omit timeout when unset so the OpenAI SDK keeps its own default.
client_kwargs: dict[str, Any] = {
"api_key": config.api_key,
"base_url": config.base_url,
}
if config.timeout is not None:
client_kwargs["timeout"] = config.timeout
self.client = AsyncOpenAI(**client_kwargs)
self.max_embedding_tokens = max_input_tokens
self.max_batch_size = config.max_batch_size or 2048
@ -317,39 +321,78 @@ class _EmbeddingClient:
fn=_call_openai,
)
async def simple_batch_embed(self, texts: list[str]) -> list[list[float]]:
def _truncate_to_token_limit(self, text: str) -> tuple[str, int]:
"""Return a prefix of `text` whose re-encoded token count fits the cap.
Decode/re-encode after slicing: BPE boundaries can re-expand past the cap.
"""
Batch-embed a list of text strings. Each input must already fit within
`max_embedding_tokens`; this method does not sub-chunk oversized inputs.
token_ids = self.encoding.encode(text)
keep = self.max_embedding_tokens
while len(token_ids) > self.max_embedding_tokens:
keep = min(keep, len(token_ids) - 1)
if keep < 1:
return "", 0
text = self.encoding.decode(token_ids[:keep])
token_ids = self.encoding.encode(text)
keep -= 1
return text, len(token_ids)
async def simple_batch_embed(
self,
texts: list[str],
*,
on_oversize: Literal["raise", "truncate"] = "raise",
) -> list[list[float]]:
"""
Batch-embed a list of text strings. Does not sub-chunk oversized inputs.
Internally goes through the same token-aware batching pipeline as
`batch_embed()` so the per-request token cap is respected.
Args:
texts: List of text strings to embed
on_oversize: ``"raise"`` (default) errors; ``"truncate"`` embeds a
token-capped prefix.
Returns:
List of embedding vectors, one per input text (in order)
Raises:
ValueError: If any text exceeds token limits
ValueError: If any text exceeds token limits and `on_oversize` is
``"raise"``
"""
if not texts:
return []
# Validate per-input token limit and collect token counts for batching
# Validate / cap per-input token limit and collect counts for batching
prepared_texts: list[str] = []
token_counts: list[int] = []
for idx, text in enumerate(texts):
tokens = len(self.encoding.encode(text))
if tokens > self.max_embedding_tokens:
raise ValueError(
f"Text at index {idx} exceeds maximum token limit of {self.max_embedding_tokens} tokens (got {tokens} tokens)"
)
token_ids = self.encoding.encode(text)
if len(token_ids) > self.max_embedding_tokens:
if on_oversize == "truncate":
original_count = len(token_ids)
text, tokens = self._truncate_to_token_limit(text)
logger.warning(
"truncated oversize embedding input at idx %d: %d->%d tokens",
idx,
original_count,
tokens,
)
else:
raise ValueError(
f"Text at index {idx} exceeds maximum token limit of "
+ f"{self.max_embedding_tokens} tokens (got {len(token_ids)} tokens)"
)
else:
tokens = len(token_ids)
prepared_texts.append(text)
token_counts.append(tokens)
# Use positional indices as text_ids so we can reassemble in input order.
text_chunks: dict[str, list[tuple[str, int]]] = {
str(i): [(text, token_counts[i])] for i, text in enumerate(texts)
str(i): [(prepared_texts[i], token_counts[i])]
for i in range(len(prepared_texts))
}
batches = self._create_batches(text_chunks)
@ -691,9 +734,16 @@ class EmbeddingClient:
"""Embed a single query string."""
return await self._get_client().embed(query)
async def simple_batch_embed(self, texts: list[str]) -> list[list[float]]:
async def simple_batch_embed(
self,
texts: list[str],
*,
on_oversize: Literal["raise", "truncate"] = "raise",
) -> list[list[float]]:
"""Batch embed a list of text strings (each must fit token limit)."""
return await self._get_client().simple_batch_embed(texts)
return await self._get_client().simple_batch_embed(
texts, on_oversize=on_oversize
)
def prepare_chunks(self, id_resource_dict: dict[str, str]) -> dict[str, list[str]]:
"""Chunk texts using the same rules as `batch_embed` (no network)."""

View File

@ -133,6 +133,14 @@ class VectorStoreError(HonchoException):
detail = "Vector store operation failed"
@final
class RepresentationSaveError(HonchoException):
"""Raised when every observer's representation save fails in a batch."""
status_code: int = 500
detail: str = "Representation save failed for all observers"
class LLMError(Exception):
"""Exception raised when an LLM call fails.

View File

@ -121,6 +121,8 @@ class OpenAIHistoryAdapter:
}
if result.reasoning_details:
message["reasoning_details"] = result.reasoning_details
elif result.thinking_content:
message["reasoning_content"] = result.thinking_content
return message
def format_tool_results(

View File

@ -212,6 +212,7 @@ def format_assistant_tool_message(
tool_calls: list[dict[str, Any]],
thinking_blocks: list[dict[str, Any]] | None = None,
reasoning_details: list[dict[str, Any]] | None = None,
thinking_content: str | None = None,
) -> dict[str, Any]:
"""Format an assistant message with tool calls in provider-native shape."""
from .backend import CompletionResult as BackendCompletionResult
@ -229,6 +230,7 @@ def format_assistant_tool_message(
)
for tool_call in tool_calls
],
thinking_content=thinking_content,
thinking_blocks=thinking_blocks or [],
reasoning_details=reasoning_details or [],
)
@ -573,6 +575,7 @@ async def execute_tool_loop(
response.tool_calls_made,
response.thinking_blocks,
response.reasoning_details,
response.thinking_content,
)
conversation_messages.append(assistant_message)

View File

@ -110,6 +110,12 @@ async def lifespan(_: FastAPI):
register_db_pool_collector("api")
register_db_query_instrumentation("api")
# region ai
# Zero-init bounded-label counters so a missing series signals a broken scrape,
# not "no events" — see initialize_bounded_metrics. No-op if metrics off.
# endregion
prometheus_metrics.initialize_bounded_metrics(instance_type="api")
# Validate embedding schema before serving any traffic. Fails closed: if
# the configured EMBEDDING_VECTOR_DIMENSIONS does not match the physical
# pgvector columns, the process refuses to start rather than silently

View File

@ -21,6 +21,7 @@ from src import models
from src.config import settings
from src.dependencies import tracked_db
from src.models import QueueItem
from src.reconciler.sync_vectors import record_pending_embeddings_backlog
logger = logging.getLogger(__name__)
@ -145,15 +146,26 @@ class ReconcilerScheduler:
async def _scheduler_loop(self) -> None:
"""
Main scheduler loop that enqueues tasks based on their intervals.
Main scheduler loop that enqueues tasks based on their intervals, and
refreshes the service-wide pending-embeddings backlog gauge each pass.
Each task has its own interval and the loop checks all tasks on each
iteration, enqueueing any that are due.
iteration, enqueueing any that are due. The loop sleeps until the next
task is due, so the gauge's refresh cadence tracks the SHORTEST task
interval.
"""
try:
while not self._shutdown_event.is_set():
now = datetime.now(timezone.utc)
# region ai
# Refresh on EVERY replica, not just whichever wins the sync_vectors
# work unit: the count is DB-global, so a replica that never ran a
# cycle would otherwise export a stale (or zero-initialized) value
# forever. Full rationale in record_pending_embeddings_backlog.
# endregion
await record_pending_embeddings_backlog()
# Check each task and enqueue if due
for task_name, task in RECONCILER_TASKS.items():
next_run = self._next_run.get(task_name, now)

View File

@ -23,6 +23,7 @@ from src.config import settings
from src.dependencies import tracked_db
from src.embedding_client import embedding_client
from src.exceptions import VectorStoreError
from src.telemetry import prometheus_metrics
from src.telemetry.events import EmbeddingCallPurpose
from src.utils.types import embedding_call_purpose
from src.vector_store import VectorRecord, VectorStore, get_external_vector_store
@ -301,7 +302,9 @@ async def _sync_documents(
EmbeddingCallPurpose.VECTOR_SYNC.value,
parent_category="reconciliation",
):
new_embeddings = await embedding_client.simple_batch_embed(contents)
new_embeddings = await embedding_client.simple_batch_embed(
contents, on_oversize="truncate"
)
if len(new_embeddings) != len(docs_needing_embed):
logger.warning(
@ -700,6 +703,42 @@ async def _cleanup_pgvector_batch(
return True
async def record_pending_embeddings_backlog() -> None:
"""Set the pending-embeddings backlog gauge to the current count of
MessageEmbedding rows awaiting a vector (sync_state='pending')."""
# region ai
# Called from ``ReconcilerScheduler._scheduler_loop``, deliberately NOT from
# ``run_vector_reconciliation_cycle``: the cycle runs off the queue behind
# work-unit dedup, so exactly one deriver replica executes it. Driving the gauge
# from there would leave every other replica exporting a stale value — or, since
# this metric is zero-initialized, a confident permanent 0 it never measured. The
# count is a property of the database, not the process, so every replica must
# refresh it on its own timer for ``max()``/``avg()`` to mean anything.
#
# Cost: one COUNT per replica per scheduler interval (~5 min by default).
# ``ix_message_embeddings_sync_state_last_sync_at`` keeps the scan proportional to
# the pending backlog, not the whole table — which is not the same as cheap: after
# an embedding outage the backlog is exactly what is large. Still a small duty
# cycle, and the cost shrinks as the reconciler drains.
#
# Best-effort: a metrics/DB hiccup here must never break the scheduler loop.
# endregion
if not settings.METRICS.ENABLED:
return
try:
async with tracked_db("reconciler_pending_count", read_only=True) as db:
count = await db.scalar(
select(func.count())
.select_from(models.MessageEmbedding)
.where(models.MessageEmbedding.sync_state == "pending")
)
prometheus_metrics.set_message_embeddings_pending(count=count or 0)
except Exception:
logger.warning(
"Failed to record pending-embeddings backlog gauge", exc_info=True
)
async def run_vector_reconciliation_cycle() -> ReconciliationMetrics:
"""
Run a complete reconciliation cycle.

View File

@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import config, crud, schemas
from src.cache.client import safe_cache_delete
from src.crud.message import get_peer_session_names
from src.crud.session import session_cache_key
from src.dependencies import db, read_db
from src.deriver.enqueue import enqueue_deletion
@ -23,6 +24,7 @@ from src.exceptions import (
from src.security import JWTParams, require_auth
from src.telemetry.events import EmbeddingCallPurpose, GetContextEvent, emit
from src.utils import summarizer
from src.utils.filter import normalize_session_allowlist
from src.utils.representation import Representation
from src.utils.search import search
from src.utils.tokens import estimate_tokens
@ -714,9 +716,27 @@ async def get_session_context(
None,
description="An (unprefixed) scope name to use as the perspective source: the representation and peer card of `peer_target` are read from the scope's observations instead of the global (or `peer_perspective`) view. Must be provided with `peer_target`; mutually exclusive with `peer_perspective`. Requires a workspace- or admin-level key.",
),
sessions: list[str] | None = Query(
None,
description=(
"Optional allowlist of session IDs confining the representation of "
"`peer_target` to those sessions. This session must be one of them. "
"Recall is restricted to conclusions stated directly in the allowed "
"sessions — conclusions synthesized across sessions are excluded, "
"since their provenance cannot be proven to sit inside the allowlist "
"— and the peer card is omitted for the same reason. Mutually "
"exclusive with `scope` and `limit_to_session`. A peer-scoped key "
"must be an active member of every session named. The 1,000-session "
"cap shared with the recall endpoints applies but is not reachable "
"here: these are repeated query parameters, so a long list exceeds "
"the request-line limit of the server or any proxy in front of it "
"(a 414/431, not a 422) at a few hundred entries. Use a named "
"`scope` for large or reusable session sets."
),
),
limit_to_session: bool = Query(
default=False,
description="Only used if `search_query` is provided. Whether to limit the representation to the session (as opposed to everything known about the target peer)",
description="Whether to limit the representation to the session (as opposed to everything known about the target peer). Narrows recall the same way `sessions` does, so the same restrictions apply: explicit-only conclusions, and the peer card is omitted because it carries no per-session provenance.",
),
search_top_k: int | None = Query(
None,
@ -801,6 +821,47 @@ async def get_session_context(
"`scope` requires a workspace- or admin-level key"
)
# The session allowlist confines the representation to a set of sessions this
# one belongs to. `scope` already determines what can be seen and
# `limit_to_session` already pins the set to this session alone, so both are
# contradictions rather than further narrowings — refused rather than given a
# silent precedence order.
session_allowlist: list[str] | None = None
if sessions is not None:
if scope is not None:
raise ValidationException("`sessions` and `scope` are mutually exclusive")
if limit_to_session:
raise ValidationException(
"`sessions` and `limit_to_session` are mutually exclusive"
)
if not peer_target:
# The allowlist only reaches the representation, and there is no
# representation without a target. Refused rather than accepted and
# silently ignored, which would read as a scoped context.
raise ValidationException(
"peer_target must be provided if sessions is provided"
)
# `must_include` keeps the allowlist from contradicting the route's own
# session: this session's messages and summary are always part of the
# response, so an allowlist excluding it would describe a context that
# cannot be assembled.
session_allowlist = normalize_session_allowlist(
sessions, field="sessions", must_include=session_id
)
# A peer-scoped key may only name sessions its peer belongs to. Mirrors
# the chat route's gate (see routers/peers.py), including `active_only`,
# so both answer the same question for a peer that has left a session.
# Reuses the handler's session rather than opening its own: this is a
# DB-only read and the handler already holds a connection.
if jwt_params.p is not None:
member_sessions = set(
await get_peer_session_names(
db, workspace_id, jwt_params.p, active_only=True
)
)
if not set(session_allowlist) <= member_sessions:
raise AuthenticationException("JWT not permissioned for this resource")
if not peer_target:
# No representation or card needed
summary, messages = await _get_session_context_task(
@ -865,6 +926,17 @@ async def get_session_context(
):
embedding = await embedding_client.embed(search_query)
# The allowlist recall must respect, whichever way the caller expressed it.
# `sessions` and `limit_to_session` are mutually exclusive (422 above), so at
# most one of these is set. `session_allowlist` is never an empty list here —
# `must_include=session_id` guarantees at least this session — so the
# None-check is the only distinction that matters.
effective_allowlist = (
session_allowlist
if session_allowlist is not None
else ([session_id] if limit_to_session else None)
)
# Sequential calls on shared DB session
representation = await _get_working_representation_task(
db,
@ -872,15 +944,34 @@ async def get_session_context(
search_query,
observer=observer,
observed=observed,
session_allowlist=[session_id] if limit_to_session else None,
session_allowlist=effective_allowlist,
search_top_k=search_top_k,
search_max_distance=search_max_distance,
include_most_derived=include_most_frequent,
max_observations=max_conclusions,
embedding=embedding,
)
card = await _get_peer_card_task(
db, workspace_id, observer=observer, observed=observed
# A peer card is keyed by (workspace, observer, observed) with no session
# dimension (crud/peer_card.py), so it is synthesized from everything the
# observer has ever seen and cannot be narrowed to an allowlist. Returning it
# would leak exactly what the allowlist exists to exclude, so it is dropped —
# the same fail-closed reasoning that limits allowlisted conclusion recall to
# ALLOWLIST_SAFE_LEVELS.
#
# Gated on the *effective* allowlist, not on `sessions` alone:
# `limit_to_session=true` narrows recall identically, so carving out only the
# newer parameter would leave a control that one parameter swap defeats.
# `scope` needs no carve-out at all — it swaps the observer to the scope peer
# above, so the card read below is the scope's own.
#
# POST /peers/{id}/chat still injects an unscoped card under an allowlist
# (src/dialectic/chat.py) — tracked in DEV-2201, not fixed here.
card = (
None
if effective_allowlist is not None
else await _get_peer_card_task(
db, workspace_id, observer=observer, observed=observed
)
)
short_summary, long_summary = await _get_both_summaries_task(
db, workspace_id, session_id

View File

@ -172,6 +172,21 @@ class TelemetryEmitter:
)
self._running = True
self._flush_task = asyncio.create_task(self._periodic_flush())
# region ai
# Pre-create the dropped-event counter children at 0: a labeled counter
# exports nothing until its first observation, so this makes the metric
# visible before any drop and lets us tell "no drops" from "metric missing".
# endregion
from src.telemetry.prometheus.metrics import prometheus_metrics
prometheus_metrics.initialize_telemetry_dropped_metrics(
reasons=[
f"{self.drop_reason_prefix}buffer_full",
f"{self.drop_reason_prefix}send_failed",
]
)
logger.info("Telemetry emitter started, endpoint: %s", self.endpoint)
async def shutdown(self) -> None:

View File

@ -138,9 +138,74 @@ __all__ = [
# Lifecycle
"initialize_telemetry_events",
"shutdown_telemetry_events",
# Zero-init registry
"ALL_EVENT_TYPES",
"HIGH_VOLUME_EVENT_TYPES",
]
# Explicit registry of CloudEvents `type` values, used to zero-initialize the
# telemetry_events_emitted / telemetry_events_sampled_out counter children.
# region ai
# See metrics.py:initialize_bounded_metrics for why absent and zero are worth
# distinguishing. Explicit literal, not a set derived from BaseEvent subclasses: a
# derived set would follow whatever happens to be imported at init time, so a type
# could drop out of the registry with no code change. A hand-maintained list plus a
# drift-guard test fails loud at the right moment instead.
#
# When you add a BaseEvent subclass, add its `_event_type` here (and to
# HIGH_VOLUME_EVENT_TYPES if `_volume_class == "high_volume"`). The drift-guard test
# tests/telemetry/test_metric_zero_init.py fails until you do — it asserts this
# registry equals the set discovered by walking BaseEvent subclasses.
# endregion
ALL_EVENT_TYPES: tuple[str, ...] = (
# api
"message.created",
"file.uploaded",
"context.retrieved",
# agent
"agent.iteration",
"agent.tool.conclusions.created",
"agent.tool.conclusions.deleted",
"agent.tool.peer_card.updated",
"agent.tool.summary.created",
"agent.tool.call.completed",
# deletion / dialectic / dream / representation
"deletion.completed",
"dialectic.completed",
"dream.run",
"dream.specialist",
"representation.completed",
# llm / embedding
"llm.call.completed",
"embedding.call.completed",
# reconciliation
"reconciliation.sync_vectors.completed",
"reconciliation.cleanup_stale_items.completed",
# trace stream
# region ai
# Only emitted when TELEMETRY.TRACE_PAYLOADS_ENABLED, but they flow through the
# same emit() path and increment the same counters, so they belong in the set.
# endregion
"llm.call.traced",
"embedding.call.traced",
"trace.content",
)
# Subset of ALL_EVENT_TYPES whose `_volume_class == "high_volume"`.
# region ai
# Only these can ever be counted by ``telemetry_events_sampled_out``; ground_truth
# events skip the sampler entirely (pre-creating their sampled_out series would be a
# permanently-misleading 0).
# endregion
HIGH_VOLUME_EVENT_TYPES: tuple[str, ...] = (
"agent.iteration",
"agent.tool.call.completed",
"llm.call.completed",
"embedding.call.completed",
)
def emit(event: BaseEvent) -> None:
"""Queue an event for emission to the telemetry backend.

View File

@ -154,6 +154,10 @@ class RepresentationCompletedEvent(BaseEvent):
default=0,
description="Number of observers this representation was saved against",
)
failed_observer_count: int = Field(
default=0,
description="Number of observers whose save_representation failed (partial or total)",
)
def get_resource_id(self) -> str:
"""Resource ID includes workspace, session, and latest message for uniqueness."""

View File

@ -20,7 +20,8 @@ from prometheus_client.core import GaugeMetricFamily
from starlette.requests import Request
from starlette.responses import Response
from src.config import settings
from src.config import REASONING_LEVELS, settings
from src.utils.types import walk_subclasses
disable_created_metrics()
@ -66,6 +67,32 @@ class DialecticComponents(Enum):
TOTAL = "total"
# Valid (token_type, component) pairs for deriver_tokens_processed, per task_type,
# used to zero-initialize counter children (see initialize_bounded_metrics).
# region ai
# NOT the cartesian product: input tokens only pair with input components, output
# only with OUTPUT_TOTAL, and PREVIOUS_SUMMARY occurs only for summary tasks
# (ingestion has no previous summary). Enumerating anything broader would fabricate
# impossible always-0 series (e.g. output/prompt, or ingestion/previous_summary).
# Explicit literal, drift-guarded by tests/telemetry/test_metric_zero_init.py.
# Sources: track_deriver_input_tokens (src/utils/tokens.py) + the OUTPUT_TOTAL sites
# in src/deriver/deriver.py and src/utils/summarizer.py.
# endregion
_DERIVER_TOKEN_COMBOS_BY_TASK: dict[str, tuple[tuple[str, str], ...]] = {
DeriverTaskTypes.INGESTION.value: (
(TokenTypes.INPUT.value, DeriverComponents.PROMPT.value),
(TokenTypes.INPUT.value, DeriverComponents.MESSAGES.value),
(TokenTypes.OUTPUT.value, DeriverComponents.OUTPUT_TOTAL.value),
),
DeriverTaskTypes.SUMMARY.value: (
(TokenTypes.INPUT.value, DeriverComponents.PROMPT.value),
(TokenTypes.INPUT.value, DeriverComponents.MESSAGES.value),
(TokenTypes.INPUT.value, DeriverComponents.PREVIOUS_SUMMARY.value),
(TokenTypes.OUTPUT.value, DeriverComponents.OUTPUT_TOTAL.value),
),
}
api_requests_counter = NamespacedCounter(
"api_requests",
"Total API requests",
@ -155,6 +182,23 @@ telemetry_buffer_size_gauge = NamespacedGauge(
["namespace"],
)
# Embedding backlog: MessageEmbedding rows still awaiting a vector
# (sync_state='pending').
# region ai
# Distinct from embed_now_tasks_in_flight (in-flight fast-path work in the API
# process) — this is the durable, DB-wide backlog the reconciler drains. Every
# deriver replica refreshes it on its own timer from
# ReconcilerScheduler._scheduler_loop, so replicas disagree by at most one interval.
# Service-wide, not per-process — hence the help string's "never sum()".
# endregion
message_embeddings_pending_gauge = NamespacedGauge(
"message_embeddings_pending",
"MessageEmbedding rows awaiting embedding (sync_state='pending'). "
+ "Service-wide DB count, reported independently by every replica — "
+ "aggregate with max() or avg(), never sum()",
["namespace"],
)
# DB connection-pool health. The in-flight gauge counts statements actually
# executing on the wire, so checked_out minus in_flight reveals connections held
# but parked (the "idle in transaction during an external call" antipattern).
@ -325,12 +369,159 @@ class PrometheusMetrics:
except Exception as e:
self._handle_metric_error("record_telemetry_event_dropped", e)
def _touch(self, counter: NamespacedCounter, **labels: str) -> None:
"""Pre-create a counter child series at 0 without incrementing it."""
# region ai
# A labeled Prometheus counter exports no time series until its first
# ``labels(...)`` call, so pre-touching a child keeps it present at 0 — a
# missing series then signals a broken scrape rather than "no events".
# Fail-soft (like the recorders): a bad init must never crash startup.
# endregion
try:
counter.labels(**labels)
except Exception as e:
self._handle_metric_error("_touch", e)
def initialize_telemetry_dropped_metrics(self, *, reasons: list[str]) -> None:
"""Pre-create telemetry_events_dropped ``(namespace, reason)`` children at 0.
Args:
reasons: The reason label values the calling emitter can produce.
"""
# region ai
# The metric stays invisible in Prometheus/Grafana until an event is actually
# dropped, so materializing the children at startup keeps it present at 0 — a
# missing series then means a broken scrape, not "no drops" (see _touch).
#
# Called per-emitter from ``TelemetryEmitter.start()`` rather than hoisted into
# the process-level ``initialize_bounded_metrics``: the trace emitter (whose
# reasons carry a ``trace_`` prefix) only exists when ``TRACE_PAYLOADS_ENABLED``
# is set, so hoisting would fabricate ``trace_*`` series on deployments that run
# with tracing off.
# endregion
if not settings.METRICS.ENABLED:
return
for reason in reasons:
self._touch(telemetry_events_dropped_counter, reason=reason)
def initialize_bounded_metrics(self, *, instance_type: str) -> None:
"""Pre-create bounded-label counter children at 0 for this process, so an
absent series means a broken scrape rather than "nothing happened".
Args:
instance_type: "api" or "deriver" selects the process-specific
counters. Event-type and buffer metrics are initialized in both.
"""
# region ai
# A Prometheus counter does not exist until its first increment, so a
# never-yet-incremented metric is indistinguishable from a broken scrape:
# you cannot graph or alert on a series that is absent. Materializing the
# children at 0 inverts that — a missing series now means something is wrong,
# and "no events" reads as a flat 0 instead of a gap.
#
# That only holds for label sets we can enumerate honestly, so a metric is
# initialized here only when its full label domain is bounded, enumerable at
# startup, and actually emitted by THIS process. High-cardinality labels
# (endpoint, workspace_name) and impossible label tuples are deliberately left
# absent — fabricating a permanently-0 series that no code path can ever
# increment is the same lie in the other direction.
#
# Multi-instance safety splits the metrics here into three buckets:
#
# 1. instance-scoped (``telemetry_buffer_size``, ``embed_now_tasks_in_flight``)
# — per-process by nature, so any aggregation is meaningful and zero-init is
# unambiguously right.
# 2. service-scoped additive (the token counters, ``telemetry_events_emitted``)
# — each instance holds a partial count and ``sum()`` reconstructs the whole,
# so multi-instance safe.
# 3. service-scoped non-additive — every instance reports the whole service's
# value, so the instances are N witnesses to one fact rather than N parts of
# one whole. ``sum()`` is therefore never correct here: it scales with the
# replica count. Scale-preserving aggregations (``max()``, ``avg()``,
# quantiles) ARE correct, but only while the witnesses disagree by a bounded
# amount — which requires every instance to refresh on its own timer (see
# ``message_embeddings_pending``, refreshed per replica from
# ``ReconcilerScheduler._scheduler_loop``). A bucket-3 metric that cannot
# meet that bar does not belong in the app at all — it belongs in an exporter
# that yields exactly one series.
#
# Prometheus stamps ``instance``/``job`` at scrape time, which is why buckets 1
# and 2 need no special handling. ``telemetry_events_dropped`` is handled
# separately, per-emitter, in ``TelemetryEmitter.start()`` (prefix-dependent).
# endregion
if not settings.METRICS.ENABLED:
return
# ai: lazy import avoids an import-time cycle (metrics is imported widely)
from src.telemetry.events import ALL_EVENT_TYPES, HIGH_VOLUME_EVENT_TYPES
# region ai
# Common: both processes run a TelemetryEmitter, so both emit their own subset
# of event types. The domain is bounded/low-cardinality (~21 types), so init
# the full set in each process rather than maintain a fragile
# per-event-type -> process map.
# endregion
for event_type in ALL_EVENT_TYPES:
self._touch(telemetry_events_emitted_counter, type=event_type)
for event_type in HIGH_VOLUME_EVENT_TYPES:
self._touch(telemetry_events_sampled_out_counter, type=event_type)
self.set_telemetry_buffer_size(size=0)
if instance_type == "api":
# dialectic tokens: token_type x component(total) x reasoning_level
for token_type in TokenTypes:
for level in REASONING_LEVELS:
self._touch(
dialectic_tokens_processed_counter,
token_type=token_type.value,
component=DialecticComponents.TOTAL.value,
reasoning_level=level,
)
# ai: embed_now fast path runs as an API-process background task
self._touch(embed_now_tasks_shed_counter)
self.set_embed_now_tasks_in_flight(0)
elif instance_type == "deriver":
# deriver tokens: only the valid (token_type, component) tuples per
# task_type (see _DERIVER_TOKEN_COMBOS_BY_TASK).
for task_type_value, combos in _DERIVER_TOKEN_COMBOS_BY_TASK.items():
for token_type_value, component_value in combos:
self._touch(
deriver_tokens_processed_counter,
task_type=task_type_value,
token_type=token_type_value,
component=component_value,
)
# dreamer tokens: specialist_name x token_type.
# region ai
# Names come from the concrete BaseSpecialist subclasses (walked recursively
# via walk_subclasses) so a new specialist can't silently miss init.
# endregion
from src.dreamer.specialists import BaseSpecialist
for specialist in walk_subclasses(BaseSpecialist):
for token_type in TokenTypes:
self._touch(
dreamer_tokens_processed_counter,
specialist_name=specialist.name,
token_type=token_type.value,
)
# ai: init at 0 so the gauge is visible before its first per-replica refresh
self.set_message_embeddings_pending(count=0)
def set_telemetry_buffer_size(self, *, size: int) -> None:
try:
telemetry_buffer_size_gauge.labels().set(size)
except Exception as e:
self._handle_metric_error("set_telemetry_buffer_size", e)
def set_message_embeddings_pending(self, *, count: int) -> None:
try:
message_embeddings_pending_gauge.labels().set(count)
except Exception as e:
self._handle_metric_error("set_message_embeddings_pending", e)
prometheus_metrics = PrometheusMetrics()

View File

@ -930,7 +930,9 @@ async def create_observations(
run_id=run_id,
parent_category=parent_category,
):
embeddings = await embedding_client.simple_batch_embed(contents)
embeddings = await embedding_client.simple_batch_embed(
contents, on_oversize="truncate"
)
embeddings_by_index = dict(
zip(range(len(normalized_observations)), embeddings, strict=True)
)
@ -1010,7 +1012,7 @@ async def create_observations(
workspace_name=workspace_name,
observer=observer,
observed=observed,
deduplicate=True,
deduplicate=settings.DERIVER.DEDUPLICATE,
)
).created_documents
logger.info(

View File

@ -279,16 +279,50 @@ def extract_session_allowlist(
'filters.session_id must be a session id, a list of session ids, or {"in": [...]}'
)
return normalize_session_allowlist(
entries, field="filters.session_id", must_include=must_include
)
def normalize_session_allowlist(
entries: Sequence[Any],
*,
field: str,
must_include: str | None = None,
) -> list[str]:
"""Validate and de-duplicate a session allowlist.
Shared by every route-level entry point that accepts one the ``filters``
body on the recall endpoints and the ``sessions`` query parameter on session
context so the cap, the id charset, and the ``must_include`` rule cannot
drift apart between them. Only the parameter *name* in error messages
differs, which is what ``field`` supplies.
Args:
entries: Raw allowlist entries as the caller supplied them.
field: Caller-facing parameter name, used in error messages.
must_include: A session id that must appear in the allowlist used by
routes that also carry a session of their own, so the two can't
contradict each other.
Returns:
The allowlist, de-duplicated, in first-seen order. An empty input yields
an empty list so downstream consumers fail closed.
Raises:
FilterError: On an over-cap list, a malformed session id, or a
``must_include`` session missing from the allowlist.
"""
if len(entries) > MAX_SESSION_ALLOWLIST_ENTRIES:
raise FilterError(
f"filters.session_id supports at most {MAX_SESSION_ALLOWLIST_ENTRIES} sessions per request"
f"{field} supports at most {MAX_SESSION_ALLOWLIST_ENTRIES} sessions per request"
)
allowlist: list[str] = []
seen: set[str] = set()
for entry in entries:
if not isinstance(entry, str) or not entry:
raise FilterError("filters.session_id entries must be non-empty strings")
raise FilterError(f"{field} entries must be non-empty strings")
# Only names a session could actually have. The allowlist reaches
# queries three ways — direct `IN`, the filter DSL, and a Python
# membership test — and they don't agree on a value like "*", which the
@ -298,14 +332,14 @@ def extract_session_allowlist(
# {"in": [...]}) which never included wildcards.
if not re.fullmatch(RESOURCE_NAME_PATTERN, entry):
raise FilterError(
f"Invalid session id in filters.session_id: {entry!r}. Session ids match {RESOURCE_NAME_PATTERN}"
f"Invalid session id in {field}: {entry!r}. Session ids match {RESOURCE_NAME_PATTERN}"
)
if entry not in seen:
seen.add(entry)
allowlist.append(entry)
if must_include is not None and must_include not in seen:
raise FilterError("session_id must be included in filters.session_id")
raise FilterError(f"session_id must be included in {field}")
return allowlist

View File

@ -1,4 +1,4 @@
from collections.abc import Awaitable, Callable, Generator
from collections.abc import Awaitable, Callable, Generator, Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
@ -6,6 +6,20 @@ from typing import Any, Generic, Literal, TypeVar
T = TypeVar("T")
def walk_subclasses(cls: type[T]) -> Iterator[type[T]]:
"""Yield every subclass of ``cls``, recursively."""
# region ai
# ``type.__subclasses__()`` is direct-children-only, so a grandchild class is
# silently invisible to it. Any registry that enumerates subclasses to decide
# what to initialize or validate wants the transitive closure — otherwise
# subclassing a concrete class is enough to slip past the check.
# endregion
for subclass in cls.__subclasses__():
yield subclass
yield from walk_subclasses(subclass)
# Context variable for tracking current iteration in tool execution loop
# This is used for telemetry to associate tool calls with their iteration
_current_iteration: ContextVar[int] = ContextVar("current_iteration", default=0)

View File

@ -17,21 +17,18 @@ from dataclasses import dataclass, field
from datetime import datetime
from logging import Logger
from pathlib import Path
from typing import Any, Generic, Literal, TypeVar, cast
from typing import Any, Generic, TypeVar, cast
from anthropic import AsyncAnthropic
from honcho import Honcho
from honcho.api_types import SessionConfiguration, SummaryConfiguration
from openai import AsyncOpenAI
from src.config import REASONING_LEVELS, ReasoningLevel
from src.telemetry.metrics_collector import MetricsCollector
_logger = logging.getLogger(__name__)
# Valid reasoning levels for dialectic chat
ReasoningLevel = Literal["minimal", "low", "medium", "high", "max"]
REASONING_LEVELS: list[str] = ["minimal", "low", "medium", "high", "max"]
# Type variable for result types
ResultT = TypeVar("ResultT")

View File

@ -604,7 +604,9 @@ def mock_openai_embeddings(request: pytest.FixtureRequest):
mock_embed.side_effect = embed_side_effect
async def mock_simple_batch_embed_func(texts: list[str]) -> list[list[float]]:
async def mock_simple_batch_embed_func(
texts: list[str], **_kwargs: object
) -> list[list[float]]:
return [_content_to_embedding(text) for text in texts]
mock_simple_batch_embed.side_effect = mock_simple_batch_embed_func

View File

@ -1006,6 +1006,46 @@ class TestDocumentCRUD:
assert documents[0].content in ["Observation 1", "Observation 2"]
assert documents[1].content in ["Observation 1", "Observation 2"]
@pytest.mark.asyncio
async def test_create_observations_embeds_with_truncate_on_oversize(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""API conclusion creates must opt into truncation on oversize content."""
test_workspace, test_peer = sample_data
test_peer2, test_session, _ = await self._setup_test_data(
db_session, test_workspace, test_peer
)
with patch(
"src.crud.document.embedding_client.simple_batch_embed",
new=AsyncMock(return_value=[[0.1] * 1536, [0.2] * 1536]),
) as mock_embed:
created = await crud.create_observations(
db_session,
observations=[
schemas.ConclusionCreate(
content="short conclusion",
observer_id=test_peer.name,
observed_id=test_peer2.name,
session_id=test_session.name,
),
schemas.ConclusionCreate(
content="another conclusion",
observer_id=test_peer.name,
observed_id=test_peer2.name,
session_id=test_session.name,
),
],
workspace_name=test_workspace.name,
)
assert len(created) == 2
mock_embed.assert_awaited_once_with(
["short conclusion", "another conclusion"], on_oversize="truncate"
)
class TestSessionPurityInvariant:
"""Regression tests for the explicit-document session-purity invariant.

View File

@ -520,7 +520,9 @@ class TestRepresentationManagerSave:
)
assert len(saved.created_documents) == 1
mock_embed.assert_awaited_once_with(["useful observation"])
mock_embed.assert_awaited_once_with(
["useful observation"], on_oversize="truncate"
)
saved_observations = _saved_observations(mock_save)
assert len(saved_observations) == 1
assert saved_observations[0].content == "useful observation"
@ -576,7 +578,9 @@ class TestRepresentationManagerSave:
)
assert len(saved.created_documents) == 1
mock_embed.assert_awaited_once_with(["inferred conclusion"])
mock_embed.assert_awaited_once_with(
["inferred conclusion"], on_oversize="truncate"
)
saved_observations = _saved_observations(mock_save)
assert len(saved_observations) == 1
assert isinstance(saved_observations[0], DeductiveObservation)
@ -630,6 +634,61 @@ class TestRepresentationManagerSave:
mock_embed.assert_not_awaited()
mock_save.assert_not_awaited()
@pytest.mark.asyncio
async def test_save_representation_embeds_with_truncate_on_oversize(self):
"""One oversize observation must not drop the rest of the batch."""
manager = RepresentationManager(
"workspace",
observer="observer",
observed="observed",
)
representation = Representation(
explicit=[
ExplicitObservation(
content="short fact",
created_at=datetime.now(timezone.utc),
message_ids=[1],
session_name="session",
)
],
deductive=[
DeductiveObservation(
conclusion="inferred fact",
premises=["premise"],
source_ids=["doc-a"],
created_at=datetime.now(timezone.utc),
message_ids=[1],
session_name="session",
)
],
)
with (
patch("src.crud.representation.tracked_db", _fake_tracked_db),
patch(
"src.crud.representation.embedding_client.simple_batch_embed",
new=AsyncMock(return_value=[[0.1], [0.2]]),
) as mock_embed,
patch.object(
manager,
"_save_representation_internal",
new=AsyncMock(
return_value=CreateDocumentsResult(created_documents=[MagicMock()])
),
),
):
await manager.save_representation(
representation,
message_ids=[1],
session_name="session",
message_created_at=datetime.now(timezone.utc),
message_level_configuration=_resolved_config(),
)
mock_embed.assert_awaited_once_with(
["inferred fact", "short fact"], on_oversize="truncate"
)
class TestVectorQueryTopKFloor:
"""Regression for HONCHO-19Q / HONCHO-4Q4.

View File

@ -7,7 +7,9 @@ import pytest
from src import crud, models
from src.config import settings
from src.crud.representation import RepresentationManager
from src.deriver.deriver import process_representation_tasks_batch
from src.exceptions import RepresentationSaveError
from src.llm import HonchoLLMCallResponse
from src.utils.representation import (
ExplicitObservationBase,
@ -70,6 +72,116 @@ class TestDeriverProcessing:
assert kwargs["model_config"].stop_sequences == expected_config.stop_sequences
assert "llm_settings" not in kwargs
async def test_all_observer_saves_failing_surfaces_failure(self):
"""When every observer's save_representation fails, the batch must raise."""
message = Mock(
id=1,
public_id="msg_1",
session_name="session-1",
workspace_name="workspace-1",
peer_name="alice",
content="hello",
token_count=5,
created_at=datetime.now(timezone.utc),
)
configuration = Mock()
configuration.reasoning.enabled = True
mock_response = HonchoLLMCallResponse(
content=PromptRepresentation(
explicit=[
ExplicitObservationBase(content="The user has a dog named Rover")
]
),
input_tokens=10,
output_tokens=5,
finish_reasons=["STOP"],
)
failing_save = AsyncMock(side_effect=RuntimeError("429 RESOURCE_EXHAUSTED"))
emitted: list[Any] = []
with (
patch(
"src.deriver.deriver.honcho_llm_call",
new_callable=AsyncMock,
return_value=mock_response,
),
patch.object(RepresentationManager, "save_representation", failing_save),
patch("src.deriver.deriver.emit", side_effect=emitted.append),
pytest.raises(RepresentationSaveError, match="save_representation failed"),
):
await process_representation_tasks_batch(
messages=[message],
message_level_configuration=configuration,
observers=["bob"],
observed="alice",
queue_item_message_ids=[1],
)
# Telemetry must fire *before* the raise so a total save failure is still
# visible to metrics. Guards against emit() being moved after the raise.
assert emitted, "expected telemetry to be emitted before the raised failure"
assert emitted[-1].observer_count == 0
assert emitted[-1].failed_observer_count == 1
async def test_partial_observer_failure_is_processed_and_surfaced(self):
"""When some observers save and one fails, the batch does NOT raise
(saved observers are kept) and the failure is visible via telemetry.
"""
message = Mock(
id=1,
public_id="msg_1",
session_name="session-1",
workspace_name="workspace-1",
peer_name="alice",
content="hello",
token_count=5,
created_at=datetime.now(timezone.utc),
)
configuration = Mock()
configuration.reasoning.enabled = True
mock_response = HonchoLLMCallResponse(
content=PromptRepresentation(
explicit=[
ExplicitObservationBase(content="The user has a dog named Rover")
]
),
input_tokens=10,
output_tokens=5,
finish_reasons=["STOP"],
)
# bob succeeds, carol fails.
partial_save = AsyncMock(
side_effect=[
crud.CreateDocumentsResult(),
RuntimeError("429 RESOURCE_EXHAUSTED"),
]
)
emitted: list[Any] = []
with (
patch(
"src.deriver.deriver.honcho_llm_call",
new_callable=AsyncMock,
return_value=mock_response,
),
patch.object(RepresentationManager, "save_representation", partial_save),
patch("src.deriver.deriver.emit", side_effect=emitted.append),
):
await process_representation_tasks_batch(
messages=[message],
message_level_configuration=configuration,
observers=["bob", "carol"],
observed="alice",
queue_item_message_ids=[1],
)
assert emitted, "expected a telemetry event to be emitted"
event = emitted[-1]
assert event.observer_count == 1
assert event.failed_observer_count == 1
async def test_process_representation_tasks_batch_passes_custom_instructions_into_prompt(
self,
) -> None:

View File

@ -551,9 +551,12 @@ class TestReEmbedding:
# Mock embedding client to track batch calls
batch_call_count = 0
async def track_batch_embed(contents: list[str]) -> list[list[float]]:
async def track_batch_embed(
contents: list[str], *, on_oversize: str, **_kwargs: object
) -> list[list[float]]:
nonlocal batch_call_count
batch_call_count += 1
assert on_oversize == "truncate"
return [[1.0] * 1536 for _ in contents]
with patch("src.reconciler.sync_vectors.embedding_client") as mock_embed_client:

View File

@ -68,5 +68,5 @@ Coverage by provider:
- OpenAI transport → OpenRouter non-reasoning models (e.g. `inception/mercury-2`): non-chat / diffusion architectures must stay on `max_tokens`, no `reasoning_effort`, tool-calling parameter-schema compatibility is the canary for exotic OR-served providers
- Gemini 2.5/3.0 classes: structured outputs, cached-content reuse, thought signatures, multi-turn tool replay
- Gemini 3.1 class: thinking and tool replay coverage by default; structured-output/caching coverage should only be added once Google documents support for that path
- Embeddings (`test_live_embeddings.py`): single embed, batched embed, batch-vs-single alignment, and chunk-to-id mapping for every configured embedding model. `gemini-embedding-2*` is the reason this exists — those models collapse a list of bare strings into one document (#745), and only a live call catches it
- Embeddings (`test_live_embeddings.py`): single embed, batched embed, batch-vs-single alignment, chunk-to-id mapping, and oversize-truncate survival (`on_oversize="truncate"`) for every configured embedding model. `gemini-embedding-2*` is the reason this exists — those models collapse a list of bare strings into one document (#745), and only a live call catches it. Also covers first-class `EmbeddingModelConfig.timeout` plumbing (one representative model per transport): configured timeout lands on the SDK client, and a near-zero timeout aborts before the provider answers
- OpenAI-compatible embedding providers (e.g. OpenRouter's `google/gemini-embedding-001`): the #932 surface. Those providers reject a base64 embedding request outright (HTTP 400) or answer HTTP 200 with empty data, so the whole matrix fails without `encoding_format="float"`. Real OpenAI accepts base64 happily, so only a third-party provider catches it. Note that OpenRouter load-balances across upstreams, so the base64 failure is per-attempt rather than guaranteed: a retry can land on an endpoint that accepts it. `test_live_openai_float_encoding_matches_base64` covers the other side, that the float switch must not move vectors on real OpenAI

View File

@ -66,14 +66,23 @@ def require_embedding_key(spec: LiveEmbeddingSpec) -> str:
return key
_EMBEDDING_CONFIG_OVERRIDE_KEYS = frozenset({"timeout", "max_batch_size"})
def make_embedding_client(
spec: LiveEmbeddingSpec, **overrides: Any
) -> _EmbeddingClient:
"""Build a live embedding client for one matrix entry.
Bypasses the `EmbeddingClient` singleton so each spec gets its own client
without mutating global settings.
without mutating global settings. `timeout` and `max_batch_size` land on
`EmbeddingModelConfig`; remaining kwargs go to `_EmbeddingClient`.
"""
config_overrides = {
key: overrides.pop(key)
for key in _EMBEDDING_CONFIG_OVERRIDE_KEYS
if key in overrides
}
kwargs: dict[str, Any] = {
"vector_dimensions": spec.dimensions,
"max_input_tokens": 2048,
@ -90,6 +99,7 @@ def make_embedding_client(
model=spec.model,
api_key=require_embedding_key(spec),
base_url=spec.base_url,
**config_overrides,
),
**kwargs,
)

View File

@ -1,10 +1,15 @@
from __future__ import annotations
import time
from typing import Any, cast
import httpx
import openai
import pytest
from openai import AsyncOpenAI
from src.config import EmbeddingTransport
from .conftest import cosine_similarity, make_embedding_client
from .embedding_matrix import LiveEmbeddingSpec, get_live_embedding_specs
@ -25,6 +30,52 @@ OPENAI_NATIVE_SPECS = tuple(
spec for spec in ALL_SPECS if spec.family == "openai_embedding"
)
GENEROUS_TIMEOUT_SECONDS = 120.0
TIGHT_TIMEOUT_SECONDS = 0.01
# Well under the client defaults; generous enough to absorb SDK retries.
TIGHT_TIMEOUT_WALL_CLOCK_LIMIT_SECONDS = 30
EMBEDDING_TIMEOUT_EXCEPTIONS: dict[
EmbeddingTransport, tuple[type[BaseException], ...]
] = {
"openai": (openai.APITimeoutError,),
# google-genai raises httpx or aiohttp timeouts depending on its transport;
# aiohttp surfaces as asyncio.TimeoutError (== builtins.TimeoutError).
"gemini": (httpx.TimeoutException, TimeoutError),
}
TRANSPORT_MARKS = {
"openai": pytest.mark.requires_openai,
"gemini": pytest.mark.requires_gemini,
}
def representative_embedding_specs() -> list[Any]:
"""One spec per transport — timeout plumbing is client-level, not model-level."""
params: list[Any] = []
for transport in ("openai", "gemini"):
specs = get_live_embedding_specs(transport=transport)
if not specs:
continue
# Prefer the native family over openai-compatible proxies.
family = f"{transport}_embedding"
native = next((s for s in specs if s.family == family), specs[0])
params.append(
pytest.param(native, marks=TRANSPORT_MARKS[transport], id=native.id)
)
return params
def assert_embedding_timeout_on_client(
client: Any, transport: EmbeddingTransport, timeout_seconds: float
) -> None:
if transport == "gemini":
http_options = client.client._api_client._http_options
assert http_options.timeout == int(timeout_seconds * 1000)
return
openai_client = cast(AsyncOpenAI, client.client)
assert openai_client.timeout == timeout_seconds
@pytest.mark.asyncio
@pytest.mark.parametrize("spec", ALL_SPECS, ids=lambda spec: spec.id)
@ -146,6 +197,26 @@ async def test_live_openai_float_encoding_matches_base64(
), f"{spec.id}: float encoding diverges from base64 (cosine={similarity:.8f})"
@pytest.mark.asyncio
@pytest.mark.parametrize("spec", ALL_SPECS, ids=lambda spec: spec.id)
async def test_live_batch_embed_truncates_oversize_instead_of_dropping_batch(
spec: LiveEmbeddingSpec,
) -> None:
"""on_oversize='truncate' keeps one vector per input when an item exceeds the cap."""
# Tiny cap so the oversize input stays cheap to tokenize and send.
client = make_embedding_client(spec, max_input_tokens=32)
oversize = " ".join(f"oversize-token-{index}" for index in range(200))
assert len(client.encoding.encode(oversize)) > client.max_embedding_tokens
texts = [BATCH_TEXTS[0], oversize, BATCH_TEXTS[1]]
embeddings = await client.simple_batch_embed(texts, on_oversize="truncate")
assert len(embeddings) == len(texts)
assert all(len(embedding) == spec.dimensions for embedding in embeddings)
# A collapsed or dropped batch would reuse a vector or return fewer.
assert len({tuple(embedding) for embedding in embeddings}) == len(texts)
@pytest.mark.asyncio
@pytest.mark.parametrize("spec", GEMINI_SPECS, ids=lambda spec: spec.id)
async def test_live_gemini_batch_embed_survives_batch_split(
@ -160,3 +231,36 @@ async def test_live_gemini_batch_embed_survives_batch_split(
assert len(embeddings) == len(BATCH_TEXTS)
assert len({tuple(embedding) for embedding in embeddings}) == len(BATCH_TEXTS)
@pytest.mark.asyncio
@pytest.mark.parametrize("spec", representative_embedding_specs())
async def test_live_embedding_timeout_reaches_the_client(
spec: LiveEmbeddingSpec,
) -> None:
"""Configured embedding timeout lands on the provider SDK client."""
client = make_embedding_client(spec, timeout=GENEROUS_TIMEOUT_SECONDS)
embedding = await client.embed(BATCH_TEXTS[0])
assert len(embedding) == spec.dimensions
assert_embedding_timeout_on_client(client, spec.transport, GENEROUS_TIMEOUT_SECONDS)
@pytest.mark.asyncio
@pytest.mark.parametrize("spec", representative_embedding_specs())
async def test_live_tight_embedding_timeout_aborts_request(
spec: LiveEmbeddingSpec,
) -> None:
"""A near-zero embedding timeout aborts before the provider can answer."""
client = make_embedding_client(spec, timeout=TIGHT_TIMEOUT_SECONDS)
started = time.monotonic()
with pytest.raises(EMBEDDING_TIMEOUT_EXCEPTIONS[spec.transport]):
await client.embed(BATCH_TEXTS[0])
elapsed = time.monotonic() - started
assert elapsed < TIGHT_TIMEOUT_WALL_CLOCK_LIMIT_SECONDS, (
f"tight embedding timeout took {elapsed:.1f}s — client timeout "
f"likely not applied"
)

View File

@ -13,6 +13,7 @@ from src.config import (
)
from src.embedding_client import (
BatchItem,
EmbeddingClient,
_EmbeddingClient, # pyright: ignore[reportPrivateUsage]
)
@ -65,7 +66,13 @@ async def test_openai_embedding_client_uses_configured_model_and_dimensions(
fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 8)
class FakeOpenAIClient:
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
def __init__(
self,
*,
api_key: str | None,
base_url: str | None,
timeout: float | None = None,
) -> None:
self.api_key: str | None = api_key
self.base_url: str | None = base_url
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
@ -104,7 +111,13 @@ async def test_openai_embedding_client_rejects_dimension_mismatch(
fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 7)
class FakeOpenAIClient:
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
def __init__(
self,
*,
api_key: str | None,
base_url: str | None,
timeout: float | None = None,
) -> None:
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
@ -220,6 +233,118 @@ async def test_gemini_embedding_client_keeps_timeout_without_base_url(
assert gemini_client.http_options.timeout == 600_000
@pytest.mark.asyncio
async def test_openai_embedding_client_forwards_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Configured embedding timeout reaches the OpenAI-compatible client."""
class FakeOpenAIClient:
def __init__(
self,
*,
api_key: str | None,
base_url: str | None,
timeout: float | None = None,
) -> None:
self.api_key: str | None = api_key
self.base_url: str | None = base_url
self.timeout: float | None = timeout
self.embeddings: FakeOpenAIEmbeddingsAPI = FakeOpenAIEmbeddingsAPI(
[0.1] * 8
)
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
client = _EmbeddingClient(
EmbeddingModelConfig(
transport="openai",
model="text-embedding-3-small",
api_key="test-key",
timeout=45,
),
vector_dimensions=8,
max_input_tokens=8192,
max_tokens_per_request=300_000,
send_dimensions=False,
)
openai_client = cast(Any, client.client)
assert openai_client.timeout == 45.0
@pytest.mark.asyncio
async def test_openai_embedding_client_omits_timeout_when_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Unset timeout omits the kwarg so the OpenAI SDK keeps its default."""
missing = object()
class FakeOpenAIClient:
def __init__(
self,
*,
api_key: str | None,
base_url: str | None,
timeout: object = missing,
) -> None:
self.api_key: str | None = api_key
self.base_url: str | None = base_url
self.timeout: object = timeout
self.embeddings: FakeOpenAIEmbeddingsAPI = FakeOpenAIEmbeddingsAPI(
[0.1] * 8
)
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
client = _EmbeddingClient(
EmbeddingModelConfig(
transport="openai",
model="text-embedding-3-small",
api_key="test-key",
),
vector_dimensions=8,
max_input_tokens=8192,
max_tokens_per_request=300_000,
send_dimensions=False,
)
openai_client = cast(Any, client.client)
assert openai_client.timeout is missing
@pytest.mark.asyncio
async def test_gemini_embedding_client_forwards_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Configured embedding timeout reaches Gemini as milliseconds."""
class FakeGeminiClient:
def __init__(self, *, api_key: str | None, http_options: Any) -> None:
self.api_key: str | None = api_key
self.http_options: Any = http_options
self.aio: Any = SimpleNamespace(models=SimpleNamespace())
monkeypatch.setattr("google.genai.Client", FakeGeminiClient)
client = _EmbeddingClient(
EmbeddingModelConfig(
transport="gemini",
model="gemini-embedding-001",
api_key="gemini-key",
timeout=45,
),
vector_dimensions=8,
max_input_tokens=4096,
max_tokens_per_request=300_000,
send_dimensions=False,
)
gemini_client = cast(Any, client.client)
assert gemini_client.http_options.timeout == 45_000
def _build_openai_client(
monkeypatch: pytest.MonkeyPatch,
*,
@ -233,7 +358,13 @@ def _build_openai_client(
fake_embeddings = FakeOpenAIEmbeddingsAPI(embedding)
class FakeOpenAIClient:
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
def __init__(
self,
*,
api_key: str | None,
base_url: str | None,
timeout: float | None = None,
) -> None:
self.api_key: str | None = api_key
self.base_url: str | None = base_url
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
@ -707,7 +838,13 @@ async def test_simple_batch_embed_respects_token_budget_per_request(
fake_embeddings = FakeOpenAIEmbeddingsAPI([0.5] * 4)
class FakeOpenAIClient:
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
def __init__(
self,
*,
api_key: str | None,
base_url: str | None,
timeout: float | None = None,
) -> None:
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
@ -745,7 +882,13 @@ async def test_simple_batch_embed_rejects_oversized_input(
fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 4)
class FakeOpenAIClient:
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
def __init__(
self,
*,
api_key: str | None,
base_url: str | None,
timeout: float | None = None,
) -> None:
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
@ -768,6 +911,131 @@ async def test_simple_batch_embed_rejects_oversized_input(
await client.simple_batch_embed([too_long])
@pytest.mark.asyncio
async def test_simple_batch_embed_truncates_oversize_when_requested(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""on_oversize='truncate' embeds a prefix instead of failing the batch."""
fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 4)
class FakeOpenAIClient:
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
client = _EmbeddingClient(
EmbeddingModelConfig(
transport="openai",
model="text-embedding-3-small",
api_key="test-key",
base_url=None,
),
vector_dimensions=4,
max_input_tokens=10,
max_tokens_per_request=1000,
send_dimensions=False,
)
short = "hello"
too_long = ("word " * 50).strip()
assert len(client.encoding.encode(too_long)) > client.max_embedding_tokens
out = await client.simple_batch_embed([short, too_long], on_oversize="truncate")
assert len(out) == 2
assert fake_embeddings.calls, "expected a provider call after truncation"
received = fake_embeddings.calls[0]["input"]
assert received[0] == short
truncated = received[1]
assert isinstance(truncated, str)
assert truncated != too_long
assert len(client.encoding.encode(truncated)) <= client.max_embedding_tokens
@pytest.mark.asyncio
async def test_simple_batch_embed_truncate_reencodes_until_under_cap(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""decode(ids[:n]) can re-encode past n; truncate must re-verify the count."""
fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 4)
class FakeOpenAIClient:
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
client = _EmbeddingClient(
EmbeddingModelConfig(
transport="openai",
model="text-embedding-3-small",
api_key="test-key",
base_url=None,
),
vector_dimensions=4,
max_input_tokens=10,
max_tokens_per_request=1000,
send_dimensions=False,
)
encode_calls = {"n": 0}
def encode(text: str) -> list[int]:
encode_calls["n"] += 1
if text.startswith("LONG"):
# 1: original oversize; 2: still over after first slice; 3+: fits.
if encode_calls["n"] == 1:
return list(range(20))
if encode_calls["n"] == 2:
return list(range(12))
return list(range(8))
return [1]
def decode(ids: list[int]) -> str:
return "LONG" + "x" * len(ids)
monkeypatch.setattr(client.encoding, "encode", encode)
monkeypatch.setattr(client.encoding, "decode", decode)
out = await client.simple_batch_embed(["LONG-input"], on_oversize="truncate")
assert len(out) == 1
received = fake_embeddings.calls[0]["input"][0]
assert isinstance(received, str)
# The provider must see the post-loop text, which encodes to 8 (<= cap).
assert encode(received) == list(range(8))
assert encode_calls["n"] >= 3
@pytest.mark.asyncio
async def test_public_embedding_client_forwards_on_oversize(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The singleton wrapper must forward on_oversize to the inner client."""
captured: dict[str, object] = {}
class FakeInner:
async def simple_batch_embed(
self,
texts: list[str],
*,
on_oversize: str = "raise",
) -> list[list[float]]:
captured["texts"] = texts
captured["on_oversize"] = on_oversize
return [[0.1]]
wrapper = EmbeddingClient()
monkeypatch.setattr(wrapper, "_get_client", lambda: FakeInner())
out = await wrapper.simple_batch_embed(["hi"], on_oversize="truncate")
assert out == [[0.1]]
assert captured["texts"] == ["hi"]
assert captured["on_oversize"] == "truncate"
def test_prepare_chunks_returns_ordered_chunks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@ -775,7 +1043,13 @@ def test_prepare_chunks_returns_ordered_chunks(
fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 4)
class FakeOpenAIClient:
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
def __init__(
self,
*,
api_key: str | None,
base_url: str | None,
timeout: float | None = None,
) -> None:
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
@ -818,6 +1092,31 @@ def test_embedding_model_config_parses_max_batch_size_from_env(
assert resolved.max_batch_size == 10
def test_embedding_model_config_parses_timeout_from_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
s = _build_embedding_settings(
{"EMBEDDING_MODEL_CONFIG__TIMEOUT": "90.0"},
monkeypatch,
)
assert s.MODEL_CONFIG.timeout == 90.0
resolved = resolve_embedding_model_config(s.MODEL_CONFIG)
assert resolved.timeout == 90.0
def test_embedding_model_config_rejects_invalid_timeout() -> None:
with pytest.raises(
ValueError, match=r"provider_params\.timeout must be a positive number"
):
EmbeddingModelConfig(
transport="openai",
model="text-embedding-3-small",
timeout=-1,
)
@pytest.mark.asyncio
async def test_gemini_process_batch_wraps_contents_as_content_part(
monkeypatch: pytest.MonkeyPatch,

View File

@ -1,3 +1,5 @@
import pytest
from src.llm.backend import CompletionResult, ToolCallResult
from src.llm.history_adapters import (
AnthropicHistoryAdapter,
@ -65,3 +67,49 @@ def test_openai_history_adapter_preserves_reasoning_details() -> None:
assert message["role"] == "assistant"
assert message["reasoning_details"] == [{"type": "reasoning", "content": "step 1"}]
assert message["tool_calls"][0]["function"]["name"] == "search"
def test_openai_history_adapter_preserves_thinking_content() -> None:
adapter = OpenAIHistoryAdapter()
result = CompletionResult(
content="Calling a tool",
thinking_content="step 1",
tool_calls=[
ToolCallResult(id="tool_1", name="search", input={"query": "honcho"})
],
)
message = adapter.format_assistant_tool_message(result)
assert message["reasoning_content"] == "step 1"
assert "reasoning_details" not in message
def test_openai_history_adapter_prefers_reasoning_details() -> None:
adapter = OpenAIHistoryAdapter()
reasoning_details = [{"type": "reasoning", "content": "step 1"}]
result = CompletionResult(
content="Calling a tool",
thinking_content="duplicate step 1",
reasoning_details=reasoning_details,
)
message = adapter.format_assistant_tool_message(result)
assert message["reasoning_details"] == reasoning_details
assert "reasoning_content" not in message
@pytest.mark.parametrize("thinking_content", [None, ""])
def test_openai_history_adapter_omits_empty_thinking_content(
thinking_content: str | None,
) -> None:
adapter = OpenAIHistoryAdapter()
result = CompletionResult(
content="Calling a tool",
thinking_content=thinking_content,
)
message = adapter.format_assistant_tool_message(result)
assert "reasoning_content" not in message

View File

@ -0,0 +1,104 @@
from __future__ import annotations
from copy import deepcopy
from typing import Any, cast
from unittest.mock import patch
import pytest
from src.config import ModelConfig
from src.llm import tool_loop
from src.llm.runtime import AttemptPlan
from src.llm.tool_loop import execute_tool_loop
from src.llm.types import HonchoLLMCallResponse, ProviderClient
def _make_plan() -> AttemptPlan:
return AttemptPlan(
provider="openai",
model="deepseek-v4-pro",
client=cast(ProviderClient, object()),
thinking_budget_tokens=None,
reasoning_effort=None,
selected_config=ModelConfig(
model="deepseek-v4-pro",
transport="openai",
),
attempt=1,
retry_attempts=1,
is_fallback=False,
)
@pytest.mark.asyncio
async def test_tool_loop_replays_reasoning_content_on_continuation() -> None:
calls: list[list[dict[str, Any]]] = []
responses = iter(
[
HonchoLLMCallResponse(
content="",
output_tokens=5,
finish_reasons=["tool_calls"],
tool_calls_made=[
{
"id": "call_1",
"name": "search",
"input": {"query": "honcho"},
}
],
thinking_content="DeepSeek reasoning",
),
HonchoLLMCallResponse(
content="done",
output_tokens=3,
finish_reasons=["stop"],
tool_calls_made=[],
),
]
)
async def fake_call(*_args: Any, **kwargs: Any) -> HonchoLLMCallResponse[Any]:
calls.append(deepcopy(kwargs["messages"]))
return next(responses)
async def execute_search(_name: str, _input: dict[str, Any]) -> str:
return "result"
with patch.object(tool_loop, "honcho_llm_call_inner", new=fake_call):
result = await execute_tool_loop(
prompt="hi",
max_tokens=64,
messages=[{"role": "user", "content": "hi"}],
tools=[
{
"name": "search",
"description": "Search",
"input_schema": {"type": "object"},
}
],
tool_choice="auto",
tool_executor=execute_search,
max_tool_iterations=5,
response_model=None,
json_mode=False,
temperature=None,
stop_seqs=None,
verbosity=None,
enable_retry=False,
retry_attempts=1,
max_input_tokens=None,
get_attempt_plan=_make_plan,
before_retry_callback=lambda _retry_state: None,
stream_final=False,
telemetry=None,
)
assert isinstance(result, HonchoLLMCallResponse)
assert len(calls) == 2
assert calls[1][1]["reasoning_content"] == "DeepSeek reasoning"
assert calls[1][1]["tool_calls"][0]["function"]["name"] == "search"
assert calls[1][2] == {
"role": "tool",
"tool_call_id": "call_1",
"content": "result",
}

View File

@ -0,0 +1,86 @@
"""The pending-embeddings backlog gauge must be refreshed per-replica.
These tests pin both halves: the scheduler loop drives the refresh, and the
queue-driven reconciliation cycle does not.
"""
# region ai
# ``message_embeddings_pending`` reports a DB-global count, so it is the one gauge
# here whose value is service-wide rather than per-process. It is also zero-
# initialized at startup, which makes a missing refresh actively harmful: a replica
# that never measured the backlog would export a confident, permanently-healthy 0.
# So the count is driven from ``ReconcilerScheduler._scheduler_loop`` (runs on every
# replica, every interval), NOT from ``run_vector_reconciliation_cycle`` (runs off
# the queue behind work-unit dedup, so exactly one replica per cycle executes it).
# endregion
import asyncio
import pytest
from src.reconciler import scheduler as scheduler_module
from src.reconciler import sync_vectors
from src.reconciler.scheduler import ReconcilerScheduler
@pytest.fixture(autouse=True)
def _reset_scheduler_singleton(): # pyright: ignore[reportUnusedFunction]
ReconcilerScheduler.reset_singleton()
yield
ReconcilerScheduler.reset_singleton()
async def test_scheduler_loop_refreshes_backlog_gauge(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Every scheduler iteration refreshes the gauge, on every replica.
Patched at the scheduler's own reference so this asserts the call site, not
just that the function exists.
"""
calls = 0
refreshed = asyncio.Event()
async def _fake_refresh() -> None:
nonlocal calls
calls += 1
refreshed.set()
# region ai
# Patched onto the class, so it is invoked as a bound method — it needs the
# ``self`` parameter or the call raises TypeError, which ``_scheduler_loop`` would
# then swallow, leaving this guard silently inert.
# endregion
async def _never_enqueue(_self: object, _task: object) -> bool:
return False
monkeypatch.setattr(
scheduler_module, "record_pending_embeddings_backlog", _fake_refresh
)
monkeypatch.setattr(
ReconcilerScheduler, "_try_enqueue_task", _never_enqueue, raising=True
)
scheduler = ReconcilerScheduler()
await scheduler.start()
try:
await asyncio.wait_for(refreshed.wait(), timeout=5.0)
finally:
await scheduler.shutdown()
assert calls >= 1, "scheduler loop never refreshed the backlog gauge"
def test_reconciliation_cycle_does_not_drive_the_gauge() -> None:
"""The queue-driven cycle must not be the thing that sets the gauge."""
# region ai
# If the refresh moves back into ``run_vector_reconciliation_cycle``, only the
# replica that wins the ``sync_vectors`` work unit would ever measure the backlog,
# and the zero-init would go back to lying on all the others.
#
# Structural guard: the cycle is a long DB-driven coroutine, so this inspects the
# global names it references rather than executing it.
# endregion
assert hasattr(sync_vectors, "record_pending_embeddings_backlog")
referenced = sync_vectors.run_vector_reconciliation_cycle.__code__.co_names
assert "record_pending_embeddings_backlog" not in referenced

View File

@ -6,7 +6,7 @@ from sdks.python.src.honcho.client import Honcho
from sdks.python.src.honcho.conclusions import (
Conclusion,
ConclusionCreateParams,
ConclusionScope,
ConclusionsView,
)
@ -34,7 +34,7 @@ async def test_observation_create_single(
# Get observation scope for observer -> target
obs_scope = observer.conclusions_of(target)
assert isinstance(obs_scope, ConclusionScope)
assert isinstance(obs_scope, ConclusionsView)
# Create a single observation
created = await obs_scope.aio.create(
@ -68,7 +68,7 @@ async def test_observation_create_single(
# Get observation scope for observer -> target
obs_scope = observer.conclusions_of(target)
assert isinstance(obs_scope, ConclusionScope)
assert isinstance(obs_scope, ConclusionsView)
# Create a single observation
created = obs_scope.create(
@ -422,7 +422,7 @@ async def test_self_observation_create(
# Get self-observation scope
obs_scope = peer.conclusions
assert isinstance(obs_scope, ConclusionScope)
assert isinstance(obs_scope, ConclusionsView)
assert obs_scope.observer == peer.id
assert obs_scope.observed == peer.id
@ -443,7 +443,7 @@ async def test_self_observation_create(
# Get self-observation scope
obs_scope = peer.conclusions
assert isinstance(obs_scope, ConclusionScope)
assert isinstance(obs_scope, ConclusionsView)
assert obs_scope.observer == peer.id
assert obs_scope.observed == peer.id
@ -796,7 +796,7 @@ async def test_list_rejects_reserved_scope_filter_keys(
target = await honcho_client.aio.peer(id="test-obs-reserved-list-target")
obs_scope = observer.conclusions_of(target)
for key in reserved:
with pytest.raises(ValueError, match="managed by this conclusion scope"):
with pytest.raises(ValueError, match="managed by this conclusions view"):
await obs_scope.aio.list(filters={key: "someone-else"})
# A non-reserved filter (level) is allowed through.
await obs_scope.aio.list(filters={"level": "explicit"})
@ -805,7 +805,7 @@ async def test_list_rejects_reserved_scope_filter_keys(
target = honcho_client.peer(id="test-obs-reserved-list-target")
obs_scope = observer.conclusions_of(target)
for key in reserved:
with pytest.raises(ValueError, match="managed by this conclusion scope"):
with pytest.raises(ValueError, match="managed by this conclusions view"):
obs_scope.list(filters={key: "someone-else"})
obs_scope.list(filters={"level": "explicit"})
@ -827,7 +827,7 @@ async def test_query_rejects_reserved_scope_filter_keys(
target = await honcho_client.aio.peer(id="test-obs-reserved-query-target")
obs_scope = observer.conclusions_of(target)
for key in reserved:
with pytest.raises(ValueError, match="managed by this conclusion scope"):
with pytest.raises(ValueError, match="managed by this conclusions view"):
await obs_scope.aio.query("q", filters={key: "someone-else"})
# session_id is a normal filter for query (no dedicated param) — allowed.
await obs_scope.aio.query("q", filters={"session_id": "some-session"})
@ -836,6 +836,6 @@ async def test_query_rejects_reserved_scope_filter_keys(
target = honcho_client.peer(id="test-obs-reserved-query-target")
obs_scope = observer.conclusions_of(target)
for key in reserved:
with pytest.raises(ValueError, match="managed by this conclusion scope"):
with pytest.raises(ValueError, match="managed by this conclusions view"):
obs_scope.query("q", filters={key: "someone-else"})
obs_scope.query("q", filters={"session_id": "some-session"})

View File

@ -0,0 +1,236 @@
"""Unit tests for the SDK's scope / session-allowlist option handling.
Pure logic no server, no database. These pin the wire translation the server
expects, so a rename or a shape change fails here rather than as a 422 at runtime.
"""
import sys
from pathlib import Path
import pytest
# Add the SDK src to the path to allow imports
sdk_src_path = Path(__file__).parent.parent.parent / "sdks" / "python" / "src"
sys.path.insert(0, str(sdk_src_path))
from sdks.python.src.honcho.utils.scopes import ( # noqa: E402
MAX_SCOPES_PER_OPTION,
MAX_SESSION_ALLOWLIST_ENTRIES,
MAX_SESSIONS_PER_ADD,
resolve_scope_membership,
resolve_scope_option,
resolve_scope_session,
scope_context_fields,
scope_recall_fields,
validate_scope_id,
)
def context_fields(**overrides: object) -> dict[str, object]:
"""Call scope_context_fields with the neutral defaults filled in."""
kwargs: dict[str, object] = {
"scope": None,
"sessions": None,
"peer_target": "user",
"peer_perspective": None,
"limit_to_session": False,
}
kwargs.update(overrides)
return scope_context_fields(**kwargs) # pyright: ignore[reportArgumentType]
class TestValidateScopeId:
def test_accepts_a_plain_name(self):
assert validate_scope_id("therapy") == "therapy"
def test_rejects_the_reserved_prefix_by_name(self):
# 'scope.therapy' violates both the prefix rule and the charset. The
# prefix message is the actionable one, so it must be the one raised.
with pytest.raises(ValueError, match="reserved prefix"):
validate_scope_id("scope.therapy")
def test_rejects_characters_outside_the_charset(self):
with pytest.raises(ValueError, match="must match pattern"):
validate_scope_id("my scope")
def test_rejects_empty(self):
with pytest.raises(ValueError, match="between 1 and"):
validate_scope_id("")
def test_rejects_a_name_that_leaves_no_room_for_the_prefix(self):
# 512 - len("scope.") is the ceiling: the server stores the name prefixed
# into a 512-character peer name.
with pytest.raises(ValueError, match="between 1 and"):
validate_scope_id("a" * 507)
class TestResolveScopeOption:
def test_a_single_scope_stays_a_string(self):
# The shapes are not interchangeable to the server: one scope reads that
# scope's own view, a list restricts to the union of member sessions.
assert resolve_scope_option("therapy") == "therapy"
def test_a_sequence_becomes_a_list(self):
assert resolve_scope_option(["therapy", "work"]) == ["therapy", "work"]
def test_rejects_an_empty_sequence(self):
with pytest.raises(ValueError, match="at least one scope"):
resolve_scope_option([])
def test_rejects_an_over_cap_sequence(self):
with pytest.raises(ValueError, match="at most"):
resolve_scope_option([f"s{i}" for i in range(MAX_SCOPES_PER_OPTION + 1)])
class TestScopeRecallFields:
def test_neither_option_contributes_nothing(self):
assert scope_recall_fields(scope=None, sessions=None) == {}
def test_sessions_becomes_a_session_id_filter(self):
# `sessions` is sugar. It must never reach the wire as its own key —
# the server rejects unknown keys with a 422.
fields = scope_recall_fields(scope=None, sessions=["a", "b"])
assert fields == {"filters": {"session_id": ["a", "b"]}}
assert "sessions" not in fields
def test_scope_passes_through_under_its_own_key(self):
assert scope_recall_fields(scope="therapy", sessions=None) == {
"scope": "therapy"
}
def test_scope_and_sessions_are_mutually_exclusive(self):
with pytest.raises(ValueError, match="mutually exclusive"):
scope_recall_fields(scope="therapy", sessions=["a"])
def test_scope_and_a_single_session_are_mutually_exclusive(self):
with pytest.raises(ValueError, match="mutually exclusive"):
scope_recall_fields(scope="therapy", sessions=None, session_id="a")
def test_sessions_composes_with_a_single_session(self):
# Unlike `scope`, an allowlist may accompany a session_id — the server
# only requires that the session be inside the allowlist.
assert scope_recall_fields(scope=None, sessions=["a", "b"], session_id="a") == {
"filters": {"session_id": ["a", "b"]}
}
def test_rejects_an_empty_allowlist(self):
# An empty allowlist is fail-closed server-side (recalls nothing), which
# is never what `sessions=[]` intends.
with pytest.raises(ValueError, match="at least one session"):
scope_recall_fields(scope=None, sessions=[])
def test_rejects_an_over_cap_allowlist(self):
with pytest.raises(ValueError, match="at most"):
scope_recall_fields(
scope=None,
sessions=[f"s{i}" for i in range(MAX_SESSION_ALLOWLIST_ENTRIES + 1)],
)
def test_resolves_objects_with_an_id(self):
class FakeSession:
id: str = "session-a"
fields = scope_recall_fields(scope=None, sessions=[FakeSession()]) # pyright: ignore[reportArgumentType]
assert fields == {"filters": {"session_id": ["session-a"]}}
class TestScopeContextFields:
"""The context route takes these as query params, not as a `filters` body."""
def test_neither_option_contributes_nothing(self):
assert context_fields() == {}
def test_scope_passes_through(self):
assert context_fields(scope="therapy") == {"scope": "therapy"}
def test_sessions_stays_a_plain_list(self):
# Not wrapped in `filters` — this route reads a repeated query parameter.
assert context_fields(sessions=["a", "b"]) == {"sessions": ["a", "b"]}
def test_scope_and_peer_perspective_are_mutually_exclusive(self):
with pytest.raises(ValueError, match="mutually exclusive"):
context_fields(scope="therapy", peer_perspective="assistant")
def test_scope_and_sessions_are_mutually_exclusive(self):
with pytest.raises(ValueError, match="mutually exclusive"):
context_fields(scope="therapy", sessions=["a"])
def test_sessions_and_limit_to_session_are_mutually_exclusive(self):
with pytest.raises(ValueError, match="mutually exclusive"):
context_fields(sessions=["a"], limit_to_session=True)
@pytest.mark.parametrize("option", [{"scope": "therapy"}, {"sessions": ["a"]}])
def test_either_option_requires_a_peer_target(self, option: dict[str, object]):
# Both only reach the representation, and there is none without a target.
# Refused rather than accepted and silently ignored.
with pytest.raises(ValueError, match="peer_target"):
context_fields(peer_target=None, **option)
def test_limit_to_session_alone_is_untouched(self):
# The neutral case must not start emitting a scope/sessions key.
assert context_fields(limit_to_session=True) == {}
class TestResolveScopeMembership:
def test_resolves_ids_and_objects_in_order(self):
class FakeSession:
id: str = "session-b"
assert resolve_scope_membership(["session-a", FakeSession()]) == [ # pyright: ignore[reportArgumentType]
"session-a",
"session-b",
]
def test_rejects_empty(self):
with pytest.raises(ValueError, match="At least one session"):
resolve_scope_membership([])
def test_rejects_over_the_per_call_cap_rather_than_chunking(self):
with pytest.raises(ValueError, match="At most"):
resolve_scope_membership([f"s{i}" for i in range(MAX_SESSIONS_PER_ADD + 1)])
def test_rejects_a_malformed_id(self):
with pytest.raises(ValueError, match="must match pattern"):
resolve_scope_membership(["ok-session", "valid-session?typo"])
class TestResolveScopeSession:
"""Guards the ID that gets interpolated into a scope membership URL path."""
def test_resolves_a_plain_id(self):
assert resolve_scope_session("session-a") == "session-a"
def test_resolves_an_object(self):
class FakeSession:
id: str = "session-a"
assert resolve_scope_session(FakeSession()) == "session-a" # pyright: ignore[reportArgumentType]
@pytest.mark.parametrize(
"malformed",
[
"valid-session?typo", # would address `valid-session` + a query string
"valid-session/../other", # would climb the path
"valid session",
"",
],
)
def test_rejects_ids_that_would_alter_the_request_path(self, malformed: str):
# This value lands in a DELETE path. An unvalidated id silently changes
# which session is removed, and removal triggers reconciliation against
# whatever it hits.
with pytest.raises(ValueError, match="Session ID"):
resolve_scope_session(malformed)
def test_deprecated_conclusion_scope_aliases_still_resolve():
"""The rename keeps working for callers on the old name."""
from sdks.python.src.honcho import (
ConclusionScope,
ConclusionScopeAio,
ConclusionsView,
ConclusionsViewAio,
)
assert ConclusionScope is ConclusionsView
assert ConclusionScopeAio is ConclusionsViewAio

View File

@ -0,0 +1,357 @@
"""Tests for startup zero-initialization of bounded-label metrics.
Asserts that:
- bounded-label counter children are materialized at 0 before any event,
- high-cardinality / impossible label combinations are deliberately NOT,
- per-process init doesn't materialize the other process's counters,
- the explicit registries stay in sync with the source of truth (drift guards).
"""
# region ai
# Reads use ``REGISTRY.get_sample_value`` (returns the value if a series exists,
# ``None`` if it does not) rather than ``counter.labels(...)``, because ``.labels``
# would itself materialize the child and destroy the presence/absence signal.
# endregion
from collections.abc import Iterator
from typing import cast
from uuid import uuid4
import pytest
from prometheus_client import REGISTRY
from src.config import REASONING_LEVELS, settings
from src.dreamer.specialists import BaseSpecialist
from src.telemetry.events import ALL_EVENT_TYPES, HIGH_VOLUME_EVENT_TYPES
from src.telemetry.events.base import BaseEvent
from src.telemetry.prometheus.metrics import (
_DERIVER_TOKEN_COMBOS_BY_TASK, # pyright: ignore[reportPrivateUsage]
DeriverComponents,
DeriverTaskTypes,
DialecticComponents,
TokenTypes,
prometheus_metrics,
)
from src.utils.types import walk_subclasses
def unique_ns(tag: str) -> str:
"""A ``namespace`` label value no other test can have materialized under."""
# region ai
# Every assertion here reads the process-global ``REGISTRY``, which keeps a child
# series for the rest of the session once anything materializes it. A shared
# namespace (several other suites pin ``"test"``) would let another test's children
# satisfy a presence assertion, or break an absence assertion, independently of
# what the initializer under test actually did.
# endregion
return f"test_metric_zero_init_{tag}_{uuid4().hex[:8]}"
@pytest.fixture
def metrics_enabled(monkeypatch: pytest.MonkeyPatch) -> Iterator[str]:
"""Enable metrics under a namespace unique to the requesting test."""
ns = unique_ns("enabled")
monkeypatch.setattr("src.config.settings.METRICS.ENABLED", True)
monkeypatch.setattr("src.config.settings.METRICS.NAMESPACE", ns)
yield ns
def sample(name: str, **labels: str) -> float | None:
"""Value of a series if it exists, else None. Never materializes it.
Resolves the namespace from settings, so it always reads the unique one the
active test pinned.
"""
ns = cast(str, settings.METRICS.NAMESPACE)
return REGISTRY.get_sample_value(name, {"namespace": ns, **labels})
# ---------------------------------------------------------------------------
# Drift guards (pure logic — no registry). Adding an event type / token component
# without updating the registry fails here, with a pointer to what to fix.
# ---------------------------------------------------------------------------
def test_all_event_types_registry_matches_subclasses():
"""ALL_EVENT_TYPES must equal every BaseEvent subclass's _event_type.
If this fails you added/removed a BaseEvent subclass without updating
ALL_EVENT_TYPES in src/telemetry/events/__init__.py its Prometheus counter
would not be zero-initialized. Update the registry.
"""
discovered = {
event_type
for cls in walk_subclasses(BaseEvent)
if (event_type := getattr(cls, "_event_type", None)) is not None
}
assert set(ALL_EVENT_TYPES) == discovered
assert len(ALL_EVENT_TYPES) == len(set(ALL_EVENT_TYPES)), "duplicate event types"
def test_high_volume_registry_matches_subclasses():
"""HIGH_VOLUME_EVENT_TYPES must equal the high_volume-classed subclasses."""
discovered = {
event_type
for cls in walk_subclasses(BaseEvent)
if (event_type := getattr(cls, "_event_type", None)) is not None
and getattr(cls, "_volume_class", None) == "high_volume"
}
assert set(HIGH_VOLUME_EVENT_TYPES) == discovered
assert set(HIGH_VOLUME_EVENT_TYPES) <= set(ALL_EVENT_TYPES)
def test_deriver_token_combos_are_valid_and_complete():
"""Every combo uses real enum values; the union across tasks covers every
DeriverComponent; and no task enumerates an impossible pair.
Fails if a DeriverComponent/DeriverTaskType is added without deciding which
task_type + token_type it pairs with in _DERIVER_TOKEN_COMBOS_BY_TASK.
"""
valid_token_types = {t.value for t in TokenTypes}
valid_components = {c.value for c in DeriverComponents}
valid_task_types = {t.value for t in DeriverTaskTypes}
assert set(_DERIVER_TOKEN_COMBOS_BY_TASK) == valid_task_types
all_components: set[str] = set()
for task_type, combos in _DERIVER_TOKEN_COMBOS_BY_TASK.items():
assert task_type in valid_task_types
for token_type, component in combos:
assert token_type in valid_token_types
assert component in valid_components
# each task enumerates fewer than its cartesian product (no impossible pairs)
assert len(combos) < len(valid_token_types) * len(valid_components)
all_components.update(comp for _, comp in combos)
# every component is reachable via some task
assert all_components == valid_components
# previous_summary is summary-only: ingestion must NOT enumerate it
ingestion = _DERIVER_TOKEN_COMBOS_BY_TASK[DeriverTaskTypes.INGESTION.value]
assert (
TokenTypes.INPUT.value,
DeriverComponents.PREVIOUS_SUMMARY.value,
) not in ingestion
# ---------------------------------------------------------------------------
# API-process zero-init
# ---------------------------------------------------------------------------
@pytest.mark.usefixtures("metrics_enabled")
def test_api_init_materializes_event_type_children():
prometheus_metrics.initialize_bounded_metrics(instance_type="api")
for event_type in ALL_EVENT_TYPES:
assert sample("telemetry_events_emitted_total", type=event_type) is not None
for event_type in HIGH_VOLUME_EVENT_TYPES:
assert sample("telemetry_events_sampled_out_total", type=event_type) is not None
@pytest.mark.usefixtures("metrics_enabled")
def test_api_init_materializes_dialectic_and_embed():
prometheus_metrics.initialize_bounded_metrics(instance_type="api")
for token_type in TokenTypes:
for level in REASONING_LEVELS:
assert (
sample(
"dialectic_tokens_processed_total",
token_type=token_type.value,
component=DialecticComponents.TOTAL.value,
reasoning_level=level,
)
is not None
)
assert sample("embed_now_tasks_shed_total") is not None
assert sample("embed_now_tasks_in_flight") == 0.0 # gauge, explicit .set(0)
@pytest.mark.usefixtures("metrics_enabled")
def test_sampled_out_excludes_ground_truth_event_types():
"""Ground-truth events can never be sampled out, so their sampled_out series
must NOT be pre-created (they'd be permanently misleading zeros)."""
prometheus_metrics.initialize_bounded_metrics(instance_type="api")
ground_truth = set(ALL_EVENT_TYPES) - set(HIGH_VOLUME_EVENT_TYPES)
for event_type in ground_truth:
assert sample("telemetry_events_sampled_out_total", type=event_type) is None
# ---------------------------------------------------------------------------
# Deriver-process zero-init
# ---------------------------------------------------------------------------
@pytest.mark.usefixtures("metrics_enabled")
def test_deriver_init_materializes_token_and_backlog():
prometheus_metrics.initialize_bounded_metrics(instance_type="deriver")
for task_type, combos in _DERIVER_TOKEN_COMBOS_BY_TASK.items():
for token_type, component in combos:
assert (
sample(
"deriver_tokens_processed_total",
task_type=task_type,
token_type=token_type,
component=component,
)
is not None
)
# region ai
# Specialist names are derived from the concrete BaseSpecialist subclasses here
# too, rather than hardcoded: a hardcoded list would keep passing when a new
# specialist is added (it only asserts presence), silently leaving it uncovered.
# endregion
specialist_names = {
name
for cls in walk_subclasses(BaseSpecialist)
if (name := getattr(cls, "name", None)) is not None
}
assert {"deduction", "induction", "card_refresh"} <= specialist_names
for specialist_name in specialist_names:
assert (
sample(
"dreamer_tokens_processed_total",
specialist_name=specialist_name,
token_type=TokenTypes.INPUT.value,
)
is not None
), f"specialist {specialist_name!r} was not zero-initialized"
assert sample("message_embeddings_pending") == 0.0 # gauge zero-init
@pytest.mark.usefixtures("metrics_enabled")
def test_deriver_init_omits_impossible_token_combos():
"""The cartesian product includes combos that never occur (e.g. output tokens
with an input component). Those must not be materialized."""
prometheus_metrics.initialize_bounded_metrics(instance_type="deriver")
# output tokens never pair with an input component
assert (
sample(
"deriver_tokens_processed_total",
task_type=DeriverTaskTypes.INGESTION.value,
token_type=TokenTypes.OUTPUT.value,
component=DeriverComponents.PROMPT.value,
)
is None
)
# previous_summary is summary-only — ingestion must not materialize it
assert (
sample(
"deriver_tokens_processed_total",
task_type=DeriverTaskTypes.INGESTION.value,
token_type=TokenTypes.INPUT.value,
component=DeriverComponents.PREVIOUS_SUMMARY.value,
)
is None
)
# base specialist is abstract and never emits — must not be materialized
assert (
sample(
"dreamer_tokens_processed_total",
specialist_name="base",
token_type=TokenTypes.INPUT.value,
)
is None
)
# ---------------------------------------------------------------------------
# High-cardinality counters are left open, and per-process isolation holds
# ---------------------------------------------------------------------------
@pytest.mark.usefixtures("metrics_enabled")
def test_high_cardinality_counters_not_materialized():
prometheus_metrics.initialize_bounded_metrics(instance_type="api")
prometheus_metrics.initialize_bounded_metrics(instance_type="deriver")
# no endpoint/workspace_name series fabricated
assert (
sample(
"api_requests_total",
method="GET",
endpoint="/v3/does-not-exist",
status_code="200",
)
is None
)
assert sample("messages_created_total", workspace_name="nope_ws") is None
@pytest.mark.usefixtures("metrics_enabled")
def test_api_init_does_not_touch_deriver_counters():
"""api-only init must not materialize or change a deriver-only counter.
Delta-based (before == after) so it's robust to prior tests having
materialized the series.
"""
labels = dict(
task_type=DeriverTaskTypes.INGESTION.value,
token_type=TokenTypes.INPUT.value,
component=DeriverComponents.PROMPT.value,
)
before = sample("deriver_tokens_processed_total", **labels)
prometheus_metrics.initialize_bounded_metrics(instance_type="api")
after = sample("deriver_tokens_processed_total", **labels)
assert before == after
@pytest.mark.usefixtures("metrics_enabled")
def test_deriver_init_does_not_touch_api_counters():
"""The inverse: deriver-only init must not materialize an API-only counter.
Without this, a deriver-startup regression could silently fabricate API
series (permanently-0 dialectic tokens on a process that never serves chat).
"""
labels = dict(
token_type=TokenTypes.INPUT.value,
component=DialecticComponents.TOTAL.value,
reasoning_level=REASONING_LEVELS[0],
)
before = sample("dialectic_tokens_processed_total", **labels)
prometheus_metrics.initialize_bounded_metrics(instance_type="deriver")
after = sample("dialectic_tokens_processed_total", **labels)
assert before == after
# the API-process embed_now counters are equally off-limits
assert sample("embed_now_tasks_shed_total") is None
assert sample("embed_now_tasks_in_flight") is None
# ---------------------------------------------------------------------------
# telemetry_events_dropped: per-emitter child materialization
# ---------------------------------------------------------------------------
@pytest.mark.usefixtures("metrics_enabled")
def test_dropped_counter_children_materialized():
prometheus_metrics.initialize_telemetry_dropped_metrics(
reasons=["buffer_full", "send_failed"]
)
assert sample("telemetry_events_dropped_total", reason="buffer_full") is not None
assert sample("telemetry_events_dropped_total", reason="send_failed") is not None
def test_dropped_counter_init_noop_when_metrics_disabled(
monkeypatch: pytest.MonkeyPatch,
):
"""The per-emitter initializer must no-op when metrics are disabled."""
# region ai
# The enabled/disabled pair above and below this line exists for
# ``initialize_bounded_metrics`` (see ``test_init_noop_when_metrics_disabled``);
# without this test the sibling initializer had only the enabled half, so its
# ``METRICS.ENABLED`` guard could be deleted with the suite staying green. The
# unique namespace is what makes the absence assertion mean anything — the enabled
# test above materializes these same two reason values under a different one.
# endregion
monkeypatch.setattr("src.config.settings.METRICS.ENABLED", False)
monkeypatch.setattr(
"src.config.settings.METRICS.NAMESPACE", unique_ns("dropped_disabled")
)
prometheus_metrics.initialize_telemetry_dropped_metrics(
reasons=["buffer_full", "send_failed"]
)
assert sample("telemetry_events_dropped_total", reason="buffer_full") is None
assert sample("telemetry_events_dropped_total", reason="send_failed") is None
def test_init_noop_when_metrics_disabled(monkeypatch: pytest.MonkeyPatch):
"""With metrics disabled, init must not fabricate series for a fresh label."""
monkeypatch.setattr("src.config.settings.METRICS.ENABLED", False)
monkeypatch.setattr("src.config.settings.METRICS.NAMESPACE", unique_ns("disabled"))
prometheus_metrics.initialize_bounded_metrics(instance_type="api")
assert sample("telemetry_events_emitted_total", type="message.created") is None

View File

@ -51,6 +51,7 @@ class TestRepresentationV2AdditiveFields:
assert event.exact_dup_existing_count == 0
assert event.semantic_dup_rejected_count == 0
assert event.semantic_dup_replaced_count == 0
assert event.failed_observer_count == 0
def test_input_tokens_semantics_preserved(self):
"""The downstream metering key must remain 'queued-message tokens'.
@ -161,6 +162,7 @@ class TestRepresentationV2AdditiveFields:
"exact_dup_existing_count",
"semantic_dup_rejected_count",
"semantic_dup_replaced_count",
"failed_observer_count",
):
assert field in data, f"missing field: {field}"
assert data["hit_batch_token_cap"] is True

View File

@ -334,7 +334,9 @@ class TestCreateObservations:
"""If batch embedding fails but individual embeds succeed, all observations are created."""
workspace, peer1, peer2, session, _, _ = tool_test_data
async def fail_batch_embed(_texts: list[str]) -> list[list[float]]:
async def fail_batch_embed(
_texts: list[str], **_kwargs: object
) -> list[list[float]]:
raise RuntimeError("embedding provider timeout")
async def succeed_single_embed(_content: str) -> list[float]:
@ -393,7 +395,9 @@ class TestCreateObservations:
"""If batch embedding fails and some individual embeds also fail, only successful ones are created."""
workspace, peer1, peer2, session, _, _ = tool_test_data
async def fail_batch_embed(_texts: list[str]) -> list[list[float]]:
async def fail_batch_embed(
_texts: list[str], **_kwargs: object
) -> list[list[float]]:
raise RuntimeError("embedding provider timeout")
async def embed_per_observation(content: str) -> list[float]:
@ -458,7 +462,9 @@ class TestCreateObservations:
workspace, peer1, peer2, session, _, _ = tool_test_data
created_documents: list[Any] = []
async def fake_batch_embed(texts: list[str]) -> list[list[float]]:
async def fake_batch_embed(
texts: list[str], **_kwargs: object
) -> list[list[float]]:
assert texts == ["trimmed observation"]
return [[0.4, 0.5, 0.6]]
@ -504,6 +510,60 @@ class TestCreateObservations:
assert len(created_documents) == 1
assert created_documents[0].content == "trimmed observation"
async def test_create_observations_embeds_with_truncate_on_oversize(
self,
tool_test_data: Any,
monkeypatch: pytest.MonkeyPatch,
):
"""Storage path must opt into truncation so one long obs cannot drop the batch."""
workspace, peer1, peer2, session, _, _ = tool_test_data
captured: dict[str, object] = {}
async def fake_batch_embed(
texts: list[str], *, on_oversize: str = "raise", **_kwargs: object
) -> list[list[float]]:
captured["texts"] = texts
captured["on_oversize"] = on_oversize
return [[0.1] for _ in texts]
async def fake_create_documents(
_db: AsyncSession,
documents: list[Any],
workspace_name: str,
*,
observer: str,
observed: str,
deduplicate: bool = False,
) -> crud.CreateDocumentsResult:
_ = (workspace_name, observer, observed, deduplicate)
return crud.CreateDocumentsResult(created_documents=documents)
monkeypatch.setattr(
"src.utils.agent_tools.embedding_client.simple_batch_embed",
fake_batch_embed,
)
monkeypatch.setattr(
"src.utils.agent_tools.crud.create_documents", fake_create_documents
)
result = await create_observations(
observations=[
schemas.ObservationInput(content="short fact", level="explicit"),
schemas.ObservationInput(content="long fact", level="explicit"),
],
observer=peer1.name,
observed=peer2.name,
session_name=session.name,
workspace_name=workspace.name,
message_ids=[],
message_created_at=str(datetime.now(timezone.utc)),
)
assert isinstance(result, ObservationsCreatedResult)
assert result.created_count == 2
assert captured["on_oversize"] == "truncate"
assert captured["texts"] == ["short fact", "long fact"]
async def test_create_observations_skips_all_blank_content(
self,
tool_test_data: Any,
@ -541,6 +601,62 @@ class TestCreateObservations:
batch_embed.assert_not_awaited()
create_documents.assert_not_awaited()
@pytest.mark.parametrize("deduplicate_setting", [True, False])
async def test_create_observations_honors_deduplicate_setting(
self,
tool_test_data: Any,
monkeypatch: pytest.MonkeyPatch,
deduplicate_setting: bool,
):
"""create_observations forwards settings.DERIVER.DEDUPLICATE to create_documents.
Guards against reintroducing a hardcoded deduplicate=True, which made
DERIVER_DEDUPLICATE=false unable to disable dedup on this path (#989).
"""
workspace, peer1, peer2, session, _, _ = tool_test_data
monkeypatch.setattr(settings.DERIVER, "DEDUPLICATE", deduplicate_setting)
captured: dict[str, Any] = {}
async def fake_batch_embed(texts: list[str]) -> list[list[float]]:
return [[0.1, 0.2, 0.3] for _ in texts]
async def fake_create_documents(
_db: AsyncSession,
documents: list[Any],
workspace_name: str,
*,
observer: str,
observed: str,
deduplicate: bool = False,
) -> crud.CreateDocumentsResult:
_ = (workspace_name, observer, observed)
captured["deduplicate"] = deduplicate
return crud.CreateDocumentsResult(created_documents=documents)
monkeypatch.setattr(
"src.utils.agent_tools.embedding_client.simple_batch_embed",
fake_batch_embed,
)
monkeypatch.setattr(
"src.utils.agent_tools.crud.create_documents", fake_create_documents
)
result = await create_observations(
observations=[
schemas.ObservationInput(content="An observation", level="explicit"),
],
observer=peer1.name,
observed=peer2.name,
session_name=session.name,
workspace_name=workspace.name,
message_ids=[],
message_created_at=str(datetime.now(timezone.utc)),
)
assert isinstance(result, ObservationsCreatedResult)
assert captured["deduplicate"] is deduplicate_setting
class TestNormalizeObservationId:
"""Unit tests for _normalize_observation_id."""