chore: merge latest main into session activity branch

This commit is contained in:
steven-ji 2026-09-04 09:00:48 +08:00
commit 08d0893f59
174 changed files with 9522 additions and 1446 deletions

View File

@ -9,6 +9,9 @@
# =============================================================================
LOG_LEVEL=INFO
PERFORMANCE_LOG_FORMAT=compact # compact|rich
# API server processes used by the Docker entrypoint (default: 1).
# Each process owns a separate pool when connection pooling is enabled.
# API_WORKERS=1
# SESSION_OBSERVERS_LIMIT=10
# GET_CONTEXT_MAX_TOKENS=100000
# MAX_FILE_SIZE=5242880 # Bytes

View File

@ -12,6 +12,7 @@ on:
- 'tests/live_llm/**'
- 'pyproject.toml'
- 'uv.lock'
- '.python-version'
- '.github/workflows/live-llm-tests.yml'
# Manual trigger for PRs: add the `run-live-llm` label to run the suite
# against the PR's merge commit. The label is purged as soon as the run
@ -111,7 +112,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
python-version-file: ".python-version"
- name: Install the project
run: uv sync --all-extras

View File

@ -16,7 +16,7 @@ jobs:
- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@v2
with:

View File

@ -151,8 +151,8 @@ jobs:
- name: Verify uv and Python
run: |
uv --version
python3.12 --version
which python3.12
python3.13 --version
which python3.13
- name: Install the project
run: uv sync --all-extras

View File

@ -11,6 +11,7 @@ on:
- '**.jsx'
- 'pyproject.toml'
- 'uv.lock'
- '.python-version'
- 'sdks/typescript/package.json'
- 'sdks/typescript/bun.lock'
- '.github/workflows/unittest.yml'
@ -24,6 +25,7 @@ on:
- '**.jsx'
- 'pyproject.toml'
- 'uv.lock'
- '.python-version'
- 'sdks/typescript/package.json'
- 'sdks/typescript/bun.lock'
- '.github/workflows/unittest.yml'
@ -48,6 +50,7 @@ jobs:
- '**.py'
- 'pyproject.toml'
- 'uv.lock'
- '.python-version'
- 'migrations/**'
- 'sdks/typescript/**'
- '.github/workflows/unittest.yml'
@ -85,7 +88,7 @@ jobs:
- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
python-version-file: ".python-version"
- name: Install bun
uses: oven-sh/setup-bun@v2

View File

@ -1 +1 @@
3.11
3.13

View File

@ -11,6 +11,21 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Session responses now expose nullable `last_message_at`, backfilled and maintained from the newest message timestamp. `POST /v3/workspaces/{workspace_id}/sessions/list` accepts `sort_by=created_at|last_message_at` alongside the existing `reverse` parameter, with stable ID tie-breaking and sessions without messages placed last in either direction (#965).
## [3.1.1] - 2026-09-02
### Changed
- Server `requires-python` is `>=3.13`, matching the production image. Self-hosters on 3.103.12 need to upgrade; SDK and CLI floors are unchanged (#1090)
### Fixed
- Concurrent `create_documents` writers to the same collection deadlocked on `times_derived` reinforcement UPDATEs issued in batch order; the error was swallowed per-document, the batch was lost, and the queue item was marked processed. Writers now lock target rows with `SELECT ... ORDER BY id FOR UPDATE` before applying, abort the batch on `SQLAlchemyError` instead of continuing through a dead session, and retry transient errors (deadlock, serialization failure, lock/statement timeout, lost connection) up to `MAX_RETRYABLE_ATTEMPTS` instead of burning the item (#1033)
- Scope backfill no longer embeds, writes, and syncs every planned copy at once. A 14k-document session is ~580MB of vectors; several concurrent backfills OOM-killed the deriver at its 1000Mi limit and crash-looped because the work units never completed. Phases 24 now run per chunk of 500 specs, reload source embeddings per chunk, and drop them once synced. Membership is locked across chunk writes so a concurrent leave cannot commit between the check and the inserts (#1104)
- Model-generated observations with NUL bytes (`\u0000`) no longer fail the exact-content dedup pre-fetch with a Postgres `DataError` that dropped the whole observer batch. Ingress already stripped NUL from user content; the deriver now strips it so stored text matches embedded text. All-NUL content is dropped rather than stored empty (#1095)
- `search_messages` no longer forwards `top_k=0` to Turbopuffer (which requires 1..10000). Zero/negative limits short-circuit to empty results; tool limits are floored at 1. The documents path was already guarded (#970); this closes the message path (#1084)
- OpenAI-compatible tool-call turns with `content=null` keep null through history replay instead of being coerced to `""`. Providers that bind reasoning state to the exact assistant message shape were breaking on the empty string. Tool-less null still becomes `""` (#1064)
- The production image now ships `pyproject.toml` in the runtime stage, so the service reports its real version instead of `unknown` in OpenAPI and telemetry (#1074)
## [3.1.0] - 2026-08-25
### Added

View File

@ -118,6 +118,16 @@ cd sdks/typescript && bun run tsc --noEmit
- **Never hold a DB session during external calls** (LLM, embedding, HTTP). If a function needs both a DB session and an external call result, compute the external result first and pass it as a parameter. This avoids tying up DB connections during slow network I/O. Use `tracked_db` for short-lived, DB-only operations; pass a shared session when multiple DB-only calls can reuse one connection.
- **Never write through a read-only session** (`tracked_db(..., read_only=True)`, `get_read_db`, `ReadSessionLocal`). These run in AUTOCOMMIT mode with no transaction: writes are NOT blocked by the database — they silently commit immediately, and `begin_nested()` savepoints break. There is no runtime guard; this is enforced by convention only. Use `read_only=True` strictly for SELECT-only windows; anything that mutates (including get-or-create paths) must use a regular write session.
#### Multi-row locking and deadlocks
Tables written concurrently by more than one worker — `documents` (deriver, dreamer, scope backfill/removal, reconciler) and `queue` (every deriver replica) — deadlock when two writers touch an overlapping row set in different orders. Rules:
- **A multi-row `SELECT ... FOR UPDATE` MUST carry an explicit `ORDER BY <pk>`.** Without it Postgres locks in scan order, which differs per plan, so two writers with overlapping sets can cycle. `_apply_document_row_updates` in `src/crud/document.py` is the reference implementation.
- **`WHERE id IN (...)` does NOT impose an order**, so sorting the Python list is a no-op — the list order is discarded and the planner picks `Bitmap Heap Scan` (ctid order), `Index Scan` (id order), or `Seq Scan` per invocation. Deterministic ordering requires either a preceding `SELECT ... ORDER BY id FOR UPDATE` or `WHERE id IN (SELECT id ... ORDER BY id FOR UPDATE)`.
- **`Document.id` is a random nanoid** (`models.py`), so id order is uncorrelated with physical order — an unordered predicate `UPDATE`/`DELETE` is roughly a coin flip against an id-ordered locker per row pair, not a rare edge case. (`QueueItem.id` is an integer identity, so there id order is also chronological.)
- **Prefer no lock at all.** A single `UPDATE ... WHERE <predicate>` acquires row locks as it writes and has no separate lock phase to get wrong. Reach for `FOR UPDATE` only when a value must be read, computed in Python, and written back — that read-modify-write is the only reason `_apply_document_row_updates` locks (it replaced a server-side `func.greatest()`), and `populate_existing=True` is required with it so the identity map doesn't serve a stale pre-lock value. Server-side expressions (`func.greatest`, the JSONB `-` operator) avoid the lock entirely; see `_clear_work_unit_retry_attempts` in `src/deriver/queue_manager.py`.
- `FOR UPDATE SKIP LOCKED` (the reconciler's claim pattern) never waits, so it cannot be a deadlock partner — but holding those locks across an external call still stalls other writers. See the "never hold a DB session during external calls" rule above.
#### Auth scoping
- **`allow_member_read=True` (in `require_auth(...)`) is read-only — NEVER set it on a route that mutates state.** It lets a peer-scoped key reach a session route when its peer is an active member of the session, so on a mutating route it would hand any session member write access (message injection, config mutation, deletion). HTTP method is not a reliable read/write signal here (some read routes use POST for a richer body), so this is enforced by an explicit allowlist in `tests/routes/test_auth_route_policy.py` — adding the flag to a new route fails that test until you consciously add the route to `EXPECTED_MEMBER_READ_ROUTES`, and you must never add a mutating method there.

View File

@ -219,6 +219,45 @@ uv run python -m src.deriver # background worker
Everything Python goes through `uv run`. Redis is optional for local development; without it
caching is simply disabled.
### Running without a model provider
`src/mock_provider/` is a deterministic, OpenAI-compatible endpoint, so you can run the full
stack with no provider account, no API key, and no spend. It answers `/v1/chat/completions`
and `/v1/embeddings` with obviously-synthetic content derived from the request, and the same
request always produces the same response. Run it from the standard image or the repo:
```bash
uv run fastapi run --host 0.0.0.0 --port 8106 src/mock_provider/main.py
```
Then point Honcho at it. All three variables are required:
```bash
export LLM_OPENAI_API_KEY=any-non-empty-string # only truthiness is checked
export LLM_OPENAI_BASE_URL=http://localhost:8106/v1
export EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=http://localhost:8106/v1
```
The key's *value* is never checked — the mock reads no Authorization header, and Honcho only
tests it for truthiness before building the client (`src/llm/registry.py`). Set the base URL
without it and the client is never constructed, so the base URL is silently ignored. Keep the
value obviously fake, so a module that ever escapes the override 401s rather than spends.
Embeddings resolve through a separate client that reads the base URL only from the per-module
override, so without the third variable your embedding calls go to `api.openai.com` for real.
Do not set any per-module credential override (`..._OVERRIDES__API_KEY` / `API_KEY_ENV`) —
that makes the module ignore the global base URL.
Two things to know:
- **A repo `.env` beats your exported environment.** `src/config.py` calls
`load_dotenv(override=True)` at import, so a stale `.env` silently wins over the variables
above. Set `PYTHON_DOTENV_DISABLED=1` (and `HONCHO_CONFIG_TOML_DISABLED=1` for a local
`config.toml`) when you need the environment to be the only input.
- **Mock embeddings are hash-derived and carry no semantic similarity.** Two paraphrases are as
far apart as two unrelated strings. Recall against this provider must use lexical/full-text
search; anything asserting on vector ranking needs a real embedding provider.
## Making the change
### Branches and commits
@ -335,6 +374,9 @@ either route works — but a bare `#123` mention is only a reference and does no
If a PR goes quiet, nudge us in [Discord](https://discord.gg/honcho).
Please respond within 7 days - we may close any PRs that have seen no activity within a 7 day
window. If you need more time, let us know in the PR comments.
## Reporting bugs and requesting features
Use the [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). There is

View File

@ -8,7 +8,7 @@
---
![Static Badge](https://img.shields.io/badge/Server-3.1.0-blue)
![Static Badge](https://img.shields.io/badge/Server-3.1.1-blue)
[![PyPI version](https://img.shields.io/pypi/v/honcho-ai.svg)](https://pypi.org/project/honcho-ai/)
[![NPM version](https://img.shields.io/npm/v/@honcho-ai/sdk.svg)](https://npmjs.org/package/@honcho-ai/sdk)
[![CLI](https://img.shields.io/pypi/v/honcho-cli.svg?label=honcho-cli)](https://pypi.org/project/honcho-cli/)
@ -173,6 +173,23 @@ See the full [SDK Reference](https://honcho.dev/docs/v3/documentation/reference/
## Integrations
Honcho ships a first-party memory plugin for every major coding agent. They all read the same
`~/.honcho/config.json`, so one key configures all of them — and pointing two at the same `workspace`
gives them one shared memory.
| Agent | Install | Source |
| ---------------- | ------------------------------------------------------- | ------------------------------------------------------------------ |
| Claude Code | `/plugin marketplace add plastic-labs/claude-honcho` | [claude-honcho](https://github.com/plastic-labs/claude-honcho) |
| Codex | `npm install -g @honcho-ai/codex-honcho` | [codex-honcho](https://github.com/plastic-labs/codex-honcho) |
| Cursor | `curl -fsSL .../cursor-honcho/main/install.sh \| bash` | [cursor-honcho](https://github.com/plastic-labs/cursor-honcho) |
| DeepSeek Harness | `dsh plugin --profile <name> add @honcho-ai/dsh-honcho` | [dsh-honcho](https://github.com/plastic-labs/dsh-honcho) |
| OpenCode | `opencode plugin "@honcho-ai/opencode-honcho" --global` | [opencode-honcho](https://github.com/plastic-labs/opencode-honcho) |
| OpenClaw | `openclaw plugins install @honcho-ai/openclaw-honcho` | [openclaw-honcho](https://github.com/plastic-labs/openclaw-honcho) |
| Hermes | `hermes memory setup` | built in upstream |
| Any MCP client | `claude mcp add honcho --transport http ...` | [MCP guide](https://honcho.dev/docs/v3/guides/integrations/mcp) |
Get a key at [app.honcho.dev](https://app.honcho.dev), then `honcho init` (or `uv tool install honcho-cli && honcho init`) writes it to `~/.honcho/config.json` once for every integration.
### Claude Code
Two ways, depending on how deep you want to go:
@ -194,7 +211,33 @@ claude mcp add honcho \
--header "X-Honcho-User-Name: YourName"
```
Details: [Claude Code guide](https://honcho.dev/docs/v3/guides/integrations/claude-code) · [MCP guide](https://honcho.dev/docs/v3/guides/integrations/mcp).
Details: [Claude Code guide](https://honcho.dev/docs/v3/guides/integrations/claude-code) · [MCP guide](https://honcho.dev/docs/v3/guides/integrations/mcp) · [repo](https://github.com/plastic-labs/claude-honcho).
### Codex
```bash
npm install -g @honcho-ai/codex-honcho
codex-honcho install # registers hooks + MCP + skill in ~/.codex
```
Restart Codex to load the hooks. Details: [Codex guide](https://honcho.dev/docs/v3/guides/integrations/codex) · [repo](https://github.com/plastic-labs/codex-honcho).
### Cursor
```bash
curl -fsSL https://raw.githubusercontent.com/plastic-labs/cursor-honcho/main/install.sh | bash
```
Windows (PowerShell): `irm https://raw.githubusercontent.com/plastic-labs/cursor-honcho/main/install.ps1 | iex`. The installer wires global hooks and MCP config. Details: [cursor-honcho](https://github.com/plastic-labs/cursor-honcho).
### DeepSeek Harness
```bash
dsh plugin --profile <name> add @honcho-ai/dsh-honcho
```
A native Cordis plugin. It injects memory into the system prompt and captures new information from the session event feed. The model gets three tools — honcho_search, honcho_chat, and honcho_remember — and you can run /honcho to check status.
Details: [DeepSeek Harness guide](https://honcho.dev/docs/v3/guides/integrations/deepseek-harness) · [repo](https://github.com/plastic-labs/dsh-honcho).
### OpenCode
@ -202,7 +245,7 @@ Details: [Claude Code guide](https://honcho.dev/docs/v3/guides/integrations/clau
opencode plugin "@honcho-ai/opencode-honcho" --global
```
Details: [OpenCode guide](https://honcho.dev/docs/v3/guides/integrations/opencode).
Details: [OpenCode guide](https://honcho.dev/docs/v3/guides/integrations/opencode) · [repo](https://github.com/plastic-labs/opencode-honcho).
### OpenClaw
@ -212,7 +255,7 @@ openclaw honcho setup
openclaw gateway --force
```
`openclaw honcho setup` prompts for your API key, writes the config, and optionally migrates legacy `MEMORY.md` / `USER.md` / `IDENTITY.md` files into Honcho (non-destructive — originals are never deleted). Details: [OpenClaw guide](https://honcho.dev/docs/v3/guides/integrations/openclaw).
`openclaw honcho setup` prompts for your API key, writes the config, and optionally migrates legacy `MEMORY.md` / `USER.md` / `IDENTITY.md` files into Honcho (non-destructive — originals are never deleted). Details: [OpenClaw guide](https://honcho.dev/docs/v3/guides/integrations/openclaw) · [repo](https://github.com/plastic-labs/openclaw-honcho).
### Hermes

View File

@ -80,6 +80,34 @@ services:
required: false
restart: unless-stopped
mcp:
build:
context: ./mcp
dockerfile: Dockerfile
depends_on:
api:
condition: service_healthy
ports:
- "127.0.0.1:3000:3000"
environment:
- HONCHO_API_URL=http://api:8000
env_file:
- path: .env
required: false
healthcheck:
test:
[
"CMD",
"bun",
"-e",
"fetch('http://127.0.0.1:3000/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
restart: unless-stopped
database:
image: pgvector/pgvector:pg15
restart: unless-stopped

View File

@ -5,4 +5,4 @@ echo "Running database migrations..."
/app/.venv/bin/python scripts/provision_db.py
echo "Starting API server..."
exec /app/.venv/bin/fastapi run --host 0.0.0.0 src/main.py
exec /app/.venv/bin/fastapi run --host 0.0.0.0 --workers "${API_WORKERS:-1}" src/main.py

View File

@ -30,7 +30,8 @@ This guide helps you match the right SDK version to your Honcho API version. New
| Honcho API Version | TypeScript SDK | Python SDK |
|-------------------|---------------|------------|
| v3.1.0 (Current) | v2.4.0 | v2.4.0 |
| v3.1.1 (Current) | v2.4.0 | v2.4.0 |
| v3.1.0 | v2.4.0 | v2.4.0 |
| v3.0.12 | v2.3.0 | v2.3.0 |
| v3.0.11 | v2.1.2 | v2.1.2 |
| v3.0.10 | v2.1.2 | v2.1.2 |

View File

@ -27,7 +27,22 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
### Honcho API and SDK Changelogs
<Tabs>
<Tab title="Honcho API">
<Update label="v3.1.0 (Current)">
<Update label="v3.1.1 (Current)">
### Changed
- Server `requires-python` is `>=3.13`, matching the production image. Self-hosters on 3.103.12 need to upgrade; SDK and CLI floors are unchanged (#1090)
### Fixed
- Concurrent `create_documents` writers to the same collection deadlocked on `times_derived` reinforcement UPDATEs issued in batch order; the error was swallowed per-document, the batch was lost, and the queue item was marked processed. Writers now lock target rows with `SELECT ... ORDER BY id FOR UPDATE` before applying, abort the batch on `SQLAlchemyError` instead of continuing through a dead session, and retry transient errors (deadlock, serialization failure, lock/statement timeout, lost connection) up to `MAX_RETRYABLE_ATTEMPTS` instead of burning the item (#1033)
- Scope backfill no longer embeds, writes, and syncs every planned copy at once. A 14k-document session is ~580MB of vectors; several concurrent backfills OOM-killed the deriver at its 1000Mi limit and crash-looped because the work units never completed. Phases 24 now run per chunk of 500 specs, reload source embeddings per chunk, and drop them once synced. Membership is locked across chunk writes so a concurrent leave cannot commit between the check and the inserts (#1104)
- Model-generated observations with NUL bytes (`\u0000`) no longer fail the exact-content dedup pre-fetch with a Postgres `DataError` that dropped the whole observer batch. Ingress already stripped NUL from user content; the deriver now strips it so stored text matches embedded text. All-NUL content is dropped rather than stored empty (#1095)
- `search_messages` no longer forwards `top_k=0` to Turbopuffer (which requires 1..10000). Zero/negative limits short-circuit to empty results; tool limits are floored at 1. The documents path was already guarded (#970); this closes the message path (#1084)
- OpenAI-compatible tool-call turns with `content=null` keep null through history replay instead of being coerced to `""`. Providers that bind reasoning state to the exact assistant message shape were breaking on the empty string. Tool-less null still becomes `""` (#1064)
- The production image now ships `pyproject.toml` in the runtime stage, so the service reports its real version instead of `unknown` in OpenAPI and telemetry (#1074)
</Update>
<Update label="v3.1.0">
### Added
- Scopes: a named grouping of sessions that acts as a visibility boundary on recall, implemented as a facade over an observer peer (`scope.{name}` with `{"kind": "scope"}`). Developers manage them exclusively through `/v3/workspaces/{workspace_id}/scopes` (create-or-get, list, get, add/list/remove session membership) and an optional `scopes` field on session create — never through the observer/observed mechanics. Scope peers cannot author messages, cannot be a chat or representation `target`, are excluded from `peers.list` by default (`PeerGet.kind` = `"scope"` / `"all"` switches the view), and are rejected on the generic session-peer routes. Workspace-level key required; peer- and session-scoped keys get 401. Legacy peers occupying a reserved `scope.` name without the kind flag are refused with 409, never adopted (#884)
@ -785,7 +800,7 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
<Tab title="Python SDK">
[Python SDK](https://pypi.org/project/honcho-ai/)
<Update label="v2.4.0 (Current)">
<Update label="v2.4.0">
### Added
- Scopes: `Honcho.scope()` / `HonchoAio.scope()` get-or-create a named visibility boundary, `Honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `Honcho.session(..., scopes=[...])` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
@ -964,7 +979,7 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
<Tab title="TypeScript SDK">
[TypeScript SDK](https://www.npmjs.com/package/@honcho-ai/sdk)
<Update label="v2.4.0 (Current)">
<Update label="v2.4.0">
### Added
- Scopes: `honcho.scope()` get-or-creates a named visibility boundary, `honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `honcho.session({ scopes: [...] })` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
@ -1170,7 +1185,7 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
</Tab>
<Tab title="Honcho CLI">
[Honcho CLI](https://pypi.org/project/honcho-cli/)
<Update label="v0.1.4 (Current)">
<Update label="v0.1.4">
### Added
- A TTY notice when a newer `honcho-cli` is on PyPI (`uv tool upgrade honcho-cli`). Skipped in JSON mode; disable with `HONCHO_NO_UPDATE_CHECK`

View File

@ -10,6 +10,10 @@
{
"source": "/v3/guides/integrations/claudecode",
"destination": "/v3/guides/integrations/claude-code"
},
{
"source": "/v3/documentation/features/advanced/representation-scopes",
"destination": "/v3/documentation/features/advanced/directional-representations"
}
],
"colors": {
@ -24,7 +28,7 @@
"navigation": {
"versions": [
{
"version": "v3.1.0",
"version": "v3.1.1",
"api": {
"openapi": ["v3/openapi.json"]
},
@ -62,7 +66,8 @@
"v3/documentation/features/advanced/reasoning-configuration",
"v3/documentation/features/advanced/summarizer",
"v3/documentation/features/advanced/peer-card",
"v3/documentation/features/advanced/representation-scopes",
"v3/documentation/features/advanced/directional-representations",
"v3/documentation/features/advanced/scopes",
"v3/documentation/features/advanced/dreaming",
"v3/documentation/features/advanced/queue-status",
"v3/documentation/features/advanced/webhooks",
@ -70,7 +75,8 @@
"v3/documentation/features/advanced/using-filters",
"v3/documentation/features/advanced/structured-outputs",
"v3/documentation/features/advanced/streaming-response",
"v3/documentation/features/advanced/file-uploads"
"v3/documentation/features/advanced/file-uploads",
"v3/documentation/features/advanced/deleting-data"
]
}
]
@ -98,6 +104,7 @@
"v3/guides/integrations/claude-code",
"v3/guides/integrations/opencode",
"v3/guides/integrations/codex",
"v3/guides/integrations/deepseek-harness",
"v3/guides/integrations/vercel-ai-sdk",
"v3/guides/integrations/crewai",
"v3/guides/integrations/langgraph",
@ -208,6 +215,18 @@
"v3/api-reference/endpoint/sessions/search-session"
]
},
{
"group": "scopes",
"pages": [
"v3/api-reference/endpoint/scopes/get-or-create-scope",
"v3/api-reference/endpoint/scopes/get-scopes",
"v3/api-reference/endpoint/scopes/get-scope",
"v3/api-reference/endpoint/scopes/add-sessions-to-scope",
"v3/api-reference/endpoint/scopes/get-scope-sessions",
"v3/api-reference/endpoint/scopes/remove-session-from-scope",
"v3/api-reference/endpoint/scopes/get-scope-status"
]
},
{
"group": "messages",
"pages": [
@ -593,8 +612,8 @@
}
},
"integrations": {
"posthog": {
"apiKey": "phc_1yrzzcgywqXGcerkkI4g7C0YfyPMcAKNOOvGcjTCiUk"
"gtm": {
"tagId": "GTM-NSPT9PJF"
}
}
}

69
docs/posthog-consent.js Normal file
View File

@ -0,0 +1,69 @@
// Loads PostHog only when the CookieConsent cookie grants Statistics; the
// cookie is host-scoped, so a landing-page answer covers the docs.
;(function () {
var KEY = 'phc_1yrzzcgywqXGcerkkI4g7C0YfyPMcAKNOOvGcjTCiUk'
var loaded = false
function granted() {
var m = document.cookie.match(/(?:^|;\s*)CookieConsent=([^;]*)/)
if (!m) return false
var v = decodeURIComponent(m[1])
// "-1" is Cookiebot's consent-not-required marker.
return v === '-1' || /statistics\s*:\s*true/.test(v)
}
function loadPosthog() {
if (loaded) return
loaded = true
var s = document.createElement('script')
s.src = 'https://us-assets.i.posthog.com/static/array.js'
s.async = true
s.onerror = function () {
loaded = false
}
s.onload = function () {
// Consent withdrawn while array.js was downloading: skip init, allow a retry on re-grant.
if (!granted()) {
loaded = false
return
}
window.posthog.init(KEY, {
api_host: 'https://us.i.posthog.com',
ui_host: 'https://us.posthog.com',
cross_subdomain_cookie: true,
person_profiles: 'identified_only',
capture_pageview: 'history_change',
})
}
document.head.appendChild(s)
}
function sync() {
if (granted()) {
if (!loaded) {
loadPosthog()
} else if (
window.posthog &&
window.posthog.has_opted_out_capturing &&
window.posthog.has_opted_out_capturing()
) {
window.posthog.opt_in_capturing()
}
return
}
// Withdrawal mid-session: an already running instance must stop.
if (loaded && window.posthog && window.posthog.opt_out_capturing) {
window.posthog.opt_out_capturing()
}
}
sync()
var events = [
'CookiebotOnConsentReady',
'CookiebotOnAccept',
'CookiebotOnDecline',
]
for (var i = 0; i < events.length; i++) {
window.addEventListener(events[i], sync)
}
})()

View File

@ -1,3 +1,15 @@
---
openapi: post /v3/keys
---
<Note>
Requires an admin key. On Honcho Cloud (`api.honcho.dev`) the returned key is a
real cloud key on the calling key's instance, attributed to its owner and
revocable from the [API Keys page](https://app.honcho.dev/api-keys). On a
self-hosted instance it returns an error when `AUTH_USE_AUTH` is disabled.
Provide at least one of `workspace_id`, `peer_id`, or `session_id` — a request
carrying none of them is rejected. A key scoped to a peer or a session must also
carry its `workspace_id`. On Honcho Cloud, pass either `admin=true` or a
`workspace_id`.
</Note>

View File

@ -0,0 +1,3 @@
---
openapi: post /v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions
---

View File

@ -0,0 +1,3 @@
---
openapi: post /v3/workspaces/{workspace_id}/scopes
---

View File

@ -0,0 +1,3 @@
---
openapi: post /v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/list
---

View File

@ -0,0 +1,3 @@
---
openapi: get /v3/workspaces/{workspace_id}/scopes/{scope_id}/status
---

View File

@ -0,0 +1,3 @@
---
openapi: get /v3/workspaces/{workspace_id}/scopes/{scope_id}
---

View File

@ -0,0 +1,3 @@
---
openapi: post /v3/workspaces/{workspace_id}/scopes/list
---

View File

@ -0,0 +1,3 @@
---
openapi: delete /v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/{session_id}
---

View File

@ -389,6 +389,20 @@ The default compose file is already production-oriented — ports bound to `127.
- You can also run multiple deriver processes across machines — they coordinate via the database queue
- Monitor deriver logs for processing backlog
### Scaling the API
Set `API_WORKERS` to run multiple API server processes in the Docker container. It defaults to `1`, preserving the existing single-process behavior.
When connection pooling is enabled (`DB_POOL_CLASS` is not `null`), each API process creates its own SQLAlchemy connection pool. Keep the combined capacity below the PostgreSQL connection limit:
```text
API_WORKERS * (DB_POOL_SIZE + DB_MAX_OVERFLOW) < PostgreSQL max_connections
```
With the default pooled settings (`10 + 20`), each API worker can open up to 30 connections. For example, `API_WORKERS=3` allows up to 90 API connections. Leave additional headroom for the deriver, migrations, administration, and monitoring.
When `DB_POOL_CLASS=null`, SQLAlchemy uses `NullPool`; `DB_POOL_SIZE` and `DB_MAX_OVERFLOW` do not apply, and connections are opened and closed per use.
### Caching
- The production compose enables Redis caching by default (`CACHE_ENABLED=true`)
- For the development compose, enable manually: `CACHE_ENABLED=true`

View File

@ -109,7 +109,6 @@ Messages are stored but no observations, summaries, or representations are being
```bash
DERIVER_WORKERS=4
```
5. **Representation Batching** — By default the deriver buffers representation work until a work unit has accumulated enough tokens, set via `DERIVER_REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS` (`0` disables the accumulation gate). A separate setting, `DERIVER_REPRESENTATION_BATCH_TARGET_INPUT_TOKENS`, caps the conversation window fed to each deriver LLM call when draining a claimed work unit. Sub-threshold tails become eligible after `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800 seconds), so quiet sessions eventually flush without disabling batching globally. Set the age to `0` for legacy behavior where sub-threshold tails wait indefinitely. See [token batching](/v3/documentation/core-concepts/reasoning#token-batching) for more details
## Alternative Provider Issues

View File

@ -34,7 +34,7 @@ Honcho has a hierarchical data model centered around the entities below.
Workspaces are the top-level containers in Honcho. They provide complete isolation between different applications or environments, essentially serving as a namespace to keep different workloads separate. You might use separate workspaces for development, staging, and production environments, or to isolate different product lines. They also enable multi-tenant SaaS applications where each customer gets their own isolated workspace with complete data separation.
Authentication is scoped to the workspace level, and configuration settings can be applied workspace-wide to control behavior across all peers and sessions within that workspace.
Authentication is issued at the workspace level, and configuration settings can be applied workspace-wide to control behavior across all peers and sessions within that workspace.
---
@ -50,12 +50,14 @@ You can use peers for any entity that persists over time--individual users in ch
### <Icon icon="message" /> Sessions
Sessions represent interaction threads or contexts between peers. A session can involve multiple peers and provides temporal boundaries for when a set of interactions starts and ends. This lets you scope context and memory to specific interactions while still maintaining longer-term peer representations that span sessions.
Sessions represent interaction threads or contexts between peers. A session can involve multiple peers and provides temporal boundaries for when a set of interactions starts and ends. This lets you confine context and memory to specific interactions while still maintaining longer-term peer representations that span sessions.
Use sessions to scope things like support tickets, meeting transcripts, learning sessions, or conversations. You can also use single-peer sessions as a way to import external data--create a session with just one peer and structure emails, documents, or files as messages to enrich that peer's representation.
Use sessions for things like support tickets, meeting transcripts, learning sessions, or conversations. You can also use single-peer sessions as a way to import external data--create a session with just one peer and structure emails, documents, or files as messages to enrich that peer's representation.
Session-level configuration gives you fine-grained control over perspective-taking behavior. You can configure whether a peer should form representations of other peers in the session, and whether other peers should form representations of them.
Sessions are also the unit of visibility: when one peer's history spans contexts that shouldn't inform each other, you can group sessions into named [scopes](/v3/documentation/features/advanced/scopes) that bound recall to just those sessions.
---
### <Icon icon="envelope" /> Messages
@ -84,7 +86,7 @@ Honcho runs as two cooperating processes: an **API server** that handles request
**Write path (synchronous).** A message is stored and a reasoning task is enqueued in the same request; the API returns immediately. Nothing about the reasoning that follows blocks the caller.
**Deriver + Summarizer (async, per-message).** The worker picks up queued tasks in small batches. The Deriver reads new messages and extracts conclusions about the peer--explicit statements and direct deductions. In parallel, the Summarizer periodically rolls up recent messages into short- and long-form session summaries. Both run per-message (well, per-batch) rather than on a schedule.
**Deriver + Summarizer (async, per-message).** The worker picks up queued tasks. The Deriver reads new messages and extracts conclusions about the peer--explicit statements and direct deductions. In parallel, the Summarizer periodically rolls up recent messages into short- and long-form session summaries. Both run per-message rather than on a schedule.
**Dreamer (periodic).** On a schedule (or triggered on demand), the Dreamer revisits existing conclusions to consolidate and deepen them: removing redundant or stale ones, drawing inductive conclusions across patterns that span multiple messages, and updating peer cards--compact biographical summaries of a peer. This is where memory gets richer over time, not just larger.

View File

@ -12,22 +12,26 @@ Ready to add Honcho to your codebase? The **`/honcho-integration` skill** applie
## Quick Reference
**Workspaces isolate, peers persist, and sessions scope the active context.**
**Workspaces isolate, peers persist, and sessions bound the active context.**
| Decision | Recommendation |
|----------|---------------|
| How many workspaces? | One workspace per application, tool, tenant, or collaboration boundary. Split workspaces only when you need hard isolation between products, customers, environments, or agents. |
| When should agents share a workspace? | When agents collaborate over the same product, project, team, user, customer, or game state. Separate them when they should not see or influence each other's memory. |
| Who should be a peer? | Any persistent participant whose messages should be attributed or reasoned about: users, agents, assistants, NPCs, students, or customers. Use one peer for the same entity across sessions and platforms. |
| How should I scope sessions? | Scope sessions to the active interaction: per-conversation, per-channel, per-task run, per-project, per-import, or other bounded context. Reuse a session when local context should keep accumulating. |
| How should I divide sessions? | Match each session to the active interaction: per-conversation, per-channel, per-task run, per-project, per-import, or other bounded context. Reuse a session when local context should keep accumulating. |
| How does cross-session reasoning work? | Session memory stays local to one session. Peer representations accumulate across every session where the peer is included, and `session.context()` becomes cross-session when you include a peer target. |
| Should I set `observe_me: false`? | Yes, for deterministic peers Honcho does not need to model, like bots or tool agents. Still save their messages so other peers have session context. Keep it enabled for users and evolving agents. |
| Do I need `observe_others`? | Only when a peer needs its own perspective on another participant, such as in games, multi-agent systems, or parent/subagent workflows. |
| When do I need a scope? | When one peer's history spans contexts that must not leak into each other's recall — but you still want one workspace and one unified peer. Group the confidential sessions into a [scope](/v3/documentation/features/advanced/scopes) and pass it at query time. |
| Perspectives or scopes? | `observe_others` gives a *participant* its own view of another peer. A scope bounds recall to *where things were said*, for a reader that isn't a participant. If the reader is in the session, use perspectives; if you're fencing off a set of sessions, use a scope. |
## Workspace Design
A workspace is a hard isolation boundary. **Default to one workspace per application,** and split only at a real privacy, compliance, or product boundary (e.g. per-tenant SaaS, or a tool that needs intentionally isolated memory). Agents that collaborate over the same product, user, or game state belong in the *same* workspace so each can retrieve what the others produced.
If what you actually need is "this part of a peer's history shouldn't inform that assistant," don't split the workspace — that severs the peer's identity too. Use a [scope](/v3/documentation/features/advanced/scopes) instead: the peer stays whole, and recall through the scope sees only its member sessions.
Honcho plugins default to one workspace *per host* (`hermes`, `claude_code`, `cursor`, `opencode`). To unify memory across them, point each at the same workspace — see [Unified Memory Setup](/v3/guides/recipes/unified-memory-setup).
<Info>
@ -48,11 +52,11 @@ For unified context across Honcho plugins, set the same user peer ID (`peerName`
## Session Design
Sessions define the temporal boundaries of an interaction. How you scope them affects how summaries are generated, how context is retrieved, and when reasoning fires.
Sessions define the temporal boundaries of an interaction. Where you draw those boundaries affects how summaries are generated and how context is retrieved.
**Common session patterns**
| Pattern | Session scoped to | Example |
| Pattern | Session covers | Example |
|---------|-------------------|---------|
| Per-conversation | Each new chat thread | ChatGPT or Claude Code style UI where each thread is a session |
| Per-channel | A persistent channel or room | Discord channel, Slack thread |
@ -62,10 +66,6 @@ Sessions define the temporal boundaries of an interaction. How you scope them af
Create a **new** session when context resets (new conversation, new day, new topic); **reuse** one when context should keep accumulating (ongoing channel, persistent thread).
<Warning>
**Don't scope sessions too thin.** Honcho batches reasoning until a peer accumulates ~1,000 tokens *within a single session*, with a default age-based flush for quiet tails ([token batching](/v3/documentation/core-concepts/reasoning#token-batching)). Low-volume or trickle inputs should still append to one ongoing session rather than fragment across many, so reasoning runs with useful context instead of many small delayed batches.
</Warning>
**How cross-session reasoning works**
- **Session memory** is local to an interaction — summaries and recent-message context describe only what happened there.
@ -75,14 +75,33 @@ So you can start a session fresh or pull in a peer's long-term memory. [`session
---
## Choosing an Isolation Boundary
Honcho gives you three boundaries at different strengths. Pick the weakest one that solves your problem:
| Boundary | Strength | Use when |
|----------|----------|----------|
| **Workspace** | Hard isolation — nothing crosses, including the peer itself | Different products, tenants, or environments |
| **[Scope](/v3/documentation/features/advanced/scopes)** | Recall boundary — one peer, but queries through the scope see only its sessions | One peer's contexts must not leak into each other (clinical vs. billing, per-reseller support) |
| **Session allowlist** (`sessions=[...]`) | Ad-hoc recall restriction, decided per request | The session set varies per query, or you need a quick boundary without provisioning anything |
Two things scopes are **not**:
- **Not authorization.** A workspace key reads any session, scoped or not. A scope constrains queries that name it; it doesn't protect data from queries that don't.
- **Not topic filtering.** Scopes bound recall by *where something was said*, not what it's about. A therapy detail mentioned in a billing session lands in the billing scope. If you might ever need a scope boundary, align your session boundaries with your confidentiality boundaries from the start — the session is the unit scopes can enforce.
---
## Common Mistakes
- **Splitting one identity across peer IDs** -- If the same user is `alice`, `alice-discord`, and `alice-cursor`, Honcho builds separate representations. Use one stable peer ID when you want unified memory.
- **Too many tiny sessions** -- Summaries and recent messages are session-scoped, and reasoning only fires past ~1,000 tokens per session. Splitting a continuous conversation across many sessions fragments local context and can stall reasoning. Reuse a session when context should flow continuously.
- **Too many tiny sessions** -- Summaries and recent messages are local to one session. Splitting a continuous conversation across many sessions fragments that local context. Reuse a session when context should flow continuously.
- **Separating agents that should collaborate** -- If agents need shared product, customer, or team context, put them in the same workspace. Separate workspaces are hard isolation boundaries.
- **Leaving `observe_me` on for assistants** -- Wastes reasoning compute on a peer you control. Deterministic behavior doesn't need to be modeled.
- **Turning on `observe_others` everywhere** -- Directional representations are powerful, but they add complexity. Use them when peers need distinct perspectives, not just because a session has multiple peers.
- **Forgetting `peer_target` on session context** -- `session.context()` defaults to the active session's summary and recent messages, which are session-scoped. It becomes cross-session only through adding a peer_target which includes the peer representation.
- **A scope per reader** -- Scopes should map to real confidentiality boundaries, not to consumers. If every assistant gets its own scope, you've rebuilt workspace fragmentation inside one workspace, and each projection reasons over a thin slice. Fewer, boundary-shaped scopes; many readers can share one.
- **Treating scopes as access control** -- A scope bounds *recall*, not *access*. Enforce who may query what in your application layer; use scopes to keep the answers themselves from drawing on out-of-bounds sessions.
- **Forgetting `peer_target` on session context** -- `session.context()` defaults to the active session's summary and recent messages, which are local to that session. It becomes cross-session only through adding a peer_target which includes the peer representation.
- **Blocking on processing** -- Messages are processed asynchronously in the background. Don't poll or wait for reasoning to complete before continuing your application flow.
## Next Steps
@ -94,6 +113,9 @@ So you can start a session fresh or pull in a peer's long-term memory. [`session
<Card title="Get Context" icon="messages" href="/v3/documentation/features/get-context">
Retrieve formatted context from sessions for your LLM
</Card>
<Card title="Scopes" icon="shield-halved" href="/v3/documentation/features/advanced/scopes">
Bound recall to named sets of sessions
</Card>
<Card title="Chat Endpoint" icon="comments" href="/v3/documentation/features/chat">
Query Honcho about your peers with natural language
</Card>

View File

@ -66,21 +66,11 @@ The reasoning outputs--conclusions, summaries, peer cards--are stored as part of
The diagram above shows how agents write messages to Honcho, which triggers reasoning that updates peer representations. Agents can then query representations to get additional context for their next response.
### Token Batching
Rather than running inference on every individual message, Honcho accumulates messages in the queue and processes them as a batch once the total token count of pending messages for a given peer representation crosses a threshold--roughly **1,000 tokens** at the current batch size. This keeps ingestion costs down, since Honcho charges based on reasoning passes, and ensures each pass has a meaningful amount of context to work with. At ~1,000 tokens the batch comfortably fits in the context window of any modern LLM, so no content is lost.
If a user sends several short messages in a row (e.g., "yes", "ok", "sounds good"), those messages sit in the queue until enough content has accumulated. Once the threshold is met, the full batch is processed together in a single reasoning call.
<Note>
This batching only applies to **representation** tasks (conclusion extraction). Summary and dream tasks have their own scheduling logic and are not subject to the token threshold.
</Note>
## Balances & Design Choices
Off-the-shelf LLMs can perform formal logical reasoning, but they aren't optimized for it. Honcho uses custom models trained specifically for logical rigor (following formal reasoning rules rather than plausible-sounding text), structured output (consistent JSON schema with premises and conclusions), and efficiency (smaller, faster models tuned for this specific task). This allows Honcho to reason more reliably and at lower cost than general-purpose frontier LLMs.
The approach balances quality with practical constraints. Custom models are smaller and cheaper to run, scaffolded conclusions are more token-efficient than raw conversation history, and we batch where appropriate to optimize update frequency.
The approach balances quality with practical constraints. Custom models are smaller and cheaper to run, and scaffolded conclusions are more token-efficient than raw conversation history.
Honcho's reasoning capabilities are actively being improved. Current areas of development include enhanced inductive and abductive reasoning, multi-hop and temporal reasoning, and expanded file types and modalities. The system is designed to be extensible--new reasoning capabilities can be added without breaking existing functionality.

View File

@ -0,0 +1,131 @@
---
title: 'Deleting Data'
description: 'How to delete sessions, workspaces, and conclusions — and what survives each'
icon: 'trash'
---
Deletion in Honcho is **permanent and cannot be undone**. There is no soft
delete, no trash, and no restore.
## What can be deleted
| Resource | Endpoint | Behavior |
|---|---|---|
| Session | `DELETE /v3/workspaces/{workspace_id}/sessions/{session_id}` | `202` — cascade runs in the background |
| Workspace | `DELETE /v3/workspaces/{workspace_id}` | `202` — cascade runs in the background |
| Conclusion | `DELETE /v3/workspaces/{workspace_id}/conclusions/{conclusion_id}` | `204` — immediate |
| Webhook endpoint | `DELETE /v3/workspaces/{workspace_id}/webhooks/{endpoint_id}` | Immediate |
**Peers and individual messages cannot be deleted.** To remove a peer's data,
delete the sessions it participated in, then delete its remaining conclusions
(see [Conclusions outlive their sessions](#conclusions-outlive-their-sessions)).
To remove a peer from one conversation without deleting anything, use
[remove peers from session](/v3/api-reference/endpoint/sessions/remove-peers-from-session)
instead.
## Deleting a session
```bash
curl -X DELETE "$HONCHO_URL/v3/workspaces/my-app/sessions/session-1" \
-H "Authorization: Bearer $HONCHO_API_KEY"
```
The session is marked inactive immediately and the endpoint returns `202
Accepted`. The cascade — messages, message embeddings, queued reasoning work,
session-scoped conclusions, and peer associations — is processed in the
background with retries.
Because the work is asynchronous, a `202` means *accepted*, not *finished*. The
session drops out of session listings right away, but its messages and
conclusions drain afterwards. Deletion tasks are internal infrastructure work
and do **not** appear in
[queue status](/v3/documentation/features/advanced/queue-status) counts, so
there is no endpoint that reports when the cascade has finished.
<CodeGroup>
```python Python
session.delete()
```
```typescript TypeScript
await session.delete();
```
</CodeGroup>
## Deleting a workspace
A workspace can only be deleted once it has **no active sessions**. Deleting a
workspace that still has sessions returns `409 Conflict`:
```json
{"detail": "Cannot delete workspace 'my-app': active session(s) remain. Delete all sessions first."}
```
The correct order is:
1. List the workspace's sessions — `POST /v3/workspaces/{workspace_id}/sessions/list`
2. Delete each session — `DELETE /v3/workspaces/{workspace_id}/sessions/{session_id}`
3. Delete the workspace — `DELETE /v3/workspaces/{workspace_id}`
Step 2 returns `202`, so the session deletions are still draining when step 3
runs. That is fine: a session is marked inactive synchronously, so the workspace
delete stops returning `409` as soon as the deletes are accepted. Any session
created after the workspace deletion is accepted is cascade-deleted too.
<CodeGroup>
```python Python
# Materialize the list first — deleting shifts the pagination window
for session in list(honcho.sessions()):
session.delete()
honcho.delete_workspace("my-app")
```
```typescript TypeScript
// Materialize the list first — deleting shifts the pagination window
const sessions = [];
for await (const session of await honcho.sessions()) sessions.push(session);
for (const session of sessions) await session.delete();
await honcho.deleteWorkspace("my-app");
```
</CodeGroup>
Deleting a workspace removes every peer, session, message, conclusion,
collection, embedding, webhook endpoint, and queued task belonging to it.
## Conclusions outlive their sessions
This is the most common surprise. Deleting a session does **not** erase
everything Honcho learned in it.
- **Explicit conclusions** — direct facts drawn from messages — are tied to the
session they came from and are deleted with it.
- **Derived conclusions** (deductive, inductive, contradiction) are consolidations
that may draw on several sessions. They are stored at the workspace level with
no owning session, so they survive session deletion and stay in the peer's
[representation](/v3/documentation/core-concepts/representation).
To remove those, list and delete them directly:
<CodeGroup>
```python Python
for conclusion in alice.conclusions.list():
alice.conclusions.delete(conclusion.id)
```
```typescript TypeScript
for (const conclusion of await alice.conclusions.list()) {
await alice.conclusions.delete(conclusion.id);
}
```
</CodeGroup>
Deleting the whole workspace removes conclusions at every level and needs no
follow-up.
## Permissions
Session and workspace deletion accept any key scoped to that workspace — an
admin key is not required. Deleting a session additionally accepts a
session-scoped key.

View File

@ -1,6 +1,6 @@
---
title: 'Representation Scopes'
description: 'Advanced configuration and querying for representations'
title: 'Directional Representations'
description: 'How peers build and query representations of other peers'
icon: 'circle'
---
@ -214,7 +214,7 @@ Most applications don't need directional representations. Start with the default
Under the hood, Honcho stores representations as (observer, observed) pairs in internal collections:
- **Collection**: A unique (observer, observed, workspace) tuple containing documents
- **Documents**: Individual conclusions and artifacts (deductive, inductive, abductive conclusions, summaries, peer cards) with session scoping
- **Documents**: Individual conclusions and artifacts (deductive, inductive, abductive conclusions, summaries, peer cards) with per-session filtering
When you retrieve with `target`, Honcho fetches documents from the specific (observer, observed) collection. When you retrieve without `target`, it fetches from the (peer, peer) collection—the peer's self-representation.
@ -225,7 +225,7 @@ This architecture enables:
## Semantic Search Parameters
Both `representation()` and `chat()` support semantic filtering to retrieve a subset of relevant conclusions. You can optionally filter by session — pass `session` to scope to a single session, or use the REST-only [session allowlist](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions) to scope to a set of sessions:
Both `representation()` and `chat()` support semantic filtering to retrieve a subset of relevant conclusions. You can optionally filter by session — pass `session` to restrict to a single session, or use the REST-only [session allowlist](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions) to restrict to a set of sessions:
| Parameter | Type | Description |
|-----------|------|-------------|
@ -265,7 +265,7 @@ Directional representations update automatically through the reasoning pipeline
2. The message sender has `observe_me=true` (or session-level equivalent)
3. Other peers in the session have `observe_others=true`
The pipeline respects scoping—Honcho's representations reason over messages across all sessions, while directional representations only reason over messages from sessions where the observer was an active participant.
The pipeline respects these boundaries—Honcho's representations reason over messages across all sessions, while directional representations only reason over messages from sessions where the observer was an active participant.
### Peer Join Order Matters

View File

@ -12,7 +12,8 @@ Advanced features give you fine-grained control over Honcho's behavior and imple
- [Configuration](/v3/documentation/features/advanced/reasoning-configuration) - Configure reasoning models and behavior
- [Summarizer](/v3/documentation/features/advanced/summarizer) - Automatic session summarization
- [Peer Card](/v3/documentation/features/advanced/peer-card) - Quick-reference profile of stable biographical facts about a peer
- [Representation Scopes](/v3/documentation/features/advanced/representation-scopes) - Directional representations for multi-peer scenarios
- [Directional Representations](/v3/documentation/features/advanced/directional-representations) - How peers build separate representations of each other
- [Scopes](/v3/documentation/features/advanced/scopes) - Named sets of sessions that act as visibility boundaries for recall
- [Dreaming](/v3/documentation/features/advanced/dreaming) - Autonomous memory consolidation and self-improvement
- [Queue Status](/v3/documentation/features/advanced/queue-status) - Monitor background processing and reasoning tasks
@ -22,3 +23,4 @@ Advanced features give you fine-grained control over Honcho's behavior and imple
- [Filters](/v3/documentation/features/advanced/using-filters) - Filter queries with advanced parameters
- [Streaming Responses](/v3/documentation/features/advanced/streaming-response) - Stream dialectic responses in real-time
- [File Uploads](/v3/documentation/features/advanced/file-uploads) - Ingest files into peer memory
- [Deleting Data](/v3/documentation/features/advanced/deleting-data) - Delete sessions, workspaces, and conclusions

View File

@ -79,7 +79,7 @@ console.log(card);
## Directional Peer Cards
Peer cards follow the same observer-observed model as [representations](/v3/documentation/features/advanced/representation-scopes). When `observe_others` is enabled, a peer can have a **different** card for each peer it observes.
Peer cards follow the same observer-observed model as [representations](/v3/documentation/features/advanced/directional-representations). When `observe_others` is enabled, a peer can have a **different** card for each peer it observes.
For example, if Alice and Bob are in a session together and Alice has `observe_others: true`, Alice will build her own peer card for Bob--separate from Honcho's peer card for Bob. You can read and write these directional cards using the `target` parameter.

View File

@ -8,9 +8,9 @@ Whenever messages are stored in Honcho, background processes kick off to [reason
Reasoning is an asynchronous process and will not immediately
generate insights for the latest message you've sent. This is
by design: we want to reason efficiently over batches of messages
rather than assessing each message in a vacuum. Honcho provides
several utilities to check the status of the queue.
by design: Honcho reasons in the background rather than on the
write path. Honcho provides several utilities to check the status
of the queue.
<CodeGroup>
```python Python
@ -95,7 +95,7 @@ not the total number of items ever processed.
</Note>
The `queue_status` method can take additional
parameters to scope the status to a specific work unit:
parameters to filter the status by a matching observer, sender, or session:
<CodeGroup>
```python Python

View File

@ -157,7 +157,7 @@ You may therefore disable observation of a peer by setting the `observe_me` flag
If the peer has a session-level configuration, it will override this configuration. If the flag is not set, or is set to `true`, the peer will be observed.
<Info>
For session-level observation controls and local representations (where peers build separate models of each other), see [Representation Scopes](/v3/documentation/features/advanced/representation-scopes).
For session-level observation controls and local representations (where peers build separate models of each other), see [Directional Representations](/v3/documentation/features/advanced/directional-representations).
</Info>
<CodeGroup>

View File

@ -0,0 +1,355 @@
---
title: 'Scopes'
description: 'Named sets of sessions that act as visibility boundaries for recall'
icon: 'shield-halved'
---
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 peer keeps its single unified representation of everything it has ever
participated in.
Use scopes when one peer's history spans contexts that must not leak into each
other — a therapy app where the clinical sessions must not inform the billing
assistant, a support product where a reseller's agent may only answer from its
own tickets, a multi-tenant deployment where one human works across tenants.
## Projection, Not Partition
The peer keeps one representation. A scope is a **projection** of it: a view
built only from evidence in the member sessions.
```mermaid
graph TB
P[Peer: user-123<br/>one unified representation]
P --> S1[session: therapy-1]
P --> S2[session: therapy-2]
P --> S3[session: billing-1]
P --> S4[session: onboarding-1]
SC1[scope: therapy] -.->|projects| S1
SC1 -.->|projects| S2
SC2[scope: billing] -.->|projects| S3
style P fill:#B6DBFF,stroke:#333,color:#000
style S1 fill:#B6DBFF,stroke:#333,color:#000
style S2 fill:#B6DBFF,stroke:#333,color:#000
style S3 fill:#B6DBFF,stroke:#333,color:#000
style S4 fill:#B6DBFF,stroke:#333,color:#000
style SC1 fill:#FFE0B2,stroke:#333,color:#000
style SC2 fill:#FFE0B2,stroke:#333,color:#000
```
- **Sessions can belong to more than one scope.** Membership is many-to-many.
- **Sessions can belong to no scope.** `onboarding-1` above is reachable
by an unscoped request and by nothing else.
- **An unscoped request still sees everything.** A scope constrains the requests
that name it; it does not hide the sessions from requests that don't.
<Warning>
Scopes are a recall boundary, not an authorization boundary. Who may call the
API is still governed by workspace, session, and peer keys.
</Warning>
## The Two Arms
There are two ways to confine recall, and they behave differently. Picking the
wrong one is the most common mistake with this feature.
| | `scope="therapy"` (named scope) | `sessions=[...]` / `scope=["a","b"]` (allowlist) |
|---|---|---|
| **Mechanism** | Reads the scope's own representation of the peer | Restricts the peer's own representation to a set of sessions |
| **Conclusions** | All levels — `explicit`, plus `deductive` / `inductive` reasoned **within** the scope | `explicit` only |
| **Reasoning chains** | Available | Unavailable |
| **Setup required** | Yes — create the scope, add sessions, wait for backfill | None — pass session IDs ad hoc |
| **Accepts** | One scope name | A list of up to 100 scope names, or up to 1,000 session IDs |
### Named scope: depth
Passing a **single** scope name swaps the observer. Recall runs against the
scope's own view of the target peer, which the deriver and dreamer have been
building from the scope's member sessions all along. That view contains
higher-order inferences — but only ones reasoned from evidence inside the scope.
```python
answer = user.chat("What is stressing them out?", scope="therapy")
```
This is the arm you want for a durable, meaningful boundary.
### Allowlist: breadth
Passing a **list** of scopes, or a bare list of session IDs, keeps the peer as
the observer and restricts recall to the union of those sessions. Because a
dream-derived conclusion is synthesized across sessions, it cannot be attributed
to any one of them — so this arm recalls `explicit` conclusions only, and answers
from directly-stated facts rather than inference.
```python
answer = user.chat("What did they say about billing?", sessions=[s1, s2])
answer = user.chat("What did they say?", scope=["therapy", "intake"])
```
Reach for this when the set of sessions is decided per-request, or when you want
a quick boundary without provisioning a scope. See
[Scoping Recall to Sessions](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions)
for the full allowlist rules.
<Info>
A list of scopes is the allowlist arm, not "several named scopes at once". It
gives you the union of their *sessions*, at explicit-only depth — it does not
give you the union of their reasoned views. If you need depth, query one scope.
</Info>
## Creating a Scope and Managing Membership
<CodeGroup>
```python Python
from honcho import Honcho
honcho = Honcho(workspace_id="my-app")
# Get or create — idempotent; passing metadata updates the existing scope
therapy = honcho.scope("therapy")
# Add existing sessions (max 100 per call)
therapy.add_sessions(["therapy-session-1", "therapy-session-2"])
# Or attach at session creation — the scope is created if it doesn't exist
session = honcho.session("therapy-session-3", scopes=["therapy"])
# Inspect
for s in therapy.sessions():
print(s.id)
therapy.remove_session("therapy-session-1")
for scope in honcho.scopes():
print(scope.id, scope.metadata)
```
```typescript TypeScript
import { Honcho } from "@honcho-ai/sdk";
const honcho = new Honcho({ workspaceId: "my-app" });
// Get or create — idempotent; passing metadata updates the existing scope
const therapy = await honcho.scope("therapy");
// Add existing sessions (max 100 per call)
await therapy.addSessions(["therapy-session-1", "therapy-session-2"]);
// Or attach at session creation — the scope is created if it doesn't exist
const session = await honcho.session("therapy-session-3", {
scopes: ["therapy"],
});
// Inspect
for await (const s of await therapy.sessions()) {
console.log(s.id);
}
await therapy.removeSession("therapy-session-1");
for await (const scope of await honcho.scopes()) {
console.log(scope.id, scope.metadata);
}
```
```bash REST
# Get or create (201 created / 200 existing)
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes" \
-H "Authorization: Bearer $HONCHO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "therapy"}'
# Add sessions
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/sessions" \
-H "Authorization: Bearer $HONCHO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"session_ids": ["therapy-session-1", "therapy-session-2"]}'
# List membership
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/sessions/list" \
-H "Authorization: Bearer $HONCHO_API_KEY"
# Remove one session
curl -X DELETE "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/sessions/therapy-session-1" \
-H "Authorization: Bearer $HONCHO_API_KEY"
```
</CodeGroup>
Scope IDs are unprefixed, must match `^[a-zA-Z0-9_-]+$`, and are at most 506
characters. Get-or-create is idempotent: if the scope already exists, the same
call returns it, and any `metadata` you pass is written onto it.
<Note>
Every scopes route — and every read that passes `scope` — requires a
**workspace-level or admin key**. A scope's membership can exceed any single
peer's own session membership, so peer- and session-scoped keys are rejected
with `401`.
</Note>
## Membership Changes Copy, They Don't Re-Derive
A session added to a scope while empty needs nothing special: messages sent
after the change flow into the scope through the normal deriver fan-out.
A session that **already has messages** is handled retroactively by a background
job rather than by re-running the LLM over its history: adding it copies the
session's existing `explicit` conclusions into the scope, and removing it
retracts that session's contributions — including conclusions derived from them.
Copying rather than re-deriving is why membership changes are cheap and
deterministic — and why they are also **asynchronous**. It also means a freshly
backfilled scope starts at explicit depth and accrues deeper reasoning through
subsequent dreams.
Poll `status()` to tell "the scope hasn't caught up yet" apart from "the scope
has caught up and there is genuinely nothing to recall":
<CodeGroup>
```python Python
therapy.add_sessions(["old-session-with-history"])
status = therapy.status()
# {"old-session-with-history": {"state": "pending", "updated_at": "..."}}
# → later: {"state": "completed", "docs_copied": 42, "updated_at": "..."}
```
```typescript TypeScript
await therapy.addSessions(["old-session-with-history"]);
const status = await therapy.status();
// { "old-session-with-history": { state: "pending", updatedAt: "..." } }
```
```bash REST
curl "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/status" \
-H "Authorization: Bearer $HONCHO_API_KEY"
```
</CodeGroup>
`state` is `pending`, `completed`, or `failed`; `docs_copied` appears once a
backfill completes. Only sessions that have had a backfill enqueued appear, so an
empty result means none have — not that the scope is empty.
## Reading Through a Scope
`scope` is accepted on these surfaces:
| Surface | Accepts | Notes |
|---------|---------|-------|
| [`peer.chat()`](/v3/documentation/features/chat) | one scope or a list | Confines both conclusion recall and the messages the agent reads |
| `peer.representation()` | one scope or a list | Confines conclusion recall |
| [`session.context()`](/v3/documentation/features/get-context) | one scope only | Perspective source for `peer_target`'s representation and card. Requires `peer_target`; mutually exclusive with `peer_perspective` |
| `honcho.search()` | one scope only | Restricts message search to the scope's member sessions |
| `honcho.chat()` | one scope or a list | Always the allowlist arm — even a single name. There is no observer to swap |
<CodeGroup>
```python Python
# Chat — answered only from the therapy sessions
answer = user.chat("What is stressing them out?", scope="therapy")
# Representation
rep = user.representation(scope="therapy")
# Session context, using the scope as the perspective source
ctx = session.context(peer_target="user-123", scope="therapy")
# Message search, restricted to the scope's sessions
messages = honcho.search("insomnia", scope="therapy")
```
```typescript TypeScript
// Chat — answered only from the therapy sessions
const answer = await user.chat("What is stressing them out?", {
scope: "therapy",
});
// Representation
const rep = await user.representation({ scope: "therapy" });
// Session context, using the scope as the perspective source
const ctx = await session.context({
peerTarget: "user-123",
scope: "therapy",
});
// Message search, restricted to the scope's sessions
const messages = await honcho.search("insomnia", { scope: "therapy" });
```
```bash REST
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/peers/user-123/chat" \
-H "Authorization: Bearer $HONCHO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "What is stressing them out?", "scope": "therapy"}'
```
</CodeGroup>
### Rules
`scope` is mutually exclusive with `filters`, `sessions`, and `session` /
`session_id` — and on session context, with `peer_perspective` (where it also
requires `peer_target`). Like the session allowlist, it **fails closed**: a
contradiction is rejected with a `422` rather than silently widened, a scope
with no member sessions recalls nothing, and an empty list (`scope=[]`) is
rejected rather than treated as "no boundary". Per-surface caps and error
shapes are in the [API reference](/v3/api-reference/endpoint/scopes/get-or-create-scope).
## Provenance, Not Topic
A scope is defined by **where a fact was said**, not what it is about.
If a user mentions a therapy detail in a billing session, that conclusion is
formed from the billing session and lands in the `billing` scope. Querying
`scope="therapy"` will not find it, and querying `scope="billing"` will.
<Warning>
Scopes give you provenance-based privacy, not topic-based privacy. If you need
"no clinical content in the billing assistant's answers" regardless of where it
was said, that is content classification and has to be enforced above Honcho —
by controlling what reaches which session in the first place, or by filtering
the answer.
</Warning>
Design accordingly: keep the session boundary aligned with the confidentiality
boundary you actually care about, since that session boundary is the one scopes
can enforce.
## Guardrails
A few behaviors follow from how scopes are built:
- **The `scope.` prefix is reserved.** Creating a peer, or adding a peer to a
session, with a `scope.`-prefixed name is rejected.
- **List scopes through the scopes surface.** `honcho.scopes()` /
`POST /scopes/list` returns unprefixed ids. Peer listings hide scopes by
default; `kind="scope"` on `POST /peers/list` returns the backing peers named
`scope.<id>`, and `kind="all"` includes both regular peers and those backing
peers.
- **A scope can't be observed.** No representation is formed *of* a scope, so a
scope is rejected in any `target` / observed position, including as a dream
target.
- **Membership is managed only through the scopes surface.** The session
add-peers, set-peers, and remove-peers routes reject scope names and point you
at `/scopes/{scope_id}/sessions` or the `scopes` field on session create.
If you want the exact mechanics for scopes, read: [`src/routers/scopes.py`](https://github.com/plastic-labs/honcho/blob/main/src/routers/scopes.py),
[`src/crud/scope.py`](https://github.com/plastic-labs/honcho/blob/main/src/crud/scope.py),
and [`src/deriver/scope_backfill.py`](https://github.com/plastic-labs/honcho/blob/main/src/deriver/scope_backfill.py).
## Limits
| Limit | Value |
|-------|-------|
| Scope ID length | 506 characters |
| Scope ID charset | `^[a-zA-Z0-9_-]+$` |
| Sessions per membership call | 100 |
| Scopes in one `scope` read option | 100 |
| Scopes on session create | 100 |
| Sessions in a resolved allowlist | 1,000 |
Full request and response shapes are in the
[API reference](/v3/api-reference/endpoint/scopes/get-or-create-scope).

View File

@ -49,6 +49,20 @@ import { Honcho } from "@honcho-ai/sdk";
```
</CodeGroup>
Pass `scope` on workspace search to restrict matches to that
[scope](/v3/documentation/features/advanced/scopes)'s member sessions. A scope
with no members returns nothing.
<CodeGroup>
```python Python
results = honcho.search("budget planning", scope="therapy")
```
```typescript TypeScript
const results = await honcho.search("budget planning", { scope: "therapy" });
```
</CodeGroup>
### Session Search
Search within a specific session's conversation history:

View File

@ -727,7 +727,7 @@ messages = session.messages(filters={
### Filtering Conclusions
Conclusions are scoped to an observer/observed peer pair (accessed via
Conclusions belong to an observer/observed peer pair (accessed via
`peer.conclusions` for self-conclusions or `peer.conclusions_of(target)` for
conclusions about another peer). The observer and observed are filled in
automatically by the scope, so the `filters` you pass add to them.
@ -843,7 +843,7 @@ a **session allowlist**, restricting what the request can recall to the sessions
you name — conclusions on both endpoints, and on chat the messages the agent
reads as well.
This is how you scope recall to more than one session. The `session_id`
This is how you restrict recall to more than one session. The `session_id`
parameter pins a request to exactly one session; an allowlist accepts a set.
Only the `session_id` key is supported here, in three shapes:
@ -875,10 +875,34 @@ curl -X POST "$HONCHO_URL/v3/workspaces/my-app/peers/user-123/representation" \
```
</CodeGroup>
Both SDKs expose this as a `sessions` option, which goes on the wire as the
`filters` body above:
<CodeGroup>
```python Python
answer = user.chat("What did the user ask about billing?",
sessions=["support-chat-1", "support-chat-2"])
rep = user.representation(sessions=["support-chat-1", "support-chat-2"])
```
```typescript TypeScript
const answer = await user.chat("What did the user ask about billing?", {
sessions: ["support-chat-1", "support-chat-2"],
});
const rep = await user.representation({
sessions: ["support-chat-1", "support-chat-2"],
});
```
</CodeGroup>
<Note>
The session allowlist is REST-only today. The SDKs cover the single-session case
with `session`, but do not yet expose the allowlist — call the endpoint directly
when you need a set of sessions.
If the same set of sessions is a boundary you reuse, name it: a
[scope](/v3/documentation/features/advanced/scopes) is a persistent version of
this allowlist, and querying a single scope recalls at full depth rather than
`explicit`-only. `sessions` is the right tool when the set is decided
per-request.
</Note>
### Rules
@ -907,7 +931,7 @@ can only narrow.
### What Changes Under an Allowlist
Scoping recall by session narrows what the reasoning agent can draw on:
Restricting recall by session narrows what the reasoning agent can draw on:
- **Only `explicit` conclusions are recalled.** Dream-derived conclusions
(`deductive`, `inductive`) are synthesized across sessions, so they can't be

View File

@ -13,6 +13,14 @@ for a session has drained.
Webhooks are registered per workspace. Every event for that workspace is
delivered to every endpoint registered on it.
<Note>
**On Honcho Cloud, register endpoints from the dashboard.** The webhook API
below is available on self-hosted instances; on `api.honcho.dev` it returns
`405 Method Not Allowed`. Use the
[Webhooks page](https://app.honcho.dev/webhooks) instead. Everything else on
this page — payload shapes, delivery semantics — applies to both.
</Note>
## Registering an Endpoint
<CodeGroup>

View File

@ -110,11 +110,17 @@ const answer = await peer.chat("What did the user ask about?", { session: sessio
```
</CodeGroup>
To scope a request to a *set* of sessions, use the session allowlist — a
To restrict a request to a *set* of sessions, use the session allowlist — a
constrained `filters` body on the endpoint. See
[Scoping Recall to Sessions](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions)
for the accepted shapes and for what an allowlist changes about the answer.
Pass `scope="therapy"` to answer from that [scope](/v3/documentation/features/advanced/scopes)'s
own representation of the peer. A list (`scope=["therapy", "intake"]`) is an
allowlist of those scopes' sessions, not named-scope depth.
`honcho.chat(scope=)` is always the allowlist arm, even with one name. Details
are on the [scopes page](/v3/documentation/features/advanced/scopes#the-two-arms).
## Structured Outputs
When your application needs a machine-readable answer instead of prose, pass a schema as `response_format` and the answer is guaranteed to conform to it:

View File

@ -99,7 +99,7 @@ context = session.context(summary=False, tokens=2000)
### Peer Representation in Context
You can include a peer's [representation](/v3/documentation/core-concepts/representation) and peer card in the context by specifying `peer_target`. This is useful for providing the LLM with knowledge about a specific peer.
You can include a peer's [representation](/v3/documentation/core-concepts/representation) and peer card in the context by specifying `peer_target`. This is useful for providing the LLM with knowledge about a specific peer. Pass `scope` with `peer_target` to use a [named scope](/v3/documentation/features/advanced/scopes) as the perspective source (`scope` is mutually exclusive with `peer_perspective` and requires a workspace-level or admin-level key).
<CodeGroup>
```python Python
@ -119,6 +119,14 @@ context = session.context(
peer_target="user-123",
peer_perspective="assistant" # From assistant's viewpoint
)
# Or use a named scope as the perspective source (requires peer_target;
# mutually exclusive with peer_perspective)
context = session.context(
tokens=2000,
peer_target="user-123",
scope="therapy",
)
```
```typescript TypeScript
@ -139,6 +147,14 @@ context = session.context(
peerTarget: "user-123",
peerPerspective: "assistant" // From assistant's viewpoint
});
// Or use a named scope as the perspective source (requires peerTarget;
// mutually exclusive with peerPerspective)
const scopedContext = await session.context({
tokens: 2000,
peerTarget: "user-123",
scope: "therapy",
});
})();
```
</CodeGroup>
@ -211,6 +227,7 @@ context = session.context(
| `tokens` | `int` | Maximum tokens to include |
| `peer_target` | `str` | Peer ID to include representation for |
| `peer_perspective` | `str` | Peer ID for perspective (requires peer_target) |
| `scope` | `str` | Named scope as the perspective source for `peer_target`'s representation and card. Requires `peer_target` and a workspace-level or admin-level key; mutually exclusive with `peer_perspective`. See [Scopes](/v3/documentation/features/advanced/scopes) |
| `search_query` | `str` | Query for semantic search (requires peer_target) |
| `limit_to_session` | `bool` | Limit to session conclusions only |
| `search_top_k` | `int` | Semantic search results to include (1-100) |

View File

@ -89,14 +89,14 @@ and are stored under `oauth` without deleting a shared `apiKey`.
}
```
<Info>
Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s`
Per-command targeting (workspace / peer / session) is handled via `-w` / `-p` / `-s`
flags or `HONCHO_*` env vars. **Not** persisted as CLI defaults. This is
deliberate: every invocation is explicit about what it operates on.
</Info>
### Runtime overrides
Workspace, peer, and session scoping are **per-command only** — pass flags or
Workspace, peer, and session targeting are **per-command only** — pass flags or
`HONCHO_*` env vars on every invocation.
```bash

View File

@ -62,6 +62,8 @@ The **Performance** page provides comprehensive monitoring with usage metrics, h
## 3. Manage API Keys
The [API Keys](https://app.honcho.dev/api-keys) page allows you to create and manage authentication tokens for different environments. You can create admin-level keys with full instance access or scope keys to a specific `Workspace`, `Peer`, or `Session`.
Keys can also be created programmatically. `POST /v3/keys` with an admin key returns a real cloud key on that key's instance, attributed to its owner and revocable from the [API Keys](https://app.honcho.dev/api-keys) page.
Scoped keys are authorized by their narrowest claim and never widen to the whole workspace:
- A **peer-scoped** key acts on its own peer, plus **read-only** access to the sessions its peer is an active member of (context, summaries, peers, its own per-session config, search, and message reads). It cannot write to those sessions or act on other peers.

View File

@ -206,6 +206,9 @@ honcho.set_metadata(dict)
# Get list of all workspace IDs
workspaces = honcho.workspaces()
# Delete a workspace and everything in it (requires no active sessions)
honcho.delete_workspace(workspace_id)
```
```typescript TypeScript
@ -238,6 +241,9 @@ await honcho.setMetadata(metadata);
// Get list of all workspace IDs
const workspaces = await honcho.workspaces();
// Delete a workspace and everything in it (requires no active sessions)
await honcho.deleteWorkspace(workspaceId);
```
</CodeGroup>
@ -270,6 +276,9 @@ response = alice.chat("What do I know about Bob?", target="bob")
response = alice.chat("What happened in session-1?", session="session-1")
response = alice.chat("Summarize what matters most to me.", reasoning_level="high")
# Override the timeout for one non-streaming dialectic request
response = alice.chat("Give me a quick summary.", timeout=5.0)
# Add content to a session with a peer
session = honcho.session("session-1")
session.add_messages([
@ -372,6 +381,13 @@ const bobConclusions = await alice.conclusionsOf("bob").list(); // Conclusions
```
</CodeGroup>
For Python, `peer.chat(timeout=...)` and `await peer.aio.chat(timeout=...)`
accept a timeout in seconds for each HTTP attempt made by one non-streaming
request. Omit it or pass `None` to use the client-wide timeout configured on
`Honcho`. Retries still follow the client's `max_retries` setting and can extend
total elapsed time; use `max_retries=0` when a host shutdown budget permits only
one attempt.
### Peer Context
The `context()` method on peers retrieves both the working representation and peer card in a single API call:

View File

@ -23,7 +23,7 @@ The Honcho plugin is a community integration. See the [plugin README](https://gi
## How It Works
The extension hooks into pi's extension system. It automatically syncs user and assistant messages to Honcho after each agent response, injects cached user profile and project context into the system prompt with zero network latency, and exposes LLM tools (`honcho_search`, `honcho_chat`, `honcho_remember`) for active memory operations. Session scoping is configurable — memory can be shared per repo, per git branch, or per directory. If Honcho is unavailable, pi continues working normally.
The extension hooks into pi's extension system. It automatically syncs user and assistant messages to Honcho after each agent response, injects cached user profile and project context into the system prompt with zero network latency, and exposes LLM tools (`honcho_search`, `honcho_chat`, `honcho_remember`) for active memory operations. Session mapping is configurable — memory can be shared per repo, per git branch, or per directory. If Honcho is unavailable, pi continues working normally.
## Next Steps

View File

@ -0,0 +1,198 @@
---
title: "DeepSeek Harness"
icon: 'terminal'
description: "Add AI-native memory to DeepSeek Harness"
sidebarTitle: 'DeepSeek Harness'
---
`dsh` forgets everything when a session ends. This plugin gives it memory that doesn't: what you're building, how you like to work, and what you decided last week and why — carried across context resets, restarts, and fresh chats.
It is a native [Cordis](https://github.com/cordiverse/cordis) plugin, not a hook bridge, so it hooks the harness's own extension points directly.
## Quick Start
### Step 1: Get Your Honcho API Key
1. Go to **[app.honcho.dev](https://app.honcho.dev)**
2. Sign up or log in
3. Copy your API key (starts with `hch-`)
### Step 2: Install the Plugin
<Note>
This plugin requires a running [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness). Plugins install into a named profile, so pick the one you actually run — `web`, `headless`, `acp`, or your own.
</Note>
```bash
dsh plugin --profile <name> add @honcho-ai/dsh-honcho
```
`dsh plugin` forwards to your package manager and appends the plugin to that profile's bundle list. Because the package declares `dsh.bundle`, it activates as a configuration layer rather than sitting inert as a plain dependency.
### Step 3: Configure
Put your key and name in `~/.honcho/config.json`:
```jsonc
{
"peerName": "your-name",
"auth": { "apiKey": "${HONCHO_API_KEY}" },
"hosts": {
"dsh": { "workspace": "dsh" }
}
}
```
`HONCHO_API_KEY` in the environment works on its own — the config file is only needed to change defaults.
### Step 4: Verify
Start `dsh` and run `/honcho`. You'll see your peer, workspace, session, and sync status, plus a link to the session in the Honcho dashboard.
<Warning>
In the `dsh` web client, `/honcho` output renders in the collapsed command panel rather than inline in the transcript. Expand the panel to read it.
</Warning>
## What You Get
- **Memory at session start** — your profile, a summary of this project's session so far, and the conclusions relevant to what you just asked, shaped to a character budget in a single API call
- **Automatic capture** — user and assistant turns stream to Honcho in the background, debounced, and flushed at turn boundaries, before compaction, and on shutdown
- **Secret redaction** — messages are scrubbed before they leave your machine
- **Agent tools** — first-class search, reasoning, and conclusion-writing inside `dsh`
- **Shared configuration** — the same `~/.honcho/config.json` every other Honcho integration reads
## Configuration
Configuration lives in `~/.honcho/config.json`, shared with the other Honcho hosts. The root holds identity and connection; behavior lives under `hosts.dsh`.
```jsonc
{
"peerName": "your-name",
"workspace": "honcho",
"baseUrl": "https://api.honcho.dev", // bare host or …/v3 both fine
"timeoutMs": 30000,
"auth": { "apiKey": "${HONCHO_API_KEY}" },
"enabled": true, // global kill switch
"hosts": {
"dsh": {
"workspace": "dsh",
"aiPeer": "dsh", // defaults to the host name
"observationMode": "unified", // unified | directional
"sessionStrategy": "per-directory",
"sessionPeerPrefix": true, // session names are <peer>-<dir>
"sessions": { "/path/to/repo": "pinned-session-name" },
"injection": {
"sessionStart": ["directives", "summary", "peerCard"],
"perTurn": ["userContext", "dialectic"],
"tools": true,
"searchTopK": 10,
"searchMaxDistance": 0.6,
"maxConclusions": 15, // how many conclusions Honcho RETURNS
"maxRenderedConclusions": 4, // how many survive into the prompt
"contextTokens": 1500,
"cadence": { "dialectic": 5, "ttlSeconds": 300 },
"dialectic": {
"reasoning": "low", // minimal | low | medium | high | max
"maxChars": 600
}
},
"capture": {
"saveMessages": true,
"saveToolUse": false, // one-line summaries of tool activity
"writeFrequency": "async", // async | sync
"noisePatterns": [] // additive to the built-in secret patterns
},
"messageUpload": {
"maxUserTokens": 6000,
"maxAssistantTokens": 6000
}
}
}
}
```
<Note>
Unsupported or renamed keys are reported at startup rather than silently ignored, so a stale config tells you what it is no longer doing.
</Note>
### Injection Components
The two menus differ in **cadence**, not in what they can carry.
`injection.sessionStart` is injected once when a session opens: `directives`, `summary`, `peerCard`, `representation`.
`injection.perTurn` refreshes as you work:
| Component | Behavior |
| --- | --- |
| `userContext` | A fresh, prompt-scoped bundle of **representation + peer card**, retrieved using your current message as the search query — so recall is associative rather than merely recent |
| `dialectic` | A reasoned answer about you, run every `cadence.dialectic` turns. Nothing waits on it after the first turn, so a late answer reaches the next one |
To get the representation without the peer card (or vice versa), name it in `sessionStart` and set `perTurn: []` — at the cost of per-turn refresh.
### Session Strategies
| Strategy | Session name | Notes |
| --- | --- | --- |
| `per-directory` (default) | `<peer>-<dir>` | Stable across restarts and branches |
| `per-repo` | `<peer>-<repo-root>` | Same memory from any subdirectory |
| `git-branch` | `<peer>-<dir>-<branch>` | Falls back to `per-directory` outside a repo or on a detached HEAD |
| `per-session` | `<peer>-chat-<id>` | A clean slate every restart |
| `global` | `<peer>` | One memory for everything |
<Warning>
Prefer the wider scopes. The background Deriver needs a single session to accumulate enough material before it can reason well. `git-branch` splits a project's memory per branch, and `per-session` discards it on every restart.
</Warning>
### Sharing Memory With Other Integrations
Each integration defaults to its own Honcho `workspace` — `dsh` here, `claude_code` for claude-honcho — and a workspace is the isolation boundary, so **by default they do not see each other's memory.** Point them at the same `workspace` to merge them:
```jsonc
"hosts": {
"dsh": { "workspace": "shared" },
"claude_code": { "workspace": "shared" }
}
```
Keep `peerName` identical across them too, since conclusions are stored per peer.
## Commands
| Command | Description |
| --- | --- |
| `/honcho` | Status: peer, workspace, session, strategy, pending uploads, last sync, last fetch |
| `/honcho config` | Resolved settings, the file they came from, and any ignored injection components |
| `/honcho flush` | Sync now |
## Agent Tools
| Tool | Description |
| --- | --- |
| `honcho_search` | Look something up — searches raw messages **and** derived conclusions |
| `honcho_chat` | Ask a question of judgment. Reasons over everything Honcho knows; slower |
| `honcho_remember` | Save a durable fact, preference, or decision |
Set `injection.tools` to `false` to inject memory without exposing tools.
## Requirements
- Node `^22.19.0 || >=24.0.0`
- A running `dsh`
- A Honcho API key, or a self-hosted Honcho at `baseUrl`
## Next Steps
<CardGroup cols={2}>
<Card title="GitHub Repository" icon="github" href="https://github.com/plastic-labs/dsh-honcho">
Source code, issues, and README.
</Card>
<Card title="Honcho Architecture" icon="sitemap" href="../../documentation/core-concepts/architecture">
Learn about peers, sessions, and dialectic reasoning.
</Card>
</CardGroup>

View File

@ -61,11 +61,11 @@ In practice, that means agent peers can both be observed by Honcho and form repr
## How It Works
### Identity And Scope
### Identity And Mapping
The integration breaks down into four parts:
- **Identity and scope** - each Paperclip company maps to a Honcho workspace, agents and human actors map to peers, and issues map to sessions.
- **Identity and mapping** - each Paperclip company maps to a Honcho workspace, agents and human actors map to peers, and issues map to sessions.
- **What gets copied into Honcho** - issue comments and document revisions sync into Honcho, with document content sectioned and normalized message content capped before ingestion.
- **What operators get** - operators get a plugin settings page, migration preview/status data, including a per-issue migration mapping preview, repair tools, and an issue-level `Memory` tab.
- **What agents get** - agents get Honcho retrieval and peer-chat tools inside Paperclip.
@ -130,7 +130,7 @@ The plugin registers the following Honcho tools for Paperclip agents:
Review how workspaces, peers, and sessions fit together.
</Card>
<Card title="Representation Scopes" icon="messages" href="../../documentation/features/advanced/representation-scopes">
<Card title="Directional Representations" icon="messages" href="../../documentation/features/advanced/directional-representations">
Review how `observe_me` and `observe_others` change what peers can model.
</Card>
</CardGroup>

View File

@ -109,8 +109,9 @@ and `aiPeer` there. See the [Hermes guide](/v3/guides/integrations/hermes) for t
A scheduled job feeds external data (emails, meeting notes, CRM records) into Honcho.
Attribute the messages to the peer the data is *about* — not to an agent — and group
them into a session. **How you scope that session is the main decision here**, because
it controls when Honcho reasons over the data (more on that below).
them into a session. Match the session to how you want that import's local context
to accumulate: a per-run session like `email-import-{date}`, or one ongoing
per-source session like `email-import-gmail`.
```python
from datetime import datetime, timezone
@ -131,18 +132,6 @@ for i in range(0, len(messages), 100):
session.add_messages(messages[i:i + 100])
```
Honcho batches reasoning until a peer accumulates ~1,000 tokens *within a single session*,
with a default age-based flush for quiet tails
([token batching](/v3/documentation/core-concepts/reasoning#token-batching)). Scope the
session to the volume you ingest:
- **High-volume runs** (a day of emails, a CRM export) clear the threshold easily — a
per-run session like `email-import-{date}` is fine.
- **Low-volume or trickle imports** (a few short records at a time) should append to
one **ongoing per-source session** (e.g. `email-import-gmail`), so content
accumulates across runs instead of fragmenting into thin sessions that each flush
later with little context.
The [Gmail](/v3/guides/gmail) and [Granola](/v3/guides/granola) guides are related
import examples.

View File

@ -9,7 +9,7 @@
"url": "https://honcho.dev/",
"email": "hello@plasticlabs.ai"
},
"version": "3.1.0"
"version": "3.1.1"
},
"servers": [
{
@ -1587,7 +1587,7 @@
"get": {
"tags": ["sessions"],
"summary": "Get Peer Config",
"description": "Get the configuration for a Peer in a Session.\n\nMember-read lets a peer-scoped key reach this route, but a peer may only\nread its own per-session config not a co-member's. Workspace/admin and\nsession-scoped tokens (which already span the whole session) are unaffected.",
"description": "Get the configuration for a Peer in a Session.\n\nMember-read lets a peer-scoped key reach this route, but a peer may only\nread its own per-session config \u2014 not a co-member's. Workspace/admin and\nsession-scoped tokens (which already span the whole session) are unaffected.",
"operationId": "get_peer_config_v3_workspaces__workspace_id__sessions__session_id__peers__peer_id__config_get",
"security": [{ "HTTPBearer": [] }],
"parameters": [
@ -2247,6 +2247,343 @@
}
}
},
"/v3/workspaces/{workspace_id}/scopes": {
"post": {
"tags": ["scopes"],
"summary": "Get Or Create Scope",
"description": "Get a Scope by ID or create a new Scope with the given ID.\n\nReturns 201 when the scope is created and 200 when it already exists.\nA pre-existing peer occupying the scope's reserved internal name is never\nadopted; that conflict returns 409.",
"operationId": "get_or_create_scope_v3_workspaces__workspace_id__scopes_post",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "workspace_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Workspace Id" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScopeCreate",
"description": "Scope creation parameters"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Scope" }
}
}
},
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Scope" }
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/v3/workspaces/{workspace_id}/scopes/list": {
"post": {
"tags": ["scopes"],
"summary": "Get Scopes",
"description": "Get all Scopes for a Workspace. Results are paginated.",
"operationId": "get_scopes_v3_workspaces__workspace_id__scopes_list_post",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "workspace_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Workspace Id" }
},
{
"name": "reverse",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"description": "Whether to reverse the order of results",
"default": false,
"title": "Reverse"
},
"description": "Whether to reverse the order of results"
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Page_Scope_" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/v3/workspaces/{workspace_id}/scopes/{scope_id}": {
"get": {
"tags": ["scopes"],
"summary": "Get Scope",
"description": "Get a single Scope by ID.",
"operationId": "get_scope_v3_workspaces__workspace_id__scopes__scope_id__get",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "workspace_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Workspace Id" }
},
{
"name": "scope_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Scope Id" }
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Scope" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions": {
"post": {
"tags": ["scopes"],
"summary": "Add Sessions To Scope",
"description": "Add Sessions to a Scope.\n\nAll named sessions must already exist (404 otherwise). Adding a session that\nis already a member is a no-op. List the resulting membership with\n`POST /scopes/{scope_id}/sessions/list`.\n\nNote: any added session that already has messages triggers an asynchronous\nbackfill-by-copy of its existing documents into the scope; track progress\nvia ``GET /scopes/{scope_id}/status``.",
"operationId": "add_sessions_to_scope_v3_workspaces__workspace_id__scopes__scope_id__sessions_post",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "workspace_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Workspace Id" }
},
{
"name": "scope_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Scope Id" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScopeSessionsAdd",
"description": "IDs of the sessions to add to the scope"
}
}
}
},
"responses": {
"204": { "description": "Successful Response" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/{session_id}": {
"delete": {
"tags": ["scopes"],
"summary": "Remove Session From Scope",
"description": "Remove a Session from a Scope.\n\nNote: documents copied/derived while the session was a member are\nreconciled asynchronously \u2014 the session's explicit copies are soft-deleted\nfrom the scope, dependent derived documents follow (fail-closed), and the\nscope's card is rebuilt from the remaining evidence.",
"operationId": "remove_session_from_scope_v3_workspaces__workspace_id__scopes__scope_id__sessions__session_id__delete",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "workspace_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Workspace Id" }
},
{
"name": "scope_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Scope Id" }
},
{
"name": "session_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Session Id" }
}
],
"responses": {
"204": { "description": "Successful Response" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/list": {
"post": {
"tags": ["scopes"],
"summary": "Get Scope Sessions",
"description": "Get the Sessions that are members of a Scope, paginated.\n\nOrdered by how long each session has been a member: longest-standing member\nfirst, or most recently added first when `reverse` is true.",
"operationId": "get_scope_sessions_v3_workspaces__workspace_id__scopes__scope_id__sessions_list_post",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "workspace_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Workspace Id" }
},
{
"name": "scope_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Scope Id" }
},
{
"name": "reverse",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"description": "Whether to reverse the order of results",
"default": false,
"title": "Reverse"
},
"description": "Whether to reverse the order of results"
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Page_Session_" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/v3/workspaces/{workspace_id}/scopes/{scope_id}/status": {
"get": {
"tags": ["scopes"],
"summary": "Get Scope Status",
"description": "Get the backfill/reconciliation job status for a Scope.\n\nReturns a per-session map of the backfill job state (pending / completed /\nfailed) with the number of documents copied once complete. Empty when no\nbackfill has ever been enqueued for the scope.",
"operationId": "get_scope_status_v3_workspaces__workspace_id__scopes__scope_id__status_get",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "workspace_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Workspace Id" }
},
{
"name": "scope_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Scope Id" }
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ScopeStatus" }
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/v3/workspaces/{workspace_id}/conclusions": {
"post": {
"tags": ["conclusions"],
@ -2930,6 +3267,20 @@
"title": "Filters",
"description": "Optional filters to scope recall. This endpoint supports only the 'session_id' key: a session id, a list of session ids, or {\"in\": [...]}. Recall (conclusions and messages) is restricted to the allowlist; unsupported keys are rejected. When session_id is also set, it must be included in the allowlist."
},
"scope": {
"anyOf": [
{ "type": "string" },
{
"items": { "type": "string" },
"type": "array",
"maxItems": 100,
"minItems": 1
},
{ "type": "null" }
],
"title": "Scope",
"description": "Optional (unprefixed) scope name(s) to confine recall. A single scope answers from the scope's own representation of the target peer: conclusion recall is confined to what the scope observed and message recall to the scope's member sessions. A list of scopes restricts recall to the union of the scopes' member sessions (explicit allowlist, fail-closed: an empty union recalls nothing). Mutually exclusive with `filters` and `session_id`. Requires a workspace- or admin-level key."
},
"target": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Target",
@ -3187,6 +3538,22 @@
"required": ["items", "total", "page", "size", "pages"],
"title": "Page[Peer]"
},
"Page_Scope_": {
"properties": {
"items": {
"items": { "$ref": "#/components/schemas/Scope" },
"type": "array",
"title": "Items"
},
"total": { "type": "integer", "minimum": 0.0, "title": "Total" },
"page": { "type": "integer", "minimum": 1.0, "title": "Page" },
"size": { "type": "integer", "minimum": 1.0, "title": "Size" },
"pages": { "type": "integer", "minimum": 0.0, "title": "Pages" }
},
"type": "object",
"required": ["items", "total", "page", "size", "pages"],
"title": "Page[Scope]"
},
"Page_Session_": {
"properties": {
"items": {
@ -3369,6 +3736,14 @@
{ "type": "null" }
],
"title": "Filters"
},
"kind": {
"anyOf": [
{ "type": "string", "enum": ["scope", "all"] },
{ "type": "null" }
],
"title": "Kind",
"description": "Which kinds of peers to list. Omitted (default): regular peers only (scope peers are excluded). 'scope': scope peers only. 'all': every peer."
}
},
"type": "object",
@ -3389,6 +3764,20 @@
"title": "Filters",
"description": "Optional filters to scope the representation. This endpoint supports only the 'session_id' key: a session id, a list of session ids, or {\"in\": [...]}. When session_id is also set, it must be included in the allowlist."
},
"scope": {
"anyOf": [
{ "type": "string" },
{
"items": { "type": "string" },
"type": "array",
"maxItems": 100,
"minItems": 1
},
{ "type": "null" }
],
"title": "Scope",
"description": "Optional (unprefixed) scope name(s) to confine the representation. A single scope reads the scope's own representation of the target peer, formed only from the scope's member sessions. A list of scopes restricts the representation to conclusions from the union of the scopes' member sessions (explicit allowlist, fail-closed: an empty union yields an empty representation). Mutually exclusive with `filters` and `session_id`. Requires a workspace- or admin-level key."
},
"target": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Target",
@ -3555,6 +3944,72 @@
"required": ["observer", "dream_type"],
"title": "ScheduleDreamRequest"
},
"Scope": {
"properties": {
"id": { "type": "string", "title": "Id" },
"metadata": {
"additionalProperties": true,
"type": "object",
"title": "Metadata"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
}
},
"type": "object",
"required": ["id", "created_at"],
"title": "Scope",
"description": "Scope response \u2014 external view of the peer backing a scope.\n\nThe ``id`` is the unprefixed scope name; the reserved peer-name prefix is\nan internal implementation detail and never surfaces here."
},
"ScopeCreate": {
"properties": {
"id": { "type": "string", "minLength": 1, "title": "Id" },
"metadata": {
"anyOf": [
{ "additionalProperties": true, "type": "object" },
{ "type": "null" }
],
"title": "Metadata"
}
},
"type": "object",
"required": ["id"],
"title": "ScopeCreate",
"description": "Schema for creating (or getting) a scope by its unprefixed name."
},
"ScopeSessionsAdd": {
"properties": {
"session_ids": {
"items": { "type": "string" },
"type": "array",
"maxItems": 100,
"minItems": 1,
"title": "Session Ids",
"description": "IDs of existing sessions to add to the scope"
}
},
"type": "object",
"required": ["session_ids"],
"title": "ScopeSessionsAdd",
"description": "Schema for adding sessions to a scope."
},
"ScopeStatus": {
"properties": {
"backfill_status": {
"additionalProperties": {
"additionalProperties": true,
"type": "object"
},
"type": "object",
"title": "Backfill Status"
}
},
"type": "object",
"title": "ScopeStatus",
"description": "Per-session backfill/reconciliation job status for a scope.\n\n``backfill_status`` maps each session that has had a backfill enqueued to\nits current job state: ``{state, updated_at[, docs_copied]}`` where\n``state`` is ``pending``/``completed``/``failed`` and ``docs_copied`` is\npresent once a backfill completes."
},
"Session": {
"properties": {
"id": { "type": "string", "title": "Id" },
@ -3689,6 +4144,18 @@
{ "$ref": "#/components/schemas/SessionConfiguration" },
{ "type": "null" }
]
},
"scopes": {
"anyOf": [
{
"items": { "type": "string" },
"type": "array",
"maxItems": 100
},
{ "type": "null" }
],
"title": "Scopes",
"description": "Optional list of (unprefixed) scope names to add this session to. Each scope is created if it does not exist yet. If the session already has messages, its existing documents are backfilled into the scope asynchronously."
}
},
"type": "object",

2
harness-plugin-core/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
dist

View File

@ -0,0 +1,10 @@
# Changelog
All notable changes to `@honcho-ai/harness-plugin-core` will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
This package versions independently of the Honcho API, `@honcho-ai/sdk`, and host plugins.
## [Unreleased]

View File

@ -0,0 +1,72 @@
# @honcho-ai/harness-plugin-core
Shared runtime for Honcho harness plugins.
```ts
import { loadConfig, resolveConfig } from '@honcho-ai/harness-plugin-core'
const cfg = loadConfig({ host: 'harness' })
// a harness can pass its plugin config as an overlay of the same six keys:
const cfg = resolveConfig(file, { host: 'harness', overlay: { workspace: 'harness', auth: { apiKey } } })
```
Locally: `"@honcho-ai/harness-plugin-core": "file:../harness-plugin-core"` (bun imports the TypeScript source).
## File shape
```json
{
"schemaVersion": 1,
"peerName": "user",
"workspace": "honcho",
"baseUrl": "https://api.honcho.dev",
"timeoutMs": 30000,
"auth": { "apiKey": "${HONCHO_API_KEY}" },
"enabled": true,
"hosts": {
"test": { "workspace": "test" }
}
}
```
Missing `schemaVersion` is 0. On read, v0 keys (`environmentUrl`, `workspaceId`, top-level `apiKey`) are remapped in memory; the file is not rewritten.
Resolution, highest wins: `HONCHO_*` env → overlay → `hosts.<host>` → root → built-in.
A host block may override the same six fields.
Built-ins: `baseUrl = https://api.honcho.dev`, `timeoutMs = 30000`, `enabled = true`, `peerName = $USER`, `workspace` falls back to the host name. The SDK pins `/v3`; config stores the origin.
## Telemetry headers
Pass `telemetryHeaders()` as the SDK's `defaultHeaders`. Arbitrary headers are accepted by both the SDK and the Honcho API; missing identity fields are omitted.
| Header | Meaning | Example |
|---|---|---|
| `X-Honcho-Host` | Host harness, `name/version (platform)` | `harness/2.1.3 (darwin)` |
| `X-Honcho-Plugin` | Honcho integration, `name/version` | `harness-honcho/0.2.11` |
| `X-Honcho-Agent-Model` | The agent's completion model, not a Honcho model | `claude-sonnet-4-5` |
Omit `hostVersion` when the harness does not expose it; `platform` defaults to `process.platform`.
```ts
import { Honcho } from '@honcho-ai/sdk'
import { loadConfig, setTelemetryHeaders, telemetryHeaders } from '@honcho-ai/harness-plugin-core'
const cfg = loadConfig({ host: 'harness' })
const honcho = new Honcho({
apiKey: cfg.apiKey,
baseURL: cfg.baseUrl,
workspaceId: cfg.workspace,
timeout: cfg.timeoutMs,
defaultHeaders: telemetryHeaders({
host: 'harness',
hostVersion: '1.3.13',
plugin: 'harness-honcho',
pluginVersion: '0.1.3',
model: 'claude-sonnet-4-5',
}),
})
setTelemetryHeaders(honcho.http.defaultHeaders, { model: 'claude-opus-4' })
```

View File

@ -0,0 +1,25 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@honcho-ai/harness-plugin-core",
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^24.0.1",
"typescript": "^5.0.0",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
"@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="],
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

View File

@ -0,0 +1,31 @@
{
"name": "@honcho-ai/harness-plugin-core",
"version": "0.1.0",
"description": "Shared runtime for Honcho harness plugins",
"author": "Plastic Labs <hello@plasticlabs.ai>",
"license": "MIT",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"files": [
"src",
"README.md",
"CHANGELOG.md"
],
"repository": {
"type": "git",
"url": "git+https://github.com/plastic-labs/honcho.git",
"directory": "harness-plugin-core"
},
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^24.0.1",
"typescript": "^5.0.0"
}
}

View File

@ -0,0 +1,249 @@
import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
export interface AuthConfig {
apiKey?: string
oauth?: { accessToken?: string; refreshToken?: string; expiresAt?: string }
}
/** Identity + connection + kill switch. Valid at root and as a host override. */
export interface RootConfig {
peerName?: string
workspace?: string
baseUrl?: string
timeoutMs?: number
auth?: AuthConfig
enabled?: boolean
}
export type HostBlock = RootConfig
export interface FileConfig extends RootConfig {
schemaVersion?: number
hosts?: Record<string, HostBlock>
}
export interface ResolvedConfig {
host: string
peerName: string
workspace: string
baseUrl: string
timeoutMs: number
auth: AuthConfig
apiKey?: string
enabled: boolean
warnings: string[]
}
export const DEFAULT_BASE_URL = 'https://api.honcho.dev'
export const DEFAULT_TIMEOUT_MS = 30_000
export const CONFIG_SCHEMA_VERSION = 1
function isObj(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === 'object' && !Array.isArray(v)
}
/** Pre-schema files (no schemaVersion) → v1 keys. Host blocks included. */
function migrate(file: unknown): Record<string, unknown> {
if (!isObj(file)) return {}
const v = file.schemaVersion
if (typeof v === 'number' && v >= CONFIG_SCHEMA_VERSION) return { ...file }
const out: Record<string, unknown> = { ...file }
const blocks: Record<string, unknown>[] = [out]
if (isObj(out.hosts)) {
out.hosts = Object.fromEntries(
Object.entries(out.hosts).map(([k, block]) => {
if (!isObj(block)) return [k, block]
const next = { ...block }
blocks.push(next)
return [k, next]
})
)
}
for (const b of blocks) {
if (typeof b.baseUrl !== 'string') {
if (typeof b.environmentUrl === 'string') b.baseUrl = b.environmentUrl
else if (isObj(b.endpoint) && typeof b.endpoint.baseUrl === 'string') {
b.baseUrl = b.endpoint.baseUrl
}
}
if (typeof b.workspace !== 'string' && typeof b.workspaceId === 'string') {
b.workspace = b.workspaceId
}
const auth: Record<string, unknown> = isObj(b.auth) ? { ...b.auth } : {}
if (typeof auth.apiKey !== 'string' && typeof b.apiKey === 'string') auth.apiKey = b.apiKey
if (!isObj(auth.oauth) && isObj(b.oauth)) auth.oauth = b.oauth
if (Object.keys(auth).length) b.auth = auth
delete b.environmentUrl
delete b.endpoint
delete b.workspaceId
delete b.apiKey
delete b.oauth
}
out.schemaVersion = 1
return out
}
function merge<T>(base: T, over: unknown): T {
if (over === undefined || over === null) return base
if (Array.isArray(over) || !isObj(over)) return over as T
const out: Record<string, unknown> = { ...(isObj(base) ? base : {}) }
for (const [k, v] of Object.entries(over)) {
if (v !== undefined) out[k] = k in out ? merge(out[k], v) : v
}
return out as T
}
/** Make a value safe to pass to the SDK as `baseURL`. */
export function normalizeBaseUrl(input: string): string {
let s = input.trim()
if (!s) return s
if (!s.startsWith('http://') && !s.startsWith('https://')) {
const host = s.split('/')[0].split(':')[0].toLowerCase()
const local = host === 'localhost' || host === '127.0.0.1' || host === '::1'
s = `${local ? 'http' : 'https'}://${s}`
}
try {
const u = new URL(s)
u.hostname = u.hostname.toLowerCase()
const path = u.pathname === '/' ? '' : u.pathname.replace(/\/+$/, '')
return `${u.protocol}//${u.host}${path}`
} catch {
return s
}
}
function interpolate(value: string, env: NodeJS.Dict<string>, warnings: string[]): string {
return value.replace(/\$\{([^}]+)\}/g, (m, name: string) => {
const v = env[name]
if (!v) {
warnings.push(`${m} is not set`)
return m
}
return v
})
}
function walkStrings<T>(value: T, fn: (s: string) => string): T {
if (typeof value === 'string') return fn(value) as T
if (Array.isArray(value)) return value.map((x) => walkStrings(x, fn)) as T
if (isObj(value)) {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) out[k] = walkStrings(v, fn)
return out as T
}
return value
}
/** Pull only the six root fields. Extra host keys (injection, observation, …) are ignored. */
function pickRoot(block: unknown): RootConfig {
if (!isObj(block)) return {}
const auth: AuthConfig = isObj(block.auth) ? { ...(block.auth as AuthConfig) } : {}
const out: RootConfig = {}
if (typeof block.peerName === 'string') out.peerName = block.peerName
if (typeof block.workspace === 'string') out.workspace = block.workspace
if (typeof block.baseUrl === 'string') out.baseUrl = block.baseUrl
if (typeof block.timeoutMs === 'number') out.timeoutMs = block.timeoutMs
if (Object.keys(auth).length) out.auth = auth
if (typeof block.enabled === 'boolean') out.enabled = block.enabled
return out
}
function pickHost(hosts: Record<string, unknown> | undefined, name: string): RootConfig {
if (!hosts || !isObj(hosts[name])) return {}
return pickRoot(hosts[name])
}
/**
* Highest wins: HONCHO_* env overlay hosts.<host> root built-in.
*/
export function resolveConfig(
file: unknown,
opts: { host: string; env?: NodeJS.Dict<string>; overlay?: RootConfig }
): ResolvedConfig {
const warnings: string[] = []
const env = opts.env ?? process.env
const host = opts.host
const raw = migrate(file)
if (typeof raw.schemaVersion === 'number' && raw.schemaVersion > CONFIG_SCHEMA_VERSION) {
warnings.push(`config schemaVersion ${raw.schemaVersion} is newer than ${CONFIG_SCHEMA_VERSION}`)
}
const hosts = isObj(raw.hosts) ? raw.hosts : undefined
let acc: RootConfig = {
baseUrl: DEFAULT_BASE_URL,
timeoutMs: DEFAULT_TIMEOUT_MS,
enabled: true,
workspace: host,
}
acc = merge(acc, pickRoot(raw))
acc = merge(acc, pickHost(hosts, host))
acc = merge(acc, pickRoot(opts.overlay))
if (env.HONCHO_API_KEY) {
if (acc.auth?.apiKey) warnings.push('HONCHO_API_KEY shadows auth.apiKey')
acc = merge(acc, { auth: { apiKey: env.HONCHO_API_KEY } })
}
if (env.HONCHO_BASE_URL || env.HONCHO_URL || env.HONCHO_ENDPOINT) {
const token = env.HONCHO_BASE_URL || env.HONCHO_URL || env.HONCHO_ENDPOINT || ''
acc.baseUrl = token === 'local' ? 'http://localhost:8000' : token
}
if (env.HONCHO_WORKSPACE || env.HONCHO_WORKSPACE_ID) {
acc.workspace = env.HONCHO_WORKSPACE || env.HONCHO_WORKSPACE_ID
}
if (env.HONCHO_PEER_NAME) acc.peerName = env.HONCHO_PEER_NAME
if (env.HONCHO_TIMEOUT_MS) {
const n = Number(env.HONCHO_TIMEOUT_MS)
if (Number.isFinite(n) && n > 0) acc.timeoutMs = n
}
if (env.HONCHO_ENABLED === 'false') acc.enabled = false
acc = walkStrings(acc, (s) => interpolate(s, env, warnings))
if (acc.baseUrl) acc.baseUrl = normalizeBaseUrl(acc.baseUrl)
const auth = acc.auth ?? {}
return {
host,
peerName: acc.peerName || env.USER || env.USERNAME || 'user',
workspace: acc.workspace || host,
baseUrl: acc.baseUrl || DEFAULT_BASE_URL,
timeoutMs: acc.timeoutMs && acc.timeoutMs > 0 ? acc.timeoutMs : DEFAULT_TIMEOUT_MS,
auth,
apiKey: auth.apiKey,
enabled: acc.enabled !== false,
warnings,
}
}
/**
* `HONCHO_CONFIG_PATH` if set, returned verbatim. Otherwise `.honcho/config.json`
* under `HOME`, then `USERPROFILE` (Windows), then `os.homedir()`.
*
* `env.HOME` is consulted before `os.homedir()` because Bun's `homedir()` ignores
* in-process changes to `process.env.HOME`, so tests that redirect HOME would
* otherwise read and write the real config file.
*/
export function configPath(env: NodeJS.Dict<string> = process.env): string {
if (env.HONCHO_CONFIG_PATH) return env.HONCHO_CONFIG_PATH
const home = env.HOME || env.USERPROFILE || homedir()
return join(home, '.honcho', 'config.json')
}
export function loadConfig(opts: {
host: string
env?: NodeJS.Dict<string>
overlay?: RootConfig
}): ResolvedConfig {
const env = opts.env ?? process.env
const path = configPath(env)
let file: unknown = {}
if (existsSync(path)) {
try {
file = JSON.parse(readFileSync(path, 'utf-8'))
} catch {
file = {}
}
}
return resolveConfig(file, { ...opts, env })
}

View File

@ -0,0 +1,28 @@
export {
configPath,
loadConfig,
normalizeBaseUrl,
resolveConfig,
DEFAULT_BASE_URL,
DEFAULT_TIMEOUT_MS,
} from './config'
export type {
AuthConfig,
FileConfig,
HostBlock,
ResolvedConfig,
RootConfig,
} from './config'
export {
hostHeaderValue,
pluginHeaderValue,
telemetryHeaders,
setTelemetryHeaders,
HEADER_AGENT_MODEL,
HEADER_HOST,
HEADER_PLUGIN,
} from './telemetry'
export type { TelemetryIdentity } from './telemetry'

View File

@ -0,0 +1,80 @@
/** Optional identity a host plugin knows at Honcho-client construction time. */
export interface TelemetryIdentity {
/** Host harness name, e.g. `harness`. */
host?: string
/** Host harness version, e.g. `2.1.3`. Omit when the harness does not expose it. */
hostVersion?: string
/** OS platform. Defaults to `process.platform`. */
platform?: string
/** Integration (plugin) name, e.g. `harness-honcho`. */
plugin?: string
/** Integration version, e.g. `0.2.11`. */
pluginVersion?: string
/** Agent completion model, e.g. `claude-sonnet-4-5`. Not a Honcho deriver/dialectic model. */
model?: string
}
export const HEADER_HOST = 'X-Honcho-Host'
export const HEADER_PLUGIN = 'X-Honcho-Plugin'
export const HEADER_AGENT_MODEL = 'X-Honcho-Agent-Model'
function sanitize(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const s = value.replace(/[\r\n]+/g, ' ').trim()
return s || undefined
}
/** A `name/version` product token. Characters that would break parsing become `-`. */
function token(name: unknown, ver: unknown): string | undefined {
const clean = (v: unknown) => sanitize(v)?.replace(/[\s()/;]+/g, '-')
const n = clean(name)
const v = clean(ver)
if (n && v) return `${n}/${v}`
return n || v
}
/** `X-Honcho-Host` value: `harness/2.1.3 (darwin)`. Undefined when the host is unknown. */
export function hostHeaderValue(id: TelemetryIdentity = {}): string | undefined {
const host = token(id.host, id.hostVersion)
if (!host) return undefined
const platform = token(id.platform ?? process.platform, undefined)
return platform ? `${host} (${platform})` : host
}
/** `X-Honcho-Plugin` value: `harness-honcho/0.2.11`. Undefined when the plugin is unknown. */
export function pluginHeaderValue(id: TelemetryIdentity = {}): string | undefined {
return token(id.plugin, id.pluginVersion)
}
/**
* Headers to pass as the SDK's `defaultHeaders`. Fields are omitted when unknown, so a
* partial identity (e.g. just `model`) only touches the headers it names.
*/
export function telemetryHeaders(
id: TelemetryIdentity = {},
extra?: Record<string, string>
): Record<string, string> {
const headers: Record<string, string> = {}
const host = hostHeaderValue(id)
const plugin = pluginHeaderValue(id)
const model = sanitize(id.model)
if (host) headers[HEADER_HOST] = host
if (plugin) headers[HEADER_PLUGIN] = plugin
if (model) headers[HEADER_AGENT_MODEL] = model
if (extra) {
for (const [k, v] of Object.entries(extra)) {
const value = sanitize(v)
if (value) headers[k] = value
}
}
return headers
}
/** Merge identity onto a live header map (e.g. `honcho.http.defaultHeaders`). */
export function setTelemetryHeaders(
headers: Record<string, string>,
id: TelemetryIdentity = {},
extra?: Record<string, string>
): Record<string, string> {
return Object.assign(headers, telemetryHeaders(id, extra))
}

View File

@ -0,0 +1,81 @@
import { describe, expect, test } from 'bun:test'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { configPath, normalizeBaseUrl, resolveConfig } from '../src/index'
const emptyEnv = {}
describe('normalizeBaseUrl', () => {
test('adds https and lowercases the host', () => {
expect(normalizeBaseUrl('api.honcho.dev')).toBe('https://api.honcho.dev')
expect(normalizeBaseUrl('API.honcho.dev')).toBe('https://api.honcho.dev')
expect(normalizeBaseUrl('https://api.honcho.dev/')).toBe('https://api.honcho.dev')
})
test('leaves /v3 alone — the SDK owns the API version', () => {
expect(normalizeBaseUrl('https://api.honcho.dev/v3')).toBe('https://api.honcho.dev/v3')
})
test('localhost stays http', () => {
expect(normalizeBaseUrl('localhost:8000')).toBe('http://localhost:8000')
})
})
describe('resolveConfig', () => {
test('host block beats root; env beats host', () => {
const file = {
workspace: 'root-ws',
hosts: { a: { workspace: 'host-ws' } },
}
expect(resolveConfig(file, { host: 'a', env: emptyEnv }).workspace).toBe('host-ws')
expect(
resolveConfig(file, { host: 'a', env: { HONCHO_WORKSPACE: 'env-ws' } }).workspace
).toBe('env-ws')
})
test('root apiKey / workspaceId aliases still resolve', () => {
const cfg = resolveConfig(
{ apiKey: 'hch_x', workspaceId: 'from-id' },
{ host: 'a', env: emptyEnv }
)
expect(cfg.apiKey).toBe('hch_x')
expect(cfg.workspace).toBe('from-id')
})
test('v1 leftover environmentUrl is ignored', () => {
const cfg = resolveConfig(
{ schemaVersion: 1, baseUrl: 'https://keep.example', environmentUrl: 'https://old.example' },
{ host: 'a', env: emptyEnv }
)
expect(cfg.baseUrl).toBe('https://keep.example')
})
test('overlay sits below env', () => {
expect(
resolveConfig(
{},
{ host: 'a', overlay: { workspace: 'from-overlay' }, env: { HONCHO_WORKSPACE: 'from-env' } }
).workspace
).toBe('from-env')
expect(
resolveConfig({}, { host: 'a', overlay: { workspace: 'from-overlay' }, env: emptyEnv }).workspace
).toBe('from-overlay')
})
test('empty file uses built-ins; host name is not rewritten', () => {
const cfg = resolveConfig({}, { host: 'my-host', env: emptyEnv })
expect(cfg.baseUrl).toBe('https://api.honcho.dev')
expect(cfg.timeoutMs).toBe(30_000)
expect(cfg.enabled).toBe(true)
expect(cfg.host).toBe('my-host')
expect(cfg.workspace).toBe('my-host')
})
})
describe('configPath', () => {
test('HONCHO_CONFIG_PATH, then $HOME, then os.homedir()', () => {
expect(configPath({ HONCHO_CONFIG_PATH: '/x/cfg.json', HOME: '/h' })).toBe('/x/cfg.json')
expect(configPath({ HOME: '/scratch' })).toBe('/scratch/.honcho/config.json')
expect(configPath({})).toBe(join(homedir(), '.honcho', 'config.json'))
})
})

View File

@ -0,0 +1,56 @@
import { describe, expect, test } from 'bun:test'
import {
HEADER_AGENT_MODEL,
HEADER_HOST,
HEADER_PLUGIN,
setTelemetryHeaders,
telemetryHeaders,
} from '../src/index'
describe('telemetryHeaders', () => {
test('maps identity to the three headers', () => {
const headers = telemetryHeaders({
host: 'harness',
hostVersion: '2.1.3',
platform: 'darwin',
plugin: 'harness-honcho',
pluginVersion: '0.2.11',
model: 'claude-sonnet-4-5',
})
expect(headers).toEqual({
[HEADER_HOST]: 'harness/2.1.3 (darwin)',
[HEADER_PLUGIN]: 'harness-honcho/0.2.11',
[HEADER_AGENT_MODEL]: 'claude-sonnet-4-5',
})
})
test('omits unknown fields and defaults platform', () => {
expect(telemetryHeaders()).toEqual({})
expect(telemetryHeaders({ host: 'harness' })).toEqual({
[HEADER_HOST]: `harness (${process.platform})`,
})
})
test('strips separators that would break parsing', () => {
expect(telemetryHeaders({ host: 'a b;(c)/d', hostVersion: '1\r\n2', platform: 'darwin' })).toEqual({
[HEADER_HOST]: 'a-b-c-d/1-2 (darwin)',
})
})
test('extra headers win, blanks are dropped', () => {
const headers = telemetryHeaders({ plugin: 'harness-honcho' }, {
[HEADER_PLUGIN]: 'override',
'X-Empty': ' ',
})
expect(headers).toEqual({ [HEADER_PLUGIN]: 'override' })
})
})
test('setTelemetryHeaders updates only the named fields in place', () => {
const headers = telemetryHeaders({ plugin: 'harness-honcho', pluginVersion: '0.1.2' })
expect(setTelemetryHeaders(headers, { model: 'claude-opus-4' })).toBe(headers)
expect(headers).toEqual({
[HEADER_PLUGIN]: 'harness-honcho/0.1.2',
[HEADER_AGENT_MODEL]: 'claude-opus-4',
})
})

View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

7
mcp/.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
.wrangler
.dev.vars
.env
.env.*
*.log
dist

19
mcp/Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM oven/bun:1.2
WORKDIR /app
RUN chown bun:bun /app
USER bun
COPY --chown=bun:bun package.json bun.lock bunfig.toml tsconfig.json ./
COPY --chown=bun:bun instructions.md ./
COPY --chown=bun:bun src ./src
RUN bun install --frozen-lockfile --production
EXPOSE 3000
ENV PORT=3000
ENV HOST=0.0.0.0
CMD ["bun", "src/http.ts"]

View File

@ -1,6 +1,6 @@
# Honcho MCP Server
A Cloudflare Worker that implements the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) for [Honcho](https://honcho.dev), providing AI memory and personalization tools to LLM clients like Claude Desktop.
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for [Honcho](https://honcho.dev). The hosted path is a Cloudflare Worker; the same tools also run over stdio and over Streamable HTTP (`bun src/http.ts`) for Docker and other long-lived process hosts.
## Quickstart: Use the Hosted Server
@ -45,6 +45,8 @@ Every workspace-scoped tool takes a `workspace_id` argument. If you set `X-Honch
```
src/
index.ts # Worker entry point — parse config, delegate to MCP handler
stdio.ts # Local stdio host (bun src/stdio.ts)
http.ts # Streamable HTTP host (bun src/http.ts / Docker)
server.ts # createServer() — registers all tools on an McpServer
config.ts # HonchoConfig, parseConfig(), createClientFactory()
types.ts # ToolContext, result helpers
@ -64,25 +66,75 @@ Built on:
## Self-Hosted Honcho
If you run Honcho yourself (for privacy, latency, or offline use), deploy the
MCP Worker alongside your instance and set `HONCHO_API_URL` in its
environment.
If you run Honcho yourself, point this server at it with `HONCHO_API_URL`.
When unset, requests go to `https://api.honcho.dev`.
**Local dev (`bun run dev`):** create `mcp/.dev.vars`:
**Cloudflare Worker (`bun run dev` / `bun run deploy`):** create `mcp/.dev.vars`:
```
HONCHO_API_URL=http://127.0.0.1:28000
```
**Deployed Worker:**
For a deployed Worker: `wrangler secret put HONCHO_API_URL`.
## HTTP host
For Docker or any platform that runs a long-lived process, use the Streamable
HTTP entry instead of the Worker. Clients keep the same `mcp-remote` shape as
`https://mcp.honcho.dev`. Sessions live in process memory — run one instance.
```bash
wrangler secret put HONCHO_API_URL
# paste your URL when prompted
cd mcp && bun install
HONCHO_API_URL=http://127.0.0.1:8000 bun run http
```
When `HONCHO_API_URL` is unset the Worker routes to `https://api.honcho.dev`,
so this change is backward-compatible.
```bash
bunx mcp-remote http://127.0.0.1:3000 \
--header "Authorization:Bearer <key>"
```
Auth is the `Authorization: Bearer` header (same as the Worker). Established
sessions still require that same bearer. Optional `X-Honcho-Workspace-ID`
fills `workspace_id` when the tool argument is omitted.
`HOST` defaults to `0.0.0.0`, `PORT` to `3000`. `GET /health` is unauthenticated.
MCP is served at `/` and `/mcp`. Idle sessions expire after
`MCP_SESSION_IDLE_MS` (default 30 minutes); `MCP_SESSION_MAX` (default 128)
caps concurrent sessions.
A platform start command is `bun src/http.ts` (or `bun run http` from `mcp/`).
This repo does not ship a `vercel.json`; serverless replicas do not share the
in-memory session map.
### Docker
```bash
docker build -f mcp/Dockerfile -t honcho-mcp mcp
docker run --rm -p 3000:3000 \
-e HONCHO_API_URL=http://host.docker.internal:8000 \
honcho-mcp
```
`docker-compose.yml.example` includes an `mcp` service beside `api` and
`deriver` (`HONCHO_API_URL=http://api:8000`, port `127.0.0.1:3000`).
## Local stdio
For a local Honcho instance, or any MCP client that spawns a process, run the
stdio host. `--cwd` loads `mcp/bunfig.toml` (Markdown loader) from this package.
```bash
cd mcp && bun install
claude mcp add honcho \
-e HONCHO_API_KEY=hch-your-key-here \
-e HONCHO_API_URL=http://127.0.0.1:28000 \
-e HONCHO_WORKSPACE_ID=my-workspace \
-- bun --cwd "$(pwd)" src/stdio.ts
```
`HONCHO_API_URL` defaults to `https://api.honcho.dev`. `HONCHO_WORKSPACE_ID` is
optional; without it, pass `workspace_id` on each tool call.
## Development
@ -106,6 +158,8 @@ bun run tsc --noEmit
### Test locally
Worker (`bun dev`, port 8787) or HTTP host (`bun run http`, port 3000):
```bash
bunx mcp-remote http://localhost:8787 \
--header "Authorization:Bearer <key>"

5
mcp/bunfig.toml Normal file
View File

@ -0,0 +1,5 @@
[loader]
".md" = "text"
[run]
silent = true

View File

@ -1,7 +1,7 @@
{
"name": "honcho-mcp",
"version": "3.0.0",
"description": "Honcho MCP Server — Cloudflare Worker",
"description": "Honcho MCP Server",
"main": "src/index.ts",
"packageManager": "bun@1.2.0",
"engines": {
@ -11,6 +11,8 @@
"scripts": {
"preinstall": "node -e \"const ua=process.env.npm_config_user_agent||'';if(ua.includes('npm')&&!ua.includes('bun')){console.error('❌ Please use bun instead of npm!\\n📦 Run: bun install\\n🌐 Install bun: https://bun.sh/');process.exit(1)}\"",
"dev": "wrangler dev",
"stdio": "bun src/stdio.ts",
"http": "bun src/http.ts",
"deploy": "wrangler deploy",
"deploy:staging": "wrangler deploy --env staging"
},

View File

@ -3,7 +3,7 @@ import { Honcho } from "@honcho-ai/sdk";
export interface HonchoConfig {
apiKey: string;
baseUrl: string;
/** From X-Honcho-Workspace-ID when set. */
/** From X-Honcho-Workspace-ID (HTTP) or HONCHO_WORKSPACE_ID (stdio). */
workspaceId?: string;
}
@ -12,6 +12,12 @@ export interface Env {
ALERT_WEBHOOK_URL?: string;
}
export interface EnvConfig {
HONCHO_API_KEY?: string;
HONCHO_API_URL?: string;
HONCHO_WORKSPACE_ID?: string;
}
/**
* Parse configuration from request headers and Worker env bindings.
* Throws only when the Authorization bearer token is missing/empty.
@ -48,8 +54,23 @@ export function parseConfig(request: Request, env: Env = {}): HonchoConfig {
};
}
/** Parse configuration from process env. */
export function parseEnvConfig(env: EnvConfig): HonchoConfig {
const apiKey = env.HONCHO_API_KEY?.trim();
if (!apiKey) {
throw new Error(
"Missing HONCHO_API_KEY. Set HONCHO_API_KEY to your Honcho API key.",
);
}
return {
apiKey,
baseUrl: env.HONCHO_API_URL?.trim() || "https://api.honcho.dev",
workspaceId: env.HONCHO_WORKSPACE_ID?.trim() || undefined,
};
}
export const MISSING_WORKSPACE_ID_MESSAGE =
"Missing workspace_id. Pass workspace_id on the next tool call, or set the X-Honcho-Workspace-ID header on the connection so it is used automatically.";
"Missing workspace_id. Pass workspace_id on the next tool call, or set X-Honcho-Workspace-ID (HTTP) / HONCHO_WORKSPACE_ID (stdio).";
export function resolveWorkspaceId(
config: HonchoConfig,

56
mcp/src/http.test.ts Normal file
View File

@ -0,0 +1,56 @@
import { expect, test } from "bun:test";
import { fetch } from "./http.ts";
const origin = "http://127.0.0.1:3000";
const initializeBody = {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test", version: "0.0.0" },
},
};
const pingBody = { jsonrpc: "2.0", id: 2, method: "ping" };
function mcpPost(headers: Record<string, string>, body: unknown) {
return fetch(
new Request(`${origin}/mcp`, {
method: "POST",
headers: {
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
...headers,
},
body: JSON.stringify(body),
}),
);
}
test("established sessions require the initialize bearer", async () => {
const init = await mcpPost(
{ Authorization: "Bearer key-a" },
initializeBody,
);
expect(init.status).toBe(200);
const sessionId = init.headers.get("mcp-session-id");
expect(sessionId).toBeTruthy();
const missing = await mcpPost({ "mcp-session-id": sessionId! }, pingBody);
expect(missing.status).toBe(401);
const wrong = await mcpPost(
{ Authorization: "Bearer key-b", "mcp-session-id": sessionId! },
pingBody,
);
expect(wrong.status).toBe(401);
const ok = await mcpPost(
{ Authorization: "Bearer key-a", "mcp-session-id": sessionId! },
pingBody,
);
expect(ok.status).toBe(200);
});

281
mcp/src/http.ts Normal file
View File

@ -0,0 +1,281 @@
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import {
createClientFactory,
createUnscopedClient,
parseConfig,
type Env,
type HonchoConfig,
} from "./config.js";
import { createServer } from "./server.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
declare const process: {
env: Record<string, string | undefined>;
};
declare const Bun: {
serve(options: {
hostname: string;
port: number;
fetch(request: Request): Response | Promise<Response>;
}): { hostname: string; port: number };
};
const CORS_ORIGIN = "*";
const CORS_METHODS = "GET, POST, DELETE, OPTIONS";
const CORS_ALLOWED_HEADERS =
"Content-Type, Authorization, X-Honcho-Workspace-ID, mcp-session-id, mcp-protocol-version, last-event-id";
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": CORS_METHODS,
"Access-Control-Allow-Headers": CORS_ALLOWED_HEADERS,
"Access-Control-Expose-Headers": "WWW-Authenticate, mcp-session-id",
};
const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
const MCP_PATHS = new Set(["/", "/mcp"]);
type Session = {
transport: WebStandardStreamableHTTPServerTransport;
server: McpServer;
lastSeen: number;
apiKey: string;
};
const sessions = new Map<string, Session>();
const DEFAULT_SESSION_IDLE_MS = 30 * 60 * 1000;
const DEFAULT_SESSION_MAX = 128;
function envInt(name: string, fallback: number): number {
const n = Number(process.env[name]);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
function dropSession(id: string): void {
const session = sessions.get(id);
if (!session) return;
sessions.delete(id);
void session.transport.close();
void session.server.close();
}
function sweepSessions(): void {
const idleMs = envInt("MCP_SESSION_IDLE_MS", DEFAULT_SESSION_IDLE_MS);
const now = Date.now();
for (const [id, session] of sessions) {
if (now - session.lastSeen > idleMs) dropSession(id);
}
}
function envBindings(): Env {
return { HONCHO_API_URL: process.env.HONCHO_API_URL };
}
function authorizationServer(): string {
return process.env.HONCHO_API_URL?.trim() || "https://api.honcho.dev";
}
function withCors(response: Response): Response {
const headers = new Headers(response.headers);
for (const [key, value] of Object.entries(CORS_HEADERS)) {
headers.set(key, value);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
function jsonResponse(
body: unknown,
status: number,
extraHeaders?: Record<string, string>,
): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
...extraHeaders,
},
});
}
function configForRequest(request: Request) {
return parseConfig(request, envBindings());
}
function configOrUnauthorized(request: Request): HonchoConfig | Response {
try {
return configForRequest(request);
} catch (e) {
const message = e instanceof Error ? e.message : "Invalid request";
return unauthorized(request, message);
}
}
function unauthorized(request: Request, message: string): Response {
const resourceMetadata = `${new URL(request.url).origin}${PROTECTED_RESOURCE_PATH}`;
return jsonResponse(
{ error: message },
401,
{
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadata}"`,
},
);
}
async function handleMcp(request: Request): Promise<Response> {
sweepSessions();
const sessionId = request.headers.get("mcp-session-id");
if (sessionId) {
const existing = sessions.get(sessionId);
if (existing) {
const config = configOrUnauthorized(request);
if (config instanceof Response) return config;
if (config.apiKey !== existing.apiKey) {
return unauthorized(
request,
"Authorization does not match this session.",
);
}
existing.lastSeen = Date.now();
return withCors(await existing.transport.handleRequest(request));
}
}
if (request.method !== "POST") {
return jsonResponse(
{
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: No valid session ID provided",
},
id: null,
},
400,
);
}
let body: unknown;
try {
body = await request.json();
} catch {
return jsonResponse(
{
jsonrpc: "2.0",
error: { code: -32700, message: "Parse error: Invalid JSON" },
id: null,
},
400,
);
}
const messages = Array.isArray(body) ? body : [body];
if (!messages.some((message) => isInitializeRequest(message))) {
return jsonResponse(
{
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: No valid session ID provided",
},
id: null,
},
400,
);
}
const config = configOrUnauthorized(request);
if (config instanceof Response) return config;
const server = createServer({
config,
clientFor: createClientFactory(config),
unscoped: createUnscopedClient(config),
});
const maxSessions = envInt("MCP_SESSION_MAX", DEFAULT_SESSION_MAX);
if (sessions.size >= maxSessions) {
return jsonResponse(
{
jsonrpc: "2.0",
error: {
code: -32000,
message: "Too many active sessions",
},
id: null,
},
503,
);
}
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (id) => {
sessions.set(id, {
transport,
server,
lastSeen: Date.now(),
apiKey: config.apiKey,
});
},
});
transport.onclose = () => {
const id = transport.sessionId;
if (id) sessions.delete(id);
};
await server.connect(transport);
return withCors(
await transport.handleRequest(request, { parsedBody: body }),
);
}
export async function fetch(request: Request): Promise<Response> {
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
const pathname = new URL(request.url).pathname;
if (pathname === "/health") {
return jsonResponse({ status: "ok" }, 200);
}
if (pathname === PROTECTED_RESOURCE_PATH) {
return jsonResponse(
{
resource: new URL(request.url).origin,
authorization_servers: [authorizationServer()],
bearer_methods_supported: ["header"],
scopes_supported: ["read", "write"],
},
200,
);
}
if (!MCP_PATHS.has(pathname)) {
return jsonResponse({ error: "Not Found" }, 404);
}
try {
return await handleMcp(request);
} catch (e) {
const message =
e instanceof Error ? e.message : "Internal server error";
return jsonResponse({ error: message }, 500);
}
}
const isMain = Boolean((import.meta as { main?: boolean }).main);
if (isMain) {
const hostname = process.env.HOST?.trim() || "0.0.0.0";
const port = Number(process.env.PORT) || 3000;
Bun.serve({ hostname, port, fetch });
console.error(`honcho-mcp listening on http://${hostname}:${port}`);
}

30
mcp/src/stdio.ts Normal file
View File

@ -0,0 +1,30 @@
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
createClientFactory,
createUnscopedClient,
parseEnvConfig,
} from "./config.js";
import { createServer } from "./server.js";
declare const process: {
env: Record<string, string | undefined>;
exit(code?: number): never;
};
try {
const config = parseEnvConfig({
HONCHO_API_KEY: process.env.HONCHO_API_KEY,
HONCHO_API_URL: process.env.HONCHO_API_URL,
HONCHO_WORKSPACE_ID: process.env.HONCHO_WORKSPACE_ID,
});
const server = createServer({
config,
clientFor: createClientFactory(config),
unscoped: createUnscopedClient(config),
});
await server.connect(new StdioServerTransport());
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
console.error(message);
process.exit(1);
}

View File

@ -10,5 +10,5 @@
"types": ["@cloudflare/workers-types"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "src/**/*.test.ts"]
}

View File

@ -1,12 +1,12 @@
[project]
name = "honcho"
version = "3.1.0"
version = "3.1.1"
description = "Honcho Server"
authors = [
{name = "Plastic Labs", email = "hello@plasticlabs.ai"},
]
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.13"
dependencies = [
"fastapi[standard-no-fastapi-cloud-cli]>=0.131.0",
"python-dotenv>=1.0.0",

43
schemas/config/v1.json Normal file
View File

@ -0,0 +1,43 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://spec.honcho.dev/config/v1.json",
"type": "object",
"additionalProperties": true,
"$defs": {
"oauth": {
"type": "object",
"properties": {
"accessToken": { "type": "string" },
"refreshToken": { "type": "string" },
"expiresAt": { "type": "string" }
}
},
"auth": {
"type": "object",
"properties": {
"apiKey": { "type": "string" },
"oauth": { "$ref": "#/$defs/oauth" }
}
},
"hostBlock": {
"type": "object",
"additionalProperties": true,
"properties": {
"peerName": { "type": "string" },
"workspace": { "type": "string" },
"baseUrl": { "type": "string" },
"timeoutMs": { "type": "number" },
"enabled": { "type": "boolean" },
"auth": { "$ref": "#/$defs/auth" }
}
}
},
"allOf": [{ "$ref": "#/$defs/hostBlock" }],
"properties": {
"schemaVersion": { "type": "integer", "const": 1 },
"hosts": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/hostBlock" }
}
}
}

View File

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
- `Session.last_message_at` exposes the newest message timestamp, and sync/async `Honcho.sessions()` accept `sort_by="created_at" | "last_message_at"` while preserving `reverse` across pagination. Requires a Honcho server with the matching API support.
- Optional per-call `timeout` on synchronous and asynchronous `Peer.chat()`. It overrides the timeout for each HTTP attempt; when omitted or set to `None`, the client-wide timeout configured on `Honcho` remains in effect.
## [2.4.0] - 2026-08-25

View File

@ -785,6 +785,7 @@ class PeerAio(AsyncMetadataConfigMixin):
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[TResponseFormat],
timeout: float | None = None,
) -> TResponseFormat | None: ...
@overload
@ -799,6 +800,7 @@ class PeerAio(AsyncMetadataConfigMixin):
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: dict[str, Any] | None = None,
timeout: float | None = None,
) -> str | None: ...
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
@ -813,12 +815,17 @@ class PeerAio(AsyncMetadataConfigMixin):
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[BaseModel] | dict[str, Any] | None = None,
timeout: float | None = Field(
None, gt=0, description="Timeout in seconds for this chat request"
),
) -> BaseModel | str | None:
"""Query the peer's representation asynchronously.
See Peer.chat for parameter details. When response_format is a Pydantic
model class, the answer is parsed into an instance of it; when it is a
JSON Schema dict, the answer is a JSON string.
JSON Schema dict, the answer is a JSON string. When timeout is omitted,
the Honcho client's configured timeout is used; retries can extend total
elapsed time.
"""
await self._peer._honcho._ensure_workspace_async()
target_id = resolve_id(target)
@ -843,6 +850,7 @@ class PeerAio(AsyncMetadataConfigMixin):
data = await self._peer._honcho._async_http_client.post(
routes.peer_chat(self._peer.workspace_id, self._peer.id),
body=body,
timeout=timeout,
)
content = data.get("content")
if not content:

View File

@ -246,6 +246,7 @@ class Peer(PeerBase, MetadataConfigMixin):
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[TResponseFormat],
timeout: float | None = None,
) -> TResponseFormat | None: ...
@overload
@ -260,6 +261,7 @@ class Peer(PeerBase, MetadataConfigMixin):
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: dict[str, Any] | None = None,
timeout: float | None = None,
) -> str | None: ...
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
@ -274,6 +276,9 @@ class Peer(PeerBase, MetadataConfigMixin):
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[BaseModel] | dict[str, Any] | None = None,
timeout: float | None = Field(
None, gt=0, description="Timeout in seconds for this chat request"
),
) -> BaseModel | str | None:
"""
Query the peer's representation with a natural language question.
@ -310,6 +315,9 @@ class Peer(PeerBase, MetadataConfigMixin):
model class to get a parsed instance back, or a raw
JSON Schema dict (root type "object") to get the
answer as a JSON string.
timeout: Optional timeout in seconds for each HTTP attempt made by
this request. When omitted, the Honcho client's configured
timeout is used. Retries can extend total elapsed time.
Returns:
Response string containing the answer (a JSON string when a schema
@ -342,6 +350,7 @@ class Peer(PeerBase, MetadataConfigMixin):
data = self._honcho._http.post(
routes.peer_chat(self.workspace_id, self.id),
body=body,
timeout=timeout,
)
content = data.get("content")
if not content:

146
src/backlog.py Normal file
View File

@ -0,0 +1,146 @@
"""Read-only polling of the deriver's outstanding work. Schedules nothing."""
import asyncio
import contextlib
import time
from dataclasses import dataclass, field
from logging import getLogger
import sentry_sdk
from src import crud, schemas
from src.config import settings
from src.dependencies import tracked_db
from src.dreamer.dream_due import count_due_dreams
from src.telemetry import prometheus_metrics
logger = getLogger(__name__)
def active_work_seconds() -> float:
"""The value reported when work is ready for a deriver now."""
return float(max(settings.DERIVER.REPRESENTATION_BATCH_MAX_AGE_SECONDS, 1))
@dataclass
class DeriverMetricsSnapshot:
"""The last good poll result, served to callers of the route."""
signal_seconds: float = 0.0
dreams_due: int = 0
stats: schemas.DeriverMetrics = field(default_factory=schemas.DeriverMetrics)
measured_at: float | None = None
@property
def age_seconds(self) -> float | None:
if self.measured_at is None:
return None
return max(0.0, time.time() - self.measured_at)
def outstanding_work_seconds(
stats: schemas.DeriverMetrics, *, dreams_due: int
) -> float:
"""Seconds of outstanding deriver work, 0 when there is nothing to do."""
if (
stats.eligible_work_units > 0
or stats.claimed_work_units > 0
or stats.embeddings_pending_due > 0
or dreams_due > 0
):
return active_work_seconds()
if stats.pending_items > 0:
return stats.oldest_pending_age_seconds
return 0.0
class DeriverMetricsPoller:
"""Refreshes the deriver gauges and the cached snapshot on a timer."""
def __init__(self) -> None:
self._task: asyncio.Task[None] | None = None
self._shutdown_event: asyncio.Event = asyncio.Event()
self._snapshot: DeriverMetricsSnapshot = DeriverMetricsSnapshot()
self._next_dream_poll: float | None = None
self._dreams_due: int = 0
@property
def snapshot(self) -> DeriverMetricsSnapshot:
return self._snapshot
async def start(self) -> None:
if self._task is not None:
logger.warning("DeriverMetricsPoller already running")
return
self._shutdown_event.clear()
self._task = asyncio.create_task(self._loop())
logger.info(
"DeriverMetricsPoller started, interval %ss",
settings.DERIVER.BACKLOG_METRICS_POLL_INTERVAL_SECONDS,
)
async def shutdown(self) -> None:
if self._task is None:
return
logger.info("Shutting down DeriverMetricsPoller...")
self._shutdown_event.set()
try:
await asyncio.wait_for(self._task, timeout=5.0)
except TimeoutError:
logger.warning("DeriverMetricsPoller shutdown timed out, cancelling task")
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
logger.info("DeriverMetricsPoller stopped")
async def _loop(self) -> None:
interval = settings.DERIVER.BACKLOG_METRICS_POLL_INTERVAL_SECONDS
while not self._shutdown_event.is_set():
try:
await self.refresh()
except Exception as e:
logger.error("DeriverMetricsPoller refresh failed: %s", e)
if settings.SENTRY.ENABLED:
sentry_sdk.capture_exception(e)
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(self._shutdown_event.wait(), timeout=interval)
async def refresh(self) -> None:
"""One read-only pass. The snapshot only advances on a complete pass."""
async with tracked_db("deriver_metrics", read_only=True) as db:
stats = await crud.get_deriver_metrics(db)
if self._dream_poll_due():
self._dreams_due = await count_due_dreams(db)
self._next_dream_poll = (
time.monotonic() + settings.DREAM.DUE_POLL_INTERVAL_SECONDS
)
signal = outstanding_work_seconds(stats, dreams_due=self._dreams_due)
measured_at = time.time()
self._snapshot = DeriverMetricsSnapshot(
signal_seconds=signal,
dreams_due=self._dreams_due,
stats=stats,
measured_at=measured_at,
)
metrics = prometheus_metrics
metrics.set_deriver_metrics(
eligible_work_units=stats.eligible_work_units,
claimed_work_units=stats.claimed_work_units,
pending_items=stats.pending_items,
oldest_pending_age_seconds=stats.oldest_pending_age_seconds,
embeddings_pending=stats.embeddings_pending,
embeddings_pending_due=stats.embeddings_pending_due,
)
metrics.set_dreams_due(count=self._dreams_due)
metrics.set_deriver_outstanding_work(seconds=signal)
metrics.set_deriver_metrics_last_success(timestamp=measured_at)
def _dream_poll_due(self) -> bool:
"""The dream query is far more expensive, so it runs on its own spacing."""
return (
self._next_dream_poll is None or time.monotonic() >= self._next_dream_poll
)

View File

@ -972,6 +972,8 @@ class DeriverSettings(HonchoSettings):
# When enabled, bypasses the batch token threshold and processes work immediately
FLUSH_ENABLED: bool = False
BACKLOG_METRICS_POLL_INTERVAL_SECONDS: Annotated[int, Field(default=30, ge=1)] = 30
@model_validator(mode="before")
@classmethod
def _merge_model_config_defaults(cls, data: Any) -> Any:
@ -1351,6 +1353,7 @@ class DreamSettings(HonchoSettings):
DOCUMENT_THRESHOLD: Annotated[int, Field(default=50, gt=0, le=1000)] = 50
IDLE_TIMEOUT_MINUTES: Annotated[int, Field(default=60, gt=0, le=1440)] = 60
MIN_HOURS_BETWEEN_DREAMS: Annotated[int, Field(default=8, gt=0, le=72)] = 8
DUE_POLL_INTERVAL_SECONDS: Annotated[int, Field(default=300, ge=1)] = 300
ENABLED_TYPES: list[str] = ["omni"]
# Agent iteration limit - increased for extended reasoning workflow

View File

@ -3,7 +3,11 @@ from .collection import (
get_or_create_collection,
update_collection_internal_metadata,
)
from .deriver import get_deriver_status, get_queue_status
from .deriver import (
get_deriver_metrics,
get_deriver_status,
get_queue_status,
)
from .document import (
CreateDocumentsResult,
create_documents,
@ -105,6 +109,7 @@ __all__ = [
"get_or_create_collection",
"update_collection_internal_metadata",
# Deriver
"get_deriver_metrics",
"get_deriver_status",
"get_queue_status",
# Document

View File

@ -1,15 +1,165 @@
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from logging import getLogger
from typing import Any
from sqlalchemy import Select, case, func, or_, select
from sqlalchemy import ColumnElement, Select, case, func, or_, select
from sqlalchemy.engine import Row
from sqlalchemy.ext.asyncio import AsyncSession
from src import models, schemas
from src.config import settings
logger = getLogger(__name__)
REPRESENTATION_WORK_UNIT_PREFIX = "representation:"
def representation_batch_threshold_clause(
*,
work_unit_key: ColumnElement[str],
total_tokens: ColumnElement[Any],
oldest_created_at: ColumnElement[Any],
) -> ColumnElement[bool] | None:
"""The batch gate a representation work unit passes before it is claimable, or None when no gate applies."""
if settings.DERIVER.FLUSH_ENABLED:
return None
target_tokens = settings.DERIVER.REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS
if target_tokens <= 0:
return None
threshold: ColumnElement[bool] = func.coalesce(total_tokens, 0) >= target_tokens
max_age_seconds = settings.DERIVER.REPRESENTATION_BATCH_MAX_AGE_SECONDS
if max_age_seconds > 0:
threshold = or_(
threshold,
oldest_created_at <= func.now() - timedelta(seconds=max_age_seconds),
)
return or_(
~work_unit_key.startswith(REPRESENTATION_WORK_UNIT_PREFIX),
threshold,
)
def unclaimed_work_unit_clause(
work_unit_key: ColumnElement[str],
) -> ColumnElement[bool]:
"""No claim row exists for this work unit, stale ones included."""
return (
~select(models.ActiveQueueSession.id)
.where(models.ActiveQueueSession.work_unit_key == work_unit_key)
.exists()
)
def stale_claim_cutoff() -> datetime:
return datetime.now(UTC) - timedelta(
minutes=settings.DERIVER.STALE_SESSION_TIMEOUT_MINUTES
)
def not_live_claimed_work_unit_clause(
work_unit_key: ColumnElement[str],
) -> ColumnElement[bool]:
"""No claim refreshed inside the stale timeout exists, so a stale claim leaves its work unit claimable."""
return (
~select(models.ActiveQueueSession.id)
.where(
models.ActiveQueueSession.work_unit_key == work_unit_key,
models.ActiveQueueSession.last_updated >= stale_claim_cutoff(),
)
.exists()
)
async def get_deriver_metrics(db: AsyncSession) -> schemas.DeriverMetrics:
"""Count the outstanding deriver work in the whole database, read-only."""
from src.reconciler.sync_vectors import backoff_eligible # noqa: PLC0415
token_stats = (
select(
models.QueueItem.work_unit_key,
func.sum(models.Message.token_count).label("total_tokens"),
func.min(models.QueueItem.created_at).label("oldest_created_at"),
)
.join(models.Message, models.QueueItem.message_id == models.Message.id)
.where(~models.QueueItem.processed)
.where(
models.QueueItem.work_unit_key.startswith(REPRESENTATION_WORK_UNIT_PREFIX)
)
.group_by(models.QueueItem.work_unit_key)
.subquery()
)
work_units = (
select(models.QueueItem.work_unit_key)
.where(~models.QueueItem.processed)
.group_by(models.QueueItem.work_unit_key)
.subquery()
)
eligible = (
select(func.count())
.select_from(work_units)
.outerjoin(
token_stats,
work_units.c.work_unit_key == token_stats.c.work_unit_key,
)
.where(not_live_claimed_work_unit_clause(work_units.c.work_unit_key))
)
threshold_clause = representation_batch_threshold_clause(
work_unit_key=work_units.c.work_unit_key,
total_tokens=token_stats.c.total_tokens,
oldest_created_at=token_stats.c.oldest_created_at,
)
if threshold_clause is not None:
eligible = eligible.where(threshold_clause)
claimed = (
select(func.count())
.select_from(models.ActiveQueueSession)
.where(models.ActiveQueueSession.last_updated >= stale_claim_cutoff())
)
pending = select(
func.count(models.QueueItem.id),
func.coalesce(
func.extract("epoch", func.now() - func.min(models.QueueItem.created_at)),
0,
),
).where(~models.QueueItem.processed)
embeddings = select(
func.count(),
func.coalesce(
func.sum(
case(
(backoff_eligible(models.MessageEmbedding.last_sync_at), 1),
else_=0,
)
),
0,
),
).where(models.MessageEmbedding.sync_state == "pending")
eligible_count = (await db.execute(eligible)).scalar_one()
claimed_count = (await db.execute(claimed)).scalar_one()
pending_count, oldest_age = (await db.execute(pending)).one()
embeddings_pending, embeddings_due = (await db.execute(embeddings)).one()
return schemas.DeriverMetrics(
eligible_work_units=int(eligible_count),
claimed_work_units=int(claimed_count),
pending_items=int(pending_count),
oldest_pending_age_seconds=float(oldest_age),
embeddings_pending=int(embeddings_pending),
embeddings_pending_due=int(embeddings_due),
)
async def get_queue_status(
db: AsyncSession,

View File

@ -1,13 +1,14 @@
import asyncio
import datetime
from collections.abc import Sequence
from dataclasses import dataclass, field
from enum import Enum
from logging import getLogger
from typing import Any, cast
from typing import Any, Literal, cast
from sqlalchemy import delete, select, update
from sqlalchemy.engine import CursorResult
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import DBAPIError, IntegrityError, SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import Select
from sqlalchemy.sql.functions import func
@ -210,6 +211,24 @@ def _uses_pgvector() -> bool:
)
# Shared by is_rejected_duplicate and create_documents candidate resolution.
_SEMANTIC_DUP_MAX_DISTANCE = 0.05
_SEMANTIC_DUP_TOP_K = 1
_SEMANTIC_CANDIDATE_CONCURRENCY = 8
def _semantic_dup_filters(doc: schemas.DocumentCreate) -> dict[str, Any] | None:
"""Merge scope for semantic dedup: never across levels, never across
sessions for explicit documents. None when the document has no valid
merge partner (session-less explicit)."""
filters: dict[str, Any] = {"level": doc.level}
if doc.level == "explicit":
if doc.session_name is None:
return None
filters["session_name"] = doc.session_name
return filters
async def query_external_vector_document_ids(
workspace_name: str,
observer: str,
@ -473,6 +492,16 @@ def _dedup_key(
)
@dataclass(frozen=True, slots=True)
class _DocumentRowOp:
kind: Literal["reinforce", "replace"]
document_id: str
incoming_times_derived: int = 1
# When a reinforce skipped insert and the locked target is gone/deleted,
# insert this document instead of dropping it.
fallback_document: schemas.DocumentCreate | None = None
@dataclass
class CreateDocumentsResult:
created_documents: list[schemas.DocumentCreate] = field(default_factory=list)
@ -515,6 +544,43 @@ async def create_documents(
# Store (document_model, embedding) pairs - IDs aren't available until after commit
docs_with_embeddings: list[tuple[models.Document, list[float]]] = []
# Resolve external-store dup candidates before the first DB statement.
# None = pgvector in-place fallback; [] = skip semantic (no external I/O under db).
semantic_candidates: list[list[str] | None] = [None] * len(documents)
if deduplicate and not _uses_pgvector():
resolve_sem = asyncio.Semaphore(_SEMANTIC_CANDIDATE_CONCURRENCY)
async def _resolve_candidates(index: int, doc: schemas.DocumentCreate) -> None:
filters = _semantic_dup_filters(doc)
if filters is None or not doc.embedding:
semantic_candidates[index] = []
return
async with resolve_sem:
try:
ids = await query_external_vector_document_ids(
workspace_name=workspace_name,
observer=observer,
observed=observed,
embedding=doc.embedding,
top_k=_SEMANTIC_DUP_TOP_K,
max_distance=_SEMANTIC_DUP_MAX_DISTANCE,
filters=filters,
)
except Exception:
logger.exception(
"External semantic-candidate resolve failed for %s/%s/%s",
workspace_name,
observer,
observed,
)
semantic_candidates[index] = []
return
semantic_candidates[index] = ids or []
await asyncio.gather(
*(_resolve_candidates(i, doc) for i, doc in enumerate(documents))
)
# exact-content dedup (independent of `deduplicate`): pre-fetch
# existing live documents whose normalized content matches anything in this
# batch, scoped to (workspace, observer, observed). The SQL normalization must
@ -563,12 +629,14 @@ async def create_documents(
# Tracks dedup keys already accepted from this batch so exact
# duplicates within a single inference call collapse to one document.
seen_in_batch: set[tuple[str, str, str | None]] = set()
row_ops: list[_DocumentRowOp] = []
pending_times_derived: dict[str, int] = {}
exact_dup_existing_count = 0
exact_dup_in_batch_count = 0
semantic_dup_rejected_count = 0
semantic_dup_replaced_count = 0
for doc in documents:
for index, doc in enumerate(documents):
try:
# Session-purity invariant: an explicit document must always carry
# the session it was derived from. Refuse to write session-less
@ -598,88 +666,107 @@ async def create_documents(
# the re-derivation as reinforcement on the existing row.
existing_match = existing_by_key.get(dedup_key)
if existing_match is not None:
# Reinforce the existing row. greatest(...) keeps the bump atomic
# server-side (concurrent workers can't lose an increment) while
# still honoring an incoming doc that already carries accumulated
# reinforcement (times_derived > 1, e.g. a future re-ingestion or
# collection-merge path). Mirrors the superior-replacement branch
# in is_rejected_duplicate.
existing_match.times_derived = func.greatest(
models.Document.times_derived + 1,
doc.times_derived,
current_td = pending_times_derived.get(
existing_match.id, existing_match.times_derived
)
pending_times_derived[existing_match.id] = max(
current_td + 1, doc.times_derived
)
row_ops.append(
_DocumentRowOp(
"reinforce",
existing_match.id,
doc.times_derived,
fallback_document=doc,
)
)
await db.flush()
exact_dup_existing_count += 1
continue
# for each document, if deduplicate is True, perform a process
# that checks against existing documents and either rejects this document
# as a duplicate OR deletes an existing document that is a duplicate.
if deduplicate:
duplicate_result = await is_rejected_duplicate(
db, doc, workspace_name, observer=observer, observed=observed
duplicate_result, existing_dup = await _semantic_dup_decision(
db,
doc,
workspace_name,
observer=observer,
observed=observed,
candidate_document_ids=semantic_candidates[index],
)
if duplicate_result is SemanticRejectionResult.REPLACED_EXISTING:
# Existing doc was soft-deleted in favor of this one; the
# new doc still gets inserted below.
if (
duplicate_result is SemanticRejectionResult.REPLACED_EXISTING
and existing_dup is not None
):
current_td = pending_times_derived.get(
existing_dup.id, existing_dup.times_derived
)
doc.times_derived = max(doc.times_derived, current_td + 1)
pending_times_derived[existing_dup.id] = doc.times_derived
row_ops.append(_DocumentRowOp("replace", existing_dup.id))
semantic_dup_replaced_count += 1
elif duplicate_result is SemanticRejectionResult.REJECTED:
elif (
duplicate_result is SemanticRejectionResult.REJECTED
and existing_dup is not None
):
current_td = pending_times_derived.get(
existing_dup.id, existing_dup.times_derived
)
pending_times_derived[existing_dup.id] = max(
current_td + 1, doc.times_derived
)
row_ops.append(
_DocumentRowOp(
"reinforce",
existing_dup.id,
doc.times_derived,
fallback_document=doc,
)
)
semantic_dup_rejected_count += 1
continue
metadata_dict = doc.metadata.model_dump(exclude_none=True)
# Determine if we need to persist embeddings to postgres
# True when: TYPE=pgvector OR still migrating (dual-write to both stores)
store_embeddings_in_postgres = (
settings.VECTOR_STORE.TYPE == "pgvector"
or not settings.VECTOR_STORE.MIGRATED
new_doc = _document_model_from_create(
doc, workspace_name=workspace_name, observer=observer, observed=observed
)
if store_embeddings_in_postgres and doc.embedding:
new_doc = models.Document(
workspace_name=workspace_name,
observer=observer,
observed=observed,
content=doc.content,
level=doc.level,
times_derived=doc.times_derived,
internal_metadata=metadata_dict,
session_name=doc.session_name,
embedding=doc.embedding,
# Tree linkage column
source_ids=doc.source_ids,
)
else:
new_doc = models.Document(
workspace_name=workspace_name,
observer=observer,
observed=observed,
content=doc.content,
level=doc.level,
times_derived=doc.times_derived,
internal_metadata=metadata_dict,
session_name=doc.session_name,
# Tree linkage column
source_ids=doc.source_ids,
)
if doc.embedding:
new_doc.sync_state = "pending"
honcho_documents.append(new_doc)
accepted_documents.append(doc)
# Track embedding for vector store (ID will be available after commit)
if doc.embedding:
docs_with_embeddings.append((new_doc, doc.embedding))
except IntegrityError as e:
await db.rollback()
raise ValidationException(
"Failed to create documents due to integrity constraint violation"
) from e
except SQLAlchemyError:
# Dead transaction: continuing would cascade PendingRollbackErrors.
await db.rollback()
raise
except Exception as e:
# Per-document failures (bad content, metadata, token overflow).
logger.error(
f"Error adding new document to {workspace_name}/{doc.session_name}/{observer}/{observed}: {e}"
)
continue
try:
fallback_docs = await _apply_document_row_updates(
db,
row_ops,
workspace_name=workspace_name,
observer=observer,
observed=observed,
)
for fallback_doc in fallback_docs:
new_doc = _document_model_from_create(
fallback_doc,
workspace_name=workspace_name,
observer=observer,
observed=observed,
)
honcho_documents.append(new_doc)
accepted_documents.append(fallback_doc)
if fallback_doc.embedding:
docs_with_embeddings.append((new_doc, fallback_doc.embedding))
db.add_all(honcho_documents)
# NOTE
# If the process crashes after this commit but before vector upsert completes,
@ -775,6 +862,11 @@ async def create_documents(
raise ValidationException(
"Failed to create documents due to integrity constraint violation"
) from e
except DBAPIError:
# Leave the session clean for callers that own it (e.g. a deadlock
# at the final commit); the queue layer classifies and retries.
await db.rollback()
raise
return CreateDocumentsResult(
created_documents=accepted_documents,
@ -1152,12 +1244,163 @@ async def create_observations(
return honcho_documents
def _document_model_from_create(
doc: schemas.DocumentCreate,
*,
workspace_name: str,
observer: str,
observed: str,
) -> models.Document:
metadata_dict = doc.metadata.model_dump(exclude_none=True)
store_embeddings_in_postgres = (
settings.VECTOR_STORE.TYPE == "pgvector" or not settings.VECTOR_STORE.MIGRATED
)
if store_embeddings_in_postgres and doc.embedding:
new_doc = models.Document(
workspace_name=workspace_name,
observer=observer,
observed=observed,
content=doc.content,
level=doc.level,
times_derived=doc.times_derived,
internal_metadata=metadata_dict,
session_name=doc.session_name,
embedding=doc.embedding,
source_ids=doc.source_ids,
)
else:
new_doc = models.Document(
workspace_name=workspace_name,
observer=observer,
observed=observed,
content=doc.content,
level=doc.level,
times_derived=doc.times_derived,
internal_metadata=metadata_dict,
session_name=doc.session_name,
source_ids=doc.source_ids,
)
if doc.embedding:
new_doc.sync_state = "pending"
return new_doc
async def _apply_document_row_updates(
db: AsyncSession,
ops: list[_DocumentRowOp],
*,
workspace_name: str,
observer: str,
observed: str,
) -> list[schemas.DocumentCreate]:
"""Lock target rows by id, apply ops, return fallbacks for vanished targets."""
if not ops:
return []
# Deadlock fix: lock in id order (IN-clause order is ignored).
ids = sorted({op.document_id for op in ops})
result = await db.execute(
select(models.Document)
.where(
models.Document.id.in_(ids),
models.Document.workspace_name == workspace_name,
models.Document.observer == observer,
models.Document.observed == observed,
)
.order_by(models.Document.id)
.with_for_update()
# Reload identity-map rows so the Python max() sees concurrent increments.
.execution_options(populate_existing=True)
)
locked = {doc.id: doc for doc in result.scalars()}
now = datetime.datetime.now(datetime.UTC)
fallbacks: list[schemas.DocumentCreate] = []
stale_at_lock = {
op.document_id
for op in ops
if (locked_row := locked.get(op.document_id)) is None
or locked_row.deleted_at is not None
}
for op in ops:
row = locked.get(op.document_id)
if op.kind == "replace":
if row is not None and row.deleted_at is None:
row.deleted_at = now
continue
# reinforce
if op.document_id in stale_at_lock:
if op.fallback_document is not None:
fallbacks.append(op.fallback_document)
continue
if row is None or row.deleted_at is not None:
# An earlier op in this batch replaced this row.
continue
row.times_derived = max(row.times_derived + 1, op.incoming_times_derived)
await db.flush()
return fallbacks
class SemanticRejectionResult(Enum):
NOT_DUPLICATE = 0
REPLACED_EXISTING = 1
REJECTED = 2
async def _semantic_dup_decision(
db: AsyncSession,
doc: schemas.DocumentCreate,
workspace_name: str,
*,
observer: str,
observed: str,
candidate_document_ids: list[str] | None = None,
) -> tuple[SemanticRejectionResult, models.Document | None]:
"""Classify a semantic duplicate without writing."""
filters = _semantic_dup_filters(doc)
if filters is None:
return SemanticRejectionResult.NOT_DUPLICATE, None
if candidate_document_ids is not None:
similar_docs: Sequence[models.Document] = await fetch_documents_by_ids(
db=db,
workspace_name=workspace_name,
observer=observer,
observed=observed,
document_ids=candidate_document_ids,
filters=filters,
)
elif _uses_pgvector():
if not doc.embedding:
# Match external-store path: never embed under an open session.
return SemanticRejectionResult.NOT_DUPLICATE, None
similar_docs = await query_documents(
db=db,
workspace_name=workspace_name,
query=doc.content,
observer=observer,
observed=observed,
filters=filters,
max_distance=_SEMANTIC_DUP_MAX_DISTANCE,
top_k=_SEMANTIC_DUP_TOP_K,
embedding=doc.embedding,
)
else:
return SemanticRejectionResult.NOT_DUPLICATE, None
if not similar_docs:
return SemanticRejectionResult.NOT_DUPLICATE, None
existing_doc = similar_docs[0]
tokens_new = set(embedding_client.encoding.encode(doc.content))
tokens_existing = set(embedding_client.encoding.encode(existing_doc.content))
unique_new = len(tokens_new - tokens_existing)
unique_existing = len(tokens_existing - tokens_new)
score_new = len(tokens_new) + (unique_new * 10)
score_existing = len(tokens_existing) + (unique_existing * 10)
if score_new >= score_existing:
return SemanticRejectionResult.REPLACED_EXISTING, existing_doc
return SemanticRejectionResult.REJECTED, existing_doc
async def is_rejected_duplicate(
db: AsyncSession,
doc: schemas.DocumentCreate,
@ -1165,90 +1408,29 @@ async def is_rejected_duplicate(
*,
observer: str,
observed: str,
candidate_document_ids: list[str] | None = None,
) -> SemanticRejectionResult:
"""
Check if a document is a duplicate of an existing document.
Uses: 1) Cosine similarity (>=0.95), 2) Token diff for retention.
Returns True if both:
- the document is deemed a duplicate of an existing document
- the existing document is deemed a superior duplicate
If the document is not a duplicate, returns False.
If the document is a duplicate AND the new document is superior,
deletes the existing document and returns False. In this case
``doc.times_derived`` is updated in place to carry the replaced
document's reinforcement count forward.
If the document is a duplicate AND the existing document is superior,
increments the existing document's ``times_derived`` to record the
reinforcement, then returns True.
Merges are scoped so they never cross document levels, and never cross
sessions for explicit-level documents (session-purity invariant: an
explicit document records what was derived from exactly one session, so
a near-duplicate from another session must not reinforce or replace it).
"""
filters: dict[str, Any] = {"level": doc.level}
if doc.level == "explicit":
if doc.session_name is None:
# create_documents refuses session-less explicit documents; if one
# reaches here anyway it has no valid merge partner.
return SemanticRejectionResult.NOT_DUPLICATE
filters["session_name"] = doc.session_name
# Step 1: Find potential duplicates using cosine similarity
similar_docs = await query_documents(
db=db,
workspace_name=workspace_name,
query=doc.content,
"""Classify a semantic duplicate and apply the corresponding row write."""
result, existing_doc = await _semantic_dup_decision(
db,
doc,
workspace_name,
observer=observer,
observed=observed,
filters=filters,
max_distance=0.05,
top_k=1,
embedding=doc.embedding,
candidate_document_ids=candidate_document_ids,
)
if not similar_docs:
return SemanticRejectionResult.NOT_DUPLICATE
existing_doc = similar_docs[0]
# Step 2: Determine which has more information using token set difference
tokens_new = set(embedding_client.encoding.encode(doc.content))
tokens_existing = set(embedding_client.encoding.encode(existing_doc.content))
unique_new = len(tokens_new - tokens_existing)
unique_existing = len(tokens_existing - tokens_new)
score_new = len(tokens_new) + (unique_new * 10)
score_existing = len(tokens_existing) + (unique_existing * 10)
# If new document has more or equal information, keep it and delete existing
if score_new >= score_existing:
if existing_doc is None:
return result
if result is SemanticRejectionResult.REPLACED_EXISTING:
logger.debug(
"[DUPLICATE DETECTION] Deleting existing in favor of new. new=%r, existing=%r.",
doc.content,
existing_doc.content,
)
# Carry the reinforcement count forward so replacing a duplicate counts as
# another derivation rather than resetting times_derived to 1.
doc.times_derived = max(doc.times_derived, existing_doc.times_derived + 1)
# Soft-delete the existing document - reconciliation will clean up vectors and hard-delete
existing_doc.deleted_at = datetime.datetime.now(datetime.timezone.utc)
existing_doc.deleted_at = datetime.datetime.now(datetime.UTC)
await db.flush()
return (
SemanticRejectionResult.REPLACED_EXISTING
) # Don't reject the new document
# Existing document has more information, reject the new one but record the
# reinforcement: a semantic duplicate was derived again. greatest(...) keeps
# the increment atomic server-side -- concurrent workers reinforcing the same
# document must not lose updates -- while still honoring an incoming doc that
# already carries accumulated reinforcement (times_derived > 1).
return result
existing_doc.times_derived = func.greatest(
models.Document.times_derived + 1,
doc.times_derived,
@ -1259,7 +1441,7 @@ async def is_rejected_duplicate(
doc.content,
existing_doc.content,
)
return SemanticRejectionResult.REJECTED
return result
async def cleanup_soft_deleted_documents(
@ -1284,7 +1466,7 @@ async def cleanup_soft_deleted_documents(
Returns:
Count of documents cleaned up (only those where vector deletion succeeded).
"""
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
cutoff = datetime.datetime.now(datetime.UTC) - datetime.timedelta(
minutes=older_than_minutes
)

View File

@ -25,6 +25,7 @@ from src.utils.representation import (
Representation,
allowlist_safe_levels,
)
from src.utils.sanitization import strip_nul
from src.utils.types import embedding_call_purpose
logger = logging.getLogger(__name__)
@ -38,10 +39,21 @@ def _observation_text(obs: ExplicitObservation | DeductiveObservation) -> str:
def _normalized_observation(
obs: ExplicitObservation | DeductiveObservation,
) -> ExplicitObservation | DeductiveObservation:
"""Return an observation with its persisted/embed text normalized."""
text = _observation_text(obs).strip()
"""Return an observation with its persisted/embed text normalized.
NUL bytes are removed here rather than closer to the database so that the
text that gets embedded is the same text that gets stored.
"""
text = strip_nul(_observation_text(obs)).strip()
if isinstance(obs, DeductiveObservation):
return obs.model_copy(update={"conclusion": text})
return obs.model_copy(
update={
"conclusion": text,
# Premises ride along in internal_metadata, and jsonb rejects
# NUL in strings just as text columns do.
"premises": strip_nul(obs.premises),
}
)
return obs.model_copy(update={"content": text})
@ -87,10 +99,15 @@ class RepresentationManager:
logger.debug("No observations to save")
return empty_result
# Normalize before the emptiness check: str.strip() does not remove
# NUL, so content that normalizes away has to be dropped afterwards.
all_observations = [
_normalized_observation(obs)
for obs in representation.deductive + representation.explicit
if _observation_text(obs).strip()
normalized
for normalized in (
_normalized_observation(obs)
for obs in representation.deductive + representation.explicit
)
if _observation_text(normalized)
]
if not all_observations:
logger.debug("No non-empty observations to save")

View File

@ -27,6 +27,7 @@ from src.telemetry.events import (
from src.telemetry.logging import log_performance_metrics
from src.utils import summarizer
from src.utils.queue_payload import (
RETRY_ATTEMPTS_PAYLOAD_KEY,
DeletionPayload,
DreamPayload,
ReconcilerPayload,
@ -44,7 +45,11 @@ logging.getLogger("sqlalchemy.engine.Engine").disabled = True
async def process_item(queue_item: models.QueueItem) -> None:
"""Process a single item from the queue."""
task_type = queue_item.task_type
queue_payload = queue_item.payload
# Drop the work-unit retry counter before payload validation: every payload
# model sets extra="forbid", so leaving it in burns the item as
# extra_forbidden on the reclaim that was supposed to retry it.
queue_payload = dict(queue_item.payload or {})
queue_payload.pop(RETRY_ATTEMPTS_PAYLOAD_KEY, None)
workspace_name = queue_item.workspace_name
# Handle reconciler first - it's the only task type that doesn't require workspace_name

View File

@ -25,6 +25,7 @@ from src.telemetry.sentry import with_sentry_transaction
from src.utils.config_helpers import get_configuration
from src.utils.formatting import format_new_turn_with_timestamp
from src.utils.representation import PromptRepresentation, Representation
from src.utils.retryable_errors import is_retryable_error
from src.utils.tokens import track_deriver_input_tokens
from .prompts import estimate_deriver_prompt_tokens, minimal_deriver_prompt
@ -344,6 +345,12 @@ async def process_representation_tasks_batch(
)
)
retryable = next(
(exc for _, exc in save_errors if is_retryable_error(exc)),
None,
)
if retryable is not None:
raise retryable
if save_errors and successful_observer_count == 0:
details = "; ".join(
f"{observer}: {exc.__class__.__name__}: {exc}"

View File

@ -6,7 +6,7 @@ import time
from asyncio import Task
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from logging import getLogger
from typing import Any, NamedTuple, cast
@ -15,7 +15,7 @@ from dotenv import load_dotenv
from nanoid import generate as generate_nanoid
from sentry_sdk.integrations.asyncio import AsyncioIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sqlalchemy import and_, delete, or_, select, update
from sqlalchemy import Text, and_, delete, literal, select, update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.engine import CursorResult
from sqlalchemy.ext.asyncio import AsyncSession
@ -24,6 +24,11 @@ from sqlalchemy.sql import func
from src import models
from src.cache.client import close_cache, init_cache
from src.config import settings
from src.crud.deriver import (
REPRESENTATION_WORK_UNIT_PREFIX,
representation_batch_threshold_clause,
unclaimed_work_unit_clause,
)
from src.dependencies import tracked_db
from src.deriver.consumer import (
process_item,
@ -43,6 +48,8 @@ from src.reconciler import (
from src.schemas import ResolvedConfiguration
from src.telemetry import prometheus_metrics
from src.telemetry.sentry import initialize_sentry
from src.utils.queue_payload import RETRY_ATTEMPTS_PAYLOAD_KEY
from src.utils.retryable_errors import is_retryable_error
from src.utils.work_unit import parse_work_unit_key
from src.webhooks.events import (
QueueEmptyEvent,
@ -53,6 +60,12 @@ logger = getLogger(__name__)
load_dotenv(override=True)
# Total processing attempts per work unit for transient errors. Count is
# stored on the oldest unprocessed queue item so every deriver instance
# shares one budget.
MAX_RETRYABLE_ATTEMPTS = 3
RETRY_BACKOFF_SECONDS = 1.0
class WorkerOwnership(NamedTuple):
"""Represents the instance of a work unit that a worker is processing."""
@ -301,7 +314,7 @@ class QueueManager:
async def cleanup_stale_work_units(self) -> None:
"""Clean up stale work units"""
async with tracked_db("cleanup_stale_work_units") as db:
cutoff = datetime.now(timezone.utc) - timedelta(
cutoff = datetime.now(UTC) - timedelta(
minutes=settings.DERIVER.STALE_SESSION_TIMEOUT_MINUTES
)
@ -345,7 +358,7 @@ class QueueManager:
)
async with tracked_db("get_available_work_units") as db:
representation_prefix = "representation:"
representation_prefix = REPRESENTATION_WORK_UNIT_PREFIX
token_stats_subq = (
select(
models.QueueItem.work_unit_key,
@ -382,14 +395,7 @@ class QueueManager:
token_stats_subq,
work_units_subq.c.work_unit_key == token_stats_subq.c.work_unit_key,
)
.where(
~select(models.ActiveQueueSession.id)
.where(
models.ActiveQueueSession.work_unit_key
== work_units_subq.c.work_unit_key
)
.exists()
)
.where(unclaimed_work_unit_clause(work_units_subq.c.work_unit_key))
.order_by(
work_units_subq.c.oldest_created_at.asc(),
work_units_subq.c.work_unit_key.asc(),
@ -398,26 +404,13 @@ class QueueManager:
)
# Apply batch threshold filter (skip if FLUSH_ENABLED is True)
if not settings.DERIVER.FLUSH_ENABLED and work_unit_target_tokens > 0:
max_age_seconds = settings.DERIVER.REPRESENTATION_BATCH_MAX_AGE_SECONDS
threshold_clause = (
func.coalesce(token_stats_subq.c.total_tokens, 0)
>= work_unit_target_tokens
)
if max_age_seconds > 0:
threshold_clause = or_(
threshold_clause,
token_stats_subq.c.oldest_created_at
<= func.now() - timedelta(seconds=max_age_seconds),
)
query = query.where(
or_(
~work_units_subq.c.work_unit_key.startswith(
representation_prefix
),
threshold_clause,
)
)
threshold_clause = representation_batch_threshold_clause(
work_unit_key=work_units_subq.c.work_unit_key,
total_tokens=token_stats_subq.c.total_tokens,
oldest_created_at=token_stats_subq.c.oldest_created_at,
)
if threshold_clause is not None:
query = query.where(threshold_clause)
result = await db.execute(query)
available_rows = result.all()
@ -591,11 +584,24 @@ class QueueManager:
items: list[QueueItem],
work_unit_key: str,
context: str,
) -> None:
) -> bool:
"""
Handle processing errors by marking queue items as errored, logging, and forwarding to Sentry.
We only mark the first queue item as errored so we don't potentially throw away a batch. This allows us
to incrementally attempt to process the batch while still maintaining progress in a work unit.
Handle a processing error. Returns True when the caller should stop
processing and release the work unit for a later re-claim.
Transient errors (is_retryable_error) get up to MAX_RETRYABLE_ATTEMPTS
attempts per work unit: items stay unprocessed with no error recorded.
The attempt count lives on the oldest unprocessed queue item so a
different deriver instance continues the same budget after reclaim.
Reprocessing is at-least-once, not idempotent: the batch is re-derived
by a fresh LLM call, so identical text collapses via exact dedup and
near-identical text via semantic dedup. Retries can therefore inflate
times_derived and double-count LLM telemetry -- acceptable because the
alternative is dropping the batch.
Terminal errors mark only the first queue item as errored so we don't
potentially throw away a batch. This allows us to incrementally attempt
to process the batch while still maintaining progress in a work unit.
Args:
error: The exception that occurred
@ -603,12 +609,37 @@ class QueueManager:
work_unit_key: The work unit key for the queue items
context: Context string describing what was being processed (e.g., "processing representation batch")
"""
if is_retryable_error(error):
try:
attempts = await self._get_work_unit_retry_attempts(work_unit_key) + 1
if attempts < MAX_RETRYABLE_ATTEMPTS:
await self._set_work_unit_retry_attempts(work_unit_key, attempts)
logger.warning(
"Transient error %s for work unit %s (attempt %d/%d); leaving items unprocessed for retry",
context,
work_unit_key,
attempts,
MAX_RETRYABLE_ATTEMPTS,
exc_info=error,
)
return True
except Exception: # noqa: BLE001
logger.exception(
"Retry-counter I/O failed for work unit %s; releasing %s without recording an attempt",
work_unit_key,
context,
)
return True
error_msg = f"{error.__class__.__name__}: {str(error)}"
try:
if items:
# Clear retry metadata only after the terminal mark commits so a
# failed mark leaves the shared budget intact for the next claim.
await self.mark_queue_item_as_errored(
items[0], work_unit_key, error_msg
)
await self._clear_work_unit_retry_attempts(work_unit_key)
except Exception as mark_error:
logger.error(
f"Failed to mark queue items as errored for work unit {work_unit_key}: {mark_error}",
@ -621,6 +652,7 @@ class QueueManager:
)
if settings.SENTRY.ENABLED:
sentry_sdk.capture_exception(error)
return False
async def process_work_unit(self, work_unit_key: str, worker_id: str) -> None:
"""Process all queue items for a specific work unit by routing to the correct handler."""
@ -686,12 +718,18 @@ class QueueManager:
)
queue_item_count += len(items_to_process)
except Exception as e:
await self._handle_processing_error(
if await self._handle_processing_error(
e,
items_to_process,
work_unit_key,
f"processing {work_unit.task_type} batch",
)
):
# Release the work unit (via the finally
# below) and let a later poll re-claim it.
await asyncio.sleep(
self._jitter(RETRY_BACKOFF_SECONDS)
)
break
else:
queue_item = await self.get_next_queue_item(
@ -710,12 +748,16 @@ class QueueManager:
)
queue_item_count += 1
except Exception as e:
await self._handle_processing_error(
if await self._handle_processing_error(
e,
[queue_item],
work_unit_key,
"processing queue item",
)
):
await asyncio.sleep(
self._jitter(RETRY_BACKOFF_SECONDS)
)
break
except Exception as e:
logger.error(
@ -1068,6 +1110,87 @@ class QueueManager:
batch_max_tokens=batch_max_tokens,
)
async def _oldest_unprocessed_item(
self,
db: AsyncSession,
work_unit_key: str,
*,
for_update: bool = False,
) -> models.QueueItem | None:
stmt = (
select(models.QueueItem)
.where(
models.QueueItem.work_unit_key == work_unit_key,
models.QueueItem.processed.is_(False),
)
.order_by(models.QueueItem.id)
.limit(1)
)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def _get_work_unit_retry_attempts(self, work_unit_key: str) -> int:
"""Read the shared transient-failure attempt count for a work unit."""
async with tracked_db("get_work_unit_retry_attempts") as db:
item = await self._oldest_unprocessed_item(db, work_unit_key)
if item is None:
return 0
raw = (item.payload or {}).get(RETRY_ATTEMPTS_PAYLOAD_KEY, 0)
try:
return max(0, int(raw))
except (TypeError, ValueError):
return 0
async def _set_work_unit_retry_attempts(
self, work_unit_key: str, attempts: int
) -> None:
"""Persist the shared attempt count on the oldest unprocessed item."""
async with tracked_db("set_work_unit_retry_attempts") as db:
item = await self._oldest_unprocessed_item(
db, work_unit_key, for_update=True
)
if item is None:
await db.commit()
return
new_payload = dict(item.payload or {})
new_payload[RETRY_ATTEMPTS_PAYLOAD_KEY] = attempts
await db.execute(
update(models.QueueItem)
.where(models.QueueItem.id == item.id)
.values(payload=new_payload)
)
await db.commit()
async def _clear_work_unit_retry_attempts(self, work_unit_key: str) -> None:
"""Drop the shared attempt count from remaining unprocessed items.
One statement on purpose: a multi-row ``SELECT ... FOR UPDATE`` here
would take locks on ``queue`` in scan order, which is a deadlock partner
for any other multi-row writer on the same table. The JSONB ``-``
operator does the strip server-side, so no rows are locked ahead of the
write and there is no lock order to get wrong.
"""
async with tracked_db("clear_work_unit_retry_attempts") as db:
await db.execute(
update(models.QueueItem)
.where(
models.QueueItem.work_unit_key == work_unit_key,
models.QueueItem.processed.is_(False),
models.QueueItem.payload.has_key(RETRY_ATTEMPTS_PAYLOAD_KEY),
)
.values(
# literal(..., Text) is required: an untyped bind leaves
# Postgres unable to pick between jsonb - text and its
# integer/array siblings.
payload=models.QueueItem.payload.op("-")(
literal(RETRY_ATTEMPTS_PAYLOAD_KEY, Text)
)
)
)
await db.commit()
async def mark_queue_items_as_processed(
self, items: list[QueueItem], work_unit_key: str
) -> None:

View File

@ -30,12 +30,12 @@ from typing import Any
from sqlalchemy import select, update
from sqlalchemy.dialects.postgresql import array
from sqlalchemy.orm import load_only
from sqlalchemy.sql.functions import func
from src import crud, models
from src.config import settings
from src.crud.scope import ScopeBackfillState
from src.crud.session import is_peer_in_session
from src.dependencies import tracked_db
from src.embedding_client import embedding_client
from src.schemas import DreamType
@ -49,6 +49,10 @@ logger = logging.getLogger(__name__)
# was copied from. The presence of this key is the idempotency marker.
COPIED_FROM_KEY = "copied_from"
# Specs embedded, written, and synced per pass. Bounds the live embeddings
# (~40KB each as Python floats) so a large session cannot OOM the deriver.
BACKFILL_CHUNK_SIZE = 500
def _store_embeddings_in_postgres() -> bool:
"""Whether document embeddings are persisted to the postgres column.
@ -173,7 +177,23 @@ async def _run_backfill(
plans: list[_CopySpec] = []
async with tracked_db("scope_backfill.plan") as db:
source_result = await db.execute(
select(models.Document).where(
select(models.Document)
.options(
load_only(
models.Document.id,
models.Document.workspace_name,
models.Document.observer,
models.Document.observed,
models.Document.content,
models.Document.level,
models.Document.times_derived,
models.Document.internal_metadata,
models.Document.session_name,
models.Document.source_ids,
models.Document.deleted_at,
)
)
.where(
models.Document.workspace_name == workspace_name,
models.Document.session_name == session_name,
models.Document.level == "explicit",
@ -196,7 +216,19 @@ async def _run_backfill(
# by (observed, copied_from). Includes soft-deleted rows: those are
# restore candidates, not blockers.
copies_result = await db.execute(
select(models.Document).where(
select(models.Document)
.options(
load_only(
models.Document.id,
models.Document.workspace_name,
models.Document.observer,
models.Document.observed,
models.Document.session_name,
models.Document.internal_metadata,
models.Document.deleted_at,
)
)
.where(
models.Document.workspace_name == workspace_name,
models.Document.observer == scope_peer,
models.Document.session_name == session_name,
@ -216,12 +248,13 @@ async def _run_backfill(
key = (source.observed, source.id)
if key in live_copies:
continue
# Vectors hydrate per chunk; plans only carry ids + content.
plans.append(
_CopySpec(
observed=source.observed,
source_id=source.id,
content=source.content,
embedding=_embedding_as_list(source.embedding),
embedding=None,
internal_metadata=dict(source.internal_metadata),
times_derived=source.times_derived,
source_ids=list(source.source_ids)
@ -235,7 +268,54 @@ async def _run_backfill(
if not plans:
return 0, set()
# Phase 2 (no DB): fill missing embeddings. Source rows have NULL
# Phases 2-4 run per chunk so only one chunk's embeddings are alive at a
# time; each chunk's vectors are dropped once synced.
store_in_postgres = _store_embeddings_in_postgres()
touched_observed: set[str] = set()
copied = 0
for start in range(0, len(plans), BACKFILL_CHUNK_SIZE):
chunk = plans[start : start + BACKFILL_CHUNK_SIZE]
if not await _copy_chunk(
workspace_name, scope_peer, session_name, chunk, store_in_postgres
):
return None
copied += len(chunk)
touched_observed.update(spec.observed for spec in chunk)
for spec in chunk:
spec.embedding = None
return copied, touched_observed
async def _hydrate_chunk_embeddings(
workspace_name: str, plans: list[_CopySpec]
) -> None:
"""Load this chunk's source embeddings from postgres (if any)."""
source_ids = [spec.source_id for spec in plans]
async with tracked_db("scope_backfill.hydrate_embeddings") as db:
result = await db.execute(
select(models.Document.id, models.Document.embedding).where(
models.Document.workspace_name == workspace_name,
models.Document.id.in_(source_ids),
)
)
by_id = {row.id: _embedding_as_list(row.embedding) for row in result.all()}
for spec in plans:
spec.embedding = by_id.get(spec.source_id)
async def _copy_chunk(
workspace_name: str,
scope_peer: str,
session_name: str,
plans: list[_CopySpec],
store_in_postgres: bool,
) -> bool:
"""Embed, write, and sync one chunk. False if the session left the scope."""
# Phase 2a (DB): pull this chunk's embeddings only.
await _hydrate_chunk_embeddings(workspace_name, plans)
# Phase 2b (no DB): fill missing embeddings. Source rows have NULL
# embeddings on external-store deployments (and soft-deleted copies may
# have lost their vectors) — re-embed via the embedding API only; no LLM.
missing = [spec for spec in plans if spec.embedding is None]
@ -254,17 +334,22 @@ async def _run_backfill(
spec.embedding = embedding
# Phase 3 (DB): write the copies.
store_in_postgres = _store_embeddings_in_postgres()
touched_observed = {spec.observed for spec in plans}
new_rows: list[models.Document] = []
async with tracked_db("scope_backfill.write") as db:
# scope_backfill and scope_removal carry different work-unit keys, so
# nothing orders them: a removal enqueued right after the add (or one
# that landed while phase 2 was embedding) can sweep the scope before
# these copies exist. Re-checking membership here, in the transaction
# that inserts, keeps a removed session from being copied back in.
if not await is_peer_in_session(db, workspace_name, session_name, scope_peer):
return None
# Row-lock active membership for this txn so a concurrent leave
# (``left_at``) cannot commit between the check and the inserts.
membership = await db.scalar(
select(models.SessionPeer.peer_name)
.where(models.SessionPeer.workspace_name == workspace_name)
.where(models.SessionPeer.session_name == session_name)
.where(models.SessionPeer.peer_name == scope_peer)
.where(models.SessionPeer.left_at.is_(None))
.with_for_update()
.limit(1)
)
if membership is None:
return False
for observed in sorted(touched_observed):
await crud.get_or_create_collection(
@ -319,8 +404,7 @@ async def _run_backfill(
# Phase 4: sync to the external vector store (or mark synced in pgvector
# mode). Failures leave rows in sync_state='pending' for the reconciler.
await _sync_copies_to_vector_store(workspace_name, scope_peer, plans, copied_ids)
return len(plans), touched_observed
return True
async def _sync_copies_to_vector_store(

View File

@ -14,7 +14,12 @@ from nanoid import generate as generate_nanoid
from pydantic import BaseModel
from src import crud
from src.config import ConfiguredModelSettings, ReasoningLevel, settings
from src.config import (
ConfiguredModelSettings,
DialecticLevelSettings,
ReasoningLevel,
settings,
)
from src.dependencies import tracked_db
from src.dialectic import prompts
from src.embedding_client import embedding_client
@ -139,6 +144,20 @@ class DialecticAgent:
tools = [t for t in tools if t.get("name") != "get_reasoning_chain"]
return tools
def _tool_choice(
self, level_settings: DialecticLevelSettings
) -> str | dict[str, Any] | None:
"""Pick the tool_choice for this query.
Defaults to whatever the reasoning level configures. Subclasses override
when the agent has no prefetched corpus to fall back on and so must
search before it can answer. Forcing "required"/"any" here costs exactly
one tool round rather than pinning the loop: `execute_tool_loop` relaxes
it to "auto" after the first iteration so the model can still stop and
synthesize.
"""
return level_settings.TOOL_CHOICE
async def _initialize_session_history(self) -> None:
"""Fetch and inject session history into the system prompt if configured."""
if self._session_history_initialized:
@ -505,7 +524,7 @@ class DialecticAgent:
prompt="", # Ignored since we pass messages
max_tokens=max_tokens,
tools=tools,
tool_choice=level_settings.TOOL_CHOICE,
tool_choice=self._tool_choice(level_settings),
tool_executor=tool_executor,
max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS,
messages=self.messages,
@ -581,7 +600,7 @@ class DialecticAgent:
stream=True,
stream_final_only=True,
tools=tools,
tool_choice=level_settings.TOOL_CHOICE,
tool_choice=self._tool_choice(level_settings),
tool_executor=tool_executor,
max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS,
messages=self.messages,

View File

@ -396,7 +396,11 @@ If this query is restricted to a session or a set of sessions, message tools alr
4. **Attribute**. Every fact you state names the peer it is about. If it is a cross-peer view, also name whose model it came from. Example: "Alice is a violinist." / "From Bob's model of Alice, …"
5. **Synthesize**. Answer the question. Quote exact names, dates, and numbers. For aggregations, list findings per peer. Do not narrate tool use.
5. **Synthesize**. Answer the question. Quote exact names, dates, and numbers. For aggregations, list findings per peer. Do not narrate tool use, and do not describe a search you did not run.
## NO CLARIFYING QUESTIONS
Your answer goes to a program, not to someone who can reply. No one will answer a question you ask, approve a plan you propose, or pick from options you offer your response ends the exchange. So never ask which lookup to run, never lay out a plan and stop, never present a menu. Run the searches yourself and answer from what they return. Empty results are a complete answer; an unanswered question is not.
## NEVER FABRICATE

View File

@ -20,7 +20,7 @@ from collections.abc import Callable
from typing import Any
from src import crud
from src.config import ReasoningLevel, settings
from src.config import DialecticLevelSettings, ReasoningLevel, settings
from src.dependencies import tracked_db
from src.dialectic import prompts
from src.dialectic.core import DialecticAgent
@ -161,6 +161,30 @@ class WorkspaceDialecticAgent(DialecticAgent):
tools = [t for t in tools if t.get("name") not in unscopable]
return tools
def _tool_choice(
self, level_settings: DialecticLevelSettings
) -> str | dict[str, Any] | None:
"""Require a tool call on the first turn.
The pair agent prefetches the observations relevant to its query, so it
can legitimately answer from context alone. This agent's prefetch is an
orientation overview scale, active peers, their cards not the corpus.
Left free to skip tools, the model treats that overview as everything it
has: it answers when the overview happens to carry the fact, and
otherwise writes out the search it should have run and asks the caller
which option to take. Workspace chat has no caller to answer, so that
response is dead on arrival.
Recall is the job, so make the first search mandatory and let the loop
relax to "auto" afterwards. Any other value a level configures is passed
through untouched, so this only overrides the two cases that let the
model opt out entirely.
"""
choice = level_settings.TOOL_CHOICE
if choice is None or choice == "auto":
return "required"
return choice
async def _create_tool_executor(self) -> Callable[[str, dict[str, Any]], Any]:
return await create_workspace_tool_executor(
workspace_name=self.workspace_name,

216
src/dreamer/dream_due.py Normal file
View File

@ -0,0 +1,216 @@
"""Read-only count of the collections whose next dream is due. Enqueues nothing."""
from datetime import UTC, datetime, timedelta
from logging import getLogger
from typing import Any, cast
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import aggregate_order_by
from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.config import settings
from src.schemas import DreamType
from src.utils.config_helpers import get_configuration
from src.utils.work_unit import construct_work_unit_key
logger = getLogger(__name__)
async def count_due_dreams(db: AsyncSession) -> int:
"""Count collections past the threshold, the idle timeout, the min-hours gate, any earlier attempt, and the session's dream setting."""
dream_types = [
DreamType(dream_type)
for dream_type in settings.DREAM.ENABLED_TYPES
if dream_type == DreamType.OMNI.value
]
if not settings.DREAM.ENABLED or not dream_types:
return 0
explicit_counts = (
select(
models.Document.workspace_name,
models.Document.observer,
models.Document.observed,
func.count(models.Document.id).label("explicit_count"),
func.max(models.Document.created_at).label("newest_created_at"),
func.array_agg(
aggregate_order_by(
models.Document.session_name, models.Document.created_at.desc()
)
)[1].label("newest_session_name"),
)
.where(models.Document.level == "explicit")
.group_by(
models.Document.workspace_name,
models.Document.observer,
models.Document.observed,
)
.subquery()
)
rows = (
await db.execute(
select(
models.Collection.workspace_name,
models.Collection.observer,
models.Collection.observed,
models.Collection.internal_metadata,
func.coalesce(explicit_counts.c.explicit_count, 0),
explicit_counts.c.newest_created_at,
explicit_counts.c.newest_session_name,
).outerjoin(
explicit_counts,
(models.Collection.workspace_name == explicit_counts.c.workspace_name)
& (models.Collection.observer == explicit_counts.c.observer)
& (models.Collection.observed == explicit_counts.c.observed),
)
)
).all()
now = datetime.now(UTC)
idle_cutoff = now - timedelta(minutes=settings.DREAM.IDLE_TIMEOUT_MINUTES)
candidates: dict[str, tuple[str, str, datetime]] = {}
for row in rows:
workspace_name = cast(str, row[0])
observer = cast(str, row[1])
observed = cast(str, row[2])
internal_metadata = cast("dict[str, Any] | None", row[3])
explicit_count = cast(int, row[4])
newest_created_at = cast("datetime | None", row[5])
newest_session_name = cast("str | None", row[6])
dream_metadata: dict[str, Any] = (internal_metadata or {}).get("dream", {})
since_last_dream = explicit_count - int(
dream_metadata.get("last_dream_document_count", 0)
)
if since_last_dream < settings.DREAM.DOCUMENT_THRESHOLD:
continue
if newest_created_at is None or newest_created_at > idle_cutoff:
continue
if newest_session_name is None:
continue
last_dream_at = cast("str | None", dream_metadata.get("last_dream_at"))
if last_dream_at and _within_min_hours_gate(last_dream_at, now):
continue
for dream_type in dream_types:
work_unit_key = construct_work_unit_key(
workspace_name,
{
"task_type": "dream",
"observer": observer,
"observed": observed,
"dream_type": dream_type.value,
},
)
candidates[work_unit_key] = (
workspace_name,
newest_session_name,
newest_created_at,
)
if not candidates:
return 0
attempt_rows = (
await db.execute(
select(
models.QueueItem.work_unit_key,
func.max(models.QueueItem.created_at),
)
.where(
models.QueueItem.task_type == "dream",
models.QueueItem.work_unit_key.in_(candidates.keys()),
)
.group_by(models.QueueItem.work_unit_key)
)
).all()
newest_attempts: dict[str, datetime] = {
cast(str, row[0]): cast(datetime, row[1]) for row in attempt_rows
}
unattempted = [
(workspace_name, session_name)
for work_unit_key, (
workspace_name,
session_name,
newest_created_at,
) in candidates.items()
if work_unit_key not in newest_attempts
or newest_attempts[work_unit_key] < newest_created_at
]
if not unattempted:
return 0
return await _count_with_dreams_enabled(db, unattempted)
async def _count_with_dreams_enabled(
db: AsyncSession, candidates: list[tuple[str, str]]
) -> int:
"""Drop candidates whose resolved configuration has dreams turned off."""
workspace_names = {workspace_name for workspace_name, _ in candidates}
session_keys = set(candidates)
workspaces = {
workspace.name: workspace
for workspace in (
await db.execute(
select(models.Workspace).where(
models.Workspace.name.in_(workspace_names)
)
)
)
.scalars()
.all()
}
sessions: dict[tuple[str, str], models.Session] = {}
if session_keys:
session_rows = (
(
await db.execute(
select(models.Session).where(
models.Session.workspace_name.in_(workspace_names),
models.Session.name.in_(
{session_name for _, session_name in candidates}
),
)
)
)
.scalars()
.all()
)
sessions = {
(session.workspace_name, session.name): session for session in session_rows
}
enabled = 0
for workspace_name, session_name in candidates:
configuration = get_configuration(
None,
sessions.get((workspace_name, session_name)),
workspaces.get(workspace_name),
)
if configuration.dream.enabled:
enabled += 1
return enabled
def _within_min_hours_gate(last_dream_at: str, now: datetime) -> bool:
"""True when the last dream is too recent for another one."""
try:
last_dream_time = datetime.fromisoformat(last_dream_at)
except (ValueError, TypeError):
return False
if last_dream_time.tzinfo is None:
last_dream_time = last_dream_time.replace(tzinfo=UTC)
hours_since = (now - last_dream_time).total_seconds() / 3600
return hours_since < settings.DREAM.MIN_HOURS_BETWEEN_DREAMS

View File

@ -15,6 +15,7 @@ from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
from src._version import HONCHO_VERSION
from src.backlog import DeriverMetricsPoller
from src.cache.client import close_cache, init_cache
from src.config import settings
from src.db import (
@ -26,6 +27,7 @@ from src.db import (
from src.exceptions import HonchoException
from src.routers import (
conclusions,
deriver_metrics,
keys,
messages,
peers,
@ -135,12 +137,21 @@ async def lifespan(_: FastAPI):
"Error initializing cache in api process; proceeding without cache: %s", e
)
deriver_metrics_poller = DeriverMetricsPoller()
deriver_metrics.set_deriver_metrics_poller(deriver_metrics_poller)
try:
await deriver_metrics_poller.start()
except Exception as e:
logger.error("Failed to start backlog metrics poller: %s", e)
try:
yield
finally:
# Import here to avoid circular import at module load time
from src.vector_store import close_external_vector_store
await deriver_metrics_poller.shutdown()
deriver_metrics.set_deriver_metrics_poller(None)
await close_external_vector_store()
await close_cache()
await engine.dispose()
@ -189,6 +200,7 @@ app.include_router(messages.router, prefix="/v3")
app.include_router(conclusions.router, prefix="/v3")
app.include_router(keys.router, prefix="/v3")
app.include_router(webhooks.router, prefix="/v3")
app.include_router(deriver_metrics.router)
# Prometheus metrics endpoint
app.add_route("/metrics", metrics_endpoint, methods=["GET"])

View File

@ -0,0 +1 @@
"""Deterministic OpenAI-compatible provider for local and CI use."""

214
src/mock_provider/chat.py Normal file
View File

@ -0,0 +1,214 @@
"""OpenAI-compatible ``/chat/completions``, answered without inference."""
from __future__ import annotations
import hashlib
import json
import re
from collections.abc import AsyncIterator
from typing import Any
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from src.mock_provider.coerce import as_dict, as_str
from src.mock_provider.schema_gen import generate
from src.mock_provider.schemas import ChatCompletionRequest, ChatMessage
router = APIRouter(tags=["mock-provider"])
# Honcho's json_object mode injects the schema into the prompt text rather than
# into response_format (see _apply_json_object_mode in the OpenAI backend), so
# the only machine-readable copy of the schema is inside a message.
_SCHEMA_HINT = re.compile(r"schema:\s*(\{)", re.IGNORECASE)
def _completion_id(body: ChatCompletionRequest) -> str:
"""Stable id, so a replayed request is byte-identical."""
digest = hashlib.sha256(
body.model_dump_json(exclude_none=True).encode()
).hexdigest()
return f"chatcmpl-mock-{digest[:24]}"
def _extract_balanced_json(text: str, start: int) -> dict[str, Any] | None:
"""Read one balanced ``{...}`` beginning at ``start`` and parse it.
A plain regex cannot do this a JSON Schema contains nested objects, and
braces inside string literals must not count toward the depth.
"""
depth = 0
in_string = False
escaped = False
for index in range(start, len(text)):
char = text[index]
if in_string:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
in_string = False
continue
if char == '"':
in_string = True
elif char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
try:
parsed = json.loads(text[start : index + 1])
except json.JSONDecodeError:
return None
return as_dict(parsed)
return None
def _schema_from_messages(messages: list[ChatMessage]) -> dict[str, Any] | None:
"""Recover an injected schema from the prompt, for json_object mode."""
for message in reversed(messages):
content = as_str(message.content)
if content is None:
continue
for match in _SCHEMA_HINT.finditer(content):
candidate = _extract_balanced_json(content, match.start(1))
if candidate and ("properties" in candidate or "$defs" in candidate):
return candidate
return None
def _response_content(body: ChatCompletionRequest) -> str:
"""The assistant message body: schema-conforming JSON, or prose."""
response_format = body.response_format
if response_format is not None:
kind = as_str(response_format.get("type"))
if kind == "json_schema":
wrapper = as_dict(response_format.get("json_schema"))
if wrapper is not None:
schema = as_dict(wrapper.get("schema"))
if schema is not None:
return json.dumps(generate(schema))
# A json_schema request whose schema we cannot read must not fall
# through to prose — that is the silent-empty failure this mock
# exists to avoid. An empty object at least parses.
return "{}"
if kind == "json_object":
schema = _schema_from_messages(body.messages)
return json.dumps(generate(schema)) if schema else "{}"
return (
"[mock] This is a synthetic response from Honcho's mock provider. "
"No model was called."
)
def _usage(body: ChatCompletionRequest, content: str) -> dict[str, int]:
"""Rough token accounting, so cost telemetry has plausible numbers."""
prompt_chars = 0
for message in body.messages:
text = as_str(message.content)
if text is not None:
prompt_chars += len(text)
prompt_tokens = max(1, prompt_chars // 4)
completion_tokens = max(1, len(content) // 4)
return {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
}
def _created() -> int:
# Fixed rather than time-based: a mock that changes its output between
# identical calls defeats the point.
return 1577836800 # 2020-01-01T00:00:00Z
async def _stream(
completion_id: str, model: str, content: str, usage: dict[str, int] | None
) -> AsyncIterator[bytes]:
"""Stream ``content``, ending on a usage chunk when ``usage`` is given."""
def chunk(payload: dict[str, Any]) -> bytes:
return f"data: {json.dumps(payload)}\n\n".encode()
base = {
"id": completion_id,
"object": "chat.completion.chunk",
"created": _created(),
"model": model,
}
yield chunk(
{
**base,
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": ""},
"finish_reason": None,
}
],
}
)
yield chunk(
{
**base,
"choices": [
{"index": 0, "delta": {"content": content}, "finish_reason": None}
],
}
)
yield chunk(
{
**base,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
}
)
# The usage chunk is conditional: the real API emits it only when
# stream_options.include_usage is set, and ends the stream on it — so it
# must come last and must carry choices: []. Honcho's own backend always
# asks for it (_build_params in the OpenAI backend), but a caller that does
# not must not receive a chunk it never requested.
if usage is not None:
yield chunk({**base, "choices": [], "usage": usage})
yield b"data: [DONE]\n\n"
@router.post("/chat/completions")
async def chat_completions(body: ChatCompletionRequest) -> Any:
model = body.model or "mock-model"
content = _response_content(body)
usage = _usage(body, content)
completion_id = _completion_id(body)
if body.stream:
include_usage = (
body.stream_options is not None and body.stream_options.include_usage
)
return StreamingResponse(
_stream(completion_id, model, content, usage if include_usage else None),
media_type="text/event-stream",
)
return {
"id": completion_id,
"object": "chat.completion",
"created": _created(),
"model": model,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": content,
"refusal": None,
"tool_calls": None,
},
"logprobs": None,
"finish_reason": "stop",
}
],
"usage": usage,
}

View File

@ -0,0 +1,34 @@
"""Typed narrowing for values decoded from JSON.
``isinstance(value, dict)`` on an ``Any`` narrows to ``dict[Unknown, Unknown]``,
which spreads unknown types through everything downstream. These helpers narrow
and pin the element types in one step.
"""
from __future__ import annotations
from typing import Any, cast
def as_dict(value: object) -> dict[str, Any] | None:
"""The value as a JSON object, or None if it is not one."""
return cast("dict[str, Any]", value) if isinstance(value, dict) else None
def as_list(value: object) -> list[Any] | None:
"""The value as a JSON array, or None if it is not one."""
return cast("list[Any]", value) if isinstance(value, list) else None
def as_str(value: object) -> str | None:
"""The value as a JSON string, or None if it is not one."""
return value if isinstance(value, str) else None
def as_int(value: object) -> int | None:
"""The value as a JSON integer, or None if it is not one.
``bool`` is excluded: it is an ``int`` subclass, and a JSON ``true`` reaching
a size or dimension field is a malformed request, not the number one.
"""
return value if isinstance(value, int) and not isinstance(value, bool) else None

View File

@ -0,0 +1,97 @@
"""OpenAI-compatible ``/embeddings``, answered from a content hash."""
from __future__ import annotations
import base64
import hashlib
import struct
from typing import Any
from fastapi import APIRouter
from src.mock_provider.schemas import EmbeddingsRequest
router = APIRouter(tags=["mock-provider"])
# Honcho's default. EmbeddingClient._validate_embedding_dimensions raises when a
# vector comes back at the wrong width, and validate_embedding_schema refuses to
# boot when the width disagrees with the pgvector column, so the request's own
# `dimensions` is honoured whenever it is present.
DEFAULT_DIMENSIONS = 1536
def content_to_embedding(content: str, dimensions: int) -> list[float]:
"""A deterministic vector for ``content``.
Identical input yields an identical vector, and different inputs differ
which is what deduplication logic needs. It carries no semantic similarity:
two paraphrases are as far apart as two unrelated strings. Anything
asserting on ranking quality must not use this provider.
Mirrors ``_content_to_embedding`` in tests/conftest.py.
"""
digest = hashlib.sha256(content.encode()).digest()
return [(digest[i % len(digest)] / 255.0) * 2 - 1 for i in range(dimensions)]
def _encode_base64(vector: list[float]) -> str:
"""Little-endian float32, which is what the OpenAI SDK decodes."""
return base64.b64encode(struct.pack(f"<{len(vector)}f", *vector)).decode()
def _normalize_input(
raw: str | list[str] | list[int] | list[list[int]] | None,
) -> list[str]:
"""Flatten the request input into one string per embedding to return.
Token-array inputs are rendered back to a stable string rather than
rejected the vector only has to be deterministic, not meaningful.
"""
if raw is None:
return []
if isinstance(raw, str):
return [raw]
# A flat list of ints is one tokenized input, not many single-token ones.
if raw and all(isinstance(item, int) for item in raw):
return [",".join(str(item) for item in raw)]
texts: list[str] = []
for item in raw:
if isinstance(item, str):
texts.append(item)
elif isinstance(item, list):
texts.append(",".join(str(part) for part in item))
else:
texts.append(str(item))
return texts
@router.post("/embeddings")
async def embeddings(body: EmbeddingsRequest) -> Any:
texts = _normalize_input(body.input)
# A non-positive width is rejected by the request model, so absent is the
# only case left to fill in.
dimensions = body.dimensions if body.dimensions is not None else DEFAULT_DIMENSIONS
data: list[dict[str, Any]] = []
for index, text in enumerate(texts):
vector = content_to_embedding(text, dimensions)
data.append(
{
"object": "embedding",
"index": index,
"embedding": (
vector
if body.encoding_format == "float"
else _encode_base64(vector)
),
}
)
prompt_tokens = max(1, sum(len(text) for text in texts) // 4)
return {
"object": "list",
"data": data,
"model": body.model or "mock-embedding",
"usage": {"prompt_tokens": prompt_tokens, "total_tokens": prompt_tokens},
}

93
src/mock_provider/main.py Normal file
View File

@ -0,0 +1,93 @@
"""A deterministic, OpenAI-compatible provider for local and CI use.
Lets Honcho run with no model provider, no API key, and no spend. It answers
``/v1/chat/completions`` and ``/v1/embeddings`` with obviously-synthetic content
derived from the request, so the same request always produces the same response.
Runs as its own service from the standard Honcho image:
fastapi run --host 0.0.0.0 src/mock_provider/main.py
Point Honcho at it with three variables all three are required:
LLM_OPENAI_API_KEY=any-non-empty-string # only truthiness is checked; value ignored
LLM_OPENAI_BASE_URL=http://mock-provider:8000/v1
EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=http://mock-provider:8000/v1
The key's *value* is never checked — not by this mock, which reads no
Authorization header, and not by Honcho, which only tests it for truthiness
before constructing the client (``src/llm/registry.py``). Set the base URL
without it and the client is never built, so the base URL is silently ignored.
Keep the value obviously fake: if a module ever escapes the base-URL override it
then 401s against the real provider instead of spending.
Embeddings resolve through a separate client that reads the base URL only from
the per-module override, so without the third variable embedding calls go to
api.openai.com for real. Do not set any per-module credential override
(``..._OVERRIDES__API_KEY`` / ``API_KEY_ENV``) that makes the module ignore the
global base URL.
Embeddings are hash-derived and carry no semantic similarity. Recall assertions
against this provider must use lexical/full-text search, not vector ranking.
"""
from __future__ import annotations
from typing import Any
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from src.mock_provider import chat, embeddings
app = FastAPI(
title="Honcho Mock Provider",
description="Deterministic OpenAI-compatible endpoint for local and CI use.",
version="1.0.0",
)
@app.exception_handler(RequestValidationError)
async def openai_error_response(
_request: Request, exc: RequestValidationError
) -> JSONResponse:
"""Answer a malformed request the way the real API does.
FastAPI's default is a 422 carrying its own error shape. Mid-run that reads
as a Honcho bug rather than a bad request, and it is not what an OpenAI
client expects the real API returns 400 with an ``error`` envelope, so
that is what a faithful mock returns.
"""
return JSONResponse(
status_code=400,
content={
"error": {
"message": f"Invalid request: {exc.errors()}",
"type": "invalid_request_error",
"param": None,
"code": None,
}
},
)
# Mounted at both prefixes so the base URL works with or without /v1.
for _router in (chat.router, embeddings.router):
app.include_router(_router, prefix="/v1")
app.include_router(_router)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "provider": "mock"}
@app.get("/{path:path}")
async def catch_all(path: str) -> dict[str, Any]:
"""Answer any other GET, so a bare ``/`` works as a container healthcheck.
Deliberately GET-only: an unimplemented POST returns 405 rather than a
plausible-looking 200, so a missing endpoint fails loudly.
"""
return {"object": "mock", "path": path, "detail": "mock provider placeholder"}

View File

@ -0,0 +1,373 @@
"""Generate a conforming instance from a JSON Schema.
The deriver is a structured-output caller: it sends a schema and parses the
reply back into a Pydantic model. A mock that answers with prose does not fail
loudly ``repair_response_model_json`` swallows the error and hands back an
empty ``PromptRepresentation``, which reads as "the deriver found nothing"
rather than "the mock is wrong". So generation is driven by the schema that was
actually sent, ``$ref`` indirection and all.
Values are derived from a hash of the property path, so the same schema always
produces the same instance and two different fields never collide.
Not reused from ``src/utils/schema_conversion.py``, despite the overlapping
``$ref``/``$defs`` handling, because that module answers a different question and
does so under an incompatible contract. It builds a Pydantic *model class* where
this needs an *instance*; it raises by design (conversion doubles as validation,
surfaced to callers as a 422) where a mock must degrade rather than turn its own
defect into a 500; and it rejects both ``allOf`` and recursive ``$ref`` the
latter being ordinary input here, since reasoning-tree schemas nest premises
inside conclusions.
"""
from __future__ import annotations
import hashlib
from typing import Any
from src.mock_provider.coerce import as_dict, as_int, as_list, as_str
# Depth cap for self-referential schemas. Reasoning-tree models nest premises
# inside conclusions, so a $ref cycle is normal input, not a malformed schema.
MAX_DEPTH = 6
# Absolute cap. Past MAX_DEPTH a cycle is expected to terminate on a `default`
# or a nullable/optional branch; a required, non-nullable self-reference has
# neither and would recurse until Python raises RecursionError. Degrading to an
# empty container may violate the schema, but a mock must not turn its own
# defect into a 500. Set well clear of MAX_DEPTH so no schema that terminates
# on its own ever reaches it.
HARD_MAX_DEPTH = MAX_DEPTH * 4
_WORDS = (
"synthetic",
"placeholder",
"mock",
"sample",
"fixture",
"stub",
"generated",
"example",
"inert",
"dummy",
)
def _seed(path: str) -> int:
return int.from_bytes(hashlib.sha256(path.encode()).digest()[:8], "big")
def _phrase(path: str, words: int = 6) -> str:
"""An obviously-synthetic sentence, stable for a given path."""
seed = _seed(path)
picked = [_WORDS[(seed >> (i * 5)) % len(_WORDS)] for i in range(words)]
return f"[mock] {' '.join(picked)}"
def _resolve(schema: dict[str, Any], root: dict[str, Any]) -> dict[str, Any]:
"""Follow a local ``$ref`` chain to the schema it points at.
Only local refs are supported: the mock never fetches over the network, and
Pydantic's ``model_json_schema()`` only ever emits ``#/$defs/...``.
"""
seen: set[str] = set()
current = schema
while "$ref" in current:
ref = as_str(current["$ref"])
if ref is None or not ref.startswith("#/") or ref in seen:
return {}
seen.add(ref)
target: dict[str, Any] | None = root
for part in ref[2:].split("/"):
if target is None or part not in target:
return {}
target = as_dict(target[part])
if target is None:
return {}
current = target
return current
def _merge_all_of(schema: dict[str, Any], root: dict[str, Any]) -> dict[str, Any]:
"""Flatten ``allOf`` into the parent so one pass can read properties off it."""
branches = as_list(schema.get("allOf"))
if branches is None:
return schema
merged: dict[str, Any] = {k: v for k, v in schema.items() if k != "allOf"}
for branch in branches:
resolved_branch = as_dict(branch)
if resolved_branch is None:
continue
resolved = _resolve(resolved_branch, root)
for key, value in resolved.items():
if key == "properties":
properties = as_dict(value)
if properties is not None:
# First branch to define a property wins. Strictly, `allOf`
# requires every branch's constraints to apply, so a schema
# splitting `minimum` and `maximum` for one property across
# two branches generates a value satisfying only one of
# them. Not merged recursively because Pydantic's `allOf` is
# always a $ref plus sibling annotations — it never repeats
# a property key, let alone with conflicting constraints.
existing = as_dict(merged.get("properties")) or {}
merged["properties"] = {**properties, **existing}
continue
if key == "required":
required = as_list(value)
if required is not None:
previous = as_list(merged.get("required")) or []
merged["required"] = list({*previous, *required})
continue
merged.setdefault(key, value)
return merged
def _infer_type(schema: dict[str, Any]) -> str:
"""Best-effort type when the schema omits an explicit ``type``."""
declared = schema.get("type")
if (name := as_str(declared)) is not None:
return name
if (names := as_list(declared)) is not None:
# Nullable unions arrive as ["string", "null"]; prefer the real type.
for candidate in names:
if (candidate_name := as_str(candidate)) and candidate_name != "null":
return candidate_name
return "null"
if "properties" in schema:
return "object"
if "items" in schema:
return "array"
return "string"
def generate(schema: dict[str, Any], root: dict[str, Any] | None = None) -> Any:
"""Build a value satisfying ``schema``.
``root`` carries the document that ``$ref`` resolves against; it defaults to
``schema`` itself, which is the shape Pydantic emits.
"""
return _generate(schema, root if root is not None else schema, "$", 0)
def _generate(
schema: dict[str, Any], root: dict[str, Any], path: str, depth: int
) -> Any:
resolved = _merge_all_of(_resolve(schema, root), root)
if "const" in resolved:
return resolved["const"]
enum = as_list(resolved.get("enum"))
if enum:
return enum[_seed(path) % len(enum)]
if depth >= MAX_DEPTH and "default" in resolved:
return resolved["default"]
# `oneOf` is treated as `anyOf`: a branch is picked without checking that
# the result matches only that one. A `oneOf` whose branches overlap can
# therefore yield a value matching several, which `oneOf` forbids. Enforcing
# the cardinality needs a full JSON Schema validator to test the candidate
# against every branch, and Pydantic emits `anyOf` for unions — never
# `oneOf` — so nothing Honcho sends reaches the distinction.
for key in ("anyOf", "oneOf"):
branches = as_list(resolved.get(key))
if branches:
return _generate(_pick_branch(branches, root, depth), root, path, depth)
kind = _infer_type(resolved)
# Only the two recursive kinds need the absolute cap; scalars terminate.
if kind == "object":
if depth >= HARD_MAX_DEPTH:
return {}
return _generate_object(resolved, root, path, depth)
if kind == "array":
if depth >= HARD_MAX_DEPTH:
return []
return _generate_array(resolved, root, path, depth)
if kind == "integer":
return _bounded_int(resolved, path)
if kind == "number":
return float(_bounded_int(resolved, path))
if kind == "boolean":
return _seed(path) % 2 == 0
if kind == "null":
return None
return _generate_string(resolved, path)
def _pick_branch(
branches: list[Any], root: dict[str, Any], depth: int
) -> dict[str, Any]:
"""Choose a union member, preferring a non-null one.
Past the depth cap the order flips: a nullable recursive field terminates on
``null`` instead of nesting another level.
"""
resolved: list[dict[str, Any]] = []
for branch in branches:
branch_dict = as_dict(branch)
if branch_dict is not None:
resolved.append(_resolve(branch_dict, root))
if not resolved:
return {}
if depth >= MAX_DEPTH:
nulls = [b for b in resolved if _infer_type(b) == "null"]
if nulls:
return nulls[0]
non_null = [b for b in resolved if _infer_type(b) != "null"]
return non_null[0] if non_null else resolved[0]
def _generate_object(
schema: dict[str, Any], root: dict[str, Any], path: str, depth: int
) -> dict[str, Any]:
properties = as_dict(schema.get("properties"))
if properties is None:
return {}
# OpenAI structured outputs run in strict mode, where every property is
# required. Emitting the full property set satisfies both strict and loose
# schemas, so `required` is only consulted to decide what to drop once the
# depth cap has been hit.
declared_required = as_list(schema.get("required"))
required: set[str] = (
{name for name in (as_str(item) for item in declared_required) if name}
if declared_required is not None
else set(properties)
)
result: dict[str, Any] = {}
for name, subschema in properties.items():
if depth >= MAX_DEPTH and name not in required:
continue
child = as_dict(subschema)
if child is None:
continue
result[name] = _generate(child, root, f"{path}.{name}", depth + 1)
return result
def _generate_array(
schema: dict[str, Any], root: dict[str, Any], path: str, depth: int
) -> list[Any]:
# A fixed-length tuple is `prefixItems` with no `items`, which is what
# Pydantic emits for `tuple[str, int]`. Reading only `items` would return []
# for it and fail the minItems/maxItems the same schema carries.
prefix: list[Any] = []
prefix_items = as_list(schema.get("prefixItems"))
if prefix_items is not None:
for index, entry in enumerate(prefix_items):
child = as_dict(entry)
if child is not None:
prefix.append(_generate(child, root, f"{path}[{index}]", depth + 1))
items = as_dict(schema.get("items"))
min_items = as_int(schema.get("minItems"))
max_items = as_int(schema.get("maxItems"))
count = 2
if min_items is not None:
count = max(count, min_items)
if max_items is not None:
count = min(count, max_items)
if depth >= MAX_DEPTH:
count = min_items or 0
# `items` describes the positions after the prefix, so only the shortfall is
# filled. With `items` absent those positions are unconstrained rather than
# disallowed: an empty schema stands in, and the target drops to whatever
# minItems demands, so a bare `{"type": "array"}` still generates nothing.
trailing = items if items is not None else {}
target = count if items is not None else min(count, min_items or 0)
return prefix + [
_generate(trailing, root, f"{path}[{len(prefix) + i}]", depth + 1)
for i in range(max(0, target - len(prefix)))
]
def _generate_string(schema: dict[str, Any], path: str) -> str:
fmt = as_str(schema.get("format"))
if fmt == "date-time":
return "2020-01-01T00:00:00Z"
if fmt == "date":
return "2020-01-01"
if fmt == "uuid":
stem = hashlib.sha256(path.encode()).hexdigest()[:8]
return f"{stem}-0000-4000-8000-000000000000"
if fmt in ("uri", "url"):
return "https://mock.invalid/placeholder"
if fmt == "email":
return "placeholder@mock.invalid"
# `pattern` is not honoured: this phrase fails any regex narrower than it,
# so a pattern-constrained string generates a value its own schema rejects.
# Satisfying an arbitrary regex needs a generator library, and no Honcho
# response model carries a `pattern` — the only ones in the codebase are on
# API request models, which are never sent as a response_format.
value = _phrase(path)
min_length = as_int(schema.get("minLength"))
max_length = as_int(schema.get("maxLength"))
if min_length is not None and len(value) < min_length:
value = value.ljust(min_length, "x")
if max_length is not None and len(value) > max_length:
value = value[:max_length]
return value
def _bounded_int(schema: dict[str, Any], path: str) -> int:
low = as_int(schema.get("minimum"))
if (
low is None
and (exclusive := as_int(schema.get("exclusiveMinimum"))) is not None
):
low = exclusive + 1
high = as_int(schema.get("maximum"))
if (
high is None
and (exclusive := as_int(schema.get("exclusiveMaximum"))) is not None
):
high = exclusive - 1
if low is not None and high is not None:
span = high - low
value = low + (_seed(path) % (span + 1) if span > 0 else 0)
elif low is not None:
value = low + (_seed(path) % 8)
elif high is not None:
value = high - (_seed(path) % 8)
else:
value = _seed(path) % 100
return _snap_to_multiple(value, as_int(schema.get("multipleOf")), low, high)
def _snap_to_multiple(
value: int, multiple: int | None, low: int | None, high: int | None
) -> int:
"""Move ``value`` onto a multiple of ``multiple``, staying within bounds.
Integer ``multipleOf`` only. The spec allows a fractional one, and an
integer can satisfy it (3 is a multiple of 1.5), but honouring it needs
exact-decimal arithmetic to avoid float drift deciding validity. ``as_int``
rejects it, so the constraint is dropped rather than approximated no
Honcho response model emits ``multipleOf`` at all.
"""
if multiple is None or multiple <= 0:
return value
# Floor division, so a negative value snaps down to the next multiple below.
snapped = (value // multiple) * multiple
if low is not None and snapped < low:
snapped = -(-low // multiple) * multiple # smallest multiple >= low
if high is not None and snapped > high:
snapped = (high // multiple) * multiple # largest multiple <= high
# No multiple exists in the window, so the schema is unsatisfiable. An
# in-range value breaks the constraint the caller is less likely to check.
if (low is not None and snapped < low) or (high is not None and snapped > high):
return value
return snapped

View File

@ -0,0 +1,71 @@
"""Request models for the mock provider's OpenAI-compatible endpoints.
Validating the request envelope rather than hand-coercing it makes the mock
behave like the thing it mocks: real OpenAI answers a malformed request with a
400 and an error envelope, and ``openai_error_response`` in ``main`` turns
Pydantic's failure into exactly that.
Two deliberate choices:
- ``extra="allow"`` on every model, and every field optional. Validation should
fire on a wrong *type* (a string where a list belongs), never on a field this
mock has not heard of otherwise a new upstream parameter turns a working
setup into a hard failure.
- Open-ended payloads stay ``dict[str, Any]``. ``response_format`` carries an
arbitrary caller-supplied JSON Schema, so only its envelope is worth typing;
``schema_gen`` walks the rest.
"""
from __future__ import annotations
from typing import Annotated, Any, ClassVar, Literal
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt
class MockRequest(BaseModel):
"""Permissive base: unknown fields pass through untouched."""
model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow")
class ChatMessage(MockRequest):
role: str | None = None
# Multimodal requests send a list of content parts rather than a string, so
# this cannot narrow further.
content: Any = None
class StreamOptions(MockRequest):
# Typed rather than left as a dict because the usage chunk is conditional on
# it. StrictBool for the same reason `dimensions` is StrictInt: plain `bool`
# coerces "yes"/"on"/"true"/"1", so a string would quietly decide the shape
# of the stream instead of failing the way the real API does.
include_usage: StrictBool = False
class ChatCompletionRequest(MockRequest):
model: str | None = None
messages: list[ChatMessage] = []
response_format: dict[str, Any] | None = None
tools: list[dict[str, Any]] | None = None
# StrictBool because this one field decides between two response *shapes* —
# a JSON body or an SSE stream — so coercing a string here is the difference
# between a working client and one that hangs waiting for events.
stream: StrictBool = False
stream_options: StreamOptions | None = None
class EmbeddingsRequest(MockRequest):
# Every input shape the OpenAI embeddings API accepts. Pydantic's smart
# union keeps list[str] and list[int] apart instead of coercing one to the
# other.
input: str | list[str] | list[int] | list[list[int]] | None = None
model: str | None = None
# StrictInt because bool is an int subclass: a JSON `true` here would
# otherwise silently become a one-dimensional vector. gt=0 because the real
# API rejects a non-positive width, and substituting the default instead
# would answer a bad request with a plausible-looking vector.
dimensions: Annotated[StrictInt, Field(gt=0)] | None = None
# The SDK omits this only when it wants base64, so absent means base64.
encoding_format: Literal["float", "base64"] = "base64"

View File

@ -33,7 +33,7 @@ from src.dependencies import tracked_db
from src.embedding_client import embedding_client
from src.exceptions import VectorStoreError
from src.reconciler.sync_vectors import (
_backoff_eligible, # pyright: ignore[reportPrivateUsage]
backoff_eligible,
build_message_vector_record,
compute_chunk_positions,
)
@ -177,7 +177,7 @@ async def _claim_and_lease(message_ids: list[str]) -> list[_ClaimedChunk]:
and_(
models.MessageEmbedding.message_id.in_(message_ids),
models.MessageEmbedding.sync_state == "pending",
_backoff_eligible(models.MessageEmbedding.last_sync_at),
backoff_eligible(models.MessageEmbedding.last_sync_at),
)
)
.order_by(models.MessageEmbedding.message_id, models.MessageEmbedding.id)

View File

@ -39,7 +39,7 @@ MAX_SYNC_ATTEMPTS = 20 # After this many failures, mark as failed
SYNC_BACKOFF = datetime.timedelta(minutes=10)
def _backoff_eligible(
def backoff_eligible(
last_sync_at: InstrumentedAttribute[datetime.datetime | None],
) -> ColumnElement[bool]:
"""Rows are eligible for sync if never attempted or past the backoff window."""
@ -92,7 +92,7 @@ async def _get_documents_needing_sync(
and_(
models.Document.deleted_at.is_(None),
models.Document.sync_state == "pending", # Only pending items
_backoff_eligible(models.Document.last_sync_at),
backoff_eligible(models.Document.last_sync_at),
)
)
.order_by(models.Document.last_sync_at.asc().nullsfirst())
@ -132,7 +132,7 @@ async def _get_message_embeddings_needing_sync(
.where(
and_(
models.MessageEmbedding.sync_state == "pending",
_backoff_eligible(models.MessageEmbedding.last_sync_at),
backoff_eligible(models.MessageEmbedding.last_sync_at),
)
)
.group_by(models.MessageEmbedding.message_id)
@ -153,7 +153,7 @@ async def _get_message_embeddings_needing_sync(
and_(
models.MessageEmbedding.message_id.in_(message_ids),
models.MessageEmbedding.sync_state == "pending",
_backoff_eligible(models.MessageEmbedding.last_sync_at),
backoff_eligible(models.MessageEmbedding.last_sync_at),
)
)
.order_by(models.MessageEmbedding.message_id, models.MessageEmbedding.id)

View File

@ -0,0 +1,41 @@
"""Deriver work metrics as JSON, with the age of the measurement alongside them."""
from logging import getLogger
from fastapi import APIRouter, HTTPException
from src.backlog import DeriverMetricsPoller
logger = getLogger(__name__)
router = APIRouter(prefix="/deriver", tags=["deriver"])
_poller: DeriverMetricsPoller | None = None
def set_deriver_metrics_poller(poller: DeriverMetricsPoller | None) -> None:
global _poller
_poller = poller
@router.get("/metrics")
async def get_deriver_metrics_response() -> dict[str, float | int]:
"""Seconds of outstanding deriver work, plus the raw counts behind it."""
snapshot = _poller.snapshot if _poller is not None else None
if snapshot is None or snapshot.measured_at is None:
raise HTTPException(
status_code=503, detail="No deriver measurement available yet"
)
return {
"outstanding_work_seconds": snapshot.signal_seconds,
"eligible_work_units": snapshot.stats.eligible_work_units,
"claimed_work_units": snapshot.stats.claimed_work_units,
"pending_items": snapshot.stats.pending_items,
"oldest_pending_age_seconds": snapshot.stats.oldest_pending_age_seconds,
"embeddings_pending": snapshot.stats.embeddings_pending,
"embeddings_pending_due": snapshot.stats.embeddings_pending_due,
"dreams_due": snapshot.dreams_due,
"measured_at": snapshot.measured_at,
"measurement_age_seconds": snapshot.age_seconds or 0.0,
}

Some files were not shown because too many files have changed in this diff Show More