chore: merge latest main into session activity branch
This commit is contained in:
commit
08d0893f59
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
3.11
|
||||
3.13
|
||||
|
|
|
|||
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -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.10–3.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 2–4 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
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
51
README.md
51
README.md
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
---
|
||||
|
||||

|
||||

|
||||
[](https://pypi.org/project/honcho-ai/)
|
||||
[](https://npmjs.org/package/@honcho-ai/sdk)
|
||||
[](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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.10–3.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 2–4 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`
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})()
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
openapi: post /v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions
|
||||
---
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
openapi: post /v3/workspaces/{workspace_id}/scopes
|
||||
---
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
openapi: post /v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/list
|
||||
---
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
openapi: get /v3/workspaces/{workspace_id}/scopes/{scope_id}/status
|
||||
---
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
openapi: get /v3/workspaces/{workspace_id}/scopes/{scope_id}
|
||||
---
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
openapi: post /v3/workspaces/{workspace_id}/scopes/list
|
||||
---
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
openapi: delete /v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/{session_id}
|
||||
---
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
node_modules
|
||||
dist
|
||||
|
|
@ -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]
|
||||
|
|
@ -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' })
|
||||
```
|
||||
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
.wrangler
|
||||
.dev.vars
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
dist
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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>"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
[loader]
|
||||
".md" = "text"
|
||||
|
||||
[run]
|
||||
silent = true
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -10,5 +10,5 @@
|
|||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "src/**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
12
src/main.py
12
src/main.py
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
"""Deterministic OpenAI-compatible provider for local and CI use."""
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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},
|
||||
}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue