Merge branch 'main' into fix/deriver-dont-silently-drop-saves
Resolve live_llm README conflict by keeping both oversize-truncate coverage (this PR) and EmbeddingModelConfig.timeout coverage (#1024).
This commit is contained in:
commit
db9d95b408
|
|
@ -0,0 +1,5 @@
|
|||
# Shell entrypoints are executed with sh/dash inside the container image. A
|
||||
# Windows checkout with core.autocrlf=true rewrites them to CRLF, and dash
|
||||
# then aborts with "set: Illegal option" because the carriage return becomes
|
||||
# part of the "-e" flag argument (docker/entrypoint.sh).
|
||||
*.sh text eol=lf
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
## Description
|
||||
|
||||
<!-- 2-3 sentences about what problem this PR solves and how -->
|
||||
|
||||
## Proofs
|
||||
|
||||
<!-- Add screenshots, logs, files as a proof that this change works -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] This PR is correlated to an existing issue, and I understand it will be closed if that issue does not have the `maintainer-approved` label.
|
||||
|
||||
<!-- Fixes #XXX -->
|
||||
|
|
@ -77,6 +77,8 @@ model = "text-embedding-3-small"
|
|||
# Optional provider request input cap. Useful for OpenAI-compatible embedding
|
||||
# APIs with smaller limits, such as DashScope text-embedding-v4.
|
||||
# max_batch_size = 10
|
||||
# Optional client HTTP timeout in seconds (OpenAI + Gemini).
|
||||
# timeout = 90.0
|
||||
|
||||
# Optional module-level endpoint overrides
|
||||
# [embedding.model_config.overrides]
|
||||
|
|
|
|||
|
|
@ -267,6 +267,7 @@ EMBEDDING_MAX_TOKENS_PER_REQUEST=300000
|
|||
EMBEDDING_MODEL_CONFIG__TRANSPORT=openai # openai, gemini
|
||||
EMBEDDING_MODEL_CONFIG__MODEL=text-embedding-3-small
|
||||
EMBEDDING_MODEL_CONFIG__MAX_BATCH_SIZE=10 # optional per-request input cap
|
||||
EMBEDDING_MODEL_CONFIG__TIMEOUT=90.0 # optional client HTTP timeout (seconds)
|
||||
|
||||
# Optional endpoint overrides
|
||||
EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=http://localhost:8000/v1
|
||||
|
|
@ -279,6 +280,13 @@ document a per-request limit. Set it when an OpenAI-compatible embedding
|
|||
provider accepts fewer inputs per request, such as DashScope
|
||||
`text-embedding-v4` with a limit of 10.
|
||||
|
||||
`EMBEDDING_MODEL_CONFIG__TIMEOUT` is an optional client HTTP timeout in
|
||||
seconds. OpenAI-compatible transports receive it as the SDK `timeout` kwarg
|
||||
(omitted when unset, so the SDK default applies). Gemini converts it to
|
||||
milliseconds on `http_options.timeout`, and keeps its existing 10-minute
|
||||
default when unset. The value is validated at config load the same way as
|
||||
LLM `provider_params.timeout` (positive, finite number of seconds).
|
||||
|
||||
Forwarding `dimensions=` to OpenAI-compatible providers is controlled by `EMBEDDING_MODEL_CONFIG__DIMENSIONS_MODE`:
|
||||
|
||||
- `auto` (default): forwards `dimensions=` when **the operator has explicitly set `EMBEDDING_VECTOR_DIMENSIONS`** — provenance, not value — and the configured model is not on the known-rejecting list (currently `text-embedding-ada-002`). Explicit `EMBEDDING_VECTOR_DIMENSIONS=1536` *does* trigger the forward; this is how `text-embedding-3-large` truncation to 1536 is expressed. Deployments that leave the setting unset get their existing behavior (`dimensions=` is not forwarded).
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ source ~/.zshrc # or ~/.bashrc
|
|||
### Step 3: Install the Plugin
|
||||
|
||||
<Note>
|
||||
This plugin requires [Bun](https://bun.sh). If you don't have it: `curl -fsSL https://bun.sh/install | bash`
|
||||
This plugin requires [Node.js](https://nodejs.org) on your PATH.
|
||||
</Note>
|
||||
|
||||
Open Claude Code and run:
|
||||
|
|
@ -74,10 +74,11 @@ Claude will interview you about your personal preferences to kickstart a represe
|
|||
|
||||
- **Persistent Memory** — Claude remembers your preferences, projects, and context across sessions
|
||||
- **Survives Context Wipes** — Even when Claude's context window resets, memory persists
|
||||
- **Configurable Memory Injection** — Choose exactly what context is injected at session start and per turn
|
||||
- **Git Awareness** — Detects branch switches, commits, and changes made outside Claude
|
||||
- **Flexible Sessions** — Map sessions per directory, per git branch, or per chat instance
|
||||
- **AI Self-Awareness** — Claude knows what it was working on, even after restarts
|
||||
- **Cross-Tool Context** — Link workspaces across Claude Code, Cursor, and other hosts so context flows between tools
|
||||
- **Secret Redaction** — Built-in patterns (plus your own) scrub secrets from tool summaries before upload
|
||||
- **Team Support** — Multiple people can share a workspace and build context together
|
||||
- **MCP Tools** — Search memory, query knowledge about you, and save insights
|
||||
|
||||
|
|
@ -97,8 +98,7 @@ All configuration lives in a single global file at `~/.honcho/config.json`. You
|
|||
"hosts": {
|
||||
"claude_code": {
|
||||
"workspace": "claude_code", // Workspace for Claude Code sessions
|
||||
"aiPeer": "claude", // AI identity in this workspace
|
||||
"linkedHosts": ["cursor"] // Read context from other hosts (optional)
|
||||
"aiPeer": "claude" // AI identity in this workspace
|
||||
},
|
||||
"cursor": {
|
||||
"workspace": "cursor",
|
||||
|
|
@ -112,6 +112,8 @@ All configuration lives in a single global file at `~/.honcho/config.json`. You
|
|||
|
||||
// Message handling
|
||||
"saveMessages": true,
|
||||
"saveToolUse": false, // Save [Tool] action summaries (default: false)
|
||||
"saveGitEvents": false, // Save [Git External] state-change events (default: false)
|
||||
"messageUpload": {
|
||||
"maxUserTokens": null, // Truncate user messages (null = no limit)
|
||||
"maxAssistantTokens": null, // Truncate assistant messages (null = no limit)
|
||||
|
|
@ -124,6 +126,19 @@ All configuration lives in a single global file at `~/.honcho/config.json`. You
|
|||
"ttlSeconds": 300, // Cache TTL for context
|
||||
"skipDialectic": false // Skip dialectic chat() calls in user-prompt hook
|
||||
},
|
||||
"reasoningLevel": "medium", // Default dialectic reasoning tier: "minimal" | "low" | "medium" | "high" | "max"
|
||||
|
||||
// Memory injection (see "Memory Injection" below)
|
||||
"injection": {
|
||||
"sessionStart": ["directives", "summary", "peerCard"],
|
||||
"perTurn": ["userContext"]
|
||||
},
|
||||
|
||||
// On-demand recall tool (see "The honcho_remember Tool" below)
|
||||
"rememberTool": false,
|
||||
|
||||
// Observation mode
|
||||
"observationMode": "unified", // "unified" (default) | "directional"
|
||||
|
||||
// Endpoint
|
||||
"endpoint": {
|
||||
|
|
@ -132,7 +147,8 @@ All configuration lives in a single global file at `~/.honcho/config.json`. You
|
|||
},
|
||||
|
||||
// Miscellaneous
|
||||
"localContext": { "maxEntries": 50 },
|
||||
"redactPatterns": [], // Extra regexes redacted from tool summaries (additive to built-in secret patterns)
|
||||
"statusline": "on", // Memory statusline visibility: "on" | "off"
|
||||
"enabled": true,
|
||||
"logging": true,
|
||||
|
||||
|
|
@ -141,7 +157,82 @@ All configuration lives in a single global file at `~/.honcho/config.json`. You
|
|||
}
|
||||
```
|
||||
|
||||
### Session Strategies
|
||||
## Memory Injection
|
||||
|
||||
The `injection` config block controls exactly what memory is injected into Claude's context, on two surfaces: **once at session start** and **per prompt**. Each surface selects zero or more components; retrieval knobs shape what those components emit.
|
||||
|
||||
Configure it interactively with `/honcho:config` (under the memory injection settings), by asking Claude to use `set_config`, or by editing `~/.honcho/config.json` directly.
|
||||
|
||||
### Session-Start Components
|
||||
|
||||
Injected once when a session opens. Default: `["directives", "summary", "peerCard"]`.
|
||||
|
||||
| Component | What it injects |
|
||||
| --- | --- |
|
||||
| `directives` | Static memory-usage guidance — tells Claude to treat injected memory as background, use `chat`/`search` for recall, and save insights with `create_conclusion` |
|
||||
| `summary` | The session's long summary narrative (skipped on a fresh session) |
|
||||
| `peerCard` | Your peer card — a structured identity/attribute list |
|
||||
| `peerRepresentation` | Your full derived representation, injected at full length |
|
||||
| `briefing` | A nudge for Claude to call the `get_briefing` MCP tool instead of injecting the summary and peer card inline. The tool call renders as an expandable row in the UI, so you can see exactly what was loaded. Use it *in place of* `summary`/`peerCard`, not alongside them |
|
||||
|
||||
```json
|
||||
{ "injection": { "sessionStart": ["directives", "briefing"] } }
|
||||
```
|
||||
|
||||
### Per-Turn Components
|
||||
|
||||
Injected with each non-trivial prompt. Default: `["userContext"]`.
|
||||
|
||||
| Component | What it injects |
|
||||
| --- | --- |
|
||||
| `userContext` | A fresh, prompt-scoped context fetch for *you* — conclusions selected by semantic search over your representation, shaped by the retrieval knobs below |
|
||||
| `assistantContext` | The same context fetch, but for the AI peer — what Honcho has derived about the assistant itself |
|
||||
| `sessionContext` | Recent raw messages from the currently mapped Honcho session, which can span other Claude instances sharing the session name |
|
||||
| `dialectic` | A reasoned `chat()` answer over your representation, seeded from `dialecticTemplate`. Off by default — it is much slower than a context fetch, so it runs on its own time budget |
|
||||
|
||||
### Retrieval Knobs
|
||||
|
||||
| Field | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `searchTopK` | `10` | Top-K conclusions pulled by the context fetch's semantic search |
|
||||
| `maxConclusions` | `15` | Max conclusions injected per context fetch |
|
||||
| `searchMaxDistance` | `0.6` | Max cosine distance for the semantic search — lower is stricter |
|
||||
| `searchQuerySource` | `"prompt"` | What drives the per-turn search: the raw `"prompt"` or extracted `"topics"` |
|
||||
| `sessionContextTokens` | `1500` | Token budget for the `sessionContext` message fetch |
|
||||
| `dialecticTemplate` | compact factual recall | Query template for the `dialectic` component; the user's prompt is substituted into `%{user_query}` |
|
||||
| `dialecticReasoning` | `"medium"` | Reasoning tier for the per-turn `dialectic` call — kept separate from the top-level `reasoningLevel` so per-turn dialectic can stay cheap |
|
||||
|
||||
If injected context feels off-topic, lower `searchMaxDistance` (stricter relevance); if it feels too sparse, raise it or bump `searchTopK`.
|
||||
|
||||
### Injection Visibility
|
||||
|
||||
By default, per-turn components report a one-line summary in the terminal instead of printing their full contents. To see exactly what a component injects, list it in `showContents`:
|
||||
|
||||
```json
|
||||
{ "injection": { "showContents": ["userContext", "sessionContext"] } }
|
||||
```
|
||||
|
||||
Components not listed still inject — they just stay quiet about it.
|
||||
|
||||
## The `honcho_remember` Tool
|
||||
|
||||
An experimental on-demand recall tool. When enabled, Claude gets a `honcho_remember` MCP tool that fans out up to 5 parallel dialectic queries about you and returns per-question answers — useful before starting a task, when catching up ("where were we?"), or whenever your history could shape the response.
|
||||
|
||||
To enable it, just ask Claude: *"Set my Honcho rememberTool config to true"* (it uses the `set_config` tool). Or set it in `~/.honcho/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hosts": {
|
||||
"claude_code": { "rememberTool": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then restart Claude Code — MCP tools register at startup.
|
||||
|
||||
When it's on, the injected session-start directives steer Claude to use it proactively as the primary recall path.
|
||||
|
||||
## Session Strategies
|
||||
|
||||
Session strategy controls how Honcho maps your conversations to sessions:
|
||||
|
||||
|
|
@ -153,7 +244,20 @@ Session strategy controls how Honcho maps your conversations to sessions:
|
|||
|
||||
Session names are prefixed with your `peerName` by default (e.g., `alice-my-project`). Set `sessionPeerPrefix: false` if you're the only user and want shorter names.
|
||||
|
||||
### Host-Aware Configuration
|
||||
Linked git worktrees resolve to their main repository's session, so a worktree shares memory with the repo it belongs to.
|
||||
|
||||
## Observation Mode
|
||||
|
||||
Controls how Honcho stores and retrieves conclusions about you. Change it via `set_config` or edit `config.json` directly. Requires a Claude Code restart.
|
||||
|
||||
| Mode | Behavior | Best for |
|
||||
| --- | --- | --- |
|
||||
| `unified` (default) | All agents write to your self-observation collection (`observer=you, observed=you`). Conclusions are portable — switch between agents without losing memory. | Most users — a unified context hub across agents |
|
||||
| `directional` | Each AI peer keeps its own separate view of you (`observer=aiPeer, observed=you`). | Multi-peer workspaces where you want isolated per-agent representations |
|
||||
|
||||
Switching modes doesn't automatically migrate existing conclusions — each mode reads from a different collection. The [plugin repository](https://github.com/plastic-labs/claude-honcho) ships a `migrate-observations.py` script to copy conclusions between collections.
|
||||
|
||||
## Host-Aware Configuration
|
||||
|
||||
The plugin auto-detects which tool is running it (Claude Code, Cursor, etc.) and reads the matching block from `hosts`. Each host gets its own workspace and AI peer name, so data stays separated by default.
|
||||
|
||||
|
|
@ -163,28 +267,7 @@ The plugin auto-detects which tool is running it (Claude Code, Cursor, etc.) and
|
|||
3. `CURSOR_PROJECT_DIR` env var (Cursor child process)
|
||||
4. Default: `claude_code`
|
||||
|
||||
### Linking Hosts for Cross-Tool Context
|
||||
|
||||
If you use both Claude Code and Cursor, you can link them so context from one is readable in the other. Writes always stay in the current host's workspace — linking only adds read access.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"hosts": {
|
||||
"claude_code": {
|
||||
"workspace": "claude_code",
|
||||
"aiPeer": "claude",
|
||||
"linkedHosts": ["cursor"] // Claude Code can read Cursor's context
|
||||
},
|
||||
"cursor": {
|
||||
"workspace": "cursor",
|
||||
"aiPeer": "cursor",
|
||||
"linkedHosts": ["claude_code"] // Cursor can read Claude Code's context
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or use `/honcho:config` and select **Workspace > Linking** to set this up interactively.
|
||||
Each host block can also carry its own `apiKey` (useful when different tools authenticate against different Honcho orgs) and override most settings — `sessionStrategy`, `injection`, `rememberTool`, `observationMode`, and more.
|
||||
|
||||
### Global Override
|
||||
|
||||
|
|
@ -237,6 +320,14 @@ Multiple people can share context by pointing to the same workspace. Each person
|
|||
|
||||
Both Alice and Bob write to the `team-acme` workspace. Their sessions are namespaced (e.g., `alice-my-project`, `bob-my-project`) so data doesn't collide, but Honcho's dialectic reasoning can draw on context from both users.
|
||||
|
||||
## Secret Redaction
|
||||
|
||||
Tool-capture summaries are scrubbed against built-in secret patterns (API keys, tokens, credentials) before upload. Add your own patterns with `redactPatterns` — an array of regexes applied on top of the defaults:
|
||||
|
||||
```json
|
||||
{ "redactPatterns": ["internal-[a-z0-9]+", "ACME_SECRET_\\w+"] }
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
The plugin logs activity to `~/.honcho/` and to Claude Code's verbose mode, so you can see exactly how Honcho is being used — what context is loaded at session start, what messages are saved, and what context is injected into Claude's prompts. Set `logging` to `false` in your config (or `HONCHO_LOGGING=false`) to disable file logging.
|
||||
|
|
@ -247,20 +338,30 @@ The plugin provides these tools via MCP:
|
|||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `search` | Semantic search across session messages |
|
||||
| `chat` | Query Honcho's knowledge about the user |
|
||||
| `search` | Semantic search across session messages and saved conclusions |
|
||||
| `chat` | Query Honcho's knowledge about the user (dialectic reasoning) |
|
||||
| `create_conclusion` | Save insights about the user to memory |
|
||||
| `list_conclusions` | List saved conclusions |
|
||||
| `query_conclusions` | Semantic search over saved conclusions |
|
||||
| `delete_conclusion` | Delete a conclusion by ID |
|
||||
| `get_briefing` | Load the session briefing: session summary + peer card |
|
||||
| `get_context` | Retrieve the full context object (representation + peer card) |
|
||||
| `get_representation` | Retrieve the user's representation string |
|
||||
| `get_config` | View current configuration and status |
|
||||
| `set_config` | Change any configuration field programmatically |
|
||||
| `honcho_remember` | Fan-out dialectic recall (only registered when `rememberTool: true`) |
|
||||
|
||||
## Skills (Slash Commands)
|
||||
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
| `/honcho:status` | Show current memory status and connection info |
|
||||
| `/honcho:config` | Interactive configuration menu |
|
||||
| `/honcho:config` | Interactive configuration menu (including memory injection settings) |
|
||||
| `/honcho:setup` | First-time setup — validate API key and create config |
|
||||
| `/honcho:interview` | Interview to capture stable, cross-project user preferences |
|
||||
| `/honcho:briefing` | Load the session briefing via a visible tool call |
|
||||
| `/honcho:import` | Backfill past Claude Code sessions into Honcho memory |
|
||||
| `/honcho:insights` | Distill memory into CLAUDE.md edits, style rules, and skill ideas |
|
||||
|
||||
### The Interview
|
||||
|
||||
|
|
@ -289,6 +390,8 @@ Environment variables work for initial bootstrap (before a config file exists).
|
|||
| `HONCHO_ENDPOINT` | No | `production` | `production`, `local`, or a full URL |
|
||||
| `HONCHO_ENABLED` | No | `true` | Set to `false` to disable |
|
||||
| `HONCHO_SAVE_MESSAGES` | No | `true` | Set to `false` to stop saving messages |
|
||||
| `HONCHO_SAVE_TOOL_USE` | No | `false` | Set to `true` to save tool action summaries |
|
||||
| `HONCHO_SAVE_GIT_EVENTS` | No | `false` | Set to `true` to save external git state-change events |
|
||||
| `HONCHO_LOGGING` | No | `true` | Set to `false` to disable file logging to `~/.honcho/` |
|
||||
|
||||
### Using a local Honcho instance
|
||||
|
|
|
|||
|
|
@ -239,38 +239,11 @@ To teach Goose the recommended memory flow, save the [instructions](https://raw.
|
|||
|
||||
---
|
||||
|
||||
## Optional Configuration
|
||||
## Workspace
|
||||
|
||||
You can target a specific workspace by adding an extra header. It's optional.
|
||||
Every workspace-scoped tool takes a `workspace_id` argument. You can also set `X-Honcho-Workspace-ID` on the connection; that value fills `workspace_id` when the argument is omitted.
|
||||
|
||||
| Header | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `Authorization` | *required* | `Bearer hch-your-key-here` |
|
||||
| `X-Honcho-Workspace-ID` | `"default"` | Isolate memory per project |
|
||||
|
||||
Example with all headers (Claude Desktop format):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"honcho": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://mcp.honcho.dev",
|
||||
"--header",
|
||||
"Authorization:${AUTH_HEADER}",
|
||||
"--header",
|
||||
"X-Honcho-Workspace-ID:${WORKSPACE_ID}"
|
||||
],
|
||||
"env": {
|
||||
"AUTH_HEADER": "Bearer hch-your-key-here",
|
||||
"WORKSPACE_ID": "my-project"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Use `list_workspaces` to discover IDs (each result includes metadata and `created_at`), or `create_workspace` if none fit, then reuse the same ID for subsequent tool calls.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -26,15 +26,11 @@ A Cloudflare Worker that implements the [Model Context Protocol (MCP)](https://m
|
|||
}
|
||||
```
|
||||
|
||||
### Optional Headers
|
||||
|
||||
| Header | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `X-Honcho-Workspace-ID` | `"default"` | Workspace to operate in |
|
||||
Every workspace-scoped tool takes a `workspace_id` argument. If you set `X-Honcho-Workspace-ID` on the connection, that value fills `workspace_id` when the argument is omitted. Use `list_workspaces` to discover IDs.
|
||||
|
||||
## Available Tools
|
||||
|
||||
**Workspace:** `inspect_workspace` (aggregates metadata, configuration, and peer/session IDs), `list_workspaces` (enumerates accessible workspaces), `search` (semantic search scoped by optional peer/session params), `get_metadata`, `set_metadata`
|
||||
**Workspace:** `list_workspaces` (id, metadata, created_at), `create_workspace` (get-or-create with optional metadata), `inspect_workspace` (aggregates metadata, configuration, and peer/session IDs), `search` (semantic search scoped by optional peer/session params), `get_metadata`, `set_metadata`
|
||||
|
||||
**Peers:** `create_peer`, `list_peers`, `chat`, `get_peer_card`, `set_peer_card`, `get_peer_context`, `get_representation`
|
||||
|
||||
|
|
@ -50,7 +46,7 @@ A Cloudflare Worker that implements the [Model Context Protocol (MCP)](https://m
|
|||
src/
|
||||
index.ts # Worker entry point — parse config, delegate to MCP handler
|
||||
server.ts # createServer() — registers all tools on an McpServer
|
||||
config.ts # HonchoConfig, parseConfig(), createClient()
|
||||
config.ts # HonchoConfig, parseConfig(), createClientFactory()
|
||||
types.ts # ToolContext, result helpers
|
||||
tools/
|
||||
workspace.ts # inspect, list, search, metadata
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"": {
|
||||
"name": "honcho-mcp-proxy",
|
||||
"dependencies": {
|
||||
"@honcho-ai/sdk": "^2.0.0",
|
||||
"@honcho-ai/sdk": "^2.2.0",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"agents": "^0.4.0",
|
||||
"nanoid": "^5.1.7",
|
||||
|
|
@ -107,7 +107,7 @@
|
|||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
|
||||
|
||||
"@honcho-ai/sdk": ["@honcho-ai/sdk@2.0.1", "", { "dependencies": { "@types/node": "^24.0.1", "zod": "4.0.0" } }, "sha512-y/Wk49C0N1miI9BZTNWFIbzdUkMZfP4Do/EJ1q4lEIK+FAOKxQgces/zET3kPKV3zF9sOUl2pXrFb/XKYayeYw=="],
|
||||
"@honcho-ai/sdk": ["@honcho-ai/sdk@2.2.0", "", { "dependencies": { "zod": "4.0.0" } }, "sha512-SyygN+BrpUB2fRjhwcYmT+tcEhHrKmbj9nOZLVUFY7M5YBswJ+mZb/CeLpNbRh+QQTU8F8JWY3lQat95c6nwmA=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
|
||||
|
||||
|
|
@ -177,8 +177,6 @@
|
|||
|
||||
"@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
|
@ -471,8 +469,6 @@
|
|||
|
||||
"undici": ["undici@7.12.0", "", {}, "sha512-GrKEsc3ughskmGA9jevVlIOPMiiAHJ4OFUtaAH+NhfTUSiZ1wMPIQqQvAJUrJspFXJt3EBWgpAeoHEDVT1IBug=="],
|
||||
|
||||
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
|
||||
|
||||
"unenv": ["unenv@2.0.0-rc.17", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.4", "ohash": "^2.0.11", "pathe": "^2.0.3", "ufo": "^1.6.1" } }, "sha512-B06u0wXkEd+o5gOCMl/ZHl5cfpYbDZKAT+HWTL+Hws6jWu7dCiqBBXXXzMFcFVJb8D4ytAnYmxJA83uwOQRSsg=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
|
|
|||
|
|
@ -4,12 +4,21 @@
|
|||
|
||||
The simplest way to use Honcho for a standard user/assistant conversation. Three steps using the general tools.
|
||||
|
||||
Every workspace-scoped tool takes `workspace_id`. The simplest setup is for the client to set `X-Honcho-Workspace-ID` on the connection — then omit `workspace_id` on every call. Do not list or create a workspace just to rediscover a header that is already set.
|
||||
|
||||
If the header is unset and you don't already know the workspace:
|
||||
|
||||
1. Call `list_workspaces` and pick the workspace whose id or metadata best matches this work.
|
||||
2. If none fit, call `create_workspace` with a descriptive id (and optional metadata like `{ "project": "...", "purpose": "..." }`).
|
||||
3. Reuse that same `workspace_id` for the rest of the conversation.
|
||||
|
||||
### 1. Start a conversation (once per conversation)
|
||||
|
||||
Create a session and set up the user and assistant peers:
|
||||
|
||||
```
|
||||
create_session
|
||||
workspace_id: "<workspace-id>"
|
||||
session_id: "<unique-id>"
|
||||
```
|
||||
|
||||
|
|
@ -17,12 +26,15 @@ Then add peers to the session:
|
|||
|
||||
```
|
||||
create_peer
|
||||
workspace_id: "<workspace-id>"
|
||||
peer_id: "<user-name>"
|
||||
|
||||
create_peer
|
||||
workspace_id: "<workspace-id>"
|
||||
peer_id: "Assistant"
|
||||
|
||||
add_peers_to_session
|
||||
workspace_id: "<workspace-id>"
|
||||
session_id: "<session_id>"
|
||||
peers:
|
||||
- peer_id: "<user-name>"
|
||||
|
|
@ -39,6 +51,7 @@ Store the `session_id` for the rest of this conversation.
|
|||
|
||||
```
|
||||
chat
|
||||
workspace_id: "<workspace-id>"
|
||||
peer_id: "Assistant"
|
||||
query: "What communication style does this user prefer?"
|
||||
target_peer_id: "<user-name>"
|
||||
|
|
@ -58,6 +71,7 @@ This calls Honcho's reasoning system to answer your question about the user, gro
|
|||
|
||||
```
|
||||
add_messages_to_session
|
||||
workspace_id: "<workspace-id>"
|
||||
session_id: "<session_id>"
|
||||
messages:
|
||||
- peer_id: "<user-name>"
|
||||
|
|
@ -88,8 +102,9 @@ The full API for advanced use cases.
|
|||
|
||||
| Tool | When to use |
|
||||
| --- | --- |
|
||||
| `inspect_workspace` | Inspect a single workspace's details |
|
||||
| `list_workspaces` | Enumerate available workspaces |
|
||||
| `list_workspaces` | Discover available workspaces (id, metadata, created_at). No `workspace_id` needed. |
|
||||
| `create_workspace` | Get or create a workspace when none of the listed ones fit |
|
||||
| `inspect_workspace` | Inspect a single workspace's details. Requires `workspace_id`. |
|
||||
| `search` | Semantic search across messages — scope with optional `peer_id` or `session_id` params |
|
||||
| `get_metadata` | Read metadata for workspace, peer, or session (scope with optional `peer_id` or `session_id`) |
|
||||
| `set_metadata` | Store metadata for workspace, peer, or session (scope with optional `peer_id` or `session_id`) |
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
"deploy:staging": "wrangler deploy --env staging"
|
||||
},
|
||||
"dependencies": {
|
||||
"@honcho-ai/sdk": "^2.1.0",
|
||||
"@honcho-ai/sdk": "^2.2.0",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"agents": "^0.4.0",
|
||||
"nanoid": "^5.1.7",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
},
|
||||
{
|
||||
"name": "X-Honcho-Workspace-ID",
|
||||
"description": "Optional. Target Honcho workspace; defaults to 'default' when omitted.",
|
||||
"description": "Optional. Default Honcho workspace for tool calls. When set, it fills workspace_id on tools.",
|
||||
"isRequired": false,
|
||||
"isSecret": false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ import { Honcho } from "@honcho-ai/sdk";
|
|||
export interface HonchoConfig {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
workspaceId: string;
|
||||
/** From X-Honcho-Workspace-ID when set. */
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export interface Env {
|
||||
HONCHO_API_URL?: string;
|
||||
ALERT_WEBHOOK_URL?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -19,6 +21,9 @@ export interface Env {
|
|||
* instance (see the "Self-Hosted Honcho" section in README.md). It is
|
||||
* intentionally not exposed as a request header: routing public requests
|
||||
* to an internal URL would be a latency and security regression.
|
||||
*
|
||||
* Optional `X-Honcho-Workspace-ID` becomes the default `workspace_id` on
|
||||
* tools. If the header is omitted, each tool call must pass `workspace_id`.
|
||||
*/
|
||||
export function parseConfig(request: Request, env: Env = {}): HonchoConfig {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
|
|
@ -33,17 +38,60 @@ export function parseConfig(request: Request, env: Env = {}): HonchoConfig {
|
|||
throw new Error("Authorization header is empty after 'Bearer '.");
|
||||
}
|
||||
|
||||
const workspaceId =
|
||||
request.headers.get("X-Honcho-Workspace-ID")?.trim() || undefined;
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl: env.HONCHO_API_URL?.trim() || "https://api.honcho.dev",
|
||||
workspaceId: request.headers.get("X-Honcho-Workspace-ID")?.trim() || "default",
|
||||
workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
export function createClient(config: HonchoConfig): Honcho {
|
||||
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.";
|
||||
|
||||
export function resolveWorkspaceId(
|
||||
config: HonchoConfig,
|
||||
workspaceId?: string,
|
||||
): string {
|
||||
const id = workspaceId?.trim() || config.workspaceId?.trim();
|
||||
if (!id) {
|
||||
throw new Error(MISSING_WORKSPACE_ID_MESSAGE);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function createClient(
|
||||
config: HonchoConfig,
|
||||
workspaceId: string,
|
||||
): Honcho {
|
||||
return new Honcho({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseUrl,
|
||||
workspaceId: config.workspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Client used only for credential-scoped ops (list workspaces). */
|
||||
export function createUnscopedClient(config: HonchoConfig): Honcho {
|
||||
return new Honcho({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export function createClientFactory(
|
||||
config: HonchoConfig,
|
||||
): (workspaceId?: string) => Honcho {
|
||||
const cache = new Map<string, Honcho>();
|
||||
return (workspaceId?: string) => {
|
||||
const id = resolveWorkspaceId(config, workspaceId);
|
||||
let client = cache.get(id);
|
||||
if (!client) {
|
||||
client = createClient(config, id);
|
||||
cache.set(id, client);
|
||||
}
|
||||
return client;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { createMcpHandler } from "agents/mcp";
|
||||
import { parseConfig, createClient, type Env } from "./config.js";
|
||||
import {
|
||||
parseConfig,
|
||||
createClientFactory,
|
||||
createUnscopedClient,
|
||||
type Env,
|
||||
} from "./config.js";
|
||||
import { createServer } from "./server.js";
|
||||
|
||||
const CORS_ORIGIN = "*";
|
||||
|
|
@ -15,6 +20,7 @@ const CORS_HEADERS = {
|
|||
};
|
||||
|
||||
const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
|
||||
const HEALTHCHECK_TIMEOUT_MS = 15_000;
|
||||
|
||||
function resourceUrl(request: Request): string {
|
||||
return new URL(request.url).origin;
|
||||
|
|
@ -25,6 +31,41 @@ function authorizationServer(env: Env): string {
|
|||
}
|
||||
|
||||
export default {
|
||||
// Probes the authorization server API to confirm it is reachable and healthy.
|
||||
async scheduled(
|
||||
_controller: ScheduledController,
|
||||
env: Env,
|
||||
_executionCtx: ExecutionContext,
|
||||
): Promise<void> {
|
||||
const upstream = `${authorizationServer(env)}/health`;
|
||||
|
||||
let failure: string | null = null;
|
||||
try {
|
||||
const response = await fetch(upstream, {
|
||||
signal: AbortSignal.timeout(HEALTHCHECK_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) failure = `HTTP ${response.status}`;
|
||||
} catch (e) {
|
||||
failure = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
if (!failure) return;
|
||||
|
||||
console.error(`healthcheck failed: ${upstream} — ${failure}`);
|
||||
if (!env.ALERT_WEBHOOK_URL) return;
|
||||
|
||||
const alertResponse = await fetch(env.ALERT_WEBHOOK_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
content: `🚨 honcho-mcp cannot reach upstream API (${upstream}): ${failure}`,
|
||||
}),
|
||||
signal: AbortSignal.timeout(HEALTHCHECK_TIMEOUT_MS),
|
||||
});
|
||||
if (!alertResponse.ok) {
|
||||
throw new Error(`alert webhook failed: HTTP ${alertResponse.status}`);
|
||||
}
|
||||
},
|
||||
|
||||
async fetch(
|
||||
request: Request,
|
||||
env: Env,
|
||||
|
|
@ -42,6 +83,7 @@ export default {
|
|||
resource: resourceUrl(request),
|
||||
authorization_servers: [authorizationServer(env)],
|
||||
bearer_methods_supported: ["header"],
|
||||
scopes_supported: ["read", "write"],
|
||||
},
|
||||
{ headers: CORS_HEADERS },
|
||||
);
|
||||
|
|
@ -66,8 +108,11 @@ export default {
|
|||
}
|
||||
|
||||
try {
|
||||
const honcho = createClient(config);
|
||||
const server = createServer({ honcho, config });
|
||||
const server = createServer({
|
||||
config,
|
||||
clientFor: createClientFactory(config),
|
||||
unscoped: createUnscopedClient(config),
|
||||
});
|
||||
const handler = createMcpHandler(server, {
|
||||
route: "/",
|
||||
corsOptions: {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { z } from "zod";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { textResult, errorResult } from "../types.js";
|
||||
import { textResult, errorResult, workspaceIdSchema } from "../types.js";
|
||||
|
||||
export function register(server: McpServer, ctx: ToolContext) {
|
||||
// ── list_conclusions ────────────────────────────────────────────────
|
||||
|
|
@ -14,6 +14,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Returns conclusion objects with pagination metadata.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
peer_id: z.string().describe("The observer peer."),
|
||||
target_peer_id: z
|
||||
.string()
|
||||
|
|
@ -23,9 +24,9 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
),
|
||||
},
|
||||
},
|
||||
async ({ peer_id, target_peer_id }) => {
|
||||
async ({ workspace_id, peer_id, target_peer_id }) => {
|
||||
try {
|
||||
const peer = await ctx.honcho.peer(peer_id);
|
||||
const peer = await ctx.clientFor(workspace_id).peer(peer_id);
|
||||
const scope = target_peer_id
|
||||
? peer.conclusionsOf(target_peer_id)
|
||||
: peer.conclusions;
|
||||
|
|
@ -61,6 +62,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Returns an array of matching conclusions ranked by relevance.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
peer_id: z.string().describe("The observer peer."),
|
||||
query: z.string().describe("Semantic search query."),
|
||||
target_peer_id: z
|
||||
|
|
@ -71,19 +73,26 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
.number()
|
||||
.optional()
|
||||
.describe("Max results to return."),
|
||||
filters: z
|
||||
.record(z.string(), z.unknown())
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional: filter criteria, e.g. {"level": ["deductive", "inductive"]} to only return conclusions derived during dreaming. Levels: explicit (extracted directly from messages), deductive, inductive, contradiction. See https://honcho.dev/docs/v3/documentation/features/advanced/using-filters',
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ peer_id, query, target_peer_id, top_k }) => {
|
||||
async ({ workspace_id, peer_id, query, target_peer_id, top_k, filters }) => {
|
||||
try {
|
||||
const peer = await ctx.honcho.peer(peer_id);
|
||||
const peer = await ctx.clientFor(workspace_id).peer(peer_id);
|
||||
const scope = target_peer_id
|
||||
? peer.conclusionsOf(target_peer_id)
|
||||
: peer.conclusions;
|
||||
const conclusions = await scope.query(query, top_k);
|
||||
const conclusions = await scope.query(query, top_k, undefined, filters);
|
||||
return textResult(
|
||||
conclusions.map((c) => ({
|
||||
id: c.id,
|
||||
content: c.content,
|
||||
level: c.level,
|
||||
observer_id: c.observerId,
|
||||
observed_id: c.observedId,
|
||||
session_id: c.sessionId,
|
||||
|
|
@ -108,6 +117,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Returns the number of conclusions created.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
peer_id: z.string().describe("The observer peer."),
|
||||
target_peer_id: z
|
||||
.string()
|
||||
|
|
@ -123,9 +133,15 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
),
|
||||
},
|
||||
},
|
||||
async ({ peer_id, target_peer_id, conclusions, session_id }) => {
|
||||
async ({
|
||||
workspace_id,
|
||||
peer_id,
|
||||
target_peer_id,
|
||||
conclusions,
|
||||
session_id,
|
||||
}) => {
|
||||
try {
|
||||
const peer = await ctx.honcho.peer(peer_id);
|
||||
const peer = await ctx.clientFor(workspace_id).peer(peer_id);
|
||||
const scope = peer.conclusionsOf(target_peer_id);
|
||||
const params = conclusions.map((content) => ({
|
||||
content,
|
||||
|
|
@ -149,9 +165,11 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
{
|
||||
description: [
|
||||
"Delete a specific conclusion by ID.",
|
||||
"Use query_conclusions or list_conclusions to find the ID first.",
|
||||
"Use this to remove incorrect or outdated knowledge.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
peer_id: z.string().describe("The observer peer."),
|
||||
target_peer_id: z
|
||||
.string()
|
||||
|
|
@ -159,9 +177,9 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
conclusion_id: z.string().describe("The conclusion to delete."),
|
||||
},
|
||||
},
|
||||
async ({ peer_id, target_peer_id, conclusion_id }) => {
|
||||
async ({ workspace_id, peer_id, target_peer_id, conclusion_id }) => {
|
||||
try {
|
||||
const peer = await ctx.honcho.peer(peer_id);
|
||||
const peer = await ctx.clientFor(workspace_id).peer(peer_id);
|
||||
const scope = peer.conclusionsOf(target_peer_id);
|
||||
await scope.delete(conclusion_id);
|
||||
return textResult("Conclusion deleted successfully");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { z } from "zod";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { textResult, errorResult } from "../types.js";
|
||||
import { textResult, errorResult, workspaceIdSchema } from "../types.js";
|
||||
|
||||
export function register(server: McpServer, ctx: ToolContext) {
|
||||
// ── create_peer ─────────────────────────────────────────────────────
|
||||
|
|
@ -14,6 +14,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Returns the peer ID and any configuration that was set.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
peer_id: z.string().describe("Unique identifier for the peer."),
|
||||
configuration: z
|
||||
.object({
|
||||
|
|
@ -25,9 +26,11 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
.describe("Optional peer configuration."),
|
||||
},
|
||||
},
|
||||
async ({ peer_id, configuration }) => {
|
||||
async ({ workspace_id, peer_id, configuration }) => {
|
||||
try {
|
||||
const peer = await ctx.honcho.peer(peer_id, { configuration });
|
||||
const peer = await ctx.clientFor(workspace_id).peer(peer_id, {
|
||||
configuration,
|
||||
});
|
||||
return textResult({ peer_id: peer.id, configuration: peer.configuration });
|
||||
} catch (e) {
|
||||
return errorResult(
|
||||
|
|
@ -42,15 +45,17 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"list_peers",
|
||||
{
|
||||
description: [
|
||||
"List peers in the current workspace (paginated).",
|
||||
"List peers in the given workspace (paginated).",
|
||||
"Use this to discover which users and agents exist.",
|
||||
"Returns peer IDs with pagination metadata.",
|
||||
].join("\n"),
|
||||
inputSchema: {},
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
async ({ workspace_id }) => {
|
||||
try {
|
||||
const page = await ctx.honcho.peers();
|
||||
const page = await ctx.clientFor(workspace_id).peers();
|
||||
return textResult({
|
||||
peers: page.items.map((p) => ({ id: p.id })),
|
||||
total: page.total,
|
||||
|
|
@ -75,6 +80,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Returns a natural-language answer, or 'None' if no relevant information exists.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
peer_id: z.string().describe("The peer to query about."),
|
||||
query: z.string().describe("Natural-language question."),
|
||||
target_peer_id: z
|
||||
|
|
@ -93,9 +99,16 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
.describe("Reasoning effort. Higher = more detailed but slower."),
|
||||
},
|
||||
},
|
||||
async ({ peer_id, query, target_peer_id, session_id, reasoning_level }) => {
|
||||
async ({
|
||||
workspace_id,
|
||||
peer_id,
|
||||
query,
|
||||
target_peer_id,
|
||||
session_id,
|
||||
reasoning_level,
|
||||
}) => {
|
||||
try {
|
||||
const peer = await ctx.honcho.peer(peer_id);
|
||||
const peer = await ctx.clientFor(workspace_id).peer(peer_id);
|
||||
const result = await peer.chat(query, {
|
||||
target: target_peer_id,
|
||||
session: session_id,
|
||||
|
|
@ -120,6 +133,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Returns an array of fact strings, or null if no card exists yet.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
peer_id: z.string().describe("The observer peer."),
|
||||
target_peer_id: z
|
||||
.string()
|
||||
|
|
@ -129,9 +143,9 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
),
|
||||
},
|
||||
},
|
||||
async ({ peer_id, target_peer_id }) => {
|
||||
async ({ workspace_id, peer_id, target_peer_id }) => {
|
||||
try {
|
||||
const peer = await ctx.honcho.peer(peer_id);
|
||||
const peer = await ctx.clientFor(workspace_id).peer(peer_id);
|
||||
const card = await peer.getCard(target_peer_id);
|
||||
return textResult(card ?? "No peer card found.");
|
||||
} catch (e) {
|
||||
|
|
@ -152,6 +166,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Returns the updated peer card.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
peer_id: z.string().describe("The observer peer."),
|
||||
peer_card: z
|
||||
.array(z.string())
|
||||
|
|
@ -164,9 +179,9 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
),
|
||||
},
|
||||
},
|
||||
async ({ peer_id, peer_card, target_peer_id }) => {
|
||||
async ({ workspace_id, peer_id, peer_card, target_peer_id }) => {
|
||||
try {
|
||||
const peer = await ctx.honcho.peer(peer_id);
|
||||
const peer = await ctx.clientFor(workspace_id).peer(peer_id);
|
||||
const result = await peer.setCard(peer_card, target_peer_id);
|
||||
return textResult(result ?? "Peer card set successfully");
|
||||
} catch (e) {
|
||||
|
|
@ -187,6 +202,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Returns an object with representation, peer_card, peer_id, and target_id.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
peer_id: z.string().describe("The observer peer."),
|
||||
target_peer_id: z
|
||||
.string()
|
||||
|
|
@ -202,9 +218,15 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
.describe("Optional: max number of conclusions to include."),
|
||||
},
|
||||
},
|
||||
async ({ peer_id, target_peer_id, search_query, max_conclusions }) => {
|
||||
async ({
|
||||
workspace_id,
|
||||
peer_id,
|
||||
target_peer_id,
|
||||
search_query,
|
||||
max_conclusions,
|
||||
}) => {
|
||||
try {
|
||||
const peer = await ctx.honcho.peer(peer_id);
|
||||
const peer = await ctx.clientFor(workspace_id).peer(peer_id);
|
||||
const context = await peer.context({
|
||||
target: target_peer_id,
|
||||
searchQuery: search_query,
|
||||
|
|
@ -234,6 +256,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Returns a formatted string of conclusions.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
peer_id: z.string().describe("The observer peer."),
|
||||
target_peer_id: z
|
||||
.string()
|
||||
|
|
@ -254,6 +277,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
},
|
||||
},
|
||||
async ({
|
||||
workspace_id,
|
||||
peer_id,
|
||||
target_peer_id,
|
||||
session_id,
|
||||
|
|
@ -261,7 +285,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
max_conclusions,
|
||||
}) => {
|
||||
try {
|
||||
const peer = await ctx.honcho.peer(peer_id);
|
||||
const peer = await ctx.clientFor(workspace_id).peer(peer_id);
|
||||
const rep = await peer.representation({
|
||||
target: target_peer_id,
|
||||
session: session_id,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
formatMessage,
|
||||
formatMessages,
|
||||
formatSessionSummaries,
|
||||
workspaceIdSchema,
|
||||
} from "../types.js";
|
||||
|
||||
export function register(server: McpServer, ctx: ToolContext) {
|
||||
|
|
@ -20,12 +21,13 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Returns the session ID.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
session_id: z.string().describe("Unique identifier for the session."),
|
||||
},
|
||||
},
|
||||
async ({ session_id }) => {
|
||||
async ({ workspace_id, session_id }) => {
|
||||
try {
|
||||
const session = await ctx.honcho.session(session_id);
|
||||
const session = await ctx.clientFor(workspace_id).session(session_id);
|
||||
return textResult({ session_id: session.id });
|
||||
} catch (e) {
|
||||
return errorResult(
|
||||
|
|
@ -40,15 +42,17 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"list_sessions",
|
||||
{
|
||||
description: [
|
||||
"List sessions in the current workspace (paginated).",
|
||||
"List sessions in the given workspace (paginated).",
|
||||
"Use this to discover existing conversations.",
|
||||
"Returns session IDs with pagination metadata.",
|
||||
].join("\n"),
|
||||
inputSchema: {},
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
async ({ workspace_id }) => {
|
||||
try {
|
||||
const page = await ctx.honcho.sessions();
|
||||
const page = await ctx.clientFor(workspace_id).sessions();
|
||||
return textResult({
|
||||
sessions: page.items.map((s) => ({ id: s.id })),
|
||||
total: page.total,
|
||||
|
|
@ -72,12 +76,13 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"This cannot be undone.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
session_id: z.string().describe("The session to delete."),
|
||||
},
|
||||
},
|
||||
async ({ session_id }) => {
|
||||
async ({ workspace_id, session_id }) => {
|
||||
try {
|
||||
const session = await ctx.honcho.session(session_id);
|
||||
const session = await ctx.clientFor(workspace_id).session(session_id);
|
||||
await session.delete();
|
||||
return textResult("Session deleted successfully");
|
||||
} catch (e) {
|
||||
|
|
@ -98,6 +103,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Returns the new cloned session ID.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
session_id: z.string().describe("The session to clone."),
|
||||
message_id: z
|
||||
.string()
|
||||
|
|
@ -107,9 +113,9 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
),
|
||||
},
|
||||
},
|
||||
async ({ session_id, message_id }) => {
|
||||
async ({ workspace_id, session_id, message_id }) => {
|
||||
try {
|
||||
const session = await ctx.honcho.session(session_id);
|
||||
const session = await ctx.clientFor(workspace_id).session(session_id);
|
||||
const cloned = await session.clone(message_id);
|
||||
return textResult({ session_id: cloned.id });
|
||||
} catch (e) {
|
||||
|
|
@ -129,6 +135,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Use this to bring participants into a conversation.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
session_id: z.string().describe("The session to add peers to."),
|
||||
peers: z
|
||||
.array(
|
||||
|
|
@ -152,9 +159,10 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
.describe("Peers to add — plain IDs or objects with per-session config."),
|
||||
},
|
||||
},
|
||||
async ({ session_id, peers }) => {
|
||||
async ({ workspace_id, session_id, peers }) => {
|
||||
try {
|
||||
const session = await ctx.honcho.session(session_id);
|
||||
const honcho = ctx.clientFor(workspace_id);
|
||||
const session = await honcho.session(session_id);
|
||||
const additions = peers.map((p) => {
|
||||
if (typeof p === "string") return p;
|
||||
const config: { observeMe?: boolean | null; observeOthers?: boolean | null } = {};
|
||||
|
|
@ -182,15 +190,16 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Remove one or more peers from a session.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
session_id: z.string().describe("The session to remove peers from."),
|
||||
peer_ids: z
|
||||
.array(z.string())
|
||||
.describe("Peer IDs to remove."),
|
||||
},
|
||||
},
|
||||
async ({ session_id, peer_ids }) => {
|
||||
async ({ workspace_id, session_id, peer_ids }) => {
|
||||
try {
|
||||
const session = await ctx.honcho.session(session_id);
|
||||
const session = await ctx.clientFor(workspace_id).session(session_id);
|
||||
await session.removePeers(peer_ids);
|
||||
return textResult("Peers removed from session successfully");
|
||||
} catch (e) {
|
||||
|
|
@ -211,12 +220,13 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Returns an array of peer IDs.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
session_id: z.string().describe("The session to query."),
|
||||
},
|
||||
},
|
||||
async ({ session_id }) => {
|
||||
async ({ workspace_id, session_id }) => {
|
||||
try {
|
||||
const session = await ctx.honcho.session(session_id);
|
||||
const session = await ctx.clientFor(workspace_id).session(session_id);
|
||||
const peers = await session.peers();
|
||||
return textResult(peers.map((p) => p.id));
|
||||
} catch (e) {
|
||||
|
|
@ -237,12 +247,13 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Returns a single JSON object.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
session_id: z.string().describe("The session to inspect."),
|
||||
},
|
||||
},
|
||||
async ({ session_id }) => {
|
||||
async ({ workspace_id, session_id }) => {
|
||||
try {
|
||||
const session = await ctx.honcho.session(session_id);
|
||||
const session = await ctx.clientFor(workspace_id).session(session_id);
|
||||
const [peers, messagePage, summaries] = await Promise.all([
|
||||
session.peers(),
|
||||
session.messages(),
|
||||
|
|
@ -273,6 +284,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Each message must specify the peer_id of the author.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
session_id: z.string().describe("The session to add messages to."),
|
||||
messages: z
|
||||
.array(
|
||||
|
|
@ -288,15 +300,16 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
.describe("Messages to add."),
|
||||
},
|
||||
},
|
||||
async ({ session_id, messages }) => {
|
||||
async ({ workspace_id, session_id, messages }) => {
|
||||
try {
|
||||
const session = await ctx.honcho.session(session_id);
|
||||
const peerCache = new Map<string, Awaited<ReturnType<typeof ctx.honcho.peer>>>();
|
||||
const honcho = ctx.clientFor(workspace_id);
|
||||
const session = await honcho.session(session_id);
|
||||
const peerCache = new Map<string, Awaited<ReturnType<typeof honcho.peer>>>();
|
||||
const sessionMessages = [];
|
||||
for (const msg of messages) {
|
||||
let peer = peerCache.get(msg.peer_id);
|
||||
if (!peer) {
|
||||
peer = await ctx.honcho.peer(msg.peer_id);
|
||||
peer = await honcho.peer(msg.peer_id);
|
||||
peerCache.set(msg.peer_id, peer);
|
||||
}
|
||||
sessionMessages.push(
|
||||
|
|
@ -325,6 +338,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Returns the first page of messages with pagination metadata.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
session_id: z.string().describe("The session to get messages from."),
|
||||
filters: z
|
||||
.record(z.string(), z.unknown())
|
||||
|
|
@ -332,9 +346,9 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
.describe("Optional metadata filter criteria."),
|
||||
},
|
||||
},
|
||||
async ({ session_id, filters }) => {
|
||||
async ({ workspace_id, session_id, filters }) => {
|
||||
try {
|
||||
const session = await ctx.honcho.session(session_id);
|
||||
const session = await ctx.clientFor(workspace_id).session(session_id);
|
||||
const page = await session.messages(filters);
|
||||
return textResult({
|
||||
messages: formatMessages(page.items),
|
||||
|
|
@ -360,13 +374,14 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Returns the message object.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
session_id: z.string().describe("The session the message belongs to."),
|
||||
message_id: z.string().describe("The message ID to fetch."),
|
||||
},
|
||||
},
|
||||
async ({ session_id, message_id }) => {
|
||||
async ({ workspace_id, session_id, message_id }) => {
|
||||
try {
|
||||
const session = await ctx.honcho.session(session_id);
|
||||
const session = await ctx.clientFor(workspace_id).session(session_id);
|
||||
const message = await session.getMessage(message_id);
|
||||
return textResult(formatMessage(message));
|
||||
} catch (e) {
|
||||
|
|
@ -388,6 +403,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Returns messages, summary, and session ID.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
session_id: z.string().describe("The session to get context for."),
|
||||
summary: z
|
||||
.boolean()
|
||||
|
|
@ -399,9 +415,9 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
.describe("Target token budget for the context window."),
|
||||
},
|
||||
},
|
||||
async ({ session_id, summary, tokens }) => {
|
||||
async ({ workspace_id, session_id, summary, tokens }) => {
|
||||
try {
|
||||
const session = await ctx.honcho.session(session_id);
|
||||
const session = await ctx.clientFor(workspace_id).session(session_id);
|
||||
const context = await session.context({ summary, tokens });
|
||||
return textResult({
|
||||
session_id: context.sessionId,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { z } from "zod";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { textResult, errorResult } from "../types.js";
|
||||
import { textResult, errorResult, workspaceIdSchema } from "../types.js";
|
||||
|
||||
export function register(server: McpServer, ctx: ToolContext) {
|
||||
// ── schedule_dream ──────────────────────────────────────────────────
|
||||
|
|
@ -14,6 +14,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Use this after a long conversation to improve Honcho's memory quality.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
peer_id: z.string().describe("The observer peer to dream for."),
|
||||
target_peer_id: z
|
||||
.string()
|
||||
|
|
@ -27,9 +28,9 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
.describe("Optional: scope the dream to a session."),
|
||||
},
|
||||
},
|
||||
async ({ peer_id, target_peer_id, session_id }) => {
|
||||
async ({ workspace_id, peer_id, target_peer_id, session_id }) => {
|
||||
try {
|
||||
await ctx.honcho.scheduleDream({
|
||||
await ctx.clientFor(workspace_id).scheduleDream({
|
||||
observer: peer_id,
|
||||
observed: target_peer_id,
|
||||
session: session_id,
|
||||
|
|
@ -52,11 +53,13 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"Use this to check if Honcho is still processing messages before querying for insights.",
|
||||
"Returns work unit counts: total, completed, in-progress, and pending.",
|
||||
].join("\n"),
|
||||
inputSchema: {},
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
async ({ workspace_id }) => {
|
||||
try {
|
||||
const status = await ctx.honcho.queueStatus();
|
||||
const status = await ctx.clientFor(workspace_id).queueStatus();
|
||||
return textResult(status);
|
||||
} catch (e) {
|
||||
return errorResult(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,36 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
BadRequestError,
|
||||
HonchoError,
|
||||
UnprocessableEntityError,
|
||||
type PageResponse,
|
||||
type WorkspaceResponse,
|
||||
} from "@honcho-ai/sdk";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { textResult, errorResult, formatMessages } from "../types.js";
|
||||
import { resolveWorkspaceId } from "../config.js";
|
||||
import {
|
||||
textResult,
|
||||
errorResult,
|
||||
formatMessages,
|
||||
workspaceIdSchema,
|
||||
} from "../types.js";
|
||||
|
||||
function withAdminKeyHint(prefix: string, e: unknown): string {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
const denied =
|
||||
e instanceof HonchoError && (e.status === 401 || e.status === 403);
|
||||
if (!denied) return `${prefix}: ${message}`;
|
||||
return `${prefix}: ${message}. This operation is only possible with an admin API key.`;
|
||||
}
|
||||
|
||||
function formatWorkspace(workspace: WorkspaceResponse) {
|
||||
return {
|
||||
id: workspace.id,
|
||||
metadata: workspace.metadata ?? {},
|
||||
created_at: workspace.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function register(server: McpServer, ctx: ToolContext) {
|
||||
// ── inspect_workspace ───────────────────────────────────────────────
|
||||
|
|
@ -9,23 +38,27 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"inspect_workspace",
|
||||
{
|
||||
description: [
|
||||
"Inspect the current workspace at a glance.",
|
||||
"Inspect a workspace at a glance.",
|
||||
"Aggregates workspace metadata, configuration, peer IDs, and session IDs.",
|
||||
"Returns the first page of peers/sessions with total counts.",
|
||||
].join("\n"),
|
||||
inputSchema: {},
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
async ({ workspace_id }) => {
|
||||
try {
|
||||
const [metadata, configuration, peerPage, sessionPage] = await Promise.all([
|
||||
ctx.honcho.getMetadata(),
|
||||
ctx.honcho.getConfiguration(),
|
||||
ctx.honcho.peers(),
|
||||
ctx.honcho.sessions(),
|
||||
]);
|
||||
const honcho = ctx.clientFor(workspace_id);
|
||||
const [metadata, configuration, peerPage, sessionPage] =
|
||||
await Promise.all([
|
||||
honcho.getMetadata(),
|
||||
honcho.getConfiguration(),
|
||||
honcho.peers(),
|
||||
honcho.sessions(),
|
||||
]);
|
||||
|
||||
return textResult({
|
||||
workspace_id: ctx.honcho.workspaceId,
|
||||
workspace_id: honcho.workspaceId,
|
||||
metadata,
|
||||
configuration,
|
||||
peer_count: peerPage.total,
|
||||
|
|
@ -47,24 +80,82 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
{
|
||||
description: [
|
||||
"List workspaces accessible to the current credentials (paginated).",
|
||||
"Use this to discover available workspaces before selecting or switching context.",
|
||||
"Returns workspace IDs with pagination metadata.",
|
||||
"Skip this if the connection already set X-Honcho-Workspace-ID — that header is the workspace; omit workspace_id on other tools.",
|
||||
"Use this only when the header is unset and you don't already know the workspace ID.",
|
||||
"Returns each workspace's id, metadata, and created_at. If none fit, call create_workspace.",
|
||||
].join("\n"),
|
||||
inputSchema: {},
|
||||
inputSchema: {
|
||||
page: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Page number (1-indexed)."),
|
||||
size: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe("Results per page (max 100)."),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
async ({ page, size }) => {
|
||||
try {
|
||||
const page = await ctx.honcho.workspaces();
|
||||
const result = await ctx.unscoped.http.post<
|
||||
PageResponse<WorkspaceResponse>
|
||||
>("/v3/workspaces/list", {
|
||||
body: {},
|
||||
query: { page, size },
|
||||
});
|
||||
return textResult({
|
||||
workspaces: page.items.map((id) => ({ id })),
|
||||
total: page.total,
|
||||
page: page.page,
|
||||
pages: page.pages,
|
||||
workspaces: result.items.map(formatWorkspace),
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
pages: result.pages,
|
||||
});
|
||||
} catch (e) {
|
||||
return errorResult(
|
||||
`Failed to list workspaces: ${e instanceof Error ? e.message : String(e)}`,
|
||||
return errorResult(withAdminKeyHint("Failed to list workspaces", e));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── create_workspace ────────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
"create_workspace",
|
||||
{
|
||||
description: [
|
||||
"Get or create a workspace with the given ID.",
|
||||
"Skip this if the connection already set X-Honcho-Workspace-ID — that header pins the workspace without a create call.",
|
||||
"Use this only when the header is unset and list_workspaces has no suitable workspace.",
|
||||
"Optional metadata helps future list_workspaces calls identify what the workspace is for.",
|
||||
"Returns the workspace id, metadata, and created_at.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
metadata: z
|
||||
.record(z.string(), z.unknown())
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional key-value metadata to store on the workspace (e.g. project, purpose).",
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ workspace_id, metadata }) => {
|
||||
try {
|
||||
const id = resolveWorkspaceId(ctx.config, workspace_id);
|
||||
const workspace = await ctx.unscoped.http.post<WorkspaceResponse>(
|
||||
"/v3/workspaces",
|
||||
{
|
||||
body: {
|
||||
id,
|
||||
metadata,
|
||||
},
|
||||
},
|
||||
);
|
||||
return textResult(formatWorkspace(workspace));
|
||||
} catch (e) {
|
||||
return errorResult(withAdminKeyHint("Failed to create workspace", e));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -74,13 +165,16 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"search",
|
||||
{
|
||||
description: [
|
||||
"Semantic search across messages. Scope is determined by which optional params are provided:",
|
||||
"Semantic search across messages and, when peer_id is given, that peer's saved conclusions.",
|
||||
"Message scope is determined by which optional params are provided:",
|
||||
"- No scope params: search all messages in the workspace.",
|
||||
"- peer_id only: search messages authored by that peer across all sessions.",
|
||||
"- session_id only: search messages within that session.",
|
||||
"Returns an array of matching messages with their content, peer, and session info.",
|
||||
"Conclusions require peer_id (self-conclusions are searched; conclusion IDs are usable with delete_conclusion).",
|
||||
"Returns {messages, conclusions}.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
query: z.string().describe("Search query."),
|
||||
peer_id: z
|
||||
.string()
|
||||
|
|
@ -90,21 +184,95 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
.string()
|
||||
.optional()
|
||||
.describe("Optional: scope search to messages in this session."),
|
||||
message_limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Optional: max message results (1-100, default 10)."),
|
||||
message_filters: z
|
||||
.record(z.string(), z.unknown())
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional: filters for the message search, e.g. {"created_at": {"gte": "2026-01-01"}}. See https://honcho.dev/docs/v3/documentation/features/advanced/using-filters',
|
||||
),
|
||||
conclusion_top_k: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Optional: max conclusion results (default 10)."),
|
||||
conclusion_filters: z
|
||||
.record(z.string(), z.unknown())
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional: filters for the conclusion search, e.g. {"level": ["deductive", "inductive"]} to only return conclusions derived during dreaming. Levels: explicit (extracted directly from messages), deductive, inductive, contradiction. The session_id param does not scope conclusions; use {"session_id": ...} here for that.',
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ query, peer_id, session_id }) => {
|
||||
async ({
|
||||
workspace_id,
|
||||
query,
|
||||
peer_id,
|
||||
session_id,
|
||||
message_limit,
|
||||
message_filters,
|
||||
conclusion_top_k,
|
||||
conclusion_filters,
|
||||
}) => {
|
||||
try {
|
||||
let messages;
|
||||
if (session_id) {
|
||||
const session = await ctx.honcho.session(session_id);
|
||||
messages = await session.search(query);
|
||||
} else if (peer_id) {
|
||||
const peer = await ctx.honcho.peer(peer_id);
|
||||
messages = await peer.search(query);
|
||||
} else {
|
||||
messages = await ctx.honcho.search(query);
|
||||
}
|
||||
return textResult(formatMessages(messages));
|
||||
const honcho = ctx.clientFor(workspace_id);
|
||||
const peer = peer_id ? await honcho.peer(peer_id) : null;
|
||||
const messageOptions = {
|
||||
filters: message_filters,
|
||||
limit: message_limit,
|
||||
};
|
||||
|
||||
const searchMessages = async () => {
|
||||
if (session_id) {
|
||||
const session = await honcho.session(session_id);
|
||||
return session.search(query, messageOptions);
|
||||
}
|
||||
if (peer) {
|
||||
return peer.search(query, messageOptions);
|
||||
}
|
||||
return honcho.search(query, messageOptions);
|
||||
};
|
||||
|
||||
// Conclusion search needs an (observer, observed) pair, so it only
|
||||
// runs when peer_id is given.
|
||||
const searchConclusions = async () => {
|
||||
if (!peer) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return await peer.conclusions.query(
|
||||
query,
|
||||
conclusion_top_k,
|
||||
undefined,
|
||||
conclusion_filters,
|
||||
);
|
||||
} catch (e) {
|
||||
if (
|
||||
conclusion_filters &&
|
||||
(e instanceof BadRequestError ||
|
||||
e instanceof UnprocessableEntityError)
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const [messages, conclusions] = await Promise.all([
|
||||
searchMessages(),
|
||||
searchConclusions(),
|
||||
]);
|
||||
return textResult({
|
||||
messages: formatMessages(messages),
|
||||
conclusions: conclusions.map((c) => ({
|
||||
id: c.id,
|
||||
content: c.content,
|
||||
level: c.level,
|
||||
created_at: c.createdAt,
|
||||
})),
|
||||
});
|
||||
} catch (e) {
|
||||
return errorResult(
|
||||
`Search failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
|
|
@ -124,6 +292,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"- session_id only: get session metadata.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
peer_id: z
|
||||
.string()
|
||||
.optional()
|
||||
|
|
@ -134,17 +303,18 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
.describe("Optional: get metadata for this session."),
|
||||
},
|
||||
},
|
||||
async ({ peer_id, session_id }) => {
|
||||
async ({ workspace_id, peer_id, session_id }) => {
|
||||
try {
|
||||
const honcho = ctx.clientFor(workspace_id);
|
||||
let metadata;
|
||||
if (session_id) {
|
||||
const session = await ctx.honcho.session(session_id);
|
||||
const session = await honcho.session(session_id);
|
||||
metadata = await session.getMetadata();
|
||||
} else if (peer_id) {
|
||||
const peer = await ctx.honcho.peer(peer_id);
|
||||
const peer = await honcho.peer(peer_id);
|
||||
metadata = await peer.getMetadata();
|
||||
} else {
|
||||
metadata = await ctx.honcho.getMetadata();
|
||||
metadata = await honcho.getMetadata();
|
||||
}
|
||||
return textResult(metadata);
|
||||
} catch (e) {
|
||||
|
|
@ -167,6 +337,7 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
"- session_id only: set session metadata.",
|
||||
].join("\n"),
|
||||
inputSchema: {
|
||||
workspace_id: workspaceIdSchema(ctx),
|
||||
metadata: z
|
||||
.record(z.string(), z.unknown())
|
||||
.describe("Key-value pairs to set as metadata."),
|
||||
|
|
@ -180,18 +351,19 @@ export function register(server: McpServer, ctx: ToolContext) {
|
|||
.describe("Optional: set metadata for this session."),
|
||||
},
|
||||
},
|
||||
async ({ metadata, peer_id, session_id }) => {
|
||||
async ({ workspace_id, metadata, peer_id, session_id }) => {
|
||||
try {
|
||||
const honcho = ctx.clientFor(workspace_id);
|
||||
if (session_id) {
|
||||
const session = await ctx.honcho.session(session_id);
|
||||
const session = await honcho.session(session_id);
|
||||
await session.setMetadata(metadata);
|
||||
return textResult("Session metadata set successfully");
|
||||
} else if (peer_id) {
|
||||
const peer = await ctx.honcho.peer(peer_id);
|
||||
const peer = await honcho.peer(peer_id);
|
||||
await peer.setMetadata(metadata);
|
||||
return textResult("Peer metadata set successfully");
|
||||
} else {
|
||||
await ctx.honcho.setMetadata(metadata);
|
||||
await honcho.setMetadata(metadata);
|
||||
return textResult("Workspace metadata set successfully");
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,27 @@
|
|||
import { z } from "zod";
|
||||
import type { Honcho, Message, Summary, SessionSummaries } from "@honcho-ai/sdk";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { HonchoConfig } from "./config.js";
|
||||
|
||||
export interface ToolContext {
|
||||
honcho: Honcho;
|
||||
config: HonchoConfig;
|
||||
/** Return a Honcho client scoped to the given workspace (or the header default). */
|
||||
clientFor: (workspaceId?: string) => Honcho;
|
||||
/** Client used only for credential-scoped ops (list workspaces). */
|
||||
unscoped: Honcho;
|
||||
}
|
||||
|
||||
/**
|
||||
* Always optional at the schema layer so a missing value reaches clientFor,
|
||||
* which returns a clear error (header or workspace_id on the next call).
|
||||
*/
|
||||
export function workspaceIdSchema(ctx: ToolContext) {
|
||||
const fromHeader = ctx.config.workspaceId;
|
||||
const description = fromHeader
|
||||
? `Workspace to operate in. The connection already set X-Honcho-Workspace-ID=${fromHeader}; omit this argument unless you need a different workspace.`
|
||||
: "Workspace to operate in. Prefer the client setting X-Honcho-Workspace-ID on the connection — then you can omit this on every call. Only pass it (or use list_workspaces / create_workspace) when the header is unset.";
|
||||
const field = z.string().optional().describe(description);
|
||||
return fromHeader ? field.default(fromHeader) : field;
|
||||
}
|
||||
|
||||
export function textResult(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ rules = [
|
|||
{ type = "Text", globs = ["**/*.md"], fallthrough = true },
|
||||
]
|
||||
|
||||
# Executes the scheduled() handler
|
||||
[triggers]
|
||||
crons = ["*/5 * * * *"]
|
||||
|
||||
[env.production]
|
||||
name = "honcho-mcp"
|
||||
|
||||
|
|
|
|||
|
|
@ -41,11 +41,16 @@ from importlib.metadata import PackageNotFoundError, version
|
|||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from .aio import ConclusionScopeAio, HonchoAio, PeerAio, SessionAio
|
||||
from .api_types import MessageCreateParams
|
||||
from .base import PeerBase, SessionBase
|
||||
from .aio import ConclusionsViewAio, HonchoAio, PeerAio, ScopeAio, SessionAio
|
||||
from .api_types import (
|
||||
MessageCreateParams,
|
||||
ScopeBackfillJob,
|
||||
ScopeResponse,
|
||||
ScopeStatusResponse,
|
||||
)
|
||||
from .base import PeerBase, ScopeBase, SessionBase
|
||||
from .client import Honcho
|
||||
from .conclusions import Conclusion, ConclusionScope
|
||||
from .conclusions import Conclusion, ConclusionsView
|
||||
from .http.exceptions import (
|
||||
APIError,
|
||||
AuthenticationError,
|
||||
|
|
@ -63,6 +68,7 @@ from .http.exceptions import (
|
|||
from .message import Message
|
||||
from .pagination import AsyncPage, SyncPage
|
||||
from .peer import Peer
|
||||
from .scope import Scope
|
||||
from .session import Session
|
||||
from .session_context import SessionContext, SessionSummaries, Summary
|
||||
from .types import (
|
||||
|
|
@ -70,6 +76,12 @@ from .types import (
|
|||
DialecticStreamResponse,
|
||||
)
|
||||
|
||||
# Deprecated aliases. "Scope" now means a named set of sessions (see `Scope`),
|
||||
# which these are not — they are views over one observer/observed pair. Kept for
|
||||
# one more minor version.
|
||||
ConclusionScope = ConclusionsView
|
||||
ConclusionScopeAio = ConclusionsViewAio
|
||||
|
||||
|
||||
def _detect_version() -> str:
|
||||
try:
|
||||
|
|
@ -95,23 +107,32 @@ __all__ = [
|
|||
"Honcho",
|
||||
# Domain classes
|
||||
"Conclusion",
|
||||
"ConclusionScope",
|
||||
"ConclusionsView",
|
||||
"Message",
|
||||
"MessageCreateParams",
|
||||
"Peer",
|
||||
"Scope",
|
||||
"Session",
|
||||
# Aio views (for type hints)
|
||||
"ConclusionScopeAio",
|
||||
"ConclusionsViewAio",
|
||||
"HonchoAio",
|
||||
"PeerAio",
|
||||
"ScopeAio",
|
||||
"SessionAio",
|
||||
# Base classes
|
||||
"PeerBase",
|
||||
"ScopeBase",
|
||||
"SessionBase",
|
||||
# Response types
|
||||
"ScopeBackfillJob",
|
||||
"ScopeResponse",
|
||||
"ScopeStatusResponse",
|
||||
"SessionContext",
|
||||
"SessionSummaries",
|
||||
"Summary",
|
||||
# Deprecated aliases
|
||||
"ConclusionScope",
|
||||
"ConclusionScopeAio",
|
||||
# Pagination
|
||||
"AsyncPage",
|
||||
"SyncPage",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
This module provides async accessor classes that wrap the main SDK classes
|
||||
and provide async versions of all operations. Access via the `.aio` property
|
||||
on Honcho, Peer, Session, and ConclusionScope instances.
|
||||
on Honcho, Peer, Session, and ConclusionsView instances.
|
||||
|
||||
Example:
|
||||
```python
|
||||
|
|
@ -24,7 +24,7 @@ from __future__ import annotations
|
|||
import json
|
||||
import logging
|
||||
import warnings
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload
|
||||
|
||||
|
|
@ -40,15 +40,18 @@ from .api_types import (
|
|||
PeerResponse,
|
||||
QueueStatusResponse,
|
||||
RepresentationResponse,
|
||||
ScopeBackfillJob,
|
||||
ScopeResponse,
|
||||
ScopeStatusResponse,
|
||||
SessionConfiguration,
|
||||
SessionPeerConfig,
|
||||
SessionResponse,
|
||||
WorkspaceConfiguration,
|
||||
WorkspaceResponse,
|
||||
)
|
||||
from .base import PeerBase, SessionBase
|
||||
from .base import PeerBase, ScopeBase, SessionBase
|
||||
from .conclusions import (
|
||||
_SCOPE_RESERVED,
|
||||
_VIEW_RESERVED,
|
||||
Conclusion,
|
||||
_reject_reserved_filter_keys,
|
||||
)
|
||||
|
|
@ -64,14 +67,20 @@ from .utils import (
|
|||
parse_sse_astream,
|
||||
prepare_file_for_upload,
|
||||
resolve_id,
|
||||
resolve_scope_membership,
|
||||
resolve_scope_session,
|
||||
scope_context_fields,
|
||||
scope_recall_fields,
|
||||
validate_scope_id,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .client import Honcho
|
||||
from .conclusions import ConclusionScope
|
||||
from .conclusions import ConclusionsView
|
||||
|
||||
from .conclusions import ConclusionCreateParams
|
||||
from .peer import Peer, TResponseFormat, serialize_response_format
|
||||
from .scope import Scope
|
||||
from .session import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -79,8 +88,9 @@ logger = logging.getLogger(__name__)
|
|||
__all__ = [
|
||||
"HonchoAio",
|
||||
"PeerAio",
|
||||
"ScopeAio",
|
||||
"SessionAio",
|
||||
"ConclusionScopeAio",
|
||||
"ConclusionsViewAio",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -267,6 +277,7 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
| list[tuple[PeerBase | str, SessionPeerConfig]]
|
||||
| list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]]
|
||||
| None = None,
|
||||
scopes: Sequence[str | ScopeBase] | None = None,
|
||||
) -> Session:
|
||||
"""
|
||||
Get or create a session with the given ID asynchronously.
|
||||
|
|
@ -278,6 +289,11 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
peers: Optional peers to attach to the session at creation. Accepts the
|
||||
same shape as Session.add_peers (peer ID string, Peer object, list
|
||||
of either, or tuples with SessionPeerConfig).
|
||||
scopes: Optional scopes this session should join, as IDs or Scope
|
||||
objects. Each scope is created if it does not exist yet. Attaching
|
||||
at creation avoids the asynchronous backfill a later
|
||||
``scope.add_sessions()`` triggers, since there is no history to
|
||||
copy.
|
||||
|
||||
Returns:
|
||||
A Session object with cached values from the API response.
|
||||
|
|
@ -290,6 +306,8 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
body["configuration"] = configuration.model_dump(exclude_none=True)
|
||||
if peers is not None:
|
||||
body["peers"] = normalize_peers_to_dict(peers)
|
||||
if scopes is not None:
|
||||
body["scopes"] = [validate_scope_id(resolve_id(scope)) for scope in scopes]
|
||||
|
||||
data = await self._honcho._async_http_client.post(
|
||||
routes.sessions(self._honcho.workspace_id), body=body
|
||||
|
|
@ -358,6 +376,88 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
|
||||
return AsyncPage(data, SessionResponse, transform, fetch_next)
|
||||
|
||||
async def scope(
|
||||
self,
|
||||
id: str, # noqa: A002
|
||||
*,
|
||||
metadata: dict[str, object] | None = None,
|
||||
) -> Scope:
|
||||
"""
|
||||
Get or create a scope with the given ID asynchronously.
|
||||
|
||||
A scope is a named set of sessions that acts as a visibility boundary:
|
||||
recall performed through the scope sees only what happened in its sessions,
|
||||
while the underlying peer keeps its single unified representation of
|
||||
everything.
|
||||
|
||||
Args:
|
||||
id: Unprefixed scope name, unique within the workspace.
|
||||
metadata: Optional metadata dictionary to associate with this scope.
|
||||
|
||||
Returns:
|
||||
A Scope object for managing membership.
|
||||
|
||||
Raises:
|
||||
ValueError: If the scope ID is invalid.
|
||||
"""
|
||||
validate_scope_id(id)
|
||||
await self._honcho._ensure_workspace_async()
|
||||
body: dict[str, Any] = {"id": id}
|
||||
if metadata is not None:
|
||||
body["metadata"] = metadata
|
||||
|
||||
data = await self._honcho._async_http_client.post(
|
||||
routes.scopes(self._honcho.workspace_id), body=body
|
||||
)
|
||||
scope_data = ScopeResponse.model_validate(data)
|
||||
return Scope(
|
||||
id,
|
||||
self._honcho,
|
||||
metadata=scope_data.metadata,
|
||||
created_at=scope_data.created_at,
|
||||
)
|
||||
|
||||
async def scopes(
|
||||
self,
|
||||
*,
|
||||
page: int = 1,
|
||||
size: int = 50,
|
||||
reverse: bool = False,
|
||||
) -> AsyncPage[ScopeResponse, Scope]:
|
||||
"""
|
||||
Get all scopes in the current workspace asynchronously.
|
||||
|
||||
Args:
|
||||
page: Page number (1-indexed). Default: 1.
|
||||
size: Number of items per page. Default: 50.
|
||||
reverse: If True, reverses the default ordering. Default: False.
|
||||
"""
|
||||
await self._honcho._ensure_workspace_async()
|
||||
|
||||
async def fetch(next_page: int) -> dict[str, Any]:
|
||||
query: dict[str, Any] = {"page": next_page, "size": size}
|
||||
if reverse:
|
||||
query["reverse"] = "true"
|
||||
return await self._honcho._async_http_client.post(
|
||||
routes.scopes_list(self._honcho.workspace_id), query=query
|
||||
)
|
||||
|
||||
def transform(scope: ScopeResponse) -> Scope:
|
||||
"""Convert a scope API response into a Scope SDK object."""
|
||||
return Scope(
|
||||
scope.id,
|
||||
self._honcho,
|
||||
metadata=scope.metadata,
|
||||
created_at=scope.created_at,
|
||||
)
|
||||
|
||||
async def fetch_next(next_page: int) -> AsyncPage[ScopeResponse, Scope]:
|
||||
return AsyncPage(
|
||||
await fetch(next_page), ScopeResponse, transform, fetch_next
|
||||
)
|
||||
|
||||
return AsyncPage(await fetch(page), ScopeResponse, transform, fetch_next)
|
||||
|
||||
async def workspaces(
|
||||
self,
|
||||
filters: dict[str, object] | None = None,
|
||||
|
|
@ -409,12 +509,27 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
limit: int = Field(
|
||||
default=10, ge=1, le=100, description="Number of results to return"
|
||||
),
|
||||
*,
|
||||
scope: str | ScopeBase | None = None,
|
||||
) -> list[Message]:
|
||||
"""Search for messages in the current workspace asynchronously."""
|
||||
"""Search for messages in the current workspace asynchronously.
|
||||
|
||||
Args:
|
||||
query: The search query to use
|
||||
filters: Filters to scope the search.
|
||||
limit: Number of results to return (1-100, default: 10)
|
||||
scope: Optional scope (ID or Scope object) restricting the search to
|
||||
that scope's member sessions. Mutually exclusive with a
|
||||
``session_id`` filter. A scope with no member sessions matches
|
||||
nothing rather than everything.
|
||||
"""
|
||||
await self._honcho._ensure_workspace_async()
|
||||
body: dict[str, Any] = {"query": query, "filters": filters, "limit": limit}
|
||||
if scope is not None:
|
||||
body["scope"] = validate_scope_id(resolve_id(scope))
|
||||
data = await self._honcho._async_http_client.post(
|
||||
routes.workspace_search(self._honcho.workspace_id),
|
||||
body={"query": query, "filters": filters, "limit": limit},
|
||||
body=body,
|
||||
)
|
||||
return [
|
||||
Message.from_api_response(MessageResponse.model_validate(item))
|
||||
|
|
@ -584,6 +699,8 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
*,
|
||||
target: str | PeerBase | None = None,
|
||||
session: str | SessionBase | None = None,
|
||||
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
|
||||
sessions: Sequence[str | SessionBase] | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: type[TResponseFormat],
|
||||
|
|
@ -596,6 +713,8 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
*,
|
||||
target: str | PeerBase | None = None,
|
||||
session: str | SessionBase | None = None,
|
||||
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
|
||||
sessions: Sequence[str | SessionBase] | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: dict[str, Any] | None = None,
|
||||
|
|
@ -608,6 +727,8 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
*,
|
||||
target: str | PeerBase | None = None,
|
||||
session: str | SessionBase | None = None,
|
||||
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
|
||||
sessions: Sequence[str | SessionBase] | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
|
|
@ -623,6 +744,11 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
resolved_session_id = resolve_id(session)
|
||||
|
||||
body: dict[str, Any] = {"query": query, "stream": False}
|
||||
body.update(
|
||||
scope_recall_fields(
|
||||
scope=scope, sessions=sessions, session_id=resolved_session_id
|
||||
)
|
||||
)
|
||||
if target_id:
|
||||
body["target"] = target_id
|
||||
if resolved_session_id:
|
||||
|
|
@ -651,6 +777,8 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
*,
|
||||
target: str | PeerBase | None = None,
|
||||
session: str | SessionBase | None = None,
|
||||
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
|
||||
sessions: Sequence[str | SessionBase] | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
|
|
@ -666,6 +794,11 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
resolved_session_id = resolve_id(session)
|
||||
|
||||
body: dict[str, Any] = {"query": query, "stream": True}
|
||||
body.update(
|
||||
scope_recall_fields(
|
||||
scope=scope, sessions=sessions, session_id=resolved_session_id
|
||||
)
|
||||
)
|
||||
if target_id:
|
||||
body["target"] = target_id
|
||||
if resolved_session_id:
|
||||
|
|
@ -826,13 +959,22 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
search_max_distance: float | None = Field(None, ge=0.0, le=1.0),
|
||||
include_most_frequent: bool | None = None,
|
||||
max_conclusions: int | None = Field(None, ge=1, le=100),
|
||||
*,
|
||||
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
|
||||
sessions: Sequence[str | SessionBase] | None = None,
|
||||
) -> str:
|
||||
"""Get a subset of the representation of the peer asynchronously."""
|
||||
"""Get a subset of the representation of the peer asynchronously.
|
||||
|
||||
See Peer.representation for parameter details, including the depth caveat
|
||||
on ``sessions``.
|
||||
"""
|
||||
await self._peer._honcho._ensure_workspace_async()
|
||||
session_id = resolve_id(session)
|
||||
target_id = resolve_id(target)
|
||||
|
||||
body: dict[str, Any] = {}
|
||||
body: dict[str, Any] = scope_recall_fields(
|
||||
scope=scope, sessions=sessions, session_id=session_id
|
||||
)
|
||||
if session_id:
|
||||
body["session_id"] = session_id
|
||||
if target_id:
|
||||
|
|
@ -1192,6 +1334,14 @@ class SessionAio(AsyncMetadataConfigMixin):
|
|||
None,
|
||||
description="A peer ID to get context from the perspective of.",
|
||||
),
|
||||
scope: str | ScopeBase | None = Field(
|
||||
None,
|
||||
description="A scope to use as the perspective source instead of a peer.",
|
||||
),
|
||||
sessions: Sequence[str | SessionBase] | None = Field(
|
||||
None,
|
||||
description="An allowlist of sessions confining `peer_target`'s representation to that set. This session must be one of them.",
|
||||
),
|
||||
limit_to_session: bool = Field(
|
||||
False,
|
||||
description="Whether to limit the representation to this session only.",
|
||||
|
|
@ -1219,7 +1369,11 @@ class SessionAio(AsyncMetadataConfigMixin):
|
|||
description="Maximum number of conclusions to include in the representation.",
|
||||
),
|
||||
) -> SessionContext:
|
||||
"""Get optimized context for this session asynchronously."""
|
||||
"""Get optimized context for this session asynchronously.
|
||||
|
||||
See Session.context for parameter details, including the depth caveat on
|
||||
``sessions``.
|
||||
"""
|
||||
await self._session._honcho._ensure_workspace_async()
|
||||
if peer_target is None and peer_perspective is not None:
|
||||
raise ValueError(
|
||||
|
|
@ -1238,6 +1392,13 @@ class SessionAio(AsyncMetadataConfigMixin):
|
|||
query: dict[str, Any] = {
|
||||
"summary": summary,
|
||||
"limit_to_session": limit_to_session,
|
||||
**scope_context_fields(
|
||||
scope=scope,
|
||||
sessions=sessions,
|
||||
peer_target=peer_target,
|
||||
peer_perspective=peer_perspective,
|
||||
limit_to_session=limit_to_session,
|
||||
),
|
||||
}
|
||||
if tokens is not None:
|
||||
query["tokens"] = tokens
|
||||
|
|
@ -1488,19 +1649,19 @@ class SessionAio(AsyncMetadataConfigMixin):
|
|||
return Message.from_api_response(MessageResponse.model_validate(data))
|
||||
|
||||
|
||||
class ConclusionScopeAio:
|
||||
class ConclusionsViewAio:
|
||||
"""
|
||||
Async view of a ConclusionScope.
|
||||
Async view of a ConclusionsView.
|
||||
|
||||
Access via `scope.aio`. Provides async versions of all ConclusionScope methods.
|
||||
Shares state with the parent ConclusionScope instance.
|
||||
Access via `view.aio`. Provides async versions of all ConclusionsView methods.
|
||||
Shares state with the parent ConclusionsView instance.
|
||||
"""
|
||||
|
||||
__slots__: ClassVar[tuple[str, ...]] = ("_scope",)
|
||||
_scope: "ConclusionScope"
|
||||
__slots__: ClassVar[tuple[str, ...]] = ("_view",)
|
||||
_view: "ConclusionsView"
|
||||
|
||||
def __init__(self, scope: "ConclusionScope") -> None:
|
||||
self._scope = scope
|
||||
def __init__(self, view: "ConclusionsView") -> None:
|
||||
self._view = view
|
||||
|
||||
async def list(
|
||||
self,
|
||||
|
|
@ -1520,13 +1681,13 @@ class ConclusionScopeAio:
|
|||
https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
|
||||
"""
|
||||
_reject_reserved_filter_keys(
|
||||
filters, _SCOPE_RESERVED + ("session", "session_id")
|
||||
filters, _VIEW_RESERVED + ("session", "session_id")
|
||||
)
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
await self._view._honcho._ensure_workspace_async()
|
||||
resolved_session_id = resolve_id(session)
|
||||
filters = {
|
||||
"observer_id": self._scope.observer,
|
||||
"observed_id": self._scope.observed,
|
||||
"observer_id": self._view.observer,
|
||||
"observed_id": self._view.observed,
|
||||
**({"session_id": resolved_session_id} if resolved_session_id else {}),
|
||||
**(filters or {}),
|
||||
}
|
||||
|
|
@ -1534,8 +1695,8 @@ class ConclusionScopeAio:
|
|||
query: dict[str, Any] = {"page": page, "size": size}
|
||||
if reverse:
|
||||
query["reverse"] = "true"
|
||||
data = await self._scope._honcho._async_http_client.post(
|
||||
routes.conclusions_list(self._scope.workspace_id),
|
||||
data = await self._view._honcho._async_http_client.post(
|
||||
routes.conclusions_list(self._view.workspace_id),
|
||||
body={"filters": filters},
|
||||
query=query,
|
||||
)
|
||||
|
|
@ -1549,8 +1710,8 @@ class ConclusionScopeAio:
|
|||
next_query: dict[str, Any] = {"page": next_page, "size": size}
|
||||
if reverse:
|
||||
next_query["reverse"] = "true"
|
||||
next_data = await self._scope._honcho._async_http_client.post(
|
||||
routes.conclusions_list(self._scope.workspace_id),
|
||||
next_data = await self._view._honcho._async_http_client.post(
|
||||
routes.conclusions_list(self._view.workspace_id),
|
||||
body={"filters": filters},
|
||||
query=next_query,
|
||||
)
|
||||
|
|
@ -1575,11 +1736,11 @@ class ConclusionScopeAio:
|
|||
filters: Optional dictionary of additional filter criteria, merged
|
||||
with this scope's observer/observed (e.g. ``{"level": "deductive"}``).
|
||||
"""
|
||||
_reject_reserved_filter_keys(filters, _SCOPE_RESERVED)
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
_reject_reserved_filter_keys(filters, _VIEW_RESERVED)
|
||||
await self._view._honcho._ensure_workspace_async()
|
||||
filters = {
|
||||
"observer_id": self._scope.observer,
|
||||
"observed_id": self._scope.observed,
|
||||
"observer_id": self._view.observer,
|
||||
"observed_id": self._view.observed,
|
||||
**(filters or {}),
|
||||
}
|
||||
|
||||
|
|
@ -1591,8 +1752,8 @@ class ConclusionScopeAio:
|
|||
if distance is not None:
|
||||
body["distance"] = distance
|
||||
|
||||
data = await self._scope._honcho._async_http_client.post(
|
||||
routes.conclusions_query(self._scope.workspace_id),
|
||||
data = await self._view._honcho._async_http_client.post(
|
||||
routes.conclusions_query(self._view.workspace_id),
|
||||
body=body,
|
||||
)
|
||||
return [
|
||||
|
|
@ -1602,9 +1763,9 @@ class ConclusionScopeAio:
|
|||
|
||||
async def delete(self, conclusion_id: str) -> None:
|
||||
"""Delete a conclusion by ID asynchronously."""
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
await self._scope._honcho._async_http_client.delete(
|
||||
routes.conclusion(self._scope.workspace_id, conclusion_id)
|
||||
await self._view._honcho._ensure_workspace_async()
|
||||
await self._view._honcho._async_http_client.delete(
|
||||
routes.conclusion(self._view.workspace_id, conclusion_id)
|
||||
)
|
||||
|
||||
async def create(
|
||||
|
|
@ -1612,15 +1773,15 @@ class ConclusionScopeAio:
|
|||
conclusions: list[ConclusionCreateParams | dict[str, Any]],
|
||||
) -> list[Conclusion]:
|
||||
"""Create conclusions in this scope asynchronously."""
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
await self._view._honcho._ensure_workspace_async()
|
||||
|
||||
def build_conclusion_payload(
|
||||
item: ConclusionCreateParams | dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Build a single conclusion create payload."""
|
||||
payload: dict[str, Any] = {
|
||||
"observer_id": self._scope.observer,
|
||||
"observed_id": self._scope.observed,
|
||||
"observer_id": self._view.observer,
|
||||
"observed_id": self._view.observed,
|
||||
}
|
||||
if isinstance(item, ConclusionCreateParams):
|
||||
payload["content"] = item.content
|
||||
|
|
@ -1636,8 +1797,8 @@ class ConclusionScopeAio:
|
|||
|
||||
conclusion_params = [build_conclusion_payload(c) for c in conclusions]
|
||||
|
||||
data = await self._scope._honcho._async_http_client.post(
|
||||
routes.conclusions(self._scope.workspace_id),
|
||||
data = await self._view._honcho._async_http_client.post(
|
||||
routes.conclusions(self._view.workspace_id),
|
||||
body={"conclusions": conclusion_params},
|
||||
)
|
||||
return [
|
||||
|
|
@ -1654,8 +1815,8 @@ class ConclusionScopeAio:
|
|||
max_conclusions: int | None = None,
|
||||
) -> str:
|
||||
"""Get the computed representation for this scope asynchronously."""
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
body: dict[str, Any] = {"target": self._scope.observed}
|
||||
await self._view._honcho._ensure_workspace_async()
|
||||
body: dict[str, Any] = {"target": self._view.observed}
|
||||
if search_query is not None:
|
||||
body["search_query"] = search_query
|
||||
if search_top_k is not None:
|
||||
|
|
@ -1667,9 +1828,99 @@ class ConclusionScopeAio:
|
|||
if max_conclusions is not None:
|
||||
body["max_conclusions"] = max_conclusions
|
||||
|
||||
data = await self._scope._honcho._async_http_client.post(
|
||||
routes.peer_representation(self._scope.workspace_id, self._scope.observer),
|
||||
data = await self._view._honcho._async_http_client.post(
|
||||
routes.peer_representation(self._view.workspace_id, self._view.observer),
|
||||
body=body,
|
||||
)
|
||||
response = RepresentationResponse.model_validate(data)
|
||||
return response.representation
|
||||
|
||||
|
||||
class ScopeAio:
|
||||
"""
|
||||
Async view of a Scope.
|
||||
|
||||
Access via `scope.aio`. Provides async versions of all Scope methods.
|
||||
Shares state with the parent Scope instance.
|
||||
"""
|
||||
|
||||
__slots__: ClassVar[tuple[str, ...]] = ("_scope",)
|
||||
_scope: "Scope"
|
||||
|
||||
def __init__(self, scope: "Scope") -> None:
|
||||
"""Create an async view backed by a sync Scope."""
|
||||
self._scope = scope
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
async def add_sessions(self, sessions: Sequence[str | SessionBase]) -> None:
|
||||
"""Add sessions to this scope asynchronously.
|
||||
|
||||
See Scope.add_sessions for details, including the asynchronous backfill
|
||||
that sessions with existing messages trigger.
|
||||
"""
|
||||
session_ids = resolve_scope_membership(sessions)
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
await self._scope._honcho._async_http_client.post(
|
||||
routes.scope_sessions(self._scope.workspace_id, self._scope.id),
|
||||
body={"session_ids": session_ids},
|
||||
)
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
async def remove_session(self, session: str | SessionBase) -> None:
|
||||
"""Remove a session from this scope asynchronously.
|
||||
|
||||
See Scope.remove_session for details on the asynchronous reconciliation.
|
||||
"""
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
await self._scope._honcho._async_http_client.delete(
|
||||
routes.scope_session(
|
||||
self._scope.workspace_id, self._scope.id, resolve_scope_session(session)
|
||||
)
|
||||
)
|
||||
|
||||
async def sessions(
|
||||
self,
|
||||
page: int = 1,
|
||||
size: int = 50,
|
||||
*,
|
||||
reverse: bool = False,
|
||||
) -> AsyncPage[SessionResponse, Session]:
|
||||
"""Get the sessions that are members of this scope asynchronously."""
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
|
||||
async def fetch(next_page: int) -> dict[str, Any]:
|
||||
query: dict[str, Any] = {"page": next_page, "size": size}
|
||||
if reverse:
|
||||
query["reverse"] = "true"
|
||||
return await self._scope._honcho._async_http_client.post(
|
||||
routes.scope_sessions_list(self._scope.workspace_id, self._scope.id),
|
||||
query=query,
|
||||
)
|
||||
|
||||
def transform(response: SessionResponse) -> Session:
|
||||
return Session(
|
||||
response.id,
|
||||
self._scope._honcho,
|
||||
metadata=response.metadata,
|
||||
configuration=response.configuration,
|
||||
created_at=response.created_at,
|
||||
is_active=response.is_active,
|
||||
)
|
||||
|
||||
async def fetch_next(next_page: int) -> AsyncPage[SessionResponse, Session]:
|
||||
return AsyncPage(
|
||||
await fetch(next_page), SessionResponse, transform, fetch_next
|
||||
)
|
||||
|
||||
return AsyncPage(await fetch(page), SessionResponse, transform, fetch_next)
|
||||
|
||||
async def status(self) -> dict[str, ScopeBackfillJob]:
|
||||
"""Get the backfill/reconciliation progress for this scope asynchronously.
|
||||
|
||||
See Scope.status for details.
|
||||
"""
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
data = await self._scope._honcho._async_http_client.get(
|
||||
routes.scope_status(self._scope.workspace_id, self._scope.id)
|
||||
)
|
||||
return ScopeStatusResponse.model_validate(data).backfill_status
|
||||
|
|
|
|||
|
|
@ -276,6 +276,7 @@ class SessionCreateParams(BaseModel):
|
|||
metadata: dict[str, Any] | None = None
|
||||
peers: dict[str, SessionPeerConfig] | None = None
|
||||
configuration: SessionConfiguration | None = None
|
||||
scopes: list[str] | None = None
|
||||
|
||||
|
||||
class SessionUpdateParams(BaseModel):
|
||||
|
|
@ -295,6 +296,44 @@ class SessionListParams(BaseModel):
|
|||
filters: dict[str, Any] | None = None
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Scope Types
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
class ScopeResponse(BaseModel):
|
||||
"""Scope API response."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True) # pyright: ignore[reportUnannotatedClassAttribute]
|
||||
|
||||
id: str
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime.datetime
|
||||
|
||||
|
||||
class ScopeBackfillJob(BaseModel):
|
||||
"""Backfill job state for one session in a scope.
|
||||
|
||||
``docs_copied`` is present only once the backfill for that session completes.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore") # pyright: ignore[reportUnannotatedClassAttribute]
|
||||
|
||||
state: Literal["pending", "completed", "failed"]
|
||||
updated_at: datetime.datetime
|
||||
docs_copied: int | None = None
|
||||
|
||||
|
||||
class ScopeStatusResponse(BaseModel):
|
||||
"""Scope backfill/reconciliation status API response.
|
||||
|
||||
``backfill_status`` is keyed by session ID and only contains sessions that
|
||||
have had a backfill enqueued.
|
||||
"""
|
||||
|
||||
backfill_status: dict[str, ScopeBackfillJob] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Summary Types
|
||||
# ==============================================================================
|
||||
|
|
|
|||
|
|
@ -43,3 +43,20 @@ class SessionBase(BaseModel):
|
|||
workspace_id: str = Field(
|
||||
..., min_length=1, description="Workspace ID for scoping operations"
|
||||
)
|
||||
|
||||
|
||||
class ScopeBase(BaseModel):
|
||||
"""Base class for Scope objects (sync and async variants).
|
||||
|
||||
Use this type in method signatures to accept either a scope ID string or any
|
||||
Scope object.
|
||||
|
||||
Attributes:
|
||||
id: Unprefixed scope name, unique within the workspace
|
||||
workspace_id: Workspace ID for scoping operations
|
||||
"""
|
||||
|
||||
id: str = Field(..., min_length=1, description="Unprefixed name of this scope")
|
||||
workspace_id: str = Field(
|
||||
..., min_length=1, description="Workspace ID for scoping operations"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
|
|
@ -16,20 +16,22 @@ from .api_types import (
|
|||
PeerConfig,
|
||||
PeerResponse,
|
||||
QueueStatusResponse,
|
||||
ScopeResponse,
|
||||
SessionConfiguration,
|
||||
SessionPeerConfig,
|
||||
SessionResponse,
|
||||
WorkspaceConfiguration,
|
||||
WorkspaceResponse,
|
||||
)
|
||||
from .base import PeerBase, SessionBase
|
||||
from .base import PeerBase, ScopeBase, SessionBase
|
||||
from .http import AsyncHonchoHTTPClient, HonchoHTTPClient, routes
|
||||
from .message import Message
|
||||
from .mixins import MetadataConfigMixin
|
||||
from .pagination import SyncPage
|
||||
from .peer import Peer
|
||||
from .scope import Scope
|
||||
from .session import Session
|
||||
from .utils import normalize_peers_to_dict, resolve_id
|
||||
from .utils import normalize_peers_to_dict, resolve_id, validate_scope_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -419,6 +421,10 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
None,
|
||||
description="Optional peers to attach to the session at creation. Accepts the same shape as Session.add_peers.",
|
||||
),
|
||||
scopes: Sequence[str | ScopeBase] | None = Field(
|
||||
None,
|
||||
description="Optional scopes this session should join. Each scope is created if it does not exist yet.",
|
||||
),
|
||||
) -> Session:
|
||||
"""
|
||||
Get or create a session with the given ID.
|
||||
|
|
@ -433,6 +439,11 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
peers: Optional peers to attach to the session at creation. Accepts the
|
||||
same shape as Session.add_peers (peer ID string, Peer object, list
|
||||
of either, or tuples with SessionPeerConfig).
|
||||
scopes: Optional scopes this session should join, as IDs or Scope
|
||||
objects. Each scope is created if it does not exist yet. Attaching
|
||||
at creation avoids the asynchronous backfill a later
|
||||
``scope.add_sessions()`` triggers, since there is no history to
|
||||
copy.
|
||||
|
||||
Returns:
|
||||
A Session object with cached metadata, configuration, created_at, and is_active.
|
||||
|
|
@ -445,6 +456,8 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
body["configuration"] = configuration.model_dump(exclude_none=True)
|
||||
if peers is not None:
|
||||
body["peers"] = normalize_peers_to_dict(peers)
|
||||
if scopes is not None:
|
||||
body["scopes"] = [validate_scope_id(resolve_id(scope)) for scope in scopes]
|
||||
|
||||
data = self._http.post(routes.sessions(self.workspace_id), body=body)
|
||||
session_data = SessionResponse.model_validate(data)
|
||||
|
|
@ -514,6 +527,97 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
|
||||
return SyncPage(data, SessionResponse, transform, fetch_next)
|
||||
|
||||
@validate_call
|
||||
def scope(
|
||||
self,
|
||||
id: str = Field( # noqa: A002
|
||||
..., min_length=1, description="Unprefixed name for the scope"
|
||||
),
|
||||
*,
|
||||
metadata: dict[str, object] | None = Field(
|
||||
None,
|
||||
description="Optional metadata dictionary to associate with this scope.",
|
||||
),
|
||||
) -> Scope:
|
||||
"""
|
||||
Get or create a scope with the given ID.
|
||||
|
||||
A scope is a named set of sessions that acts as a visibility boundary:
|
||||
recall performed through the scope sees only what happened in its sessions,
|
||||
while the underlying peer keeps its single unified representation of
|
||||
everything.
|
||||
|
||||
Args:
|
||||
id: Unprefixed scope name, unique within the workspace.
|
||||
metadata: Optional metadata dictionary to associate with this scope.
|
||||
|
||||
Returns:
|
||||
A Scope object for managing membership.
|
||||
|
||||
Raises:
|
||||
ValueError: If the scope ID is invalid.
|
||||
|
||||
Example:
|
||||
```python
|
||||
therapy = honcho.scope("therapy")
|
||||
therapy.add_sessions([session_1, session_2])
|
||||
```
|
||||
"""
|
||||
validate_scope_id(id)
|
||||
self._ensure_workspace()
|
||||
body: dict[str, Any] = {"id": id}
|
||||
if metadata is not None:
|
||||
body["metadata"] = metadata
|
||||
|
||||
data = self._http.post(routes.scopes(self.workspace_id), body=body)
|
||||
scope_data = ScopeResponse.model_validate(data)
|
||||
return Scope(
|
||||
id,
|
||||
self,
|
||||
metadata=scope_data.metadata,
|
||||
created_at=scope_data.created_at,
|
||||
)
|
||||
|
||||
def scopes(
|
||||
self,
|
||||
*,
|
||||
page: int = 1,
|
||||
size: int = 50,
|
||||
reverse: bool = False,
|
||||
) -> SyncPage[ScopeResponse, Scope]:
|
||||
"""
|
||||
Get all scopes in the current workspace.
|
||||
|
||||
Args:
|
||||
page: Page number (1-indexed). Default: 1.
|
||||
size: Number of items per page. Default: 50.
|
||||
reverse: If True, reverses the default ordering. Default: False.
|
||||
|
||||
Returns:
|
||||
A SyncPage of Scope objects representing all scopes in the workspace.
|
||||
"""
|
||||
self._ensure_workspace()
|
||||
|
||||
def fetch(next_page: int) -> dict[str, Any]:
|
||||
query: dict[str, Any] = {"page": next_page, "size": size}
|
||||
if reverse:
|
||||
query["reverse"] = "true"
|
||||
return self._http.post(routes.scopes_list(self.workspace_id), query=query)
|
||||
|
||||
def transform(scope: ScopeResponse) -> Scope:
|
||||
"""Convert a scope API response into a Scope SDK object."""
|
||||
return Scope(
|
||||
scope.id,
|
||||
self,
|
||||
metadata=scope.metadata,
|
||||
created_at=scope.created_at,
|
||||
)
|
||||
|
||||
def fetch_next(next_page: int) -> SyncPage[ScopeResponse, Scope]:
|
||||
return SyncPage(fetch(next_page), ScopeResponse, transform, fetch_next)
|
||||
|
||||
return SyncPage(fetch(page), ScopeResponse, transform, fetch_next)
|
||||
|
||||
def workspaces(
|
||||
self,
|
||||
filters: dict[str, object] | None = None,
|
||||
|
|
@ -592,6 +696,11 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
limit: int = Field(
|
||||
default=10, ge=1, le=100, description="Number of results to return"
|
||||
),
|
||||
*,
|
||||
scope: str | ScopeBase | None = Field(
|
||||
None,
|
||||
description="Optional scope restricting the search to its member sessions",
|
||||
),
|
||||
) -> list[Message]:
|
||||
"""
|
||||
Search for messages in the current workspace.
|
||||
|
|
@ -602,15 +711,22 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
query: The search query to use
|
||||
filters: Filters to scope the search. See [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters).
|
||||
limit: Number of results to return (1-100, default: 10)
|
||||
scope: Optional scope (ID or Scope object) restricting the search to
|
||||
that scope's member sessions. Mutually exclusive with a
|
||||
``session_id`` filter. A scope with no member sessions matches
|
||||
nothing rather than everything.
|
||||
|
||||
Returns:
|
||||
A list of Message objects representing the search results.
|
||||
Returns an empty list if no messages are found.
|
||||
"""
|
||||
self._ensure_workspace()
|
||||
body: dict[str, Any] = {"query": query, "filters": filters, "limit": limit}
|
||||
if scope is not None:
|
||||
body["scope"] = validate_scope_id(resolve_id(scope))
|
||||
data = self._http.post(
|
||||
routes.workspace_search(self.workspace_id),
|
||||
body={"query": query, "filters": filters, "limit": limit},
|
||||
body=body,
|
||||
)
|
||||
return [
|
||||
Message.from_api_response(MessageResponse.model_validate(item))
|
||||
|
|
|
|||
|
|
@ -15,28 +15,28 @@ from .pagination import SyncPage
|
|||
from .utils import resolve_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .aio import ConclusionScopeAio
|
||||
from .aio import ConclusionsViewAio
|
||||
from .client import Honcho
|
||||
|
||||
__all__ = [
|
||||
"Conclusion",
|
||||
"ConclusionScope",
|
||||
"ConclusionsView",
|
||||
"ConclusionCreateParams",
|
||||
]
|
||||
|
||||
# Filter keys that define a conclusion scope (the observer/observed peer pair).
|
||||
# They are set from the scope itself, so a caller must not pass them in `filters`.
|
||||
_SCOPE_RESERVED = ("observer", "observed", "observer_id", "observed_id")
|
||||
# Filter keys that define a conclusions view (the observer/observed peer pair).
|
||||
# They are set from the view itself, so a caller must not pass them in `filters`.
|
||||
_VIEW_RESERVED = ("observer", "observed", "observer_id", "observed_id")
|
||||
|
||||
|
||||
def _reject_reserved_filter_keys(
|
||||
filters: dict[str, Any] | None, reserved: tuple[str, ...]
|
||||
) -> None:
|
||||
"""Raise if ``filters`` contains keys managed by the conclusion scope.
|
||||
"""Raise if ``filters`` contains keys managed by the conclusions view.
|
||||
|
||||
The observer/observed peer pair (and, on ``list``, the session) is fixed by
|
||||
the scope, so letting a user filter override it would silently return data
|
||||
from a different scope than requested. Fail loud instead.
|
||||
the view, so letting a user filter override it would silently return data
|
||||
from a different pair than requested. Fail loud instead.
|
||||
"""
|
||||
if not filters:
|
||||
return
|
||||
|
|
@ -48,7 +48,7 @@ def _reject_reserved_filter_keys(
|
|||
if "session" in reserved or "session_id" in reserved:
|
||||
guidance += "; use the session= parameter to filter by session"
|
||||
raise ValueError(
|
||||
f"Filter key(s) {clash} are managed by this conclusion scope and "
|
||||
f"Filter key(s) {clash} are managed by this conclusions view and "
|
||||
+ f"cannot be passed in filters. {guidance}."
|
||||
)
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ class Conclusion:
|
|||
return self.content
|
||||
|
||||
|
||||
class ConclusionScope:
|
||||
class ConclusionsView:
|
||||
"""
|
||||
Scoped access to conclusions for a specific observer/observed relationship.
|
||||
|
||||
|
|
@ -165,7 +165,7 @@ class ConclusionScope:
|
|||
observed: str,
|
||||
):
|
||||
"""
|
||||
Initialize a ConclusionScope.
|
||||
Initialize a ConclusionsView.
|
||||
|
||||
Args:
|
||||
honcho: The Honcho client instance
|
||||
|
|
@ -179,12 +179,12 @@ class ConclusionScope:
|
|||
self.observed = observed
|
||||
|
||||
@property
|
||||
def aio(self) -> "ConclusionScopeAio":
|
||||
def aio(self) -> "ConclusionsViewAio":
|
||||
"""
|
||||
Access async versions of all ConclusionScope methods.
|
||||
Access async versions of all ConclusionsView methods.
|
||||
|
||||
Returns a ConclusionScopeAio view that provides async versions of all methods
|
||||
while sharing state with this ConclusionScope instance.
|
||||
Returns a ConclusionsViewAio view that provides async versions of all methods
|
||||
while sharing state with this ConclusionsView instance.
|
||||
|
||||
Example:
|
||||
```python
|
||||
|
|
@ -194,9 +194,9 @@ class ConclusionScope:
|
|||
```
|
||||
"""
|
||||
# Import here to avoid circular import (aio.py imports from this module)
|
||||
from .aio import ConclusionScopeAio
|
||||
from .aio import ConclusionsViewAio
|
||||
|
||||
return ConclusionScopeAio(self)
|
||||
return ConclusionsViewAio(self)
|
||||
|
||||
def list(
|
||||
self,
|
||||
|
|
@ -226,7 +226,7 @@ class ConclusionScope:
|
|||
Paginated response containing Conclusion objects
|
||||
"""
|
||||
_reject_reserved_filter_keys(
|
||||
filters, _SCOPE_RESERVED + ("session", "session_id")
|
||||
filters, _VIEW_RESERVED + ("session", "session_id")
|
||||
)
|
||||
self._honcho._ensure_workspace()
|
||||
resolved_session_id = resolve_id(session)
|
||||
|
|
@ -288,7 +288,7 @@ class ConclusionScope:
|
|||
Returns:
|
||||
List of matching Conclusion objects
|
||||
"""
|
||||
_reject_reserved_filter_keys(filters, _SCOPE_RESERVED)
|
||||
_reject_reserved_filter_keys(filters, _VIEW_RESERVED)
|
||||
self._honcho._ensure_workspace()
|
||||
filters = {
|
||||
"observer_id": self.observer,
|
||||
|
|
@ -443,6 +443,6 @@ class ConclusionScope:
|
|||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"ConclusionScope(workspace_id={self.workspace_id!r}, "
|
||||
f"ConclusionsView(workspace_id={self.workspace_id!r}, "
|
||||
f"observer={self.observer!r}, observed={self.observed!r})"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -102,6 +102,31 @@ def session_peer_config(workspace_id: str, session_id: str, peer_id: str) -> str
|
|||
return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config"
|
||||
|
||||
|
||||
# Scope routes
|
||||
def scopes(workspace_id: str) -> str:
|
||||
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes"
|
||||
|
||||
|
||||
def scopes_list(workspace_id: str) -> str:
|
||||
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/list"
|
||||
|
||||
|
||||
def scope_sessions(workspace_id: str, scope_id: str) -> str:
|
||||
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/sessions"
|
||||
|
||||
|
||||
def scope_sessions_list(workspace_id: str, scope_id: str) -> str:
|
||||
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/sessions/list"
|
||||
|
||||
|
||||
def scope_session(workspace_id: str, scope_id: str, session_id: str) -> str:
|
||||
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/sessions/{session_id}"
|
||||
|
||||
|
||||
def scope_status(workspace_id: str, scope_id: str) -> str:
|
||||
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/status"
|
||||
|
||||
|
||||
# Message routes
|
||||
def messages(workspace_id: str, session_id: str) -> str:
|
||||
return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/messages"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from __future__ import annotations
|
|||
import datetime
|
||||
import logging
|
||||
import warnings
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Generator, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
|
||||
|
|
@ -22,14 +22,14 @@ from .api_types import (
|
|||
SessionConfiguration,
|
||||
SessionResponse,
|
||||
)
|
||||
from .base import PeerBase, SessionBase
|
||||
from .conclusions import ConclusionScope
|
||||
from .base import PeerBase, ScopeBase, SessionBase
|
||||
from .conclusions import ConclusionsView
|
||||
from .http import routes
|
||||
from .message import Message
|
||||
from .mixins import MetadataConfigMixin
|
||||
from .pagination import SyncPage
|
||||
from .types import DialecticStreamResponse
|
||||
from .utils import parse_datetime, parse_sse_stream, resolve_id
|
||||
from .utils import parse_datetime, parse_sse_stream, resolve_id, scope_recall_fields
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .aio import PeerAio
|
||||
|
|
@ -241,6 +241,8 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
*,
|
||||
target: str | PeerBase | None = None,
|
||||
session: str | SessionBase | None = None,
|
||||
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
|
||||
sessions: Sequence[str | SessionBase] | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: type[TResponseFormat],
|
||||
|
|
@ -253,6 +255,8 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
*,
|
||||
target: str | PeerBase | None = None,
|
||||
session: str | SessionBase | None = None,
|
||||
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
|
||||
sessions: Sequence[str | SessionBase] | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: dict[str, Any] | None = None,
|
||||
|
|
@ -265,6 +269,8 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
*,
|
||||
target: str | PeerBase | None = None,
|
||||
session: str | SessionBase | None = None,
|
||||
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
|
||||
sessions: Sequence[str | SessionBase] | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
|
|
@ -285,6 +291,19 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
session: Optional session to scope the query to. If provided, only
|
||||
information from that session is considered. Can be a session
|
||||
ID string or a Session object.
|
||||
scope: Optional scope(s) to confine the query to. A single scope answers
|
||||
from that scope's own view of the target, including the
|
||||
higher-order conclusions reasoned within it. A sequence of scopes
|
||||
restricts recall to the union of their member sessions, which —
|
||||
like ``sessions`` — yields only directly-stated conclusions.
|
||||
Mutually exclusive with ``session`` and ``sessions``, and requires
|
||||
a workspace-level key.
|
||||
sessions: Optional allowlist of sessions to confine the query to, for
|
||||
one-off questions spanning a handful of sessions. Recall is
|
||||
limited to conclusions stated directly in those sessions:
|
||||
conclusions produced by reasoning across sessions are excluded,
|
||||
because their provenance cannot be proven to sit inside the
|
||||
allowlist. Reach for a named ``scope`` when you need that depth.
|
||||
reasoning_level: Optional reasoning level for the query: "minimal", "low", "medium",
|
||||
"high", or "max". Defaults to "low" if not provided.
|
||||
response_format: Optional structure for the answer. Pass a Pydantic
|
||||
|
|
@ -296,12 +315,20 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
Response string containing the answer (a JSON string when a schema
|
||||
dict was given), a parsed model instance when a Pydantic model class
|
||||
was given, or None if no relevant information.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``scope`` is combined with ``session`` or ``sessions``.
|
||||
"""
|
||||
self._honcho._ensure_workspace()
|
||||
target_id = resolve_id(target)
|
||||
resolved_session_id = resolve_id(session)
|
||||
|
||||
body: dict[str, Any] = {"query": query, "stream": False}
|
||||
body.update(
|
||||
scope_recall_fields(
|
||||
scope=scope, sessions=sessions, session_id=resolved_session_id
|
||||
)
|
||||
)
|
||||
if target_id:
|
||||
body["target"] = target_id
|
||||
if resolved_session_id:
|
||||
|
|
@ -330,6 +357,8 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
*,
|
||||
target: str | PeerBase | None = None,
|
||||
session: str | SessionBase | None = None,
|
||||
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
|
||||
sessions: Sequence[str | SessionBase] | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
|
|
@ -350,6 +379,9 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
session: Optional session to scope the query to. If provided, only
|
||||
information from that session is considered. Can be a session
|
||||
ID string or a Session object.
|
||||
scope: Optional scope(s) to confine the query to. See :meth:`chat`.
|
||||
sessions: Optional allowlist of sessions to confine the query to. See
|
||||
:meth:`chat` for the depth caveat.
|
||||
reasoning_level: Optional reasoning level for the query: "minimal", "low", "medium",
|
||||
"high", or "max". Defaults to "low" if not provided.
|
||||
response_format: Optional structure for the answer: a Pydantic model
|
||||
|
|
@ -360,12 +392,20 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
|
||||
Returns:
|
||||
DialecticStreamResponse object that can be iterated over and provides final response
|
||||
|
||||
Raises:
|
||||
ValueError: If ``scope`` is combined with ``session`` or ``sessions``.
|
||||
"""
|
||||
self._honcho._ensure_workspace()
|
||||
target_id = resolve_id(target)
|
||||
resolved_session_id = resolve_id(session)
|
||||
|
||||
body: dict[str, Any] = {"query": query, "stream": True}
|
||||
body.update(
|
||||
scope_recall_fields(
|
||||
scope=scope, sessions=sessions, session_id=resolved_session_id
|
||||
)
|
||||
)
|
||||
if target_id:
|
||||
body["target"] = target_id
|
||||
if resolved_session_id:
|
||||
|
|
@ -633,6 +673,9 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
search_max_distance: float | None = Field(None, ge=0.0, le=1.0),
|
||||
include_most_frequent: bool | None = None,
|
||||
max_conclusions: int | None = Field(None, ge=1, le=100),
|
||||
*,
|
||||
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
|
||||
sessions: Sequence[str | SessionBase] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get a subset of the representation of the peer.
|
||||
|
|
@ -646,10 +689,18 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
|
||||
include_most_frequent: Whether to include the most frequent conclusions
|
||||
max_conclusions: Maximum number of conclusions to include
|
||||
scope: Optional scope(s) confining the representation. See
|
||||
:meth:`chat`. Mutually exclusive with ``session`` and ``sessions``.
|
||||
sessions: Optional allowlist of sessions confining the representation to
|
||||
directly-stated conclusions from those sessions. See
|
||||
:meth:`chat` for the depth caveat.
|
||||
|
||||
Returns:
|
||||
A Representation string
|
||||
|
||||
Raises:
|
||||
ValueError: If ``scope`` is combined with ``session`` or ``sessions``.
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Get global representation
|
||||
|
|
@ -671,7 +722,9 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
session_id = resolve_id(session)
|
||||
target_id = resolve_id(target)
|
||||
|
||||
body: dict[str, Any] = {}
|
||||
body: dict[str, Any] = scope_recall_fields(
|
||||
scope=scope, sessions=sessions, session_id=session_id
|
||||
)
|
||||
if session_id:
|
||||
body["session_id"] = session_id
|
||||
if target_id:
|
||||
|
|
@ -764,7 +817,7 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
return PeerContextResponse.model_validate(data)
|
||||
|
||||
@property
|
||||
def conclusions(self) -> ConclusionScope:
|
||||
def conclusions(self) -> ConclusionsView:
|
||||
"""
|
||||
Access this peer's self-conclusions (where observer == observed == self).
|
||||
|
||||
|
|
@ -772,7 +825,7 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
has made about themselves. Use this for self-conclusion scenarios.
|
||||
|
||||
Returns:
|
||||
A ConclusionScope scoped to this peer's self-conclusions
|
||||
A ConclusionsView scoped to this peer's self-conclusions
|
||||
|
||||
Example:
|
||||
```python
|
||||
|
|
@ -786,9 +839,9 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
peer.conclusions.delete("obs-123")
|
||||
```
|
||||
"""
|
||||
return ConclusionScope(self._honcho, self.workspace_id, self.id, self.id)
|
||||
return ConclusionsView(self._honcho, self.workspace_id, self.id, self.id)
|
||||
|
||||
def conclusions_of(self, target: str | PeerBase) -> ConclusionScope:
|
||||
def conclusions_of(self, target: str | PeerBase) -> ConclusionsView:
|
||||
"""
|
||||
Access conclusions this peer has made about another peer.
|
||||
|
||||
|
|
@ -799,7 +852,7 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
target: The target peer (either a Peer object or peer ID string)
|
||||
|
||||
Returns:
|
||||
A ConclusionScope scoped to this peer's conclusions of the target
|
||||
A ConclusionsView scoped to this peer's conclusions of the target
|
||||
|
||||
Example:
|
||||
```python
|
||||
|
|
@ -817,7 +870,7 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
```
|
||||
"""
|
||||
target_id = target.id if isinstance(target, PeerBase) else target
|
||||
return ConclusionScope(self._honcho, self.workspace_id, self.id, target_id)
|
||||
return ConclusionsView(self._honcho, self.workspace_id, self.id, target_id)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
# pyright: reportPrivateUsage=false
|
||||
"""Sync Scope class for Honcho SDK."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import ConfigDict, PrivateAttr, validate_call
|
||||
|
||||
from .api_types import ScopeBackfillJob, ScopeStatusResponse, SessionResponse
|
||||
from .base import ScopeBase, SessionBase
|
||||
from .http import routes
|
||||
from .pagination import SyncPage
|
||||
from .session import Session
|
||||
from .utils import resolve_scope_membership, resolve_scope_session
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .aio import ScopeAio
|
||||
from .client import Honcho
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["Scope"]
|
||||
|
||||
|
||||
class Scope(ScopeBase):
|
||||
"""
|
||||
Represents a scope in Honcho.
|
||||
|
||||
A scope is a named set of sessions that acts as a visibility boundary. Recall
|
||||
performed through a scope sees only what happened in that scope's sessions,
|
||||
while the underlying peer keeps its single unified representation across
|
||||
everything it has ever participated in.
|
||||
|
||||
Membership changes are applied asynchronously: adding a session that already
|
||||
has messages copies its existing conclusions into the scope, and removing one
|
||||
reconciles them back out. Poll :meth:`status` to watch that settle.
|
||||
|
||||
Attributes:
|
||||
id: Unprefixed scope name, unique within the workspace
|
||||
workspace_id: Workspace ID for scoping operations
|
||||
metadata: Cached metadata for this scope. May be stale if not recently
|
||||
fetched.
|
||||
created_at: When this scope was created, if known
|
||||
|
||||
Example:
|
||||
```python
|
||||
therapy = honcho.scope("therapy")
|
||||
therapy.add_sessions([session_1, session_2])
|
||||
|
||||
# Ask a question answered only from the therapy sessions
|
||||
answer = user.chat("What is stressing them out?", scope="therapy")
|
||||
```
|
||||
"""
|
||||
|
||||
_metadata: dict[str, Any] | None = PrivateAttr(default=None)
|
||||
_created_at: datetime | None = PrivateAttr(default=None)
|
||||
_honcho: "Honcho" = PrivateAttr()
|
||||
|
||||
@property
|
||||
def metadata(self) -> dict[str, Any] | None:
|
||||
"""Cached metadata for this scope. May be stale if not recently fetched."""
|
||||
return self._metadata
|
||||
|
||||
@property
|
||||
def created_at(self) -> datetime | None:
|
||||
"""When this scope was created. Only available if fetched from the API."""
|
||||
return self._created_at
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scope_id: str,
|
||||
honcho: "Honcho",
|
||||
*,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
created_at: datetime | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize a new Scope.
|
||||
|
||||
**Do not call this directly — use** ``honcho.scope()``.
|
||||
|
||||
Args:
|
||||
scope_id: Unprefixed scope name, unique within the workspace
|
||||
honcho: Honcho client instance
|
||||
metadata: Cached metadata, if already fetched
|
||||
created_at: Creation timestamp, if already fetched
|
||||
"""
|
||||
super().__init__(
|
||||
id=scope_id,
|
||||
workspace_id=honcho.workspace_id,
|
||||
)
|
||||
self._honcho = honcho
|
||||
self._metadata = metadata
|
||||
self._created_at = created_at
|
||||
|
||||
@property
|
||||
def aio(self) -> "ScopeAio":
|
||||
"""
|
||||
Access async versions of all Scope methods.
|
||||
|
||||
Returns a ScopeAio view that provides async versions of all methods while
|
||||
sharing state with this Scope instance.
|
||||
|
||||
Example:
|
||||
```python
|
||||
await scope.aio.add_sessions(["session-1"])
|
||||
status = await scope.aio.status()
|
||||
```
|
||||
"""
|
||||
# Import here to avoid circular import (aio.py imports this module)
|
||||
from .aio import ScopeAio
|
||||
|
||||
return ScopeAio(self)
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
def add_sessions(self, sessions: Sequence[str | SessionBase]) -> None:
|
||||
"""
|
||||
Add sessions to this scope.
|
||||
|
||||
Every named session must already exist. Adding a session that is already
|
||||
a member is a no-op.
|
||||
|
||||
Sessions that already hold messages are backfilled into the scope
|
||||
asynchronously, so recall through this scope may not reflect their history
|
||||
immediately — poll :meth:`status` to watch that complete.
|
||||
|
||||
Args:
|
||||
sessions: Sessions to add, as ID strings or Session objects. At most
|
||||
100 per call, matching the server's limit; split larger membership
|
||||
changes into separate calls so a failure names the batch that
|
||||
failed.
|
||||
|
||||
Raises:
|
||||
ValueError: If no sessions are given, or more than 100.
|
||||
"""
|
||||
session_ids = resolve_scope_membership(sessions)
|
||||
self._honcho._ensure_workspace()
|
||||
self._honcho._http.post(
|
||||
routes.scope_sessions(self.workspace_id, self.id),
|
||||
body={"session_ids": session_ids},
|
||||
)
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
def remove_session(self, session: str | SessionBase) -> None:
|
||||
"""
|
||||
Remove a session from this scope.
|
||||
|
||||
Conclusions copied or derived while the session was a member are
|
||||
reconciled out asynchronously, and the scope's peer card is rebuilt from
|
||||
whatever evidence remains. Poll :meth:`status` to watch that settle.
|
||||
|
||||
Args:
|
||||
session: Session to remove, as an ID string or a Session object
|
||||
"""
|
||||
self._honcho._ensure_workspace()
|
||||
self._honcho._http.delete(
|
||||
routes.scope_session(
|
||||
self.workspace_id, self.id, resolve_scope_session(session)
|
||||
)
|
||||
)
|
||||
|
||||
def sessions(
|
||||
self,
|
||||
page: int = 1,
|
||||
size: int = 50,
|
||||
*,
|
||||
reverse: bool = False,
|
||||
) -> SyncPage[SessionResponse, Session]:
|
||||
"""
|
||||
Get the sessions that are members of this scope.
|
||||
|
||||
Ordered by how long each session has been a member — longest-standing
|
||||
first, or most recently added first when ``reverse`` is True.
|
||||
|
||||
Args:
|
||||
page: Page number (1-indexed)
|
||||
size: Number of results per page
|
||||
reverse: If True, reverses the default ordering. Default: False.
|
||||
|
||||
Returns:
|
||||
Paginated response containing Session objects
|
||||
"""
|
||||
self._honcho._ensure_workspace()
|
||||
|
||||
def fetch(next_page: int) -> dict[str, Any]:
|
||||
query: dict[str, Any] = {"page": next_page, "size": size}
|
||||
if reverse:
|
||||
query["reverse"] = "true"
|
||||
return self._honcho._http.post(
|
||||
routes.scope_sessions_list(self.workspace_id, self.id),
|
||||
query=query,
|
||||
)
|
||||
|
||||
def transform(response: SessionResponse) -> Session:
|
||||
return Session(
|
||||
response.id,
|
||||
self._honcho,
|
||||
metadata=response.metadata,
|
||||
configuration=response.configuration,
|
||||
created_at=response.created_at,
|
||||
is_active=response.is_active,
|
||||
)
|
||||
|
||||
def fetch_next(next_page: int) -> SyncPage[SessionResponse, Session]:
|
||||
return SyncPage(fetch(next_page), SessionResponse, transform, fetch_next)
|
||||
|
||||
return SyncPage(fetch(page), SessionResponse, transform, fetch_next)
|
||||
|
||||
def status(self) -> dict[str, ScopeBackfillJob]:
|
||||
"""
|
||||
Get the backfill/reconciliation progress for this scope.
|
||||
|
||||
Use this after a membership change to tell "the scope knows nothing about
|
||||
that session yet" apart from "the scope has caught up and there is
|
||||
genuinely nothing to recall".
|
||||
|
||||
Returns:
|
||||
Per-session backfill state, keyed by session ID. Only sessions that
|
||||
have had a backfill enqueued appear; an empty dict means none have.
|
||||
"""
|
||||
self._honcho._ensure_workspace()
|
||||
data = self._honcho._http.get(routes.scope_status(self.workspace_id, self.id))
|
||||
return ScopeStatusResponse.model_validate(data).backfill_status
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Scope(id={self.id!r}, workspace_id={self.workspace_id!r})"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.id
|
||||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
|
@ -20,7 +21,7 @@ from .api_types import (
|
|||
SessionPeerConfig,
|
||||
SessionResponse,
|
||||
)
|
||||
from .base import PeerBase, SessionBase
|
||||
from .base import PeerBase, ScopeBase, SessionBase
|
||||
from .http import routes
|
||||
from .message import Message
|
||||
from .mixins import MetadataConfigMixin
|
||||
|
|
@ -32,6 +33,7 @@ from .utils import (
|
|||
normalize_peers_to_dict,
|
||||
prepare_file_for_upload,
|
||||
resolve_id,
|
||||
scope_context_fields,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -574,6 +576,14 @@ class Session(SessionBase, MetadataConfigMixin):
|
|||
None,
|
||||
description="A peer ID to get context *from the perspective of*. If given, response will attempt to include representation and card from the perspective of `peer_perspective`. Must be provided with `peer_target`.",
|
||||
),
|
||||
scope: str | ScopeBase | None = Field(
|
||||
None,
|
||||
description="A scope to use as the perspective source instead of a peer: `peer_target`'s representation and card are read from what that scope observed. Must be provided with `peer_target`; mutually exclusive with `peer_perspective`. Requires a workspace-level key.",
|
||||
),
|
||||
sessions: Sequence[str | SessionBase] | None = Field(
|
||||
None,
|
||||
description="An allowlist of sessions confining `peer_target`'s representation to that set. This session must be one of them. Mutually exclusive with `scope` and `limit_to_session`.",
|
||||
),
|
||||
limit_to_session: bool = Field(
|
||||
False,
|
||||
description="Whether to limit the representation to this session only. If True, only conclusions from this session will be included.",
|
||||
|
|
@ -616,6 +626,11 @@ class Session(SessionBase, MetadataConfigMixin):
|
|||
peer_target: A peer ID to get context for.
|
||||
search_query: A query string for semantic search.
|
||||
peer_perspective: A peer ID to get context from the perspective of.
|
||||
scope: A scope to read `peer_target`'s representation and card from.
|
||||
sessions: An allowlist of sessions confining `peer_target`'s
|
||||
representation. Recall is limited to conclusions stated directly in
|
||||
those sessions, and the peer card is omitted, since neither derived
|
||||
conclusions nor cards carry provable per-session provenance.
|
||||
limit_to_session: Whether to limit the representation to this session only.
|
||||
search_top_k: Number of semantically relevant facts to return.
|
||||
search_max_distance: Maximum semantic distance for search results.
|
||||
|
|
@ -627,6 +642,11 @@ class Session(SessionBase, MetadataConfigMixin):
|
|||
summary, if available, that maximizes conversational context while
|
||||
respecting the token limit
|
||||
|
||||
Raises:
|
||||
ValueError: If `peer_target` is missing when required, or if `scope`,
|
||||
`sessions`, `peer_perspective`, and `limit_to_session` are combined
|
||||
in ways the server rejects.
|
||||
|
||||
Note:
|
||||
Token counting is performed using tiktoken. For models using different
|
||||
tokenizers, you may need to adjust the token limit accordingly.
|
||||
|
|
@ -650,6 +670,13 @@ class Session(SessionBase, MetadataConfigMixin):
|
|||
query: dict[str, Any] = {
|
||||
"summary": summary,
|
||||
"limit_to_session": limit_to_session,
|
||||
**scope_context_fields(
|
||||
scope=scope,
|
||||
sessions=sessions,
|
||||
peer_target=peer_target,
|
||||
peer_perspective=peer_perspective,
|
||||
limit_to_session=limit_to_session,
|
||||
),
|
||||
}
|
||||
if tokens is not None:
|
||||
query["tokens"] = tokens
|
||||
|
|
|
|||
|
|
@ -6,6 +6,15 @@ from .datetime import datetime_to_iso, parse_datetime
|
|||
from .file_upload import normalize_file_input, prepare_file_for_upload
|
||||
from .peers import normalize_peers_to_dict
|
||||
from .resolve import resolve_id
|
||||
from .scopes import (
|
||||
resolve_scope_membership,
|
||||
resolve_scope_option,
|
||||
resolve_scope_session,
|
||||
resolve_session_allowlist,
|
||||
scope_context_fields,
|
||||
scope_recall_fields,
|
||||
validate_scope_id,
|
||||
)
|
||||
from .sse import SSEStreamParser, parse_sse_astream, parse_sse_chunk, parse_sse_stream
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -19,4 +28,11 @@ __all__ = [
|
|||
"parse_sse_stream",
|
||||
"prepare_file_for_upload",
|
||||
"resolve_id",
|
||||
"resolve_scope_membership",
|
||||
"resolve_scope_option",
|
||||
"resolve_scope_session",
|
||||
"resolve_session_allowlist",
|
||||
"scope_context_fields",
|
||||
"scope_recall_fields",
|
||||
"validate_scope_id",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
from typing import TYPE_CHECKING, overload
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..base import PeerBase, SessionBase
|
||||
from ..base import PeerBase, ScopeBase, SessionBase
|
||||
|
||||
|
||||
@overload
|
||||
|
|
@ -17,12 +17,12 @@ def resolve_id(obj: str) -> str: ...
|
|||
|
||||
|
||||
@overload
|
||||
def resolve_id(obj: "PeerBase | SessionBase") -> str: ...
|
||||
def resolve_id(obj: "PeerBase | SessionBase | ScopeBase") -> str: ...
|
||||
|
||||
|
||||
def resolve_id(obj: "str | PeerBase | SessionBase | None") -> str | None:
|
||||
def resolve_id(obj: "str | PeerBase | SessionBase | ScopeBase | None") -> str | None:
|
||||
"""
|
||||
Resolve an ID from a string, PeerBase, SessionBase, or None.
|
||||
Resolve an ID from a string, PeerBase, SessionBase, ScopeBase, or None.
|
||||
|
||||
This utility function extracts the ID from an object that may be:
|
||||
- A string (returned as-is)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,296 @@
|
|||
"""Scope and session-allowlist option handling for the Honcho Python SDK.
|
||||
|
||||
The ``scope`` and ``sessions`` options appear on several read surfaces (chat,
|
||||
representation, session context, search). Their validation and their wire
|
||||
translation live here so those surfaces cannot drift apart — the server enforces
|
||||
the same exclusions with a 422, and this raises before the round trip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .resolve import resolve_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..base import ScopeBase, SessionBase
|
||||
|
||||
__all__ = [
|
||||
"MAX_SCOPES_PER_OPTION",
|
||||
"MAX_SESSIONS_PER_ADD",
|
||||
"MAX_SESSION_ALLOWLIST_ENTRIES",
|
||||
"resolve_scope_membership",
|
||||
"resolve_scope_option",
|
||||
"resolve_scope_session",
|
||||
"resolve_session_allowlist",
|
||||
"scope_context_fields",
|
||||
"scope_recall_fields",
|
||||
"validate_scope_id",
|
||||
]
|
||||
|
||||
# Scope IDs are stored server-side as peer names with this prefix prepended, so
|
||||
# they must leave room for it within the 512-character peer name limit.
|
||||
_SCOPE_PEER_PREFIX = "scope."
|
||||
MAX_SCOPE_ID_LENGTH = 512 - len(_SCOPE_PEER_PREFIX)
|
||||
|
||||
MAX_SCOPES_PER_OPTION = 100
|
||||
MAX_SESSION_ALLOWLIST_ENTRIES = 1000
|
||||
# The server accepts at most this many sessions per membership call.
|
||||
MAX_SESSIONS_PER_ADD = 100
|
||||
|
||||
_RESOURCE_NAME_PATTERN = r"^[a-zA-Z0-9_-]+$"
|
||||
|
||||
|
||||
def validate_scope_id(value: str) -> str:
|
||||
"""Validate an unprefixed scope ID.
|
||||
|
||||
Args:
|
||||
value: The scope ID as the caller supplied it.
|
||||
|
||||
Returns:
|
||||
The validated scope ID, unchanged.
|
||||
|
||||
Raises:
|
||||
ValueError: If the ID is empty, too long, carries the reserved prefix,
|
||||
or contains characters outside the resource-name charset.
|
||||
"""
|
||||
if not 1 <= len(value) <= MAX_SCOPE_ID_LENGTH:
|
||||
raise ValueError(
|
||||
f"Scope ID must be between 1 and {MAX_SCOPE_ID_LENGTH} characters"
|
||||
)
|
||||
# Checked before the charset: the reserved prefix contains '.', which is
|
||||
# itself outside the charset, so a charset-first check would report the
|
||||
# charset instead of the real mistake for a double-prefixed ID.
|
||||
if value.startswith(_SCOPE_PEER_PREFIX):
|
||||
raise ValueError(
|
||||
f"Scope ID must not start with the reserved prefix '{_SCOPE_PEER_PREFIX}' (scope IDs are unprefixed)"
|
||||
)
|
||||
if not re.fullmatch(_RESOURCE_NAME_PATTERN, value):
|
||||
raise ValueError(f"Scope ID must match pattern {_RESOURCE_NAME_PATTERN}")
|
||||
return value
|
||||
|
||||
|
||||
def resolve_scope_option(
|
||||
scope: "str | ScopeBase | Sequence[str | ScopeBase]",
|
||||
) -> str | list[str]:
|
||||
"""Resolve the ``scope`` read option to its wire value.
|
||||
|
||||
A single scope stays a string; a sequence becomes a list of IDs. The two
|
||||
shapes mean different things to the server — one scope reads that scope's own
|
||||
view, a list restricts recall to the union of their member sessions — so the
|
||||
distinction is preserved rather than normalized away.
|
||||
|
||||
Args:
|
||||
scope: One scope (ID or ``Scope``) or a sequence of them.
|
||||
|
||||
Returns:
|
||||
A single validated scope ID, or a list of them.
|
||||
|
||||
Raises:
|
||||
ValueError: On an empty sequence, an over-cap sequence, or an invalid ID.
|
||||
"""
|
||||
# ``str`` is itself a Sequence, so both single-scope forms — an ID and a
|
||||
# ``Scope`` — are taken first; whatever remains is the list form.
|
||||
if isinstance(scope, str) or not isinstance(scope, Sequence):
|
||||
return validate_scope_id(resolve_id(scope))
|
||||
|
||||
ids = [validate_scope_id(resolve_id(entry)) for entry in scope]
|
||||
if not ids:
|
||||
# An empty list would resolve to an empty allowlist server-side and
|
||||
# silently recall nothing, which is never the intent.
|
||||
raise ValueError("scope must name at least one scope")
|
||||
if len(ids) > MAX_SCOPES_PER_OPTION:
|
||||
raise ValueError(f"scope can name at most {MAX_SCOPES_PER_OPTION} scopes")
|
||||
return ids
|
||||
|
||||
|
||||
def resolve_session_allowlist(
|
||||
sessions: "Sequence[str | SessionBase]",
|
||||
) -> list[str]:
|
||||
"""Resolve the ``sessions`` allowlist option to a list of session IDs.
|
||||
|
||||
Args:
|
||||
sessions: Sessions to allow, as IDs or ``Session`` objects.
|
||||
|
||||
Returns:
|
||||
The session IDs, in the order given.
|
||||
|
||||
Raises:
|
||||
ValueError: On an empty list or one over the server's cap.
|
||||
"""
|
||||
ids = [resolve_id(entry) for entry in sessions]
|
||||
if not ids:
|
||||
# The server treats an empty allowlist as fail-closed (recalls nothing),
|
||||
# so an empty list here is a caller mistake rather than a query.
|
||||
raise ValueError("sessions must name at least one session")
|
||||
if len(ids) > MAX_SESSION_ALLOWLIST_ENTRIES:
|
||||
raise ValueError(
|
||||
f"sessions can name at most {MAX_SESSION_ALLOWLIST_ENTRIES} sessions"
|
||||
)
|
||||
return ids
|
||||
|
||||
|
||||
def _validate_session_id(value: str) -> str:
|
||||
"""Validate a session ID against the charset the server accepts.
|
||||
|
||||
Args:
|
||||
value: The session ID as the caller supplied it.
|
||||
|
||||
Returns:
|
||||
The validated session ID, unchanged.
|
||||
|
||||
Raises:
|
||||
ValueError: If the ID is empty or contains characters outside the
|
||||
resource-name charset.
|
||||
"""
|
||||
if not value:
|
||||
raise ValueError("Session ID must be a non-empty string")
|
||||
if not re.fullmatch(_RESOURCE_NAME_PATTERN, value):
|
||||
raise ValueError(f"Session ID must match pattern {_RESOURCE_NAME_PATTERN}")
|
||||
return value
|
||||
|
||||
|
||||
def resolve_scope_session(session: "str | SessionBase") -> str:
|
||||
"""Resolve and validate a single session ID for a scope membership change.
|
||||
|
||||
Validated rather than passed through because this ID is interpolated into a
|
||||
request *path*: an unvalidated value silently changes which resource the
|
||||
request addresses. ``valid-session?typo`` would target ``valid-session``
|
||||
with a stray query string, removing the wrong session from the scope and
|
||||
triggering reconciliation against it.
|
||||
|
||||
Args:
|
||||
session: The session, as an ID or a ``Session`` object.
|
||||
|
||||
Returns:
|
||||
The validated session ID.
|
||||
|
||||
Raises:
|
||||
ValueError: If the ID is empty or malformed.
|
||||
"""
|
||||
return _validate_session_id(resolve_id(session))
|
||||
|
||||
|
||||
def resolve_scope_membership(
|
||||
sessions: "Sequence[str | SessionBase]",
|
||||
) -> list[str]:
|
||||
"""Resolve a scope membership change to a list of session IDs.
|
||||
|
||||
Capped at the server's per-call limit rather than silently chunking, so a
|
||||
rejected batch is the batch the caller passed.
|
||||
|
||||
Args:
|
||||
sessions: Sessions to add, as IDs or ``Session`` objects.
|
||||
|
||||
Returns:
|
||||
The session IDs, in the order given.
|
||||
|
||||
Raises:
|
||||
ValueError: On an empty list, one over the server's per-call cap, or a
|
||||
malformed session ID.
|
||||
"""
|
||||
ids = [_validate_session_id(resolve_id(session)) for session in sessions]
|
||||
if not ids:
|
||||
raise ValueError("At least one session must be given")
|
||||
if len(ids) > MAX_SESSIONS_PER_ADD:
|
||||
raise ValueError(
|
||||
f"At most {MAX_SESSIONS_PER_ADD} sessions can be added per call"
|
||||
)
|
||||
return ids
|
||||
|
||||
|
||||
def scope_context_fields(
|
||||
*,
|
||||
scope: "str | ScopeBase | None",
|
||||
sessions: "Sequence[str | SessionBase] | None",
|
||||
peer_target: str | None,
|
||||
peer_perspective: str | None,
|
||||
limit_to_session: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the query fields for ``scope``/``sessions`` on the context route.
|
||||
|
||||
Unlike the recall endpoints, session context takes these as query parameters
|
||||
— ``sessions`` is sent as a repeated parameter, not as a ``filters`` body.
|
||||
|
||||
Only a single scope is accepted: a scope is the *perspective source* for the
|
||||
target's representation and card, which is one observer, so a list has no
|
||||
meaning here.
|
||||
|
||||
Args:
|
||||
scope: The ``scope`` option, if given.
|
||||
sessions: The ``sessions`` allowlist option, if given.
|
||||
peer_target: The observed peer. Required by either option, since both only
|
||||
reach the representation and there is none without a target.
|
||||
peer_perspective: The observing peer, if given — a scope replaces it.
|
||||
limit_to_session: Whether recall is already pinned to this session alone.
|
||||
|
||||
Returns:
|
||||
The fields to merge into the query. Empty when neither option is set.
|
||||
|
||||
Raises:
|
||||
ValueError: If either option is combined with something it contradicts, or
|
||||
used without ``peer_target``.
|
||||
"""
|
||||
# A scope already determines what the context can see, and limit_to_session
|
||||
# already pins recall to this session alone, so combining them with a
|
||||
# perspective or an allowlist is a contradiction rather than a narrowing.
|
||||
# Raised here so the caller does not pay a round trip for a 422.
|
||||
if sessions is not None:
|
||||
if scope is not None:
|
||||
raise ValueError("`sessions` and `scope` are mutually exclusive")
|
||||
if limit_to_session:
|
||||
raise ValueError("`sessions` and `limit_to_session` are mutually exclusive")
|
||||
if peer_target is None:
|
||||
raise ValueError(
|
||||
"You must provide a `peer_target` when `sessions` is provided"
|
||||
)
|
||||
return {"sessions": resolve_session_allowlist(sessions)}
|
||||
|
||||
if scope is None:
|
||||
return {}
|
||||
|
||||
if peer_perspective is not None:
|
||||
raise ValueError("`scope` and `peer_perspective` are mutually exclusive")
|
||||
if peer_target is None:
|
||||
raise ValueError("You must provide a `peer_target` when `scope` is provided")
|
||||
return {"scope": validate_scope_id(resolve_id(scope))}
|
||||
|
||||
|
||||
def scope_recall_fields(
|
||||
*,
|
||||
scope: "str | ScopeBase | Sequence[str | ScopeBase] | None",
|
||||
sessions: "Sequence[str | SessionBase] | None",
|
||||
session_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the request-body fields for the ``scope``/``sessions`` options.
|
||||
|
||||
``sessions`` is sugar: it goes on the wire as the constrained
|
||||
``filters: {"session_id": [...]}`` body the recall endpoints accept, never as
|
||||
a field of its own, which the server would reject as an unknown key.
|
||||
|
||||
Args:
|
||||
scope: The ``scope`` option, if given.
|
||||
sessions: The ``sessions`` allowlist option, if given.
|
||||
session_id: A single session already set on the request, if any — a scope
|
||||
already determines what can be seen, so the two conflict.
|
||||
|
||||
Returns:
|
||||
The fields to merge into the request body. Empty when neither option is
|
||||
set.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``scope`` is combined with ``sessions`` or ``session_id``,
|
||||
or if either option is itself invalid.
|
||||
"""
|
||||
if scope is None:
|
||||
if sessions is None:
|
||||
return {}
|
||||
return {"filters": {"session_id": resolve_session_allowlist(sessions)}}
|
||||
|
||||
if sessions is not None:
|
||||
raise ValueError("`scope` and `sessions` are mutually exclusive")
|
||||
if session_id is not None:
|
||||
raise ValueError("`scope` and `session` are mutually exclusive")
|
||||
return {"scope": resolve_scope_option(scope)}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Conclusions Tests
|
||||
*
|
||||
* Tests for Conclusion operations via ConclusionScope.
|
||||
* Tests for Conclusion operations via ConclusionsView.
|
||||
*
|
||||
* Endpoints covered:
|
||||
* - POST /v3/workspaces/:workspaceId/conclusions (create conclusions)
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
|
||||
import { Honcho, Conclusion, ConclusionScope } from '../src'
|
||||
import { Honcho, Conclusion, ConclusionsView } from '../src'
|
||||
import { createTestClient, requireServer } from './setup'
|
||||
import { assertConclusionShape } from './helpers'
|
||||
|
||||
|
|
@ -31,16 +31,16 @@ describe('Conclusions', () => {
|
|||
})
|
||||
|
||||
// ===========================================================================
|
||||
// ConclusionScope Access
|
||||
// ConclusionsView Access
|
||||
// ===========================================================================
|
||||
|
||||
describe('ConclusionScope access', () => {
|
||||
describe('ConclusionsView access', () => {
|
||||
test('peer.conclusions returns self-scope', async () => {
|
||||
const peer = await client.peer('self-scope-peer')
|
||||
|
||||
const scope = peer.conclusions
|
||||
|
||||
expect(scope).toBeInstanceOf(ConclusionScope)
|
||||
expect(scope).toBeInstanceOf(ConclusionsView)
|
||||
expect(scope.observer).toBe(peer.id)
|
||||
expect(scope.observed).toBe(peer.id)
|
||||
expect(scope.workspaceId).toBe(client.workspaceId)
|
||||
|
|
@ -289,7 +289,7 @@ describe('Conclusions', () => {
|
|||
for (const key of ['observer', 'observed', 'observer_id', 'observed_id']) {
|
||||
await expect(
|
||||
peer.conclusions.list({ filters: { [key]: 'someone-else' } })
|
||||
).rejects.toThrow(/managed by this conclusion scope/)
|
||||
).rejects.toThrow(/managed by this conclusions view/)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -298,10 +298,10 @@ describe('Conclusions', () => {
|
|||
|
||||
await expect(
|
||||
peer.conclusions.list({ filters: { session_id: 'sess' } })
|
||||
).rejects.toThrow(/managed by this conclusion scope/)
|
||||
).rejects.toThrow(/managed by this conclusions view/)
|
||||
await expect(
|
||||
peer.conclusions.list({ filters: { session: 'sess' } })
|
||||
).rejects.toThrow(/managed by this conclusion scope/)
|
||||
).rejects.toThrow(/managed by this conclusions view/)
|
||||
})
|
||||
|
||||
test('query rejects observer/observed scope keys in filters', async () => {
|
||||
|
|
@ -310,7 +310,7 @@ describe('Conclusions', () => {
|
|||
for (const key of ['observer', 'observed', 'observer_id', 'observed_id']) {
|
||||
await expect(
|
||||
peer.conclusions.query('q', 10, undefined, { [key]: 'someone-else' })
|
||||
).rejects.toThrow(/managed by this conclusion scope/)
|
||||
).rejects.toThrow(/managed by this conclusions view/)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -464,16 +464,16 @@ describe('Conclusions', () => {
|
|||
})
|
||||
|
||||
// ===========================================================================
|
||||
// ConclusionScope toString
|
||||
// ConclusionsView toString
|
||||
// ===========================================================================
|
||||
|
||||
describe('ConclusionScope toString', () => {
|
||||
describe('ConclusionsView toString', () => {
|
||||
test('returns readable format', async () => {
|
||||
const peer = await client.peer('scope-tostring-peer')
|
||||
|
||||
const str = peer.conclusions.toString()
|
||||
|
||||
expect(str).toContain('ConclusionScope')
|
||||
expect(str).toContain('ConclusionsView')
|
||||
expect(str).toContain(peer.id)
|
||||
expect(str).toContain(client.workspaceId)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -237,6 +237,39 @@ describe('URL building', () => {
|
|||
expect(url.searchParams.get('present')).toBe('value')
|
||||
expect(url.searchParams.has('missing')).toBe(false)
|
||||
})
|
||||
|
||||
test('sends array query parameters as repeated params, not comma-joined', async () => {
|
||||
let capturedURL = ''
|
||||
globalThis.fetch = async (url) => {
|
||||
capturedURL = url.toString()
|
||||
return mockResponse({ ok: true })
|
||||
}
|
||||
|
||||
await client.get('/v1/test', {
|
||||
query: { sessions: ['session-a', 'session-b'] },
|
||||
})
|
||||
|
||||
const url = new URL(capturedURL)
|
||||
// The API reads list-valued params as ?k=a&k=b. A comma-joined single value
|
||||
// would arrive as one malformed entry.
|
||||
expect(url.searchParams.getAll('sessions')).toEqual([
|
||||
'session-a',
|
||||
'session-b',
|
||||
])
|
||||
})
|
||||
|
||||
test('an empty array query parameter contributes nothing', async () => {
|
||||
let capturedURL = ''
|
||||
globalThis.fetch = async (url) => {
|
||||
capturedURL = url.toString()
|
||||
return mockResponse({ ok: true })
|
||||
}
|
||||
|
||||
await client.get('/v1/test', { query: { sessions: [] } })
|
||||
|
||||
const url = new URL(capturedURL)
|
||||
expect(url.searchParams.has('sessions')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -671,7 +671,7 @@ describe('Peer', () => {
|
|||
// ===========================================================================
|
||||
|
||||
describe('Conclusion scope access', () => {
|
||||
test('conclusions property returns ConclusionScope for self', async () => {
|
||||
test('conclusions property returns ConclusionsView for self', async () => {
|
||||
const peer = await client.peer('self-conclusions-peer')
|
||||
|
||||
const scope = peer.conclusions
|
||||
|
|
@ -681,7 +681,7 @@ describe('Peer', () => {
|
|||
expect(scope.workspaceId).toBe(client.workspaceId)
|
||||
})
|
||||
|
||||
test('conclusionsOf returns ConclusionScope for target', async () => {
|
||||
test('conclusionsOf returns ConclusionsView for target', async () => {
|
||||
const observer = await client.peer('obs-conclusions-peer')
|
||||
const target = await client.peer('target-conclusions-peer')
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,389 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
import { ZodError } from 'zod'
|
||||
import type { HonchoHTTPClient } from '../src/http/client'
|
||||
import { Peer } from '../src/peer'
|
||||
import { Scope } from '../src/scope'
|
||||
import { Session } from '../src/session'
|
||||
import type { ScopeStatusResponse } from '../src/types/api'
|
||||
|
||||
/**
|
||||
* Capture the body of the single request a call makes, so the wire shape the
|
||||
* server actually receives is asserted rather than the SDK's own options.
|
||||
*/
|
||||
function capturingHttp(response: unknown): {
|
||||
http: HonchoHTTPClient
|
||||
body: () => Record<string, unknown> | undefined
|
||||
query: () => Record<string, unknown> | undefined
|
||||
path: () => string | undefined
|
||||
} {
|
||||
let capturedBody: Record<string, unknown> | undefined
|
||||
let capturedQuery: Record<string, unknown> | undefined
|
||||
let capturedPath: string | undefined
|
||||
const http = {
|
||||
post: async (
|
||||
path: string,
|
||||
options?: { body?: Record<string, unknown> }
|
||||
) => {
|
||||
capturedPath = path
|
||||
capturedBody = options?.body
|
||||
return response
|
||||
},
|
||||
get: async (
|
||||
path: string,
|
||||
options?: { query?: Record<string, unknown> }
|
||||
) => {
|
||||
capturedPath = path
|
||||
capturedQuery = options?.query
|
||||
return response
|
||||
},
|
||||
delete: async (path: string) => {
|
||||
capturedPath = path
|
||||
return undefined
|
||||
},
|
||||
} as unknown as HonchoHTTPClient
|
||||
return {
|
||||
http,
|
||||
body: () => capturedBody,
|
||||
query: () => capturedQuery,
|
||||
path: () => capturedPath,
|
||||
}
|
||||
}
|
||||
|
||||
describe('sessions allowlist sugar', () => {
|
||||
test('chat sends `sessions` as a session_id filter, not a bare field', async () => {
|
||||
const { http, body } = capturingHttp({ content: 'ok' })
|
||||
const peer = new Peer('alice', 'workspace-1', http)
|
||||
|
||||
await peer.chat('what happened?', {
|
||||
sessions: ['session-a', new Session('session-b', 'workspace-1', http)],
|
||||
})
|
||||
|
||||
expect(body()).toMatchObject({
|
||||
filters: { session_id: ['session-a', 'session-b'] },
|
||||
})
|
||||
// The sugar must not leak through as its own wire field — the server would
|
||||
// reject an unknown key.
|
||||
expect(body()).not.toHaveProperty('sessions')
|
||||
})
|
||||
|
||||
test('representation sends `sessions` as a session_id filter', async () => {
|
||||
const { http, body } = capturingHttp({ representation: 'ok' })
|
||||
const peer = new Peer('alice', 'workspace-1', http)
|
||||
|
||||
await peer.representation({ sessions: ['session-a'] })
|
||||
|
||||
expect(body()).toMatchObject({ filters: { session_id: ['session-a'] } })
|
||||
})
|
||||
|
||||
test('an empty allowlist is rejected rather than silently recalling nothing', async () => {
|
||||
const { http } = capturingHttp({ content: 'ok' })
|
||||
const peer = new Peer('alice', 'workspace-1', http)
|
||||
|
||||
await expect(peer.chat('q', { sessions: [] })).rejects.toBeInstanceOf(
|
||||
ZodError
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scope read option', () => {
|
||||
test('a single scope passes through as `scope`', async () => {
|
||||
const { http, body } = capturingHttp({ content: 'ok' })
|
||||
const peer = new Peer('alice', 'workspace-1', http)
|
||||
|
||||
await peer.chat('q', { scope: 'therapy' })
|
||||
|
||||
expect(body()).toMatchObject({ scope: 'therapy' })
|
||||
})
|
||||
|
||||
test('a Scope object resolves to its id', async () => {
|
||||
const { http, body } = capturingHttp({ content: 'ok' })
|
||||
const peer = new Peer('alice', 'workspace-1', http)
|
||||
|
||||
await peer.chat('q', { scope: new Scope('therapy', 'workspace-1', http) })
|
||||
|
||||
expect(body()).toMatchObject({ scope: 'therapy' })
|
||||
})
|
||||
|
||||
test('a list of scopes passes through as a list', async () => {
|
||||
const { http, body } = capturingHttp({ content: 'ok' })
|
||||
const peer = new Peer('alice', 'workspace-1', http)
|
||||
|
||||
await peer.chat('q', { scope: ['therapy', 'work'] })
|
||||
|
||||
expect(body()).toMatchObject({ scope: ['therapy', 'work'] })
|
||||
})
|
||||
|
||||
test('scope and sessions are rejected together', async () => {
|
||||
const { http } = capturingHttp({ content: 'ok' })
|
||||
const peer = new Peer('alice', 'workspace-1', http)
|
||||
|
||||
await expect(
|
||||
peer.chat('q', { scope: 'therapy', sessions: ['session-a'] })
|
||||
).rejects.toBeInstanceOf(ZodError)
|
||||
})
|
||||
|
||||
test('scope and a single session are rejected together', async () => {
|
||||
const { http } = capturingHttp({ content: 'ok' })
|
||||
const peer = new Peer('alice', 'workspace-1', http)
|
||||
|
||||
await expect(
|
||||
peer.chat('q', { scope: 'therapy', session: 'session-a' })
|
||||
).rejects.toBeInstanceOf(ZodError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scope id validation', () => {
|
||||
test('a prefixed name reports the reserved prefix, not the charset', async () => {
|
||||
const { http } = capturingHttp({ content: 'ok' })
|
||||
const peer = new Peer('alice', 'workspace-1', http)
|
||||
|
||||
// 'scope.therapy' fails both rules; the prefix message is the useful one,
|
||||
// so it must be the only one raised.
|
||||
const error = await peer
|
||||
.chat('q', { scope: 'scope.therapy' })
|
||||
.then(() => undefined)
|
||||
.catch((err: unknown) => err as ZodError)
|
||||
|
||||
expect(error).toBeInstanceOf(ZodError)
|
||||
const messages = (error as ZodError).issues.map((issue) => issue.message)
|
||||
expect(messages.some((m) => m.includes('reserved prefix'))).toBe(true)
|
||||
expect(messages.some((m) => m.includes('may only contain'))).toBe(false)
|
||||
})
|
||||
|
||||
test('a name with illegal characters is rejected', async () => {
|
||||
const { http } = capturingHttp({ content: 'ok' })
|
||||
const peer = new Peer('alice', 'workspace-1', http)
|
||||
|
||||
await expect(peer.chat('q', { scope: 'my scope' })).rejects.toBeInstanceOf(
|
||||
ZodError
|
||||
)
|
||||
})
|
||||
|
||||
test('the specific message survives the scope option union', async () => {
|
||||
// ScopeOptionSchema is a union. Zod collapses a failing union into a single
|
||||
// `invalid_union` / "Invalid input" issue and buries the branch errors, so
|
||||
// the rules are applied after the union resolves. Without that, every bad
|
||||
// scope reports "Invalid input" and the caller learns nothing.
|
||||
for (const [input, expected] of [
|
||||
['scope.therapy', 'reserved prefix'],
|
||||
['my scope', 'may only contain'],
|
||||
['', 'non-empty'],
|
||||
['a'.repeat(507), 'at most 506'],
|
||||
] as const) {
|
||||
const { http } = capturingHttp({ content: 'ok' })
|
||||
const peer = new Peer('alice', 'workspace-1', http)
|
||||
const error = (await peer
|
||||
.chat('q', { scope: input })
|
||||
.then(() => undefined)
|
||||
.catch((err: unknown) => err)) as ZodError
|
||||
|
||||
expect(error).toBeInstanceOf(ZodError)
|
||||
const messages = error.issues.map((i) => i.message).join(' | ')
|
||||
expect(messages).toContain(expected)
|
||||
expect(messages).not.toContain('Invalid input')
|
||||
}
|
||||
})
|
||||
|
||||
test('list-form messages also survive the union', async () => {
|
||||
const { http } = capturingHttp({ content: 'ok' })
|
||||
const peer = new Peer('alice', 'workspace-1', http)
|
||||
|
||||
for (const [input, expected] of [
|
||||
[[], 'at least one scope'],
|
||||
[['ok', 'scope.bad'], 'reserved prefix'],
|
||||
[Array.from({ length: 101 }, (_, i) => `s${i}`), 'at most 100 scopes'],
|
||||
] as const) {
|
||||
const error = (await peer
|
||||
.chat('q', { scope: input as string[] })
|
||||
.then(() => undefined)
|
||||
.catch((err: unknown) => err)) as ZodError
|
||||
|
||||
expect(error.issues.map((i) => i.message).join(' | ')).toContain(expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty-string options fail closed', () => {
|
||||
test("session.context rejects scope: '' instead of returning unscoped context", async () => {
|
||||
const { http, query } = capturingHttp({
|
||||
id: 'session-a',
|
||||
messages: [],
|
||||
summary: null,
|
||||
peer_representation: null,
|
||||
peer_card: null,
|
||||
})
|
||||
const session = new Session('session-a', 'workspace-1', http)
|
||||
|
||||
// A truthiness check here would drop the option and silently return the
|
||||
// unscoped context — the opposite of what an invalid scope should do.
|
||||
await expect(
|
||||
session.context({ peerTarget: 'user', scope: '' })
|
||||
).rejects.toBeInstanceOf(ZodError)
|
||||
expect(query()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scope membership ids are validated before reaching a URL', () => {
|
||||
test('removeSession rejects an id that would alter the request path', async () => {
|
||||
const { http, path } = capturingHttp(undefined)
|
||||
const scope = new Scope('therapy', 'workspace-1', http)
|
||||
|
||||
// `valid-session?typo` would address `valid-session` with a stray query
|
||||
// string, removing the wrong session and reconciling against it.
|
||||
await expect(
|
||||
scope.removeSession('valid-session?typo')
|
||||
).rejects.toBeInstanceOf(ZodError)
|
||||
expect(path()).toBeUndefined()
|
||||
})
|
||||
|
||||
test('addSessions rejects the same shape', async () => {
|
||||
const { http, body } = capturingHttp(undefined)
|
||||
const scope = new Scope('therapy', 'workspace-1', http)
|
||||
|
||||
await expect(
|
||||
scope.addSessions(['ok-session', 'valid-session?typo'])
|
||||
).rejects.toBeInstanceOf(ZodError)
|
||||
expect(body()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.context scoping', () => {
|
||||
const contextResponse = {
|
||||
id: 'session-a',
|
||||
messages: [],
|
||||
summary: null,
|
||||
peer_representation: null,
|
||||
peer_card: null,
|
||||
}
|
||||
|
||||
test('scope and sessions are sent as their own query params', async () => {
|
||||
const { http, query } = capturingHttp(contextResponse)
|
||||
const session = new Session('session-a', 'workspace-1', http)
|
||||
|
||||
await session.context({
|
||||
peerTarget: 'user',
|
||||
sessions: ['session-a', 'session-b'],
|
||||
})
|
||||
|
||||
// An array here relies on the HTTP client emitting repeated params; see
|
||||
// http-client.test.ts.
|
||||
expect(query()).toMatchObject({ sessions: ['session-a', 'session-b'] })
|
||||
})
|
||||
|
||||
test('sessions without peerTarget is rejected, not silently ignored', async () => {
|
||||
const { http } = capturingHttp(contextResponse)
|
||||
const session = new Session('session-a', 'workspace-1', http)
|
||||
|
||||
await expect(
|
||||
session.context({ sessions: ['session-a'] })
|
||||
).rejects.toBeInstanceOf(ZodError)
|
||||
})
|
||||
|
||||
test('sessions and scope are rejected together', async () => {
|
||||
const { http } = capturingHttp(contextResponse)
|
||||
const session = new Session('session-a', 'workspace-1', http)
|
||||
|
||||
await expect(
|
||||
session.context({
|
||||
peerTarget: 'user',
|
||||
scope: 'therapy',
|
||||
sessions: ['session-a'],
|
||||
})
|
||||
).rejects.toBeInstanceOf(ZodError)
|
||||
})
|
||||
|
||||
test('sessions and limitToSession are rejected together', async () => {
|
||||
const { http } = capturingHttp(contextResponse)
|
||||
const session = new Session('session-a', 'workspace-1', http)
|
||||
|
||||
await expect(
|
||||
session.context({
|
||||
peerTarget: 'user',
|
||||
limitToSession: true,
|
||||
sessions: ['session-a'],
|
||||
})
|
||||
).rejects.toBeInstanceOf(ZodError)
|
||||
})
|
||||
|
||||
test('scope and peerPerspective are rejected together', async () => {
|
||||
const { http } = capturingHttp(contextResponse)
|
||||
const session = new Session('session-a', 'workspace-1', http)
|
||||
|
||||
await expect(
|
||||
session.context({
|
||||
peerTarget: 'user',
|
||||
peerPerspective: 'assistant',
|
||||
scope: 'therapy',
|
||||
})
|
||||
).rejects.toBeInstanceOf(ZodError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Scope membership and status', () => {
|
||||
test('addSessions posts session_ids and resolves Session objects', async () => {
|
||||
const { http, body, path } = capturingHttp(undefined)
|
||||
const scope = new Scope('therapy', 'workspace-1', http)
|
||||
|
||||
await scope.addSessions([
|
||||
'session-a',
|
||||
new Session('session-b', 'workspace-1', http),
|
||||
])
|
||||
|
||||
expect(path()).toBe('/v3/workspaces/workspace-1/scopes/therapy/sessions')
|
||||
expect(body()).toEqual({ session_ids: ['session-a', 'session-b'] })
|
||||
})
|
||||
|
||||
test('addSessions rejects a batch over the server limit instead of chunking', async () => {
|
||||
const { http } = capturingHttp(undefined)
|
||||
const scope = new Scope('therapy', 'workspace-1', http)
|
||||
|
||||
const tooMany = Array.from({ length: 101 }, (_, i) => `session-${i}`)
|
||||
|
||||
await expect(scope.addSessions(tooMany)).rejects.toBeInstanceOf(ZodError)
|
||||
})
|
||||
|
||||
test('removeSession targets the session subpath', async () => {
|
||||
const { http, path } = capturingHttp(undefined)
|
||||
const scope = new Scope('therapy', 'workspace-1', http)
|
||||
|
||||
await scope.removeSession(new Session('session-b', 'workspace-1', http))
|
||||
|
||||
expect(path()).toBe(
|
||||
'/v3/workspaces/workspace-1/scopes/therapy/sessions/session-b'
|
||||
)
|
||||
})
|
||||
|
||||
test('status maps snake_case job fields to camelCase', async () => {
|
||||
const response: ScopeStatusResponse = {
|
||||
backfill_status: {
|
||||
'session-a': {
|
||||
state: 'completed',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
docs_copied: 12,
|
||||
},
|
||||
'session-b': { state: 'pending', updated_at: '2024-01-02T00:00:00Z' },
|
||||
},
|
||||
}
|
||||
const { http } = capturingHttp(response)
|
||||
const scope = new Scope('therapy', 'workspace-1', http)
|
||||
|
||||
const status = await scope.status()
|
||||
|
||||
expect(status.backfillStatus['session-a']).toEqual({
|
||||
state: 'completed',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
docsCopied: 12,
|
||||
})
|
||||
expect(status.backfillStatus['session-b']?.docsCopied).toBeUndefined()
|
||||
})
|
||||
|
||||
test('status on a scope with no backfill is an empty map, not a throw', async () => {
|
||||
// The server omits the key entirely when nothing was ever enqueued.
|
||||
const { http } = capturingHttp({} as ScopeStatusResponse)
|
||||
const scope = new Scope('therapy', 'workspace-1', http)
|
||||
|
||||
const status = await scope.status()
|
||||
|
||||
expect(status.backfillStatus).toEqual({})
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,7 @@ import { HonchoHTTPClient } from './http/client'
|
|||
import { Message } from './message'
|
||||
import { Page } from './pagination'
|
||||
import { Peer } from './peer'
|
||||
import { Scope } from './scope'
|
||||
import { Session } from './session'
|
||||
import type {
|
||||
MessageResponse,
|
||||
|
|
@ -11,6 +12,7 @@ import type {
|
|||
QueueStatus,
|
||||
QueueStatusParams,
|
||||
QueueStatusResponse,
|
||||
ScopeResponse,
|
||||
SessionResponse,
|
||||
WorkspaceResponse,
|
||||
} from './types/api'
|
||||
|
|
@ -32,12 +34,14 @@ import {
|
|||
peerConfigFromApi,
|
||||
peerConfigToApi,
|
||||
type QueueStatusOptions,
|
||||
ScopeIdSchema,
|
||||
SearchQuerySchema,
|
||||
type SessionConfig,
|
||||
SessionConfigSchema,
|
||||
SessionIdSchema,
|
||||
type SessionMetadata,
|
||||
SessionMetadataSchema,
|
||||
SessionScopesSchema,
|
||||
sessionConfigFromApi,
|
||||
sessionConfigToApi,
|
||||
type WorkspaceConfig,
|
||||
|
|
@ -254,6 +258,7 @@ export class Honcho {
|
|||
params: {
|
||||
query: string
|
||||
filters?: Record<string, unknown>
|
||||
scope?: string
|
||||
limit?: number
|
||||
}
|
||||
): Promise<MessageResponse[]> {
|
||||
|
|
@ -314,6 +319,39 @@ export class Honcho {
|
|||
)
|
||||
}
|
||||
|
||||
private async _getOrCreateScope(
|
||||
workspaceId: string,
|
||||
params: {
|
||||
id: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
): Promise<ScopeResponse> {
|
||||
return this._http.post<ScopeResponse>(
|
||||
`/${API_VERSION}/workspaces/${workspaceId}/scopes`,
|
||||
{ body: params }
|
||||
)
|
||||
}
|
||||
|
||||
private async _listScopes(
|
||||
workspaceId: string,
|
||||
params?: {
|
||||
page?: number
|
||||
size?: number
|
||||
reverse?: boolean
|
||||
}
|
||||
): Promise<PageResponse<ScopeResponse>> {
|
||||
return this._http.post<PageResponse<ScopeResponse>>(
|
||||
`/${API_VERSION}/workspaces/${workspaceId}/scopes/list`,
|
||||
{
|
||||
query: {
|
||||
page: params?.page,
|
||||
size: params?.size,
|
||||
reverse: params?.reverse ? 'true' : undefined,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private async _listSessions(
|
||||
workspaceId: string,
|
||||
params?: {
|
||||
|
|
@ -346,6 +384,7 @@ export class Honcho {
|
|||
string,
|
||||
{ observe_me?: boolean | null; observe_others?: boolean | null }
|
||||
>
|
||||
scopes?: string[]
|
||||
}
|
||||
): Promise<SessionResponse> {
|
||||
return this._http.post<SessionResponse>(
|
||||
|
|
@ -356,6 +395,7 @@ export class Honcho {
|
|||
metadata: params.metadata,
|
||||
configuration: sessionConfigToApi(params.configuration),
|
||||
peers: params.peers,
|
||||
scopes: params.scopes,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -504,6 +544,10 @@ export class Honcho {
|
|||
* @param options.peers - Optional peers to attach to the session at creation.
|
||||
* Accepts the same shape as `session.addPeers()` (peer ID strings,
|
||||
* Peer objects, arrays of either, or a record with per-peer config).
|
||||
* @param options.scopes - Optional scopes this session should join. Each scope is
|
||||
* created if it does not exist yet. Attaching at creation avoids the
|
||||
* asynchronous backfill that a later `scope.addSessions()` triggers,
|
||||
* since there is no history to copy.
|
||||
* @returns Promise resolving to a Session object that can be used to add peers,
|
||||
* send messages, and manage conversation context
|
||||
* @throws Error if the session ID is empty or invalid
|
||||
|
|
@ -514,6 +558,7 @@ export class Honcho {
|
|||
metadata?: SessionMetadata
|
||||
configuration?: SessionConfig
|
||||
peers?: PeerAddition
|
||||
scopes?: (string | Scope)[]
|
||||
}
|
||||
): Promise<Session> {
|
||||
await this._ensureWorkspace()
|
||||
|
|
@ -528,12 +573,17 @@ export class Honcho {
|
|||
options?.peers !== undefined
|
||||
? PeerAdditionToApiSchema.parse(options.peers)
|
||||
: undefined
|
||||
const validatedScopes =
|
||||
options?.scopes !== undefined
|
||||
? SessionScopesSchema.parse(options.scopes.map(resolveId))
|
||||
: undefined
|
||||
|
||||
const sessionData = await this._getOrCreateSession(this.workspaceId, {
|
||||
id: validatedId,
|
||||
configuration: validatedConfiguration,
|
||||
metadata: validatedMetadata,
|
||||
peers: validatedPeers,
|
||||
scopes: validatedScopes,
|
||||
})
|
||||
return new Session(
|
||||
validatedId,
|
||||
|
|
@ -547,6 +597,90 @@ export class Honcho {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a scope with the given ID.
|
||||
*
|
||||
* A scope is a named set of sessions that acts as a visibility boundary: recall
|
||||
* performed through the scope sees only what happened in its sessions, while the
|
||||
* underlying peer keeps its single unified representation of everything.
|
||||
*
|
||||
* @param id - Unprefixed scope name, unique within the workspace
|
||||
* @param options.metadata - Optional metadata to associate with this scope
|
||||
* @returns Promise resolving to a Scope object for managing membership
|
||||
* @throws Error if the scope ID is empty or invalid, or if a peer already occupies
|
||||
* the scope's reserved internal name
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const therapy = await honcho.scope('therapy')
|
||||
* await therapy.addSessions([session1, session2])
|
||||
* ```
|
||||
*/
|
||||
async scope(
|
||||
id: string,
|
||||
options?: {
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
): Promise<Scope> {
|
||||
await this._ensureWorkspace()
|
||||
const validatedId = ScopeIdSchema.parse(id)
|
||||
|
||||
const scopeData = await this._getOrCreateScope(this.workspaceId, {
|
||||
id: validatedId,
|
||||
metadata: options?.metadata,
|
||||
})
|
||||
return new Scope(
|
||||
validatedId,
|
||||
this.workspaceId,
|
||||
this._http,
|
||||
scopeData.metadata ?? undefined,
|
||||
() => this._ensureWorkspace(),
|
||||
scopeData.created_at
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scopes in the current workspace.
|
||||
*
|
||||
* @param options - Pagination options: `page`, `size`, and `reverse`
|
||||
* @returns Promise resolving to a Page of Scope objects. Returns an empty page if
|
||||
* no scopes exist
|
||||
*/
|
||||
async scopes(options?: {
|
||||
page?: number
|
||||
size?: number
|
||||
reverse?: boolean
|
||||
}): Promise<Page<Scope, ScopeResponse>> {
|
||||
await this._ensureWorkspace()
|
||||
const reverse = options?.reverse
|
||||
const scopesPage = await this._listScopes(this.workspaceId, {
|
||||
page: options?.page,
|
||||
size: options?.size,
|
||||
reverse,
|
||||
})
|
||||
|
||||
const fetchNextPage = async (
|
||||
page: number,
|
||||
size: number
|
||||
): Promise<PageResponse<ScopeResponse>> => {
|
||||
return this._listScopes(this.workspaceId, { page, size, reverse })
|
||||
}
|
||||
|
||||
return new Page(
|
||||
scopesPage,
|
||||
(scope) =>
|
||||
new Scope(
|
||||
scope.id,
|
||||
this.workspaceId,
|
||||
this._http,
|
||||
scope.metadata ?? undefined,
|
||||
() => this._ensureWorkspace(),
|
||||
scope.created_at
|
||||
),
|
||||
fetchNextPage
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sessions in the current workspace.
|
||||
*
|
||||
|
|
@ -775,6 +909,9 @@ export class Honcho {
|
|||
*
|
||||
* @param query - The search query to use
|
||||
* @param filters - Optional filters to scope the search. See [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters).
|
||||
* @param options.scope - Optional scope to restrict the search to that scope's member
|
||||
* sessions. Mutually exclusive with a `session_id` filter. A scope
|
||||
* with no member sessions matches nothing rather than everything.
|
||||
* @param limit - Number of results to return (1-100, default: 10).
|
||||
* @returns Promise resolving to an array of Message objects representing the search results.
|
||||
* Returns an empty array if no messages are found.
|
||||
|
|
@ -784,6 +921,7 @@ export class Honcho {
|
|||
query: string,
|
||||
options?: {
|
||||
filters?: Filters
|
||||
scope?: string | Scope
|
||||
limit?: number
|
||||
}
|
||||
): Promise<Message[]> {
|
||||
|
|
@ -792,12 +930,19 @@ export class Honcho {
|
|||
const validatedFilters = options?.filters
|
||||
? FilterSchema.parse(options.filters)
|
||||
: undefined
|
||||
// Checked against undefined, not truthiness: `scope: ''` is invalid, and
|
||||
// dropping it silently would diverge from the Python SDK, which rejects it.
|
||||
const validatedScope =
|
||||
options?.scope !== undefined
|
||||
? ScopeIdSchema.parse(resolveId(options.scope))
|
||||
: undefined
|
||||
const validatedLimit = options?.limit
|
||||
? LimitSchema.parse(options.limit)
|
||||
: undefined
|
||||
const response = await this._searchWorkspace(this.workspaceId, {
|
||||
query: validatedQuery,
|
||||
filters: validatedFilters,
|
||||
scope: validatedScope,
|
||||
limit: validatedLimit,
|
||||
})
|
||||
return response.map(Message.fromApiResponse)
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ import type {
|
|||
import { normalizeSearchQuery, RepresentationOptionsSchema } from './validation'
|
||||
|
||||
/**
|
||||
* Filter keys that define a conclusion scope (the observer/observed peer pair).
|
||||
* They are set from the scope itself, so a caller must not pass them in `filters`.
|
||||
* Filter keys that define a conclusions view (the observer/observed peer pair).
|
||||
* They are set from the view itself, so a caller must not pass them in `filters`.
|
||||
*/
|
||||
const SCOPE_RESERVED_KEYS = [
|
||||
const VIEW_RESERVED_KEYS = [
|
||||
'observer',
|
||||
'observed',
|
||||
'observer_id',
|
||||
|
|
@ -23,11 +23,11 @@ const SCOPE_RESERVED_KEYS = [
|
|||
]
|
||||
|
||||
/**
|
||||
* Throw if `filters` contains keys managed by the conclusion scope.
|
||||
* Throw if `filters` contains keys managed by the conclusions view.
|
||||
*
|
||||
* The observer/observed peer pair (and, on `list`, the session) is fixed by the
|
||||
* scope, so letting a user filter override it would silently return data from a
|
||||
* different scope than requested. Fail loud instead.
|
||||
* view, so letting a user filter override it would silently return data from a
|
||||
* different pair than requested. Fail loud instead.
|
||||
*/
|
||||
function rejectReservedFilterKeys(
|
||||
filters: Record<string, unknown> | undefined,
|
||||
|
|
@ -42,7 +42,7 @@ function rejectReservedFilterKeys(
|
|||
guidance += '; use the session option to filter by session'
|
||||
}
|
||||
throw new Error(
|
||||
`Filter key(s) ${clash.join(', ')} are managed by this conclusion scope ` +
|
||||
`Filter key(s) ${clash.join(', ')} are managed by this conclusions view ` +
|
||||
`and cannot be passed in filters. ${guidance}.`
|
||||
)
|
||||
}
|
||||
|
|
@ -120,7 +120,7 @@ export class Conclusion {
|
|||
/**
|
||||
* Scoped access to conclusions for a specific observer/observed relationship.
|
||||
*/
|
||||
export class ConclusionScope {
|
||||
export class ConclusionsView {
|
||||
private _http: HonchoHTTPClient
|
||||
private _ensureWorkspace: () => Promise<void>
|
||||
readonly workspaceId: string
|
||||
|
|
@ -223,14 +223,14 @@ export class ConclusionScope {
|
|||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* List conclusions in this scope.
|
||||
* List conclusions in this view.
|
||||
*
|
||||
* @param options - Optional configuration for the list request
|
||||
* @param options.page - Page number (1-indexed, default: 1)
|
||||
* @param options.size - Number of items per page (default: 50)
|
||||
* @param options.session - Optional session (ID string or Session object) to filter by
|
||||
* @param options.filters - Optional additional filter criteria, merged with
|
||||
* this scope's observer/observed (and session, if given). Supports the same
|
||||
* this view's observer/observed (and session, if given). Supports the same
|
||||
* operators as other list endpoints — e.g. `{ level: 'explicit' }` to get
|
||||
* only conclusions extracted directly from messages (i.e. not derived during
|
||||
* dreaming). See
|
||||
|
|
@ -245,7 +245,7 @@ export class ConclusionScope {
|
|||
reverse?: boolean
|
||||
}): Promise<Page<Conclusion, ConclusionResponse>> {
|
||||
rejectReservedFilterKeys(options?.filters, [
|
||||
...SCOPE_RESERVED_KEYS,
|
||||
...VIEW_RESERVED_KEYS,
|
||||
'session',
|
||||
'session_id',
|
||||
])
|
||||
|
|
@ -284,13 +284,13 @@ export class ConclusionScope {
|
|||
}
|
||||
|
||||
/**
|
||||
* Semantic search for conclusions in this scope.
|
||||
* Semantic search for conclusions in this view.
|
||||
*
|
||||
* @param query - The search query string
|
||||
* @param topK - Maximum number of results to return (default: 10)
|
||||
* @param distance - Maximum cosine distance threshold (0.0-1.0)
|
||||
* @param filters - Optional additional filter criteria, merged with this
|
||||
* scope's observer/observed. Supports the same operators as the list
|
||||
* view's observer/observed. Supports the same operators as the list
|
||||
* endpoint — e.g. `{ level: 'deductive' }` to search only conclusions
|
||||
* derived during dreaming. See
|
||||
* https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
|
||||
|
|
@ -301,7 +301,7 @@ export class ConclusionScope {
|
|||
distance?: number,
|
||||
filters?: Record<string, unknown>
|
||||
): Promise<Conclusion[]> {
|
||||
rejectReservedFilterKeys(filters, SCOPE_RESERVED_KEYS)
|
||||
rejectReservedFilterKeys(filters, VIEW_RESERVED_KEYS)
|
||||
const response = await this._query({
|
||||
query,
|
||||
top_k: topK,
|
||||
|
|
@ -324,7 +324,7 @@ export class ConclusionScope {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create conclusions in this scope.
|
||||
* Create conclusions in this view.
|
||||
*/
|
||||
async create(
|
||||
conclusions: ConclusionCreateParams | ConclusionCreateParams[]
|
||||
|
|
@ -351,7 +351,7 @@ export class ConclusionScope {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get the computed representation for this scope.
|
||||
* Get the computed representation for this view.
|
||||
*/
|
||||
async representation(options?: RepresentationOptions): Promise<string> {
|
||||
const searchQuery = normalizeSearchQuery(options?.searchQuery)
|
||||
|
|
@ -375,6 +375,6 @@ export class ConclusionScope {
|
|||
}
|
||||
|
||||
toString(): string {
|
||||
return `ConclusionScope(workspaceId='${this.workspaceId}', observer='${this.observer}', observed='${this.observed}')`
|
||||
return `ConclusionsView(workspaceId='${this.workspaceId}', observer='${this.observer}', observed='${this.observed}')`
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,18 +6,27 @@ import {
|
|||
TimeoutError,
|
||||
} from './errors'
|
||||
|
||||
/**
|
||||
* Query parameters for a request. An array value is sent as repeated
|
||||
* parameters (`?k=a&k=b`), which is how the API reads list-valued parameters.
|
||||
*/
|
||||
export type QueryParams = Record<
|
||||
string,
|
||||
string | number | boolean | readonly (string | number | boolean)[] | undefined
|
||||
>
|
||||
|
||||
export interface HonchoHTTPClientConfig {
|
||||
baseURL: string
|
||||
apiKey?: string
|
||||
timeout?: number
|
||||
maxRetries?: number
|
||||
defaultHeaders?: Record<string, string>
|
||||
defaultQuery?: Record<string, string | number | boolean | undefined>
|
||||
defaultQuery?: QueryParams
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
body?: unknown
|
||||
query?: Record<string, string | number | boolean | undefined>
|
||||
query?: QueryParams
|
||||
headers?: Record<string, string>
|
||||
timeout?: number
|
||||
signal?: AbortSignal
|
||||
|
|
@ -37,7 +46,7 @@ export class HonchoHTTPClient {
|
|||
readonly timeout: number
|
||||
readonly maxRetries: number
|
||||
readonly defaultHeaders: Record<string, string>
|
||||
readonly defaultQuery?: Record<string, string | number | boolean | undefined>
|
||||
readonly defaultQuery?: QueryParams
|
||||
|
||||
constructor(config: HonchoHTTPClientConfig) {
|
||||
// Remove trailing slash from baseURL
|
||||
|
|
@ -273,21 +282,28 @@ export class HonchoHTTPClient {
|
|||
return JSON.parse(text) as T
|
||||
}
|
||||
|
||||
private buildURL(
|
||||
path: string,
|
||||
query?: Record<string, string | number | boolean | undefined>
|
||||
): string {
|
||||
private buildURL(path: string, query?: QueryParams): string {
|
||||
const url = new URL(path, this.baseURL)
|
||||
|
||||
const mergedQuery: Record<string, string | number | boolean | undefined> = {
|
||||
const mergedQuery: QueryParams = {
|
||||
...(this.defaultQuery ?? {}),
|
||||
...(query ?? {}),
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(mergedQuery)) {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.set(key, String(value))
|
||||
if (value === undefined) {
|
||||
continue
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
// Repeated params, not a comma-joined value: the API reads list-valued
|
||||
// query parameters as `?k=a&k=b`, and String([a, b]) would arrive as a
|
||||
// single malformed entry.
|
||||
for (const entry of value) {
|
||||
url.searchParams.append(key, String(entry))
|
||||
}
|
||||
continue
|
||||
}
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@ export { Honcho } from './client'
|
|||
export {
|
||||
Conclusion,
|
||||
type ConclusionCreateParams,
|
||||
ConclusionScope,
|
||||
/**
|
||||
* @deprecated Renamed to `ConclusionsView`. "Scope" now means a named set of
|
||||
* sessions (see `Scope`), which this class is not — it is a view over one
|
||||
* observer/observed pair. Kept as an alias for one more minor version.
|
||||
*/
|
||||
ConclusionsView as ConclusionScope,
|
||||
ConclusionsView,
|
||||
} from './conclusions'
|
||||
// HTTP infrastructure
|
||||
export {
|
||||
|
|
@ -30,6 +36,11 @@ export {
|
|||
export { Message, type MessageInput } from './message'
|
||||
export { Page } from './pagination'
|
||||
export { Peer, PeerContext } from './peer'
|
||||
export {
|
||||
Scope,
|
||||
type ScopeBackfillState,
|
||||
type ScopeStatus,
|
||||
} from './scope'
|
||||
export { Session } from './session'
|
||||
export {
|
||||
SessionContext,
|
||||
|
|
@ -50,6 +61,9 @@ export type {
|
|||
QueueStatus,
|
||||
QueueStatusResponse,
|
||||
RepresentationOptions,
|
||||
ScopeBackfillJob,
|
||||
ScopeResponse,
|
||||
ScopeStatusResponse,
|
||||
SessionContextResponse,
|
||||
SessionQueueStatus,
|
||||
SessionResponse,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ZodType, z } from 'zod'
|
||||
import { API_VERSION } from './api-version'
|
||||
import { ConclusionScope } from './conclusions'
|
||||
import { ConclusionsView } from './conclusions'
|
||||
import type { HonchoHTTPClient } from './http/client'
|
||||
import {
|
||||
createDialecticStream,
|
||||
|
|
@ -8,6 +8,9 @@ import {
|
|||
} from './http/streaming'
|
||||
import { Message, type MessageInput } from './message'
|
||||
import { Page } from './pagination'
|
||||
// Type-only: scope.ts imports Session, which imports Peer. Importing the type
|
||||
// keeps that cycle out of the emitted JS.
|
||||
import type { Scope } from './scope'
|
||||
import { Session } from './session'
|
||||
import type {
|
||||
MessageResponse,
|
||||
|
|
@ -40,6 +43,7 @@ import {
|
|||
peerConfigToApi,
|
||||
RepresentationOptionsSchema,
|
||||
SearchQuerySchema,
|
||||
scopeRecallFields,
|
||||
sessionConfigFromApi,
|
||||
} from './validation'
|
||||
|
||||
|
|
@ -251,6 +255,8 @@ export class Peer {
|
|||
stream?: boolean
|
||||
target?: string
|
||||
session_id?: string
|
||||
scope?: string | string[]
|
||||
filters?: Record<string, unknown>
|
||||
reasoning_level?: string
|
||||
response_format?: Record<string, unknown>
|
||||
}): Promise<PeerChatResponse> {
|
||||
|
|
@ -265,6 +271,8 @@ export class Peer {
|
|||
query: string
|
||||
target?: string
|
||||
session_id?: string
|
||||
scope?: string | string[]
|
||||
filters?: Record<string, unknown>
|
||||
reasoning_level?: string
|
||||
response_format?: Record<string, unknown>
|
||||
}): Promise<Response> {
|
||||
|
|
@ -295,6 +303,8 @@ export class Peer {
|
|||
|
||||
private async _getRepresentation(params: {
|
||||
session_id?: string
|
||||
scope?: string | string[]
|
||||
filters?: Record<string, unknown>
|
||||
target?: string
|
||||
search_query?: string
|
||||
search_top_k?: number
|
||||
|
|
@ -365,6 +375,18 @@ export class Peer {
|
|||
* @param options.session - Optional session to scope the query to. If provided, only
|
||||
* information from that session is considered. Can be a session
|
||||
* ID string or a Session object.
|
||||
* @param options.scope - Optional scope(s) to confine the query to. A single scope answers
|
||||
* from that scope's own view of the target, including the higher-order
|
||||
* conclusions reasoned within it. A list of scopes restricts recall to
|
||||
* the union of their member sessions, which — like `sessions` — yields
|
||||
* only directly-stated conclusions. Mutually exclusive with `session`
|
||||
* and `sessions`, and requires a workspace-level key.
|
||||
* @param options.sessions - Optional allowlist of sessions to confine the query to, for
|
||||
* one-off questions that span a handful of sessions. Recall is
|
||||
* limited to conclusions stated directly in those sessions:
|
||||
* conclusions produced by reasoning across sessions are excluded,
|
||||
* because their provenance cannot be proven to sit inside the
|
||||
* allowlist. Reach for a named `scope` when you need that depth.
|
||||
* @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low", "medium",
|
||||
* "high", or "max". Defaults to "low" if not provided.
|
||||
* @returns Promise resolving to the response string, or null if no relevant information
|
||||
|
|
@ -379,6 +401,16 @@ export class Peer {
|
|||
* target: otherPeer,
|
||||
* reasoningLevel: 'high'
|
||||
* })
|
||||
*
|
||||
* // Answer only from a named scope
|
||||
* const response = await peer.chat('What is stressing them out?', {
|
||||
* scope: 'therapy',
|
||||
* })
|
||||
*
|
||||
* // Answer only from an ad-hoc set of sessions
|
||||
* const response = await peer.chat('What did we decide?', {
|
||||
* sessions: [session1, session2],
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
async chat<T>(
|
||||
|
|
@ -386,6 +418,8 @@ export class Peer {
|
|||
options: {
|
||||
target?: string | Peer
|
||||
session?: string | Session
|
||||
scope?: string | Scope | (string | Scope)[]
|
||||
sessions?: (string | Session)[]
|
||||
reasoningLevel?: string
|
||||
responseFormat: ZodType<T>
|
||||
}
|
||||
|
|
@ -395,6 +429,8 @@ export class Peer {
|
|||
options?: {
|
||||
target?: string | Peer
|
||||
session?: string | Session
|
||||
scope?: string | Scope | (string | Scope)[]
|
||||
sessions?: (string | Session)[]
|
||||
reasoningLevel?: string
|
||||
responseFormat?: Record<string, unknown>
|
||||
}
|
||||
|
|
@ -404,6 +440,8 @@ export class Peer {
|
|||
options?: {
|
||||
target?: string | Peer
|
||||
session?: string | Session
|
||||
scope?: string | Scope | (string | Scope)[]
|
||||
sessions?: (string | Session)[]
|
||||
reasoningLevel?: string
|
||||
responseFormat?: ZodType<T> | Record<string, unknown>
|
||||
}
|
||||
|
|
@ -423,6 +461,8 @@ export class Peer {
|
|||
query,
|
||||
target: targetId,
|
||||
session: resolvedSessionId,
|
||||
scope: options?.scope,
|
||||
sessions: options?.sessions,
|
||||
reasoningLevel: options?.reasoningLevel,
|
||||
responseFormat: options?.responseFormat,
|
||||
})
|
||||
|
|
@ -437,6 +477,7 @@ export class Peer {
|
|||
stream: false,
|
||||
target: chatParams.target,
|
||||
session_id: chatParams.session,
|
||||
...scopeRecallFields(chatParams),
|
||||
reasoning_level: chatParams.reasoningLevel,
|
||||
response_format: Peer.toResponseFormatSchema(options?.responseFormat),
|
||||
})
|
||||
|
|
@ -465,6 +506,9 @@ export class Peer {
|
|||
* @param options.session - Optional session to scope the query to. If provided, only
|
||||
* information from that session is considered. Can be a session
|
||||
* ID string or a Session object.
|
||||
* @param options.scope - Optional scope(s) to confine the query to. See {@link Peer.chat}.
|
||||
* @param options.sessions - Optional allowlist of sessions to confine the query to.
|
||||
* See {@link Peer.chat} for the depth caveat.
|
||||
* @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low", "medium",
|
||||
* "high", or "max". Defaults to "low" if not provided.
|
||||
* @returns Promise resolving to a DialecticStreamResponse that can be iterated over
|
||||
|
|
@ -489,6 +533,8 @@ export class Peer {
|
|||
options?: {
|
||||
target?: string | Peer
|
||||
session?: string | Session
|
||||
scope?: string | Scope | (string | Scope)[]
|
||||
sessions?: (string | Session)[]
|
||||
reasoningLevel?: string
|
||||
responseFormat?: ZodType | Record<string, unknown>
|
||||
}
|
||||
|
|
@ -508,6 +554,8 @@ export class Peer {
|
|||
query,
|
||||
target: targetId,
|
||||
session: resolvedSessionId,
|
||||
scope: options?.scope,
|
||||
sessions: options?.sessions,
|
||||
reasoningLevel: options?.reasoningLevel,
|
||||
responseFormat: options?.responseFormat,
|
||||
})
|
||||
|
|
@ -516,6 +564,7 @@ export class Peer {
|
|||
query: chatParams.query,
|
||||
target: chatParams.target,
|
||||
session_id: chatParams.session,
|
||||
...scopeRecallFields(chatParams),
|
||||
reasoning_level: chatParams.reasoningLevel,
|
||||
response_format: Peer.toResponseFormatSchema(options?.responseFormat),
|
||||
})
|
||||
|
|
@ -846,6 +895,8 @@ export class Peer {
|
|||
*/
|
||||
async representation(options?: {
|
||||
session?: string | Session
|
||||
scope?: string | Scope | (string | Scope)[]
|
||||
sessions?: (string | Session)[]
|
||||
target?: string | Peer
|
||||
searchQuery?: string | Message
|
||||
searchTopK?: number
|
||||
|
|
@ -856,6 +907,8 @@ export class Peer {
|
|||
const searchQuery = normalizeSearchQuery(options?.searchQuery)
|
||||
const getRepresentationParams = PeerGetRepresentationParamsSchema.parse({
|
||||
session: options?.session,
|
||||
scope: options?.scope,
|
||||
sessions: options?.sessions,
|
||||
target: options?.target,
|
||||
options: {
|
||||
searchQuery,
|
||||
|
|
@ -878,6 +931,7 @@ export class Peer {
|
|||
|
||||
const response = await this._getRepresentation({
|
||||
session_id: sessionId,
|
||||
...scopeRecallFields(getRepresentationParams),
|
||||
target: targetId,
|
||||
search_query: searchQuery,
|
||||
search_top_k: getRepresentationParams.options?.searchTopK,
|
||||
|
|
@ -964,7 +1018,7 @@ export class Peer {
|
|||
* This property provides a convenient way to access conclusions that this peer
|
||||
* has made about themselves. Use this for self-conclusion scenarios.
|
||||
*
|
||||
* @returns A ConclusionScope scoped to this peer's self-conclusions
|
||||
* @returns A ConclusionsView scoped to this peer's self-conclusions
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
|
|
@ -978,8 +1032,8 @@ export class Peer {
|
|||
* await peer.conclusions.delete('obs-123')
|
||||
* ```
|
||||
*/
|
||||
get conclusions(): ConclusionScope {
|
||||
return new ConclusionScope(
|
||||
get conclusions(): ConclusionsView {
|
||||
return new ConclusionsView(
|
||||
this._http,
|
||||
this.workspaceId,
|
||||
this.id,
|
||||
|
|
@ -995,7 +1049,7 @@ export class Peer {
|
|||
* observer and the target is the observed peer.
|
||||
*
|
||||
* @param target - The target peer (either a Peer object or peer ID string)
|
||||
* @returns A ConclusionScope scoped to this peer's conclusions of the target
|
||||
* @returns A ConclusionsView scoped to this peer's conclusions of the target
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
|
|
@ -1012,9 +1066,9 @@ export class Peer {
|
|||
* const rep = await bobConclusions.representation()
|
||||
* ```
|
||||
*/
|
||||
conclusionsOf(target: string | Peer): ConclusionScope {
|
||||
conclusionsOf(target: string | Peer): ConclusionsView {
|
||||
const targetId = typeof target === 'string' ? target : target.id
|
||||
return new ConclusionScope(
|
||||
return new ConclusionsView(
|
||||
this._http,
|
||||
this.workspaceId,
|
||||
this.id,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,278 @@
|
|||
import { API_VERSION } from './api-version'
|
||||
import type { HonchoHTTPClient } from './http/client'
|
||||
import { Page } from './pagination'
|
||||
import { Session } from './session'
|
||||
import type {
|
||||
PageResponse,
|
||||
ScopeStatusResponse,
|
||||
SessionResponse,
|
||||
} from './types/api'
|
||||
import { resolveId } from './utils'
|
||||
import {
|
||||
ScopeSessionsSchema,
|
||||
SessionIdSchema,
|
||||
sessionConfigFromApi,
|
||||
} from './validation'
|
||||
|
||||
/**
|
||||
* Backfill job state for one session in a scope.
|
||||
*/
|
||||
export interface ScopeBackfillState {
|
||||
state: 'pending' | 'completed' | 'failed'
|
||||
updatedAt: string
|
||||
/**
|
||||
* Number of documents copied into the scope. Present only once the backfill
|
||||
* for this session completes.
|
||||
*/
|
||||
docsCopied?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill/reconciliation progress for a scope, keyed by session ID.
|
||||
*
|
||||
* Only sessions that have had a backfill enqueued appear. A scope whose
|
||||
* sessions were all empty when added has an empty `backfillStatus`.
|
||||
*/
|
||||
export interface ScopeStatus {
|
||||
backfillStatus: Record<string, ScopeBackfillState>
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a scope in the Honcho system.
|
||||
*
|
||||
* A scope is a named set of sessions that acts as a visibility boundary. Recall
|
||||
* performed through a scope sees only what happened in that scope's sessions,
|
||||
* while the underlying peer keeps its single unified representation across
|
||||
* everything it has ever participated in.
|
||||
*
|
||||
* Membership changes are applied asynchronously: adding a session that already
|
||||
* has messages copies its existing conclusions into the scope, and removing one
|
||||
* reconciles them back out. Poll {@link Scope.status} to watch that settle.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const therapy = await honcho.scope('therapy')
|
||||
* await therapy.addSessions([session1, session2])
|
||||
*
|
||||
* // Ask a question answered only from the therapy sessions
|
||||
* const answer = await user.chat('What is stressing them out?', {
|
||||
* scope: 'therapy',
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export class Scope {
|
||||
/**
|
||||
* Unique identifier for this scope, without the server-side `scope.` prefix.
|
||||
*/
|
||||
readonly id: string
|
||||
/**
|
||||
* Workspace ID for scoping operations.
|
||||
*/
|
||||
readonly workspaceId: string
|
||||
private _http: HonchoHTTPClient
|
||||
private _metadata?: Record<string, unknown>
|
||||
private _createdAt?: string
|
||||
private _ensureWorkspace: () => Promise<void>
|
||||
|
||||
/**
|
||||
* Cached metadata for this scope. May be stale if the scope was not recently
|
||||
* fetched from the API.
|
||||
*/
|
||||
get metadata(): Record<string, unknown> | undefined {
|
||||
return this._metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamp when this scope was created. Only available if fetched from the API.
|
||||
*/
|
||||
get createdAt(): string | undefined {
|
||||
return this._createdAt
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a new Scope. **Do not call this directly, use the client.scope() method instead.**
|
||||
*
|
||||
* @param id - Unprefixed scope name, unique within the workspace
|
||||
* @param workspaceId - Workspace ID for scoping operations
|
||||
* @param http - Reference to the HTTP client instance
|
||||
* @param metadata - Optional metadata to initialize the cached value
|
||||
* @param ensureWorkspace - Callback that guarantees the workspace exists
|
||||
* @param createdAt - Creation timestamp, if already fetched
|
||||
*/
|
||||
constructor(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
http: HonchoHTTPClient,
|
||||
metadata?: Record<string, unknown>,
|
||||
ensureWorkspace: () => Promise<void> = async () => undefined,
|
||||
createdAt?: string
|
||||
) {
|
||||
this.id = id
|
||||
this.workspaceId = workspaceId
|
||||
this._http = http
|
||||
this._metadata = metadata
|
||||
this._ensureWorkspace = ensureWorkspace
|
||||
this._createdAt = createdAt
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Private API Methods
|
||||
// ===========================================================================
|
||||
|
||||
private get _basePath(): string {
|
||||
return `/${API_VERSION}/workspaces/${this.workspaceId}/scopes/${this.id}`
|
||||
}
|
||||
|
||||
private async _addSessions(sessionIds: string[]): Promise<void> {
|
||||
await this._ensureWorkspace()
|
||||
await this._http.post(`${this._basePath}/sessions`, {
|
||||
body: { session_ids: sessionIds },
|
||||
})
|
||||
}
|
||||
|
||||
private async _removeSession(sessionId: string): Promise<void> {
|
||||
await this._ensureWorkspace()
|
||||
await this._http.delete(`${this._basePath}/sessions/${sessionId}`)
|
||||
}
|
||||
|
||||
private async _listSessions(params?: {
|
||||
page?: number
|
||||
size?: number
|
||||
reverse?: boolean
|
||||
}): Promise<PageResponse<SessionResponse>> {
|
||||
await this._ensureWorkspace()
|
||||
return this._http.post<PageResponse<SessionResponse>>(
|
||||
`${this._basePath}/sessions/list`,
|
||||
{
|
||||
query: {
|
||||
page: params?.page,
|
||||
size: params?.size,
|
||||
reverse: params?.reverse ? 'true' : undefined,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private async _getStatus(): Promise<ScopeStatusResponse> {
|
||||
await this._ensureWorkspace()
|
||||
return this._http.get<ScopeStatusResponse>(`${this._basePath}/status`)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Public API Methods
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Add sessions to this scope.
|
||||
*
|
||||
* Every named session must already exist. Adding a session that is already a
|
||||
* member is a no-op.
|
||||
*
|
||||
* Sessions that already hold messages are backfilled into the scope
|
||||
* asynchronously, so recall through this scope may not reflect their history
|
||||
* immediately — poll {@link Scope.status} to watch that complete.
|
||||
*
|
||||
* @param sessions - Sessions to add, as ID strings or Session objects. At most
|
||||
* 100 per call, matching the server's limit; split larger
|
||||
* membership changes into separate calls so a failure names
|
||||
* the batch that failed.
|
||||
*/
|
||||
async addSessions(sessions: (string | Session)[]): Promise<void> {
|
||||
const sessionIds = ScopeSessionsSchema.parse(sessions.map(resolveId))
|
||||
await this._addSessions(sessionIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a session from this scope.
|
||||
*
|
||||
* Conclusions copied or derived while the session was a member are
|
||||
* reconciled out asynchronously, and the scope's peer card is rebuilt from
|
||||
* whatever evidence remains. Poll {@link Scope.status} to watch that settle.
|
||||
*
|
||||
* @param session - Session to remove, as an ID string or a Session object
|
||||
* @throws If the session ID is malformed
|
||||
*/
|
||||
async removeSession(session: string | Session): Promise<void> {
|
||||
// Validated because this ID is interpolated into a request *path*: an
|
||||
// unvalidated value silently changes which resource the request addresses.
|
||||
// `valid-session?typo` would target `valid-session` with a stray query
|
||||
// string, removing the wrong session and reconciling against it.
|
||||
await this._removeSession(SessionIdSchema.parse(resolveId(session)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sessions that are members of this scope.
|
||||
*
|
||||
* Ordered by how long each session has been a member — longest-standing
|
||||
* first, or most recently added first when `reverse` is true.
|
||||
*
|
||||
* @param options - Pagination options: `page`, `size`, and `reverse`
|
||||
* @returns Promise resolving to a paginated list of member Sessions
|
||||
*/
|
||||
async sessions(options?: {
|
||||
page?: number
|
||||
size?: number
|
||||
reverse?: boolean
|
||||
}): Promise<Page<Session, SessionResponse>> {
|
||||
const reverse = options?.reverse
|
||||
const sessionsPage = await this._listSessions({
|
||||
page: options?.page,
|
||||
size: options?.size,
|
||||
reverse,
|
||||
})
|
||||
|
||||
const fetchNextPage = async (
|
||||
page: number,
|
||||
size: number
|
||||
): Promise<PageResponse<SessionResponse>> => {
|
||||
return this._listSessions({ page, size, reverse })
|
||||
}
|
||||
|
||||
return new Page(
|
||||
sessionsPage,
|
||||
(session) =>
|
||||
new Session(
|
||||
session.id,
|
||||
this.workspaceId,
|
||||
this._http,
|
||||
session.metadata ?? undefined,
|
||||
sessionConfigFromApi(session.configuration) ?? undefined,
|
||||
() => this._ensureWorkspace(),
|
||||
session.created_at,
|
||||
session.is_active
|
||||
),
|
||||
fetchNextPage
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backfill/reconciliation progress for this scope.
|
||||
*
|
||||
* Use this after a membership change to tell "the scope knows nothing about
|
||||
* that session yet" apart from "the scope has caught up and there is genuinely
|
||||
* nothing to recall".
|
||||
*
|
||||
* @returns Promise resolving to per-session backfill state
|
||||
*/
|
||||
async status(): Promise<ScopeStatus> {
|
||||
const response = await this._getStatus()
|
||||
return {
|
||||
backfillStatus: Object.fromEntries(
|
||||
Object.entries(response.backfill_status ?? {}).map(
|
||||
([sessionId, job]) => [
|
||||
sessionId,
|
||||
{
|
||||
state: job.state,
|
||||
updatedAt: job.updated_at,
|
||||
docsCopied: job.docs_copied,
|
||||
},
|
||||
]
|
||||
)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `Scope(id='${this.id}', workspaceId='${this.workspaceId}')`
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@ import type { HonchoHTTPClient } from './http/client'
|
|||
import { Message } from './message'
|
||||
import { Page } from './pagination'
|
||||
import { Peer } from './peer'
|
||||
// Type-only: scope.ts imports this module. Importing the type keeps that cycle
|
||||
// out of the emitted JS.
|
||||
import type { Scope } from './scope'
|
||||
import { SessionContext, SessionSummaries } from './session_context'
|
||||
import type {
|
||||
MessageResponse,
|
||||
|
|
@ -17,7 +20,7 @@ import type {
|
|||
SessionResponse,
|
||||
SessionSummariesResponse,
|
||||
} from './types/api'
|
||||
import { transformQueueStatus } from './utils'
|
||||
import { resolveId, transformQueueStatus } from './utils'
|
||||
import {
|
||||
ContextParamsSchema,
|
||||
FileUploadSchema,
|
||||
|
|
@ -220,6 +223,8 @@ export class Session {
|
|||
search_query?: string
|
||||
peer_target?: string
|
||||
peer_perspective?: string
|
||||
scope?: string
|
||||
sessions?: string[]
|
||||
limit_to_session?: boolean
|
||||
search_top_k?: number
|
||||
search_max_distance?: number
|
||||
|
|
@ -752,6 +757,17 @@ export class Session {
|
|||
* @param options.tokens - Target token count for the context window
|
||||
* @param options.peerTarget - The peer to get representation for
|
||||
* @param options.peerPerspective - The peer whose perspective to use for representation
|
||||
* @param options.scope - A scope to use as the perspective source instead of a peer: the
|
||||
* target's representation and card are read from what that scope
|
||||
* observed. Requires `peerTarget`, is mutually exclusive with
|
||||
* `peerPerspective`, and requires a workspace-level key.
|
||||
* @param options.sessions - Allowlist of sessions confining the target's representation
|
||||
* to that set. This session must be one of them. Recall is
|
||||
* limited to conclusions stated directly in those sessions, and
|
||||
* the peer card is omitted, since neither derived conclusions
|
||||
* nor cards carry provable per-session provenance. Mutually
|
||||
* exclusive with `scope` and `limitToSession`; requires
|
||||
* `peerTarget`.
|
||||
* @param options.limitToSession - Whether to limit representation to this session only
|
||||
* @param options.representationOptions - Options for representation retrieval (searchQuery, searchTopK, etc.)
|
||||
* @returns Promise resolving to a SessionContext with messages, summary, and representation
|
||||
|
|
@ -764,6 +780,12 @@ export class Session {
|
|||
* peerTarget: user
|
||||
* })
|
||||
*
|
||||
* // Build the context from what a scope observed
|
||||
* const ctx = await session.context({
|
||||
* peerTarget: user,
|
||||
* scope: 'therapy',
|
||||
* })
|
||||
*
|
||||
* // Convert to OpenAI format
|
||||
* const messages = ctx.toOpenAI(assistant)
|
||||
* ```
|
||||
|
|
@ -773,6 +795,8 @@ export class Session {
|
|||
tokens?: number
|
||||
peerTarget?: string | Peer
|
||||
peerPerspective?: string | Peer
|
||||
scope?: string | Scope
|
||||
sessions?: (string | Session)[]
|
||||
limitToSession?: boolean
|
||||
representationOptions?: RepresentationOptions
|
||||
}): Promise<SessionContext> {
|
||||
|
|
@ -795,6 +819,10 @@ export class Session {
|
|||
tokens: opts.tokens,
|
||||
peerTarget: peerTargetId,
|
||||
peerPerspective: peerPerspectiveId,
|
||||
// Checked against undefined, not truthiness: `scope: ''` must reach the
|
||||
// schema and be rejected, not be dropped into an unscoped context.
|
||||
scope: opts.scope !== undefined ? resolveId(opts.scope) : undefined,
|
||||
sessions: opts.sessions,
|
||||
limitToSession: opts.limitToSession,
|
||||
representationOptions: opts.representationOptions
|
||||
? {
|
||||
|
|
@ -810,6 +838,8 @@ export class Session {
|
|||
search_query: searchQuery,
|
||||
peer_target: contextParams.peerTarget,
|
||||
peer_perspective: contextParams.peerPerspective,
|
||||
scope: contextParams.scope,
|
||||
sessions: contextParams.sessions,
|
||||
limit_to_session: contextParams.limitToSession,
|
||||
search_top_k: contextParams.representationOptions?.searchTopK,
|
||||
search_max_distance:
|
||||
|
|
|
|||
|
|
@ -133,6 +133,28 @@ export interface SessionCreateParams {
|
|||
metadata?: Record<string, unknown>
|
||||
configuration?: SessionConfigApi
|
||||
peers?: Record<string, SessionPeerConfigParams>
|
||||
scopes?: string[]
|
||||
}
|
||||
|
||||
export interface ScopeResponse {
|
||||
id: string
|
||||
metadata: Record<string, unknown>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session backfill job state for a scope.
|
||||
*
|
||||
* `docs_copied` is present only once a backfill completes.
|
||||
*/
|
||||
export interface ScopeBackfillJob {
|
||||
state: 'pending' | 'completed' | 'failed'
|
||||
updated_at: string
|
||||
docs_copied?: number
|
||||
}
|
||||
|
||||
export interface ScopeStatusResponse {
|
||||
backfill_status: Record<string, ScopeBackfillJob>
|
||||
}
|
||||
|
||||
export interface SessionUpdateParams {
|
||||
|
|
|
|||
|
|
@ -159,6 +159,149 @@ export const SessionIdSchema = z
|
|||
*/
|
||||
const SessionIdObjectSchema = z.object({ id: SessionIdSchema })
|
||||
|
||||
/**
|
||||
* Reserved peer-name prefix the server uses to store a scope.
|
||||
*/
|
||||
const SCOPE_PEER_PREFIX = 'scope.'
|
||||
|
||||
/**
|
||||
* Scope IDs are stored as peer names with the reserved prefix prepended, so
|
||||
* they must leave room for it within the 512-character peer name limit.
|
||||
*/
|
||||
const SCOPE_ID_MAX_LENGTH = 512 - SCOPE_PEER_PREFIX.length
|
||||
|
||||
/**
|
||||
* The scope ID rules, as a plain function rather than only a schema.
|
||||
*
|
||||
* Zod reports a failing union as a single `invalid_union` / "Invalid input"
|
||||
* issue and buries the branch errors, so a schema alone cannot carry these
|
||||
* messages out of `ScopeOptionSchema`. Keeping the rules callable lets both the
|
||||
* bare schema and the union surface the same specific message.
|
||||
*
|
||||
* @returns The problems found, or an empty array when the ID is valid.
|
||||
*/
|
||||
function scopeIdIssues(value: string): string[] {
|
||||
if (value.length < 1) {
|
||||
return ['Scope ID must be a non-empty string']
|
||||
}
|
||||
if (value.length > SCOPE_ID_MAX_LENGTH) {
|
||||
return [`Scope ID can be at most ${SCOPE_ID_MAX_LENGTH} characters`]
|
||||
}
|
||||
// Checked before the charset: the reserved prefix contains '.', which is
|
||||
// itself outside the charset, so a charset-first check would report the
|
||||
// charset instead of the real mistake for a double-prefixed name.
|
||||
if (value.startsWith(SCOPE_PEER_PREFIX)) {
|
||||
return [
|
||||
`Scope ID must not start with the reserved prefix '${SCOPE_PEER_PREFIX}' (scope IDs are unprefixed)`,
|
||||
]
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
||||
return [
|
||||
'Scope ID may only contain letters, numbers, underscores, and hyphens',
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Add every scope ID problem in `values` as a top-level issue.
|
||||
*/
|
||||
function addScopeIdIssues(values: string[], ctx: z.RefinementCtx): void {
|
||||
for (const value of values) {
|
||||
for (const message of scopeIdIssues(value)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for scope ID validation.
|
||||
*
|
||||
* Scope IDs are unprefixed — the `scope.` prefix is a server-side storage
|
||||
* detail and never appears on the wire.
|
||||
*/
|
||||
export const ScopeIdSchema = z.string().superRefine((val, ctx) => {
|
||||
addScopeIdIssues([val], ctx)
|
||||
})
|
||||
|
||||
/**
|
||||
* Shape-only branch for the `scope` option: an ID string, or an object carrying
|
||||
* one (so a `Scope` instance is accepted). The ID itself is validated after the
|
||||
* union resolves — see `ScopeOptionSchema`.
|
||||
*/
|
||||
const ScopeIdLikeSchema = z.union([z.string(), z.object({ id: z.string() })])
|
||||
|
||||
/**
|
||||
* Schema for the `scope` read option: one scope, or a bounded list of them.
|
||||
*
|
||||
* A single scope reads that scope's own view. A list restricts recall to the
|
||||
* union of the scopes' member sessions. An empty list is rejected rather than
|
||||
* resolved to an empty allowlist, which would silently recall nothing.
|
||||
*
|
||||
* The union discriminates shape only; IDs and list bounds are checked after the
|
||||
* transform so their messages are not swallowed as `invalid_union`.
|
||||
*/
|
||||
export const ScopeOptionSchema = z
|
||||
.union([ScopeIdLikeSchema, z.array(ScopeIdLikeSchema)])
|
||||
.transform((val) =>
|
||||
Array.isArray(val)
|
||||
? val.map((entry) => (typeof entry === 'string' ? entry : entry.id))
|
||||
: typeof val === 'string'
|
||||
? val
|
||||
: val.id
|
||||
)
|
||||
.superRefine((resolved, ctx) => {
|
||||
if (Array.isArray(resolved)) {
|
||||
if (resolved.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'scope must name at least one scope',
|
||||
})
|
||||
}
|
||||
if (resolved.length > 100) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'scope can name at most 100 scopes',
|
||||
})
|
||||
}
|
||||
}
|
||||
addScopeIdIssues(Array.isArray(resolved) ? resolved : [resolved], ctx)
|
||||
})
|
||||
|
||||
/**
|
||||
* Schema for a scope membership change: the sessions to add to a scope.
|
||||
*
|
||||
* Capped at 100 to match the server rather than silently chunking, so a
|
||||
* rejected batch is the batch the caller passed.
|
||||
*/
|
||||
export const ScopeSessionsSchema = z
|
||||
.array(SessionIdSchema)
|
||||
.min(1, 'At least one session must be given')
|
||||
.max(100, 'At most 100 sessions can be added per call')
|
||||
|
||||
/**
|
||||
* Schema for the `scopes` option on session creation: the scopes a new session
|
||||
* should join.
|
||||
*/
|
||||
export const SessionScopesSchema = z
|
||||
.array(ScopeIdSchema)
|
||||
.min(1, 'scopes must name at least one scope')
|
||||
.max(100, 'scopes can name at most 100 scopes')
|
||||
|
||||
/**
|
||||
* Schema for the `sessions` allowlist option — sugar for the wire-level
|
||||
* `filters: { session_id: [...] }`.
|
||||
*
|
||||
* Capped at 1,000 entries to match the server. An empty list is rejected: the
|
||||
* server treats an empty allowlist as fail-closed (recalls nothing), which is
|
||||
* never what a caller passing `sessions: []` intends.
|
||||
*/
|
||||
export const SessionAllowlistSchema = z
|
||||
.array(z.union([SessionIdSchema, SessionIdObjectSchema]))
|
||||
.min(1, 'sessions must name at least one session')
|
||||
.max(1000, 'sessions can name at most 1000 sessions')
|
||||
.transform((vals) => vals.map((v) => (typeof v === 'string' ? v : v.id)))
|
||||
|
||||
/**
|
||||
* Schema for session peer configuration.
|
||||
*/
|
||||
|
|
@ -291,6 +434,58 @@ export function normalizeListOptions<T extends { filters?: Filters }>(
|
|||
return { filters: input as Filters } as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate validated `scope` / `sessions` options into their wire fields.
|
||||
*
|
||||
* `sessions` is sugar: it goes out as the constrained
|
||||
* `filters: { session_id: [...] }` body the recall endpoints accept, never as a
|
||||
* field of its own — the server rejects unknown keys with a 422. Shared by chat,
|
||||
* chatStream, and representation so the three cannot drift apart.
|
||||
*
|
||||
* Purely a translation; the schemas have already rejected the invalid
|
||||
* combinations by the time this runs.
|
||||
*/
|
||||
export function scopeRecallFields(options: {
|
||||
scope?: string | string[]
|
||||
sessions?: string[]
|
||||
}): { scope?: string | string[]; filters?: Record<string, unknown> } {
|
||||
return {
|
||||
scope: options.scope,
|
||||
filters: options.sessions ? { session_id: options.sessions } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add issues for the `scope` exclusions the server enforces with a 422.
|
||||
*
|
||||
* A scope already determines what a query can see, so combining it with a
|
||||
* session allowlist or a single session is a contradiction rather than a
|
||||
* narrowing. Shared by the chat, representation, and context schemas so the
|
||||
* three surfaces cannot drift apart.
|
||||
*/
|
||||
function scopeExclusivityIssues(
|
||||
data: { scope?: unknown; sessions?: unknown; session?: unknown },
|
||||
ctx: z.RefinementCtx
|
||||
): void {
|
||||
if (data.scope === undefined) {
|
||||
return
|
||||
}
|
||||
if (data.sessions !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'scope and sessions are mutually exclusive',
|
||||
path: ['sessions'],
|
||||
})
|
||||
}
|
||||
if (data.session !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'scope and session are mutually exclusive',
|
||||
path: ['session'],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for chat query parameters.
|
||||
*/
|
||||
|
|
@ -309,6 +504,8 @@ export const ChatQuerySchema = z
|
|||
.transform((val) =>
|
||||
val ? (typeof val === 'string' ? val : val.id) : undefined
|
||||
),
|
||||
scope: ScopeOptionSchema.optional(),
|
||||
sessions: SessionAllowlistSchema.optional(),
|
||||
reasoningLevel: z
|
||||
.enum(['minimal', 'low', 'medium', 'high', 'max'])
|
||||
.optional(),
|
||||
|
|
@ -319,6 +516,7 @@ export const ChatQuerySchema = z
|
|||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine(scopeExclusivityIssues)
|
||||
|
||||
/**
|
||||
* Schema for representation options.
|
||||
|
|
@ -356,11 +554,40 @@ export const ContextParamsSchema = z
|
|||
tokens: z.int('Token limit must be an integer').optional(),
|
||||
peerTarget: PeerIdSchema.optional(),
|
||||
peerPerspective: PeerIdSchema.optional(),
|
||||
// Only a single scope is accepted here: the context route uses a scope as
|
||||
// the *perspective source* for the target's representation and card, which
|
||||
// is one observer. A list of scopes has no meaning for that.
|
||||
scope: ScopeIdSchema.optional(),
|
||||
sessions: SessionAllowlistSchema.optional(),
|
||||
limitToSession: z.boolean().optional(),
|
||||
representationOptions: RepresentationOptionsSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.sessions && !data.peerTarget) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'peerTarget is required when sessions is provided',
|
||||
path: ['sessions'],
|
||||
})
|
||||
}
|
||||
|
||||
if (data.sessions && data.scope) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'sessions and scope are mutually exclusive',
|
||||
path: ['sessions'],
|
||||
})
|
||||
}
|
||||
|
||||
if (data.sessions && data.limitToSession) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'sessions and limitToSession are mutually exclusive',
|
||||
path: ['sessions'],
|
||||
})
|
||||
}
|
||||
|
||||
if (data.representationOptions?.searchQuery && !data.peerTarget) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
|
|
@ -376,6 +603,22 @@ export const ContextParamsSchema = z
|
|||
path: ['peerPerspective'],
|
||||
})
|
||||
}
|
||||
|
||||
if (data.scope && !data.peerTarget) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'peerTarget is required when scope is provided',
|
||||
path: ['scope'],
|
||||
})
|
||||
}
|
||||
|
||||
if (data.scope && data.peerPerspective) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'scope and peerPerspective are mutually exclusive',
|
||||
path: ['scope'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
@ -437,10 +680,13 @@ export const GetRepresentationParamsSchema = z
|
|||
export const PeerGetRepresentationParamsSchema = z
|
||||
.object({
|
||||
session: z.union([SessionIdSchema, SessionIdObjectSchema]).optional(),
|
||||
scope: ScopeOptionSchema.optional(),
|
||||
sessions: SessionAllowlistSchema.optional(),
|
||||
target: z.union([PeerIdSchema, PeerIdObjectSchema]).optional(),
|
||||
options: RepresentationOptionsSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine(scopeExclusivityIssues)
|
||||
|
||||
/**
|
||||
* Schema for peer card target parameter.
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ If you're unsure, list your available tools and look for Honcho memory tools (an
|
|||
|
||||
You need a Honcho API key — get one free at <https://app.honcho.dev> (starts with `hch-`). Then connect via the path you picked above — a purpose-built integration (recommended), or a raw connection:
|
||||
|
||||
- **MCP** — point your client at `https://mcp.honcho.dev` with two headers: `Authorization: Bearer hch-your-key-here` and `X-Honcho-User-Name: YourName` (what Honcho should call the user). Optional: `X-Honcho-Assistant-Name` (default `Assistant`) and `X-Honcho-Workspace-ID` (default `default`; set it to isolate memory per project). Restart the client fully after adding config. Per-client config snippets (Claude Desktop, Cursor, Codex, Windsurf, VS Code, Cline, Zed) are in the [MCP integration guide](https://honcho.dev/docs/v3/guides/integrations/mcp.md). Once connected, the server tells your assistant how to use the tools automatically.
|
||||
- **MCP** — point your client at `https://mcp.honcho.dev` with `Authorization: Bearer hch-your-key-here`. Optional `X-Honcho-Workspace-ID` fills the `workspace_id` tool argument when omitted; otherwise pass `workspace_id` on each call (use `list_workspaces` to discover IDs). Restart the client fully after adding config. Per-client config snippets (Claude Desktop, Cursor, Codex, Windsurf, VS Code, Cline, Zed) are in the [MCP integration guide](https://honcho.dev/docs/v3/guides/integrations/mcp.md). Once connected, the server tells your assistant how to use the tools automatically.
|
||||
- **CLI** — use the `honcho-cli` skill.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
name: pre-pr
|
||||
description: Prepare a Honcho change for a pull request to plastic-labs/honcho. Invoke before opening a PR, when drafting a PR body, when asked if a branch is PR-ready, or when filling the pull request template. Checks the linked issue, required tests and docs, then writes Description / Proofs / Fixes.
|
||||
---
|
||||
|
||||
# Pre-PR checklist
|
||||
|
||||
Do this after the change works, before anyone opens the GitHub PR. Output is a filled template body — do not create the PR.
|
||||
|
||||
The template lives at `.github/pull_request_template.md`. Do not add extra sections.
|
||||
|
||||
## 1. Issue gate (hard stop)
|
||||
|
||||
A PR without a maintainer-approved issue will be closed.
|
||||
|
||||
```bash
|
||||
gh issue view <N> --repo plastic-labs/honcho --json number,title,labels,state
|
||||
```
|
||||
|
||||
Stop if any of these fail:
|
||||
|
||||
- no issue number, or the issue is not in `plastic-labs/honcho`
|
||||
- issue is closed (unless this PR is explicitly reopening it)
|
||||
- labels do not include `maintainer-approved`
|
||||
|
||||
Say which check failed. Do not draft a PR body around it.
|
||||
|
||||
## 2. Classify the diff
|
||||
|
||||
```bash
|
||||
git diff main...HEAD --stat
|
||||
```
|
||||
|
||||
Pick one primary kind: bug, feature, docs. Then decide layers:
|
||||
|
||||
| Surface touched | Required |
|
||||
| --- | --- |
|
||||
| `src/` (non-prompt) | unit tests under the matching `tests/` tree |
|
||||
| deriver / dialectic / dreamer / LLM path | unit + consider live-llm (`tests/live_llm`) |
|
||||
| queue, config hierarchy, multi-turn, SDK contract | unified (`uv run python -m tests.unified.run`) |
|
||||
| `/v3` HTTP or deriver queue behavior | `/verify` skill (runtime, not just pytest) |
|
||||
| public API, SDK exports, `config.toml` / settings, mintlify `docs/` | documentation in the matching file |
|
||||
|
||||
Skip a layer only with a one-line reason (e.g. "docs-only", "comment-only"). "When appropriate" is not a skip.
|
||||
|
||||
Invoke `/verify` when the runtime surface moved. Do not restate that skill here.
|
||||
|
||||
Lint/type before claiming tests are green: `uv run ruff check src/` → `uv run basedpyright` → the pytest command for the layer.
|
||||
|
||||
## 3. Proofs
|
||||
|
||||
Collect evidence that belongs in the PR, not in the commit:
|
||||
|
||||
- command + pass/fail for what you ran
|
||||
- a log snippet, screenshot, or file path that shows the new behavior
|
||||
- for bugs: the failing case before vs after, if you have it
|
||||
|
||||
If `/verify` ran, the proofs *are* that session's output. Do not invent green runs.
|
||||
|
||||
## 4. Write the body
|
||||
|
||||
Fill the description, proofs, checklist portion of the pull request description template.
|
||||
Make sure to link the related github issue, otherwise the PR will be auto-closed.
|
||||
|
|
@ -394,6 +394,15 @@ class ConfiguredEmbeddingModelSettings(BaseModel):
|
|||
dimensions_mode: EmbeddingDimensionsMode = "auto"
|
||||
encoding_format_mode: EmbeddingEncodingFormatMode = "auto"
|
||||
max_batch_size: Annotated[int, Field(gt=0)] | None = None
|
||||
# Client HTTP timeout in seconds. OpenAI receives seconds; Gemini converts to ms.
|
||||
timeout: float | None = None
|
||||
|
||||
@field_validator("timeout", mode="before")
|
||||
@classmethod
|
||||
def _validate_timeout(cls, v: Any) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
return coerce_provider_timeout(v)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
|
@ -431,6 +440,15 @@ class EmbeddingModelConfig(BaseModel):
|
|||
api_key: str | None = None
|
||||
base_url: str | None = None
|
||||
max_batch_size: Annotated[int, Field(gt=0)] | None = None
|
||||
# Client HTTP timeout in seconds. OpenAI receives seconds; Gemini converts to ms.
|
||||
timeout: float | None = None
|
||||
|
||||
@field_validator("timeout", mode="before")
|
||||
@classmethod
|
||||
def _validate_timeout(cls, v: Any) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
return coerce_provider_timeout(v)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
|
@ -556,6 +574,7 @@ def resolve_embedding_model_config(
|
|||
api_key=api_key,
|
||||
base_url=configured.overrides.base_url,
|
||||
max_batch_size=configured.max_batch_size,
|
||||
timeout=configured.timeout,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -49,12 +49,16 @@ from .representation import (
|
|||
)
|
||||
from .scope import (
|
||||
add_sessions_to_scope,
|
||||
clear_scope_backfill_status,
|
||||
get_or_create_scopes,
|
||||
get_scope_backfill_status,
|
||||
get_scope_or_raise,
|
||||
get_scope_sessions,
|
||||
get_scopes,
|
||||
invalidate_scope_peer_cache,
|
||||
remove_session_from_scope,
|
||||
resolve_scope_peers,
|
||||
update_scope_backfill_status,
|
||||
)
|
||||
from .session import (
|
||||
SessionDeletionResult,
|
||||
|
|
@ -137,12 +141,16 @@ __all__ = [
|
|||
"get_working_representation",
|
||||
# Scope
|
||||
"add_sessions_to_scope",
|
||||
"clear_scope_backfill_status",
|
||||
"get_or_create_scopes",
|
||||
"get_scope_backfill_status",
|
||||
"get_scope_or_raise",
|
||||
"get_scope_sessions",
|
||||
"get_scopes",
|
||||
"invalidate_scope_peer_cache",
|
||||
"remove_session_from_scope",
|
||||
"resolve_scope_peers",
|
||||
"update_scope_backfill_status",
|
||||
# Session
|
||||
"SessionDeletionResult",
|
||||
"get_sessions",
|
||||
|
|
|
|||
|
|
@ -7,17 +7,26 @@ authoritative, user-unwritable flag) and ``{"observe_me": false}`` in
|
|||
and never speaks.
|
||||
See ``src/utils/scopes.py`` for the namespace helpers.
|
||||
|
||||
Membership only affects messages ingested *after* a session is added to a
|
||||
scope. Conclusions already derived are neither backfilled on add nor
|
||||
reconciled on removal.
|
||||
Messages ingested after a membership change flow to the scope via the normal
|
||||
deriver fan-out. Retroactive changes are handled by queue jobs: adding a session
|
||||
that already has messages enqueues a ``scope_backfill`` task (copy of the
|
||||
session's explicit documents into the scope's collections) and removing a
|
||||
session enqueues a ``scope_removal`` task (soft-delete of the session's
|
||||
documents plus dependent derived documents).
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timezone
|
||||
from logging import getLogger
|
||||
from typing import Any, Literal
|
||||
from typing import cast as py_cast
|
||||
|
||||
from sqlalchemy import Select, select
|
||||
from sqlalchemy import Select, Text, cast, select, update
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, array
|
||||
from sqlalchemy.engine import CursorResult
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.functions import func
|
||||
|
||||
from src import models, schemas
|
||||
from src.cache.client import safe_cache_delete
|
||||
|
|
@ -38,11 +47,21 @@ from .workspace import get_or_create_workspace
|
|||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
# Key inside the scope peer's internal_metadata that holds per-session
|
||||
# backfill job status: {<session_name>: {state, updated_at[, docs_copied]}}.
|
||||
BACKFILL_STATUS_KEY = "backfill_status"
|
||||
|
||||
ScopeBackfillState = Literal["pending", "completed", "failed"]
|
||||
|
||||
# Internal metadata stamped on every scope peer at creation. `kind` is the
|
||||
# authoritative scope flag and lives here — NOT in `configuration` — because
|
||||
# `configuration` is user-writable (`PeerCreate`/`PeerUpdate` accept a free-form
|
||||
# dict, and `update_peer` replaces it wholesale), so a user could forge or clear
|
||||
# the flag. `internal_metadata` appears in no API schema at all.
|
||||
#
|
||||
# Backfill job status shares this field under BACKFILL_STATUS_KEY below. Every
|
||||
# write to it must be a JSONB merge scoped to that key, never a wholesale
|
||||
# replacement, or the `kind` flag goes with it and the peer stops being a scope.
|
||||
SCOPE_PEER_INTERNAL_METADATA: dict[str, str] = {
|
||||
"kind": SCOPE_KIND,
|
||||
}
|
||||
|
|
@ -355,9 +374,9 @@ async def add_sessions_to_scope(
|
|||
|
||||
Each membership is a ``session_peers`` row for the scope peer with
|
||||
``observe_others=true, observe_me=false`` — exactly what a hand-built
|
||||
observer peer would carry. No backfill happens here: membership only affects
|
||||
messages ingested after this call, and conclusions already derived are left
|
||||
as they are.
|
||||
observer peer would carry. Sessions that already have messages get a
|
||||
``scope_backfill`` queue task so their existing explicit documents are
|
||||
copied into the scope's collections; fresh sessions need nothing.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
|
@ -400,6 +419,23 @@ async def add_sessions_to_scope(
|
|||
|
||||
await db.commit()
|
||||
|
||||
# Backfill: only sessions that already have messages need it.
|
||||
# Imported lazily: src.deriver.enqueue imports crud at module level.
|
||||
from src.deriver.enqueue import enqueue_scope_backfill
|
||||
|
||||
msg_result = await db.execute(
|
||||
select(models.Message.session_name)
|
||||
.where(models.Message.workspace_name == workspace_name)
|
||||
.where(models.Message.session_name.in_(requested))
|
||||
.distinct()
|
||||
)
|
||||
for session_with_messages in sorted({row[0] for row in msg_result.all()}):
|
||||
await enqueue_scope_backfill(
|
||||
workspace_name,
|
||||
scope_peer=scope_peer_name(scope_name),
|
||||
session_name=session_with_messages,
|
||||
)
|
||||
|
||||
|
||||
async def remove_session_from_scope(
|
||||
db: AsyncSession,
|
||||
|
|
@ -411,8 +447,9 @@ async def remove_session_from_scope(
|
|||
Remove a session from a scope by ending the scope peer's membership.
|
||||
|
||||
Ends the membership the same way the generic remove-peer path does (sets
|
||||
``left_at``). Conclusions derived while the session was a member are left in
|
||||
place — nothing reconciles them.
|
||||
``left_at``), then enqueues a ``scope_removal`` reconciliation task that
|
||||
soft-deletes the session's documents from the scope's collections along
|
||||
with any derived documents whose support left the scope.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
|
@ -424,6 +461,8 @@ async def remove_session_from_scope(
|
|||
ResourceNotFoundException: If the scope or session does not exist
|
||||
"""
|
||||
# Lazy import for the same circular-import reason as add_sessions_to_scope.
|
||||
from src.deriver.enqueue import enqueue_scope_removal
|
||||
|
||||
from .session import remove_peers_from_session
|
||||
|
||||
await get_scope_or_raise(db, workspace_name, scope_name)
|
||||
|
|
@ -436,3 +475,124 @@ async def remove_session_from_scope(
|
|||
# This *is* the supported path for ending scope membership.
|
||||
_allow_scope_peers=True,
|
||||
)
|
||||
|
||||
await enqueue_scope_removal(
|
||||
workspace_name,
|
||||
scope_peer=scope_peer_name(scope_name),
|
||||
session_name=session_name,
|
||||
)
|
||||
|
||||
|
||||
async def update_scope_backfill_status(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
scope_peer: str,
|
||||
session_name: str,
|
||||
*,
|
||||
state: ScopeBackfillState,
|
||||
docs_copied: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Merge one session's backfill status into the scope peer's internal_metadata.
|
||||
|
||||
Uses a single-statement nested JSONB merge so concurrent writers updating
|
||||
*different* session keys never clobber each other (the merge is computed
|
||||
from the row's current committed value under the row lock, never from a
|
||||
stale Python-side read):
|
||||
|
||||
internal_metadata || {"backfill_status":
|
||||
coalesce(internal_metadata->'backfill_status', '{}') || {<session>: <entry>}}
|
||||
|
||||
Does not commit; the caller owns the transaction. Callers should
|
||||
invalidate the peer cache after committing (``peer_cache_key``).
|
||||
"""
|
||||
entry: dict[str, Any] = {
|
||||
"state": state,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if docs_copied is not None:
|
||||
entry["docs_copied"] = docs_copied
|
||||
|
||||
# NOTE: the JSONB operand must be a Python dict, not a json.dumps() string.
|
||||
# cast(<already-serialized-str>, JSONB) double-encodes: psycopg's JSONB
|
||||
# bind adapter serializes the *string* again, producing a JSONB string
|
||||
# scalar instead of an object. `||` between two non-array jsonb scalars
|
||||
# doesn't merge keys — it silently wraps both sides into a 2-element
|
||||
# array, corrupting backfill_status into a list and later crashing
|
||||
# clear_scope_backfill_status's `#-` path delete (which then sees an
|
||||
# array where it expects an object).
|
||||
merged_status = func.coalesce(
|
||||
models.Peer.internal_metadata.op("->")(BACKFILL_STATUS_KEY),
|
||||
cast({}, JSONB),
|
||||
).op("||")(cast({session_name: entry}, JSONB))
|
||||
|
||||
stmt = (
|
||||
update(models.Peer)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name == scope_peer)
|
||||
.values(
|
||||
internal_metadata=models.Peer.internal_metadata.op("||")(
|
||||
func.jsonb_build_object(BACKFILL_STATUS_KEY, merged_status)
|
||||
)
|
||||
)
|
||||
)
|
||||
result = py_cast(CursorResult[Any], await db.execute(stmt))
|
||||
if result.rowcount == 0:
|
||||
raise ResourceNotFoundException(
|
||||
f"Scope peer {scope_peer} not found in workspace {workspace_name}"
|
||||
)
|
||||
|
||||
|
||||
async def clear_scope_backfill_status(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
scope_peer: str,
|
||||
session_name: str,
|
||||
) -> None:
|
||||
"""
|
||||
Drop one session's entry from the scope peer's backfill status.
|
||||
|
||||
Called by the removal reconciliation job: once a session leaves a scope its
|
||||
backfill status is moot, and clearing it keeps add→remove→re-add cycles
|
||||
honest (the re-add starts from a fresh ``pending``). Single-statement
|
||||
``#-`` delete, safe against concurrent per-key merges. Does not commit.
|
||||
Missing peers are a no-op (removal is idempotent).
|
||||
"""
|
||||
stmt = (
|
||||
update(models.Peer)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name == scope_peer)
|
||||
.values(
|
||||
internal_metadata=models.Peer.internal_metadata.op("#-")(
|
||||
cast(array([BACKFILL_STATUS_KEY, session_name]), ARRAY(Text))
|
||||
)
|
||||
)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
|
||||
|
||||
async def invalidate_scope_peer_cache(workspace_name: str, scope_peer: str) -> None:
|
||||
"""Invalidate the cached peer row after a status write commits."""
|
||||
await safe_cache_delete(peer_cache_key(workspace_name, scope_peer))
|
||||
|
||||
|
||||
async def get_scope_backfill_status(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
scope_name: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Read the per-session backfill job status map for a scope.
|
||||
|
||||
Returns:
|
||||
``{<session_name>: {state, updated_at[, docs_copied]}}`` (empty when no
|
||||
backfill has ever been enqueued for the scope)
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the scope does not exist
|
||||
"""
|
||||
peer = await get_scope_or_raise(db, workspace_name, scope_name)
|
||||
status = peer.internal_metadata.get(BACKFILL_STATUS_KEY, {})
|
||||
if not isinstance(status, dict):
|
||||
return {}
|
||||
return py_cast(dict[str, Any], status)
|
||||
|
|
|
|||
|
|
@ -320,8 +320,8 @@ async def get_or_create_session(
|
|||
|
||||
# Add the session to any requested scopes: create-or-get each scope peer
|
||||
# and record an observer membership (observe_others=true, observe_me=false).
|
||||
# No backfill happens here — membership only affects messages ingested
|
||||
# after this point.
|
||||
# If the session already has messages, a backfill task is enqueued after
|
||||
# commit (below) so its existing documents are copied into the scope.
|
||||
scopes_result = None
|
||||
if session.scopes:
|
||||
scopes_result = await get_or_create_scopes(
|
||||
|
|
@ -352,6 +352,30 @@ async def get_or_create_session(
|
|||
if scopes_result is not None:
|
||||
await scopes_result.post_commit()
|
||||
|
||||
# Backfill (DEV-1999): a pre-existing session added to scopes at
|
||||
# create-or-get time may already have messages; those need a
|
||||
# backfill-by-copy task per scope. Fresh sessions need nothing.
|
||||
if session.scopes:
|
||||
has_messages = await db.scalar(
|
||||
select(
|
||||
exists(
|
||||
select(models.Message.id)
|
||||
.where(models.Message.workspace_name == workspace_name)
|
||||
.where(models.Message.session_name == session.name)
|
||||
)
|
||||
)
|
||||
)
|
||||
if has_messages:
|
||||
# Imported lazily: src.deriver.enqueue imports crud at module level.
|
||||
from src.deriver.enqueue import enqueue_scope_backfill
|
||||
|
||||
for scope_name in session.scopes:
|
||||
await enqueue_scope_backfill(
|
||||
workspace_name,
|
||||
scope_peer=scope_peer_name(scope_name),
|
||||
session_name=session.name,
|
||||
)
|
||||
|
||||
# Only update cache if session data changed or was newly created
|
||||
if needs_cache_update:
|
||||
cache_key = session_cache_key(workspace_name, session.name)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ from sqlalchemy import select
|
|||
from src import crud, models
|
||||
from src.dependencies import tracked_db
|
||||
from src.deriver.deriver import process_representation_tasks_batch
|
||||
from src.deriver.scope_backfill import (
|
||||
process_scope_backfill,
|
||||
process_scope_removal,
|
||||
)
|
||||
from src.dreamer import process_dream
|
||||
from src.exceptions import ResourceNotFoundException, ValidationException
|
||||
from src.models import Message
|
||||
|
|
@ -26,6 +30,8 @@ from src.utils.queue_payload import (
|
|||
DeletionPayload,
|
||||
DreamPayload,
|
||||
ReconcilerPayload,
|
||||
ScopeBackfillPayload,
|
||||
ScopeRemovalPayload,
|
||||
SummaryPayload,
|
||||
WebhookPayload,
|
||||
)
|
||||
|
|
@ -150,6 +156,36 @@ async def process_item(queue_item: models.QueueItem) -> None:
|
|||
raise ValueError(f"Invalid payload structure: {str(e)}") from e
|
||||
await process_deletion(validated, workspace_name)
|
||||
|
||||
elif task_type == "scope_backfill":
|
||||
with sentry_sdk.start_transaction(
|
||||
name="process_scope_backfill_task", op="deriver"
|
||||
):
|
||||
try:
|
||||
validated = ScopeBackfillPayload(**queue_payload)
|
||||
except ValidationError as e:
|
||||
logger.error(
|
||||
"Invalid scope_backfill payload received: %s. Payload: %s",
|
||||
str(e),
|
||||
queue_payload,
|
||||
)
|
||||
raise ValueError(f"Invalid payload structure: {str(e)}") from e
|
||||
await process_scope_backfill(validated, workspace_name)
|
||||
|
||||
elif task_type == "scope_removal":
|
||||
with sentry_sdk.start_transaction(
|
||||
name="process_scope_removal_task", op="deriver"
|
||||
):
|
||||
try:
|
||||
validated = ScopeRemovalPayload(**queue_payload)
|
||||
except ValidationError as e:
|
||||
logger.error(
|
||||
"Invalid scope_removal payload received: %s. Payload: %s",
|
||||
str(e),
|
||||
queue_payload,
|
||||
)
|
||||
raise ValueError(f"Invalid payload structure: {str(e)}") from e
|
||||
await process_scope_removal(validated, workspace_name)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Invalid task type: {task_type}")
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from src.utils.queue_payload import (
|
|||
create_deletion_payload,
|
||||
create_dream_payload,
|
||||
create_payload,
|
||||
create_scope_task_payload,
|
||||
)
|
||||
from src.utils.work_unit import construct_work_unit_key
|
||||
|
||||
|
|
@ -569,6 +570,168 @@ async def enqueue_dream(
|
|||
raise
|
||||
|
||||
|
||||
def create_scope_task_record(
|
||||
workspace_name: str,
|
||||
*,
|
||||
task_type: Literal["scope_backfill", "scope_removal"],
|
||||
scope_peer: str,
|
||||
session_name: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a queue record for a scope backfill / removal task (DEV-1999).
|
||||
|
||||
Args:
|
||||
workspace_name: Name of the workspace
|
||||
task_type: "scope_backfill" or "scope_removal"
|
||||
scope_peer: Prefixed name of the peer backing the scope
|
||||
session_name: Name of the session whose membership changed
|
||||
|
||||
Returns:
|
||||
Queue record dictionary ready for insertion into the queue
|
||||
"""
|
||||
payload = create_scope_task_payload(
|
||||
task_type, scope_peer=scope_peer, session_name=session_name
|
||||
)
|
||||
return {
|
||||
"work_unit_key": construct_work_unit_key(workspace_name, payload),
|
||||
"payload": payload,
|
||||
"session_id": None,
|
||||
"task_type": task_type,
|
||||
"workspace_name": workspace_name,
|
||||
"message_id": None,
|
||||
}
|
||||
|
||||
|
||||
async def _enqueue_scope_task(
|
||||
workspace_name: str,
|
||||
*,
|
||||
task_type: Literal["scope_backfill", "scope_removal"],
|
||||
scope_peer: str,
|
||||
session_name: str,
|
||||
) -> None:
|
||||
"""
|
||||
Enqueue a scope backfill / removal task, deduplicating like enqueue_dream.
|
||||
|
||||
If a task with the same work_unit_key is already in progress (has an
|
||||
ActiveQueueSession) or pending in the queue, the enqueue is skipped — the
|
||||
handlers are idempotent, so a queued task already covers this membership
|
||||
change.
|
||||
|
||||
For backfill tasks, the scope peer's per-session status entry is written
|
||||
to "pending" in the same transaction as the queue insert, so the status
|
||||
surface never claims a job exists that was never enqueued (or vice versa).
|
||||
"""
|
||||
async with tracked_db("scope_task_enqueue") as db_session:
|
||||
try:
|
||||
record = create_scope_task_record(
|
||||
workspace_name,
|
||||
task_type=task_type,
|
||||
scope_peer=scope_peer,
|
||||
session_name=session_name,
|
||||
)
|
||||
work_unit_key = record["work_unit_key"]
|
||||
|
||||
is_in_progress = await db_session.scalar(
|
||||
select(
|
||||
exists(
|
||||
select(models.ActiveQueueSession.id).where(
|
||||
models.ActiveQueueSession.work_unit_key == work_unit_key
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
if is_in_progress:
|
||||
logger.debug(
|
||||
"Skipping %s enqueue - already in progress: %s",
|
||||
task_type,
|
||||
work_unit_key,
|
||||
)
|
||||
return
|
||||
|
||||
is_pending = await db_session.scalar(
|
||||
select(
|
||||
exists(
|
||||
select(QueueItem.id).where(
|
||||
QueueItem.work_unit_key == work_unit_key,
|
||||
QueueItem.processed == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
if is_pending:
|
||||
logger.debug(
|
||||
"%s already pending in queue: %s", task_type, work_unit_key
|
||||
)
|
||||
return
|
||||
|
||||
stmt = insert(QueueItem).returning(QueueItem)
|
||||
await db_session.execute(stmt, [record])
|
||||
|
||||
if task_type == "scope_backfill":
|
||||
await crud.update_scope_backfill_status(
|
||||
db_session,
|
||||
workspace_name,
|
||||
scope_peer,
|
||||
session_name,
|
||||
state="pending",
|
||||
)
|
||||
|
||||
await db_session.commit()
|
||||
await crud.invalidate_scope_peer_cache(workspace_name, scope_peer)
|
||||
|
||||
logger.info(
|
||||
"Enqueued %s task for %s/%s/%s",
|
||||
task_type,
|
||||
workspace_name,
|
||||
scope_peer,
|
||||
session_name,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to enqueue %s task!", task_type)
|
||||
if settings.SENTRY.ENABLED:
|
||||
import sentry_sdk
|
||||
|
||||
sentry_sdk.capture_exception(e)
|
||||
raise
|
||||
|
||||
|
||||
async def enqueue_scope_backfill(
|
||||
workspace_name: str,
|
||||
*,
|
||||
scope_peer: str,
|
||||
session_name: str,
|
||||
) -> None:
|
||||
"""
|
||||
Enqueue a backfill-by-copy task for a session newly added to a scope.
|
||||
|
||||
Only call this for sessions that already have messages — fresh sessions
|
||||
need nothing (the deriver fan-out covers everything ingested after the
|
||||
membership change).
|
||||
"""
|
||||
await _enqueue_scope_task(
|
||||
workspace_name,
|
||||
task_type="scope_backfill",
|
||||
scope_peer=scope_peer,
|
||||
session_name=session_name,
|
||||
)
|
||||
|
||||
|
||||
async def enqueue_scope_removal(
|
||||
workspace_name: str,
|
||||
*,
|
||||
scope_peer: str,
|
||||
session_name: str,
|
||||
) -> None:
|
||||
"""Enqueue a removal reconciliation task for a session removed from a scope."""
|
||||
await _enqueue_scope_task(
|
||||
workspace_name,
|
||||
task_type="scope_removal",
|
||||
scope_peer=scope_peer,
|
||||
session_name=session_name,
|
||||
)
|
||||
|
||||
|
||||
def create_deletion_record(
|
||||
workspace_name: str,
|
||||
deletion_type: Literal["session", "observation", "workspace"],
|
||||
|
|
|
|||
|
|
@ -71,10 +71,14 @@ RULES:
|
|||
- Extract ALL observations from the target peer's messages, using others as context.
|
||||
- Contextualize each observation sufficiently (e.g. "Ann is nervous about the job interview at the pharmacy" not just "Ann is nervous")
|
||||
|
||||
<examples>
|
||||
These examples are fabricated illustrations of the output format. Never emit a conclusion for which content comes from these examples. Every conclusion must be supported by the <messages> block only.
|
||||
|
||||
EXAMPLES (using `alice` as the target peer id):
|
||||
- EXPLICIT: "I just turned 25" → "alice is 25 years old"
|
||||
- EXPLICIT: "I took my dog for a walk in NYC" → "alice has a dog", "alice walked her dog in NYC"
|
||||
- EXPLICIT: "I've lived in NYC for six years" → "alice lives in NYC", "alice has lived in NYC for six years"
|
||||
</examples>
|
||||
|
||||
{custom_instructions_section}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import random
|
|||
import signal
|
||||
import time
|
||||
from asyncio import Task
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from logging import getLogger
|
||||
|
|
@ -476,6 +476,17 @@ class QueueManager:
|
|||
"""Snap the polling interval back to the base after finding work."""
|
||||
self._current_poll_interval = settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS
|
||||
|
||||
@staticmethod
|
||||
def _is_tenant_work(work_unit_keys: Iterable[str]) -> bool:
|
||||
"""True if any claimed work unit is real tenant work, not housekeeping."""
|
||||
for key in work_unit_keys:
|
||||
try:
|
||||
if parse_work_unit_key(key).task_type != "reconciler":
|
||||
return True
|
||||
except ValueError:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _jitter(self, seconds: float) -> float:
|
||||
"""Scatter a sleep by +/- POLLING_JITTER_RATIO to avoid lockstep polling.
|
||||
|
||||
|
|
@ -542,7 +553,8 @@ class QueueManager:
|
|||
await self._maybe_cleanup_stale_work_units()
|
||||
claimed_work_units = await self.get_and_claim_work_units()
|
||||
if claimed_work_units:
|
||||
self._reset_poll_interval()
|
||||
if self._is_tenant_work(claimed_work_units):
|
||||
self._reset_poll_interval()
|
||||
for work_unit_key, aqs_id in claimed_work_units.items():
|
||||
# Create a new task for processing this work unit
|
||||
if not self.shutdown_event.is_set():
|
||||
|
|
|
|||
|
|
@ -0,0 +1,558 @@
|
|||
"""Scope membership reconciliation jobs (DEV-1999, part of the Scopes RFC).
|
||||
|
||||
Two queue task handlers, dispatched from ``consumer.process_item``:
|
||||
|
||||
- ``scope_backfill`` — a session was added to a scope that already had
|
||||
messages. Copy the session's *explicit* documents from each sender peer's
|
||||
global ``(P, P)`` collection into the scope's ``(scope_peer, P)`` collection,
|
||||
then enqueue a manual omni dream per touched collection to rebuild the
|
||||
scope's higher-order layer and card.
|
||||
- ``scope_removal`` — a session was removed from a scope. Soft-delete the
|
||||
session's explicit documents from the scope's collections, cascade the
|
||||
soft-delete to derived documents whose support left the scope (fail-closed),
|
||||
then enqueue a ``card_refresh`` dream with ``rebuild=True`` plus a manual
|
||||
omni dream per touched collection.
|
||||
|
||||
Zero LLM re-derivation: explicit-level documents are session-pure and
|
||||
identical across observer collections (the DEV-2000 invariant), so retroactive
|
||||
membership is pure row copying. The only external call is an embedding lookup
|
||||
for source rows whose embedding column is NULL — embedding API only, never an
|
||||
LLM.
|
||||
|
||||
DB sessions are never held across embedding or vector-store calls: the
|
||||
handlers run in phases (plan → embed → write → sync), each phase opening its
|
||||
own short-lived session.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.dialects.postgresql import array
|
||||
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
|
||||
from src.utils.queue_payload import ScopeBackfillPayload, ScopeRemovalPayload
|
||||
from src.utils.scopes import is_scope_peer_name
|
||||
from src.vector_store import VectorRecord, get_external_vector_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# internal_metadata key linking a backfilled copy to the global document it
|
||||
# was copied from. The presence of this key is the idempotency marker.
|
||||
COPIED_FROM_KEY = "copied_from"
|
||||
|
||||
|
||||
def _store_embeddings_in_postgres() -> bool:
|
||||
"""Whether document embeddings are persisted to the postgres column.
|
||||
|
||||
True when TYPE=pgvector OR still migrating (dual-write) — mirrors
|
||||
``crud.document.create_documents``.
|
||||
"""
|
||||
return (
|
||||
settings.VECTOR_STORE.TYPE == "pgvector" or not settings.VECTOR_STORE.MIGRATED
|
||||
)
|
||||
|
||||
|
||||
def _embedding_as_list(embedding: Any) -> list[float] | None:
|
||||
"""Normalize a pgvector column value (numpy array or None) to a list."""
|
||||
if embedding is None:
|
||||
return None
|
||||
return list(embedding)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CopySpec:
|
||||
"""A planned copy of one global explicit document into a scope collection.
|
||||
|
||||
``restore_document_id`` points at an existing soft-deleted copy to restore
|
||||
when set; for new copies it is filled with the inserted row's id after the
|
||||
write phase.
|
||||
"""
|
||||
|
||||
observed: str
|
||||
source_id: str
|
||||
content: str
|
||||
embedding: list[float] | None
|
||||
internal_metadata: dict[str, Any]
|
||||
times_derived: int
|
||||
source_ids: list[str] | None
|
||||
session_name: str
|
||||
restore_document_id: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backfill (session added to a scope)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def process_scope_backfill(
|
||||
payload: ScopeBackfillPayload, workspace_name: str
|
||||
) -> None:
|
||||
"""Process a ``scope_backfill`` queue task.
|
||||
|
||||
Idempotent: a live copy (matched by ``internal_metadata.copied_from``) is
|
||||
never duplicated, and a soft-deleted copy left by an earlier removal is
|
||||
restored rather than re-inserted, so add→remove→re-add converges on
|
||||
exactly one live copy per source document.
|
||||
"""
|
||||
scope_peer = payload.scope_peer
|
||||
session_name = payload.session_name
|
||||
|
||||
try:
|
||||
result = await _run_backfill(workspace_name, scope_peer, session_name)
|
||||
except Exception:
|
||||
await _write_backfill_status(
|
||||
workspace_name, scope_peer, session_name, state="failed"
|
||||
)
|
||||
raise
|
||||
|
||||
if result is None:
|
||||
# The session left the scope before this task was drained: removal
|
||||
# already cleared the copies and the status entry, so there is nothing
|
||||
# to backfill and nothing to record.
|
||||
logger.info(
|
||||
"Scope backfill skipped for %s/%s/%s: membership is no longer active",
|
||||
workspace_name,
|
||||
scope_peer,
|
||||
session_name,
|
||||
)
|
||||
return
|
||||
docs_copied, touched_observed = result
|
||||
|
||||
# One manual (gate-bypassing) omni dream per touched collection builds the
|
||||
# scope's higher-order layer and card. enqueue_dream dedupes on the
|
||||
# work-unit key, so a batch of backfills collapses to one dream each.
|
||||
from src.deriver.enqueue import enqueue_dream
|
||||
|
||||
for observed in sorted(touched_observed):
|
||||
await enqueue_dream(
|
||||
workspace_name,
|
||||
observer=scope_peer,
|
||||
observed=observed,
|
||||
dream_type=DreamType.OMNI,
|
||||
trigger_reason="scope_backfill",
|
||||
)
|
||||
|
||||
await _write_backfill_status(
|
||||
workspace_name,
|
||||
scope_peer,
|
||||
session_name,
|
||||
state="completed",
|
||||
docs_copied=docs_copied,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Scope backfill complete for %s/%s/%s: %d documents copied across %d collections",
|
||||
workspace_name,
|
||||
scope_peer,
|
||||
session_name,
|
||||
docs_copied,
|
||||
len(touched_observed),
|
||||
)
|
||||
|
||||
|
||||
async def _run_backfill(
|
||||
workspace_name: str, scope_peer: str, session_name: str
|
||||
) -> tuple[int, set[str]] | None:
|
||||
"""Run the copy itself.
|
||||
|
||||
Returns (docs copied or restored, touched observed peers), or ``None`` if
|
||||
the session is no longer a member of the scope.
|
||||
"""
|
||||
# Phase 1 (DB): plan. Read the session's explicit documents from each
|
||||
# sender's global (P, P) collection, and any existing copies (live or
|
||||
# soft-deleted) already in the scope's collections.
|
||||
plans: list[_CopySpec] = []
|
||||
async with tracked_db("scope_backfill.plan") as db:
|
||||
source_result = await db.execute(
|
||||
select(models.Document).where(
|
||||
models.Document.workspace_name == workspace_name,
|
||||
models.Document.session_name == session_name,
|
||||
models.Document.level == "explicit",
|
||||
models.Document.observer == models.Document.observed,
|
||||
models.Document.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
source_docs = [
|
||||
doc
|
||||
for doc in source_result.scalars().all()
|
||||
# Scope peers never speak and are never observed, but stay
|
||||
# defensive: never treat another scope's rows as a source.
|
||||
if not is_scope_peer_name(doc.observer)
|
||||
]
|
||||
|
||||
if not source_docs:
|
||||
return 0, set()
|
||||
|
||||
# Existing copies in the scope's collections for this session, keyed
|
||||
# by (observed, copied_from). Includes soft-deleted rows: those are
|
||||
# restore candidates, not blockers.
|
||||
copies_result = await db.execute(
|
||||
select(models.Document).where(
|
||||
models.Document.workspace_name == workspace_name,
|
||||
models.Document.observer == scope_peer,
|
||||
models.Document.session_name == session_name,
|
||||
models.Document.internal_metadata.has_key(COPIED_FROM_KEY),
|
||||
)
|
||||
)
|
||||
live_copies: set[tuple[str, str]] = set()
|
||||
soft_deleted_copies: dict[tuple[str, str], str] = {}
|
||||
for copy_doc in copies_result.scalars().all():
|
||||
key = (copy_doc.observed, str(copy_doc.internal_metadata[COPIED_FROM_KEY]))
|
||||
if copy_doc.deleted_at is None:
|
||||
live_copies.add(key)
|
||||
else:
|
||||
soft_deleted_copies.setdefault(key, copy_doc.id)
|
||||
|
||||
for source in source_docs:
|
||||
key = (source.observed, source.id)
|
||||
if key in live_copies:
|
||||
continue
|
||||
plans.append(
|
||||
_CopySpec(
|
||||
observed=source.observed,
|
||||
source_id=source.id,
|
||||
content=source.content,
|
||||
embedding=_embedding_as_list(source.embedding),
|
||||
internal_metadata=dict(source.internal_metadata),
|
||||
times_derived=source.times_derived,
|
||||
source_ids=list(source.source_ids)
|
||||
if source.source_ids is not None
|
||||
else None,
|
||||
session_name=session_name,
|
||||
restore_document_id=soft_deleted_copies.get(key),
|
||||
)
|
||||
)
|
||||
|
||||
if not plans:
|
||||
return 0, set()
|
||||
|
||||
# Phase 2 (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]
|
||||
if missing:
|
||||
logger.info(
|
||||
"Scope backfill re-embedding %d documents with NULL embeddings for %s/%s/%s (embedding API only, no LLM)",
|
||||
len(missing),
|
||||
workspace_name,
|
||||
scope_peer,
|
||||
session_name,
|
||||
)
|
||||
embeddings = await embedding_client.simple_batch_embed(
|
||||
[spec.content for spec in missing]
|
||||
)
|
||||
for spec, embedding in zip(missing, embeddings, strict=True):
|
||||
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
|
||||
|
||||
for observed in sorted(touched_observed):
|
||||
await crud.get_or_create_collection(
|
||||
db, workspace_name, observer=scope_peer, observed=observed
|
||||
)
|
||||
|
||||
restore_ids: list[str] = []
|
||||
for spec in plans:
|
||||
if spec.restore_document_id is not None:
|
||||
restore_ids.append(spec.restore_document_id)
|
||||
continue
|
||||
row = models.Document(
|
||||
workspace_name=workspace_name,
|
||||
observer=scope_peer,
|
||||
observed=spec.observed,
|
||||
content=spec.content,
|
||||
level="explicit",
|
||||
times_derived=spec.times_derived,
|
||||
internal_metadata={
|
||||
**spec.internal_metadata,
|
||||
COPIED_FROM_KEY: spec.source_id,
|
||||
},
|
||||
session_name=spec.session_name,
|
||||
source_ids=spec.source_ids,
|
||||
embedding=spec.embedding if store_in_postgres else None,
|
||||
)
|
||||
row.sync_state = "pending"
|
||||
new_rows.append(row)
|
||||
|
||||
db.add_all(new_rows)
|
||||
if restore_ids:
|
||||
await db.execute(
|
||||
update(models.Document)
|
||||
.where(models.Document.id.in_(restore_ids))
|
||||
.values(deleted_at=None, sync_state="pending")
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# IDs are generated client-side and remain accessible post-commit
|
||||
# (expire_on_commit=False), mirroring crud.document.create_documents.
|
||||
for spec, row in zip(
|
||||
[s for s in plans if s.restore_document_id is None], new_rows, strict=True
|
||||
):
|
||||
spec.restore_document_id = row.id
|
||||
|
||||
copied_ids = [
|
||||
spec.restore_document_id
|
||||
for spec in plans
|
||||
if spec.restore_document_id is not None
|
||||
]
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
async def _sync_copies_to_vector_store(
|
||||
workspace_name: str,
|
||||
scope_peer: str,
|
||||
plans: list[_CopySpec],
|
||||
copied_ids: list[str],
|
||||
) -> None:
|
||||
"""Mirror the document-create sync path for the backfilled copies."""
|
||||
external_vector_store = get_external_vector_store()
|
||||
|
||||
if external_vector_store is None:
|
||||
# pgvector mode: embeddings live in the postgres column; nothing to sync.
|
||||
async with tracked_db("scope_backfill.mark_synced") as db:
|
||||
await db.execute(
|
||||
update(models.Document)
|
||||
.where(models.Document.id.in_(copied_ids))
|
||||
.values(sync_state="synced", last_sync_at=func.now(), sync_attempts=0)
|
||||
)
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
by_observed: dict[str, list[_CopySpec]] = {}
|
||||
for spec in plans:
|
||||
by_observed.setdefault(spec.observed, []).append(spec)
|
||||
|
||||
synced_ids: list[str] = []
|
||||
failed_ids: list[str] = []
|
||||
for observed, specs in by_observed.items():
|
||||
namespace = external_vector_store.get_vector_namespace(
|
||||
"document", workspace_name, scope_peer, observed
|
||||
)
|
||||
records = [
|
||||
VectorRecord(
|
||||
id=spec.restore_document_id,
|
||||
embedding=spec.embedding,
|
||||
metadata={
|
||||
"workspace_name": workspace_name,
|
||||
"observer": scope_peer,
|
||||
"observed": observed,
|
||||
"session_name": spec.session_name,
|
||||
"level": "explicit",
|
||||
},
|
||||
)
|
||||
for spec in specs
|
||||
if spec.restore_document_id is not None and spec.embedding is not None
|
||||
]
|
||||
ids = [record.id for record in records]
|
||||
try:
|
||||
await external_vector_store.upsert_many(namespace, records)
|
||||
synced_ids.extend(ids)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to upsert backfilled vectors to %s; leaving docs pending for the reconciler",
|
||||
namespace,
|
||||
)
|
||||
failed_ids.extend(ids)
|
||||
|
||||
async with tracked_db("scope_backfill.sync_state") as db:
|
||||
if synced_ids:
|
||||
await db.execute(
|
||||
update(models.Document)
|
||||
.where(models.Document.id.in_(synced_ids))
|
||||
.values(sync_state="synced", last_sync_at=func.now(), sync_attempts=0)
|
||||
)
|
||||
if failed_ids:
|
||||
await db.execute(
|
||||
update(models.Document)
|
||||
.where(models.Document.id.in_(failed_ids))
|
||||
.values(
|
||||
sync_attempts=models.Document.sync_attempts + 1,
|
||||
last_sync_at=func.now(),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Removal reconciliation (session removed from a scope)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def process_scope_removal(
|
||||
payload: ScopeRemovalPayload, workspace_name: str
|
||||
) -> None:
|
||||
"""Process a ``scope_removal`` queue task.
|
||||
|
||||
Soft-deletes (fail-closed) rather than hard-deletes: the reconciler's
|
||||
soft-delete sweep handles vector cleanup and eventual hard deletion, and a
|
||||
later re-add restores the same rows.
|
||||
"""
|
||||
scope_peer = payload.scope_peer
|
||||
session_name = payload.session_name
|
||||
|
||||
removed_by_observed: dict[str, list[str]] = {}
|
||||
async with tracked_db("scope_removal") as db:
|
||||
observed_result = await db.execute(
|
||||
select(models.Collection.observed).where(
|
||||
models.Collection.workspace_name == workspace_name,
|
||||
models.Collection.observer == scope_peer,
|
||||
)
|
||||
)
|
||||
for observed in [row[0] for row in observed_result.all()]:
|
||||
explicit_stmt = (
|
||||
update(models.Document)
|
||||
.where(
|
||||
models.Document.workspace_name == workspace_name,
|
||||
models.Document.observer == scope_peer,
|
||||
models.Document.observed == observed,
|
||||
models.Document.session_name == session_name,
|
||||
models.Document.level == "explicit",
|
||||
models.Document.deleted_at.is_(None),
|
||||
)
|
||||
.values(deleted_at=func.now())
|
||||
.returning(models.Document.id)
|
||||
)
|
||||
frontier = [row[0] for row in (await db.execute(explicit_stmt)).all()]
|
||||
all_removed = list(frontier)
|
||||
|
||||
# Fail-closed cascade: soft-delete derived documents whose support
|
||||
# (source_ids) intersects anything removed, transitively — a
|
||||
# deduction resting on removed evidence must leave with it, and so
|
||||
# must an induction resting on that deduction.
|
||||
while frontier:
|
||||
derived_stmt = (
|
||||
update(models.Document)
|
||||
.where(
|
||||
models.Document.workspace_name == workspace_name,
|
||||
models.Document.observer == scope_peer,
|
||||
models.Document.observed == observed,
|
||||
models.Document.level != "explicit",
|
||||
models.Document.deleted_at.is_(None),
|
||||
models.Document.source_ids.has_any(array(frontier)),
|
||||
)
|
||||
.values(deleted_at=func.now())
|
||||
.returning(models.Document.id)
|
||||
)
|
||||
frontier = [row[0] for row in (await db.execute(derived_stmt)).all()]
|
||||
all_removed.extend(frontier)
|
||||
|
||||
if all_removed:
|
||||
removed_by_observed[observed] = all_removed
|
||||
|
||||
# The session left the scope, so its backfill status entry is moot;
|
||||
# clearing it keeps a later re-add starting from a fresh "pending".
|
||||
await crud.clear_scope_backfill_status(
|
||||
db, workspace_name, scope_peer, session_name
|
||||
)
|
||||
await db.commit()
|
||||
await crud.invalidate_scope_peer_cache(workspace_name, scope_peer)
|
||||
|
||||
# Delete the vectors eagerly so recall can't surface removed memory while
|
||||
# waiting for the reconciler sweep (which remains the backstop on failure).
|
||||
external_vector_store = get_external_vector_store()
|
||||
if external_vector_store is not None:
|
||||
for observed, removed_ids in removed_by_observed.items():
|
||||
namespace = external_vector_store.get_vector_namespace(
|
||||
"document", workspace_name, scope_peer, observed
|
||||
)
|
||||
try:
|
||||
await external_vector_store.delete_many(namespace, removed_ids)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to delete removed vectors from %s; reconciler sweep will retry",
|
||||
namespace,
|
||||
)
|
||||
|
||||
# Rebuild what remains: the card must be regenerated from remaining
|
||||
# evidence only (rebuild=True drops the stale card from the prompt), and a
|
||||
# manual omni dream rebuilds the higher-order structure.
|
||||
from src.deriver.enqueue import enqueue_dream
|
||||
|
||||
for observed in sorted(removed_by_observed):
|
||||
await enqueue_dream(
|
||||
workspace_name,
|
||||
observer=scope_peer,
|
||||
observed=observed,
|
||||
dream_type=DreamType.CARD_REFRESH,
|
||||
rebuild=True,
|
||||
trigger_reason="scope_removal",
|
||||
)
|
||||
await enqueue_dream(
|
||||
workspace_name,
|
||||
observer=scope_peer,
|
||||
observed=observed,
|
||||
dream_type=DreamType.OMNI,
|
||||
trigger_reason="scope_removal",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Scope removal reconciliation complete for %s/%s/%s: %d documents soft-deleted across %d collections",
|
||||
workspace_name,
|
||||
scope_peer,
|
||||
session_name,
|
||||
sum(len(ids) for ids in removed_by_observed.values()),
|
||||
len(removed_by_observed),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _write_backfill_status(
|
||||
workspace_name: str,
|
||||
scope_peer: str,
|
||||
session_name: str,
|
||||
*,
|
||||
state: ScopeBackfillState,
|
||||
docs_copied: int | None = None,
|
||||
) -> None:
|
||||
"""Best-effort status write in its own short-lived session."""
|
||||
try:
|
||||
async with tracked_db("scope_backfill.status") as db:
|
||||
await crud.update_scope_backfill_status(
|
||||
db,
|
||||
workspace_name,
|
||||
scope_peer,
|
||||
session_name,
|
||||
state=state,
|
||||
docs_copied=docs_copied,
|
||||
)
|
||||
await db.commit()
|
||||
await crud.invalidate_scope_peer_cache(workspace_name, scope_peer)
|
||||
except Exception:
|
||||
# Never mask the underlying failure (or fail a completed backfill)
|
||||
# over a status bookkeeping write.
|
||||
logger.exception(
|
||||
"Failed to write scope backfill status %s for %s/%s/%s",
|
||||
state,
|
||||
workspace_name,
|
||||
scope_peer,
|
||||
session_name,
|
||||
)
|
||||
|
|
@ -195,13 +195,13 @@ class _EmbeddingClient:
|
|||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
|
||||
# 10-minute HTTP timeout, in lockstep with the LLM registry's Gemini
|
||||
# client (`src/llm/registry.py:_build_gemini_http_options`). Without
|
||||
# this, a stalled Gemini embedding socket wedges the deriver worker
|
||||
# exactly the way #785 describes for the LLM client.
|
||||
# Default 10-minute HTTP timeout matches the LLM registry Gemini client.
|
||||
timeout_ms = (
|
||||
int(config.timeout * 1000) if config.timeout is not None else 600_000
|
||||
)
|
||||
http_options = genai_types.HttpOptions(
|
||||
base_url=config.base_url,
|
||||
timeout=600_000,
|
||||
timeout=timeout_ms,
|
||||
)
|
||||
self.client: genai.Client | AsyncOpenAI = genai.Client(
|
||||
api_key=config.api_key,
|
||||
|
|
@ -216,10 +216,14 @@ class _EmbeddingClient:
|
|||
raise ValueError("OpenAI API key is required")
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=config.api_key,
|
||||
base_url=config.base_url,
|
||||
)
|
||||
# Omit timeout when unset so the OpenAI SDK keeps its own default.
|
||||
client_kwargs: dict[str, Any] = {
|
||||
"api_key": config.api_key,
|
||||
"base_url": config.base_url,
|
||||
}
|
||||
if config.timeout is not None:
|
||||
client_kwargs["timeout"] = config.timeout
|
||||
self.client = AsyncOpenAI(**client_kwargs)
|
||||
self.max_embedding_tokens = max_input_tokens
|
||||
self.max_batch_size = config.max_batch_size or 2048
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ that keeps the observer/observed mechanics hidden.
|
|||
All scopes routes require a workspace-level (or admin) key: scopes are an
|
||||
app-level admin surface, so peer- and session-scoped keys are rejected.
|
||||
|
||||
Note: scope membership only affects messages ingested *after* the membership
|
||||
change. Conclusions already derived are neither backfilled on add nor
|
||||
reconciled on removal.
|
||||
Retroactive membership changes are handled asynchronously: adding a session
|
||||
that already has messages enqueues a backfill-by-copy job, removing a session
|
||||
enqueues a removal-reconciliation job. Track backfill progress via
|
||||
``GET /scopes/{scope_id}/status``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -109,8 +110,9 @@ async def add_sessions_to_scope(
|
|||
is already a member is a no-op. List the resulting membership with
|
||||
`POST /scopes/{scope_id}/sessions/list`.
|
||||
|
||||
Note: membership applies only to messages ingested after this call;
|
||||
conclusions already derived are not backfilled.
|
||||
Note: any added session that already has messages triggers an asynchronous
|
||||
backfill-by-copy of its existing documents into the scope; track progress
|
||||
via ``GET /scopes/{scope_id}/status``.
|
||||
"""
|
||||
await crud.add_sessions_to_scope(
|
||||
db,
|
||||
|
|
@ -135,8 +137,10 @@ async def remove_session_from_scope(
|
|||
"""
|
||||
Remove a Session from a Scope.
|
||||
|
||||
Note: conclusions already derived while the session was a member are left in
|
||||
place.
|
||||
Note: documents copied/derived while the session was a member are
|
||||
reconciled asynchronously — the session's explicit copies are soft-deleted
|
||||
from the scope, dependent derived documents follow (fail-closed), and the
|
||||
scope's card is rebuilt from the remaining evidence.
|
||||
"""
|
||||
await crud.remove_session_from_scope(
|
||||
db,
|
||||
|
|
@ -171,3 +175,24 @@ async def get_scope_sessions(
|
|||
workspace_name=workspace_id, scope_name=scope_id, reverse=reverse
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{scope_id}/status",
|
||||
response_model=schemas.ScopeStatus,
|
||||
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
|
||||
)
|
||||
async def get_scope_status(
|
||||
workspace_id: str = Path(...),
|
||||
scope_id: str = Path(...),
|
||||
db: AsyncSession = read_db,
|
||||
):
|
||||
"""
|
||||
Get the backfill/reconciliation job status for a Scope.
|
||||
|
||||
Returns a per-session map of the backfill job state (pending / completed /
|
||||
failed) with the number of documents copied once complete. Empty when no
|
||||
backfill has ever been enqueued for the scope.
|
||||
"""
|
||||
backfill_status = await crud.get_scope_backfill_status(db, workspace_id, scope_id)
|
||||
return schemas.ScopeStatus(backfill_status=backfill_status)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from src import config, crud, schemas
|
||||
from src.cache.client import safe_cache_delete
|
||||
from src.crud.message import get_peer_session_names
|
||||
from src.crud.session import session_cache_key
|
||||
from src.dependencies import db, read_db
|
||||
from src.deriver.enqueue import enqueue_deletion
|
||||
|
|
@ -23,6 +24,7 @@ from src.exceptions import (
|
|||
from src.security import JWTParams, require_auth
|
||||
from src.telemetry.events import EmbeddingCallPurpose, GetContextEvent, emit
|
||||
from src.utils import summarizer
|
||||
from src.utils.filter import normalize_session_allowlist
|
||||
from src.utils.representation import Representation
|
||||
from src.utils.search import search
|
||||
from src.utils.tokens import estimate_tokens
|
||||
|
|
@ -714,9 +716,27 @@ async def get_session_context(
|
|||
None,
|
||||
description="An (unprefixed) scope name to use as the perspective source: the representation and peer card of `peer_target` are read from the scope's observations instead of the global (or `peer_perspective`) view. Must be provided with `peer_target`; mutually exclusive with `peer_perspective`. Requires a workspace- or admin-level key.",
|
||||
),
|
||||
sessions: list[str] | None = Query(
|
||||
None,
|
||||
description=(
|
||||
"Optional allowlist of session IDs confining the representation of "
|
||||
"`peer_target` to those sessions. This session must be one of them. "
|
||||
"Recall is restricted to conclusions stated directly in the allowed "
|
||||
"sessions — conclusions synthesized across sessions are excluded, "
|
||||
"since their provenance cannot be proven to sit inside the allowlist "
|
||||
"— and the peer card is omitted for the same reason. Mutually "
|
||||
"exclusive with `scope` and `limit_to_session`. A peer-scoped key "
|
||||
"must be an active member of every session named. The 1,000-session "
|
||||
"cap shared with the recall endpoints applies but is not reachable "
|
||||
"here: these are repeated query parameters, so a long list exceeds "
|
||||
"the request-line limit of the server or any proxy in front of it "
|
||||
"(a 414/431, not a 422) at a few hundred entries. Use a named "
|
||||
"`scope` for large or reusable session sets."
|
||||
),
|
||||
),
|
||||
limit_to_session: bool = Query(
|
||||
default=False,
|
||||
description="Only used if `search_query` is provided. Whether to limit the representation to the session (as opposed to everything known about the target peer)",
|
||||
description="Whether to limit the representation to the session (as opposed to everything known about the target peer). Narrows recall the same way `sessions` does, so the same restrictions apply: explicit-only conclusions, and the peer card is omitted because it carries no per-session provenance.",
|
||||
),
|
||||
search_top_k: int | None = Query(
|
||||
None,
|
||||
|
|
@ -801,6 +821,47 @@ async def get_session_context(
|
|||
"`scope` requires a workspace- or admin-level key"
|
||||
)
|
||||
|
||||
# The session allowlist confines the representation to a set of sessions this
|
||||
# one belongs to. `scope` already determines what can be seen and
|
||||
# `limit_to_session` already pins the set to this session alone, so both are
|
||||
# contradictions rather than further narrowings — refused rather than given a
|
||||
# silent precedence order.
|
||||
session_allowlist: list[str] | None = None
|
||||
if sessions is not None:
|
||||
if scope is not None:
|
||||
raise ValidationException("`sessions` and `scope` are mutually exclusive")
|
||||
if limit_to_session:
|
||||
raise ValidationException(
|
||||
"`sessions` and `limit_to_session` are mutually exclusive"
|
||||
)
|
||||
if not peer_target:
|
||||
# The allowlist only reaches the representation, and there is no
|
||||
# representation without a target. Refused rather than accepted and
|
||||
# silently ignored, which would read as a scoped context.
|
||||
raise ValidationException(
|
||||
"peer_target must be provided if sessions is provided"
|
||||
)
|
||||
# `must_include` keeps the allowlist from contradicting the route's own
|
||||
# session: this session's messages and summary are always part of the
|
||||
# response, so an allowlist excluding it would describe a context that
|
||||
# cannot be assembled.
|
||||
session_allowlist = normalize_session_allowlist(
|
||||
sessions, field="sessions", must_include=session_id
|
||||
)
|
||||
# A peer-scoped key may only name sessions its peer belongs to. Mirrors
|
||||
# the chat route's gate (see routers/peers.py), including `active_only`,
|
||||
# so both answer the same question for a peer that has left a session.
|
||||
# Reuses the handler's session rather than opening its own: this is a
|
||||
# DB-only read and the handler already holds a connection.
|
||||
if jwt_params.p is not None:
|
||||
member_sessions = set(
|
||||
await get_peer_session_names(
|
||||
db, workspace_id, jwt_params.p, active_only=True
|
||||
)
|
||||
)
|
||||
if not set(session_allowlist) <= member_sessions:
|
||||
raise AuthenticationException("JWT not permissioned for this resource")
|
||||
|
||||
if not peer_target:
|
||||
# No representation or card needed
|
||||
summary, messages = await _get_session_context_task(
|
||||
|
|
@ -865,6 +926,17 @@ async def get_session_context(
|
|||
):
|
||||
embedding = await embedding_client.embed(search_query)
|
||||
|
||||
# The allowlist recall must respect, whichever way the caller expressed it.
|
||||
# `sessions` and `limit_to_session` are mutually exclusive (422 above), so at
|
||||
# most one of these is set. `session_allowlist` is never an empty list here —
|
||||
# `must_include=session_id` guarantees at least this session — so the
|
||||
# None-check is the only distinction that matters.
|
||||
effective_allowlist = (
|
||||
session_allowlist
|
||||
if session_allowlist is not None
|
||||
else ([session_id] if limit_to_session else None)
|
||||
)
|
||||
|
||||
# Sequential calls on shared DB session
|
||||
representation = await _get_working_representation_task(
|
||||
db,
|
||||
|
|
@ -872,15 +944,34 @@ async def get_session_context(
|
|||
search_query,
|
||||
observer=observer,
|
||||
observed=observed,
|
||||
session_allowlist=[session_id] if limit_to_session else None,
|
||||
session_allowlist=effective_allowlist,
|
||||
search_top_k=search_top_k,
|
||||
search_max_distance=search_max_distance,
|
||||
include_most_derived=include_most_frequent,
|
||||
max_observations=max_conclusions,
|
||||
embedding=embedding,
|
||||
)
|
||||
card = await _get_peer_card_task(
|
||||
db, workspace_id, observer=observer, observed=observed
|
||||
# A peer card is keyed by (workspace, observer, observed) with no session
|
||||
# dimension (crud/peer_card.py), so it is synthesized from everything the
|
||||
# observer has ever seen and cannot be narrowed to an allowlist. Returning it
|
||||
# would leak exactly what the allowlist exists to exclude, so it is dropped —
|
||||
# the same fail-closed reasoning that limits allowlisted conclusion recall to
|
||||
# ALLOWLIST_SAFE_LEVELS.
|
||||
#
|
||||
# Gated on the *effective* allowlist, not on `sessions` alone:
|
||||
# `limit_to_session=true` narrows recall identically, so carving out only the
|
||||
# newer parameter would leave a control that one parameter swap defeats.
|
||||
# `scope` needs no carve-out at all — it swaps the observer to the scope peer
|
||||
# above, so the card read below is the scope's own.
|
||||
#
|
||||
# POST /peers/{id}/chat still injects an unscoped card under an allowlist
|
||||
# (src/dialectic/chat.py) — tracked in DEV-2201, not fixed here.
|
||||
card = (
|
||||
None
|
||||
if effective_allowlist is not None
|
||||
else await _get_peer_card_task(
|
||||
db, workspace_id, observer=observer, observed=observed
|
||||
)
|
||||
)
|
||||
short_summary, long_summary = await _get_both_summaries_task(
|
||||
db, workspace_id, session_id
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ from src.schemas.api import (
|
|||
Scope,
|
||||
ScopeCreate,
|
||||
ScopeSessionsAdd,
|
||||
ScopeStatus,
|
||||
Session,
|
||||
SessionBase,
|
||||
SessionContext,
|
||||
|
|
@ -140,6 +141,7 @@ __all__ = [
|
|||
"Scope",
|
||||
"ScopeCreate",
|
||||
"ScopeSessionsAdd",
|
||||
"ScopeStatus",
|
||||
"Session",
|
||||
"SessionBase",
|
||||
"SessionContext",
|
||||
|
|
|
|||
|
|
@ -428,9 +428,9 @@ class SessionCreate(SessionBase):
|
|||
max_length=100,
|
||||
description=(
|
||||
"Optional list of (unprefixed) scope names to add this session to. "
|
||||
"Each scope is created if it does not exist yet. Membership applies "
|
||||
"only to messages ingested after the session is added to the scope; "
|
||||
"conclusions already derived are not backfilled."
|
||||
"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."
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -591,6 +591,18 @@ class ScopeSessionsAdd(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class ScopeStatus(BaseModel):
|
||||
"""Per-session backfill/reconciliation job status for a scope.
|
||||
|
||||
``backfill_status`` maps each session that has had a backfill enqueued to
|
||||
its current job state: ``{state, updated_at[, docs_copied]}`` where
|
||||
``state`` is ``pending``/``completed``/``failed`` and ``docs_copied`` is
|
||||
present once a backfill completes.
|
||||
"""
|
||||
|
||||
backfill_status: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conclusion schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1012,7 +1012,7 @@ async def create_observations(
|
|||
workspace_name=workspace_name,
|
||||
observer=observer,
|
||||
observed=observed,
|
||||
deduplicate=True,
|
||||
deduplicate=settings.DERIVER.DEDUPLICATE,
|
||||
)
|
||||
).created_documents
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -279,16 +279,50 @@ def extract_session_allowlist(
|
|||
'filters.session_id must be a session id, a list of session ids, or {"in": [...]}'
|
||||
)
|
||||
|
||||
return normalize_session_allowlist(
|
||||
entries, field="filters.session_id", must_include=must_include
|
||||
)
|
||||
|
||||
|
||||
def normalize_session_allowlist(
|
||||
entries: Sequence[Any],
|
||||
*,
|
||||
field: str,
|
||||
must_include: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Validate and de-duplicate a session allowlist.
|
||||
|
||||
Shared by every route-level entry point that accepts one — the ``filters``
|
||||
body on the recall endpoints and the ``sessions`` query parameter on session
|
||||
context — so the cap, the id charset, and the ``must_include`` rule cannot
|
||||
drift apart between them. Only the parameter *name* in error messages
|
||||
differs, which is what ``field`` supplies.
|
||||
|
||||
Args:
|
||||
entries: Raw allowlist entries as the caller supplied them.
|
||||
field: Caller-facing parameter name, used in error messages.
|
||||
must_include: A session id that must appear in the allowlist — used by
|
||||
routes that also carry a session of their own, so the two can't
|
||||
contradict each other.
|
||||
|
||||
Returns:
|
||||
The allowlist, de-duplicated, in first-seen order. An empty input yields
|
||||
an empty list so downstream consumers fail closed.
|
||||
|
||||
Raises:
|
||||
FilterError: On an over-cap list, a malformed session id, or a
|
||||
``must_include`` session missing from the allowlist.
|
||||
"""
|
||||
if len(entries) > MAX_SESSION_ALLOWLIST_ENTRIES:
|
||||
raise FilterError(
|
||||
f"filters.session_id supports at most {MAX_SESSION_ALLOWLIST_ENTRIES} sessions per request"
|
||||
f"{field} supports at most {MAX_SESSION_ALLOWLIST_ENTRIES} sessions per request"
|
||||
)
|
||||
|
||||
allowlist: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for entry in entries:
|
||||
if not isinstance(entry, str) or not entry:
|
||||
raise FilterError("filters.session_id entries must be non-empty strings")
|
||||
raise FilterError(f"{field} entries must be non-empty strings")
|
||||
# Only names a session could actually have. The allowlist reaches
|
||||
# queries three ways — direct `IN`, the filter DSL, and a Python
|
||||
# membership test — and they don't agree on a value like "*", which the
|
||||
|
|
@ -298,14 +332,14 @@ def extract_session_allowlist(
|
|||
# {"in": [...]}) which never included wildcards.
|
||||
if not re.fullmatch(RESOURCE_NAME_PATTERN, entry):
|
||||
raise FilterError(
|
||||
f"Invalid session id in filters.session_id: {entry!r}. Session ids match {RESOURCE_NAME_PATTERN}"
|
||||
f"Invalid session id in {field}: {entry!r}. Session ids match {RESOURCE_NAME_PATTERN}"
|
||||
)
|
||||
if entry not in seen:
|
||||
seen.add(entry)
|
||||
allowlist.append(entry)
|
||||
|
||||
if must_include is not None and must_include not in seen:
|
||||
raise FilterError("session_id must be included in filters.session_id")
|
||||
raise FilterError(f"session_id must be included in {field}")
|
||||
|
||||
return allowlist
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,26 @@ class DreamPayload(BasePayload):
|
|||
rebuild: bool = False
|
||||
|
||||
|
||||
class ScopeBackfillPayload(BasePayload):
|
||||
"""Payload for scope backfill tasks (session added to a scope, DEV-1999).
|
||||
|
||||
workspace_name lives in the QueueItem's dedicated column, mirroring the
|
||||
other task payloads.
|
||||
"""
|
||||
|
||||
task_type: Literal["scope_backfill"] = "scope_backfill"
|
||||
scope_peer: str
|
||||
session_name: str
|
||||
|
||||
|
||||
class ScopeRemovalPayload(BasePayload):
|
||||
"""Payload for scope removal reconciliation tasks (session removed from a scope)."""
|
||||
|
||||
task_type: Literal["scope_removal"] = "scope_removal"
|
||||
scope_peer: str
|
||||
session_name: str
|
||||
|
||||
|
||||
class DeletionPayload(BasePayload):
|
||||
"""Payload for deletion tasks."""
|
||||
|
||||
|
|
@ -124,6 +144,22 @@ def create_dream_payload(
|
|||
).model_dump(mode="json", exclude_none=True)
|
||||
|
||||
|
||||
def create_scope_task_payload(
|
||||
task_type: Literal["scope_backfill", "scope_removal"],
|
||||
*,
|
||||
scope_peer: str,
|
||||
session_name: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a scope backfill / removal payload."""
|
||||
if task_type == "scope_backfill":
|
||||
return ScopeBackfillPayload(
|
||||
scope_peer=scope_peer, session_name=session_name
|
||||
).model_dump(mode="json", exclude_none=True)
|
||||
return ScopeRemovalPayload(
|
||||
scope_peer=scope_peer, session_name=session_name
|
||||
).model_dump(mode="json", exclude_none=True)
|
||||
|
||||
|
||||
def create_deletion_payload(
|
||||
deletion_type: Literal["session", "observation", "workspace"],
|
||||
resource_id: str,
|
||||
|
|
|
|||
|
|
@ -251,7 +251,14 @@ class GetOrCreateResult(Generic[T]):
|
|||
|
||||
|
||||
TaskType = Literal[
|
||||
"webhook", "summary", "representation", "dream", "deletion", "reconciler"
|
||||
"webhook",
|
||||
"summary",
|
||||
"representation",
|
||||
"dream",
|
||||
"deletion",
|
||||
"reconciler",
|
||||
"scope_backfill",
|
||||
"scope_removal",
|
||||
]
|
||||
VectorSyncState = Literal["synced", "pending", "failed"]
|
||||
DocumentLevel = Literal["explicit", "deductive", "inductive", "contradiction"]
|
||||
|
|
|
|||
|
|
@ -74,6 +74,15 @@ def construct_work_unit_key(
|
|||
raise ValueError("reconciler_type is required for reconciler tasks")
|
||||
return f"reconciler:{reconciler_type}"
|
||||
|
||||
if task_type in ("scope_backfill", "scope_removal"):
|
||||
scope_peer = payload.get("scope_peer")
|
||||
session_name = payload.get("session_name")
|
||||
if not scope_peer or not session_name:
|
||||
raise ValueError(
|
||||
f"scope_peer and session_name are required for {task_type} tasks"
|
||||
)
|
||||
return f"{task_type}:{workspace_name}:{scope_peer}:{session_name}"
|
||||
|
||||
raise ValueError(f"Invalid task type: {task_type}")
|
||||
|
||||
|
||||
|
|
@ -183,4 +192,19 @@ def parse_work_unit_key(work_unit_key: str) -> ParsedWorkUnit:
|
|||
observed=None,
|
||||
)
|
||||
|
||||
if task_type in ("scope_backfill", "scope_removal"):
|
||||
# {task_type}:{workspace}:{scope_peer}:{session}
|
||||
if len(parts) != 4:
|
||||
raise ValueError(
|
||||
f"Invalid work_unit_key format for task_type {task_type}: {work_unit_key}"
|
||||
)
|
||||
return ParsedWorkUnit(
|
||||
task_type=task_type,
|
||||
workspace_name=parts[1],
|
||||
session_name=parts[3],
|
||||
# The scope peer is the observer of every collection the task touches.
|
||||
observer=parts[2],
|
||||
observed=None,
|
||||
)
|
||||
|
||||
raise ValueError(f"Invalid task type in work_unit_key: {task_type}")
|
||||
|
|
|
|||
|
|
@ -959,6 +959,7 @@ def mock_tracked_db(request: pytest.FixtureRequest):
|
|||
"src.dialectic.core.tracked_db",
|
||||
"src.dreamer.specialists.tracked_db",
|
||||
"src.dreamer.surprisal.tracked_db",
|
||||
"src.deriver.scope_backfill.tracked_db",
|
||||
]
|
||||
with ExitStack() as stack:
|
||||
for target in tracked_db_targets:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,958 @@
|
|||
"""Tests for scope backfill-by-copy and removal reconciliation (DEV-1999).
|
||||
|
||||
A scope is an observer peer (``scope.<name>``). Adding a session with
|
||||
pre-existing messages to a scope enqueues a ``scope_backfill`` task; the
|
||||
handler (``src.deriver.scope_backfill``) copies each observed peer's
|
||||
explicit-level documents from their global ``(P, P)`` collection into the
|
||||
scope's ``(scope_peer, P)`` collection, stamping ``copied_from`` for
|
||||
idempotency, then enqueues a manual omni dream. Removal enqueues
|
||||
``scope_removal``, which soft-deletes the copies (cascading to dependent
|
||||
derived documents) and enqueues a card_refresh (rebuild) + omni dream.
|
||||
|
||||
These tests exercise the handlers directly (``process_scope_backfill`` /
|
||||
``process_scope_removal``) against real Collection/Document/Peer rows,
|
||||
mirroring the fixture style in tests/crud/test_document.py and
|
||||
tests/dreamer/test_card_refresh.py: rows are created directly via
|
||||
``db_session`` (never through the cache-backed ``crud.get_or_create_collection``,
|
||||
which the ``mock_crud_collection_operations`` autouse fixture stubs out to an
|
||||
unpersisted object for every other test). Fixture data must be *committed*
|
||||
(not merely flushed) because the handlers run their DB work through
|
||||
``tracked_db``, which in tests opens a separate session bound to the same
|
||||
engine (see ``mock_tracked_db_context`` in conftest.py) — a different
|
||||
connection that cannot see another session's uncommitted writes.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from nanoid import generate as generate_nanoid
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import crud, models
|
||||
from src.deriver.scope_backfill import (
|
||||
COPIED_FROM_KEY,
|
||||
process_scope_backfill,
|
||||
process_scope_removal,
|
||||
)
|
||||
from src.schemas import DreamType
|
||||
from src.utils.queue_payload import ScopeBackfillPayload, ScopeRemovalPayload
|
||||
from src.utils.scopes import is_scope_peer, scope_peer_name
|
||||
|
||||
_EMBEDDING_DIM = 1536
|
||||
|
||||
|
||||
def _embedding(seed: float = 0.5) -> list[float]:
|
||||
return [seed] * _EMBEDDING_DIM
|
||||
|
||||
|
||||
async def _create_peer(db_session: AsyncSession, workspace_name: str) -> models.Peer:
|
||||
peer = models.Peer(name=str(generate_nanoid()), workspace_name=workspace_name)
|
||||
db_session.add(peer)
|
||||
await db_session.commit()
|
||||
return peer
|
||||
|
||||
|
||||
async def _create_scope_peer(
|
||||
db_session: AsyncSession, workspace_name: str, scope_name: str
|
||||
) -> models.Peer:
|
||||
peer = models.Peer(
|
||||
name=scope_peer_name(scope_name),
|
||||
workspace_name=workspace_name,
|
||||
# The authoritative kind flag lives in internal_metadata, which is not
|
||||
# user-writable; `configuration` carries only the observe_me knob.
|
||||
internal_metadata={"kind": "scope"},
|
||||
configuration={"observe_me": False},
|
||||
)
|
||||
db_session.add(peer)
|
||||
await db_session.commit()
|
||||
return peer
|
||||
|
||||
|
||||
async def _create_session(
|
||||
db_session: AsyncSession, workspace_name: str
|
||||
) -> models.Session:
|
||||
session = models.Session(name=str(generate_nanoid()), workspace_name=workspace_name)
|
||||
db_session.add(session)
|
||||
await db_session.commit()
|
||||
return session
|
||||
|
||||
|
||||
async def _join_scope(
|
||||
db_session: AsyncSession, workspace_name: str, session_name: str, scope_peer: str
|
||||
) -> None:
|
||||
"""Record the scope peer's membership, as the scopes routes do.
|
||||
|
||||
The backfill handler refuses to copy into a scope the session has left, so
|
||||
tests driving the handler directly must stand the membership row up.
|
||||
"""
|
||||
db_session.add(
|
||||
models.SessionPeer(
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
peer_name=scope_peer,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
async def _create_collection(
|
||||
db_session: AsyncSession, workspace_name: str, observer: str, observed: str
|
||||
) -> models.Collection:
|
||||
collection = models.Collection(
|
||||
workspace_name=workspace_name, observer=observer, observed=observed
|
||||
)
|
||||
db_session.add(collection)
|
||||
await db_session.commit()
|
||||
return collection
|
||||
|
||||
|
||||
async def _create_document(
|
||||
db_session: AsyncSession,
|
||||
workspace_name: str,
|
||||
*,
|
||||
observer: str,
|
||||
observed: str,
|
||||
session_name: str | None,
|
||||
content: str = "some observation",
|
||||
level: str = "explicit",
|
||||
embedding: list[float] | None = None,
|
||||
internal_metadata: dict[str, Any] | None = None,
|
||||
source_ids: list[str] | None = None,
|
||||
) -> models.Document:
|
||||
doc = models.Document(
|
||||
workspace_name=workspace_name,
|
||||
observer=observer,
|
||||
observed=observed,
|
||||
content=content,
|
||||
level=level,
|
||||
session_name=session_name,
|
||||
embedding=embedding if embedding is not None else _embedding(),
|
||||
internal_metadata=internal_metadata or {},
|
||||
source_ids=source_ids,
|
||||
)
|
||||
db_session.add(doc)
|
||||
await db_session.commit()
|
||||
return doc
|
||||
|
||||
|
||||
async def _get_docs(
|
||||
db_session: AsyncSession,
|
||||
workspace_name: str,
|
||||
*,
|
||||
observer: str,
|
||||
observed: str | None = None,
|
||||
include_deleted: bool = True,
|
||||
) -> list[models.Document]:
|
||||
stmt = select(models.Document).where(
|
||||
models.Document.workspace_name == workspace_name,
|
||||
models.Document.observer == observer,
|
||||
)
|
||||
if observed is not None:
|
||||
stmt = stmt.where(models.Document.observed == observed)
|
||||
if not include_deleted:
|
||||
stmt = stmt.where(models.Document.deleted_at.is_(None))
|
||||
result = await db_session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _dream_items(
|
||||
db_session: AsyncSession, workspace_name: str
|
||||
) -> list[models.QueueItem]:
|
||||
result = await db_session.execute(
|
||||
select(models.QueueItem).where(
|
||||
models.QueueItem.workspace_name == workspace_name,
|
||||
models.QueueItem.task_type == "dream",
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Backfill copies exactly the target session's explicit docs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_backfill_copies_only_target_session_explicit_docs(
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
test_workspace, sender = sample_data
|
||||
workspace_name = test_workspace.name
|
||||
scope_name = str(generate_nanoid())
|
||||
scope_peer = await _create_scope_peer(db_session, workspace_name, scope_name)
|
||||
|
||||
target_session = await _create_session(db_session, workspace_name)
|
||||
other_session = await _create_session(db_session, workspace_name)
|
||||
await _join_scope(db_session, workspace_name, target_session.name, scope_peer.name)
|
||||
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=sender.name, observed=sender.name
|
||||
)
|
||||
# Destination collection: crud.get_or_create_collection is stubbed to an
|
||||
# unpersisted object by the autouse mock_crud_collection_operations
|
||||
# fixture, so the scope's own collection must already exist for the
|
||||
# copied Document rows' FK to resolve.
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=sender.name
|
||||
)
|
||||
|
||||
# In-scope: the target session's explicit doc.
|
||||
target_doc = await _create_document(
|
||||
db_session,
|
||||
workspace_name,
|
||||
observer=sender.name,
|
||||
observed=sender.name,
|
||||
session_name=target_session.name,
|
||||
content="target session explicit fact",
|
||||
embedding=_embedding(0.7),
|
||||
)
|
||||
# Out-of-scope: another session's explicit doc.
|
||||
await _create_document(
|
||||
db_session,
|
||||
workspace_name,
|
||||
observer=sender.name,
|
||||
observed=sender.name,
|
||||
session_name=other_session.name,
|
||||
content="other session explicit fact",
|
||||
)
|
||||
# Out-of-scope: a derived (non-explicit) doc for the target session.
|
||||
await _create_document(
|
||||
db_session,
|
||||
workspace_name,
|
||||
observer=sender.name,
|
||||
observed=sender.name,
|
||||
session_name=target_session.name,
|
||||
content="deductive fact",
|
||||
level="deductive",
|
||||
)
|
||||
|
||||
await process_scope_backfill(
|
||||
ScopeBackfillPayload(
|
||||
scope_peer=scope_peer.name, session_name=target_session.name
|
||||
),
|
||||
workspace_name,
|
||||
)
|
||||
|
||||
copies = await _get_docs(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=sender.name
|
||||
)
|
||||
assert len(copies) == 1
|
||||
copy = copies[0]
|
||||
assert copy.content == "target session explicit fact"
|
||||
assert copy.level == "explicit"
|
||||
assert copy.session_name == target_session.name
|
||||
assert copy.internal_metadata[COPIED_FROM_KEY] == target_doc.id
|
||||
assert list(copy.embedding) == pytest.approx( # pyright: ignore[reportUnknownMemberType]
|
||||
_embedding(0.7)
|
||||
)
|
||||
assert copy.deleted_at is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Idempotency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_backfill_processed_twice_is_idempotent(
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
test_workspace, sender = sample_data
|
||||
workspace_name = test_workspace.name
|
||||
scope_name = str(generate_nanoid())
|
||||
scope_peer = await _create_scope_peer(db_session, workspace_name, scope_name)
|
||||
session = await _create_session(db_session, workspace_name)
|
||||
await _join_scope(db_session, workspace_name, session.name, scope_peer.name)
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=sender.name, observed=sender.name
|
||||
)
|
||||
await _create_document(
|
||||
db_session,
|
||||
workspace_name,
|
||||
observer=sender.name,
|
||||
observed=sender.name,
|
||||
session_name=session.name,
|
||||
)
|
||||
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=sender.name
|
||||
)
|
||||
|
||||
payload = ScopeBackfillPayload(
|
||||
scope_peer=scope_peer.name, session_name=session.name
|
||||
)
|
||||
await process_scope_backfill(payload, workspace_name)
|
||||
await process_scope_backfill(payload, workspace_name)
|
||||
|
||||
copies = await _get_docs(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=sender.name
|
||||
)
|
||||
assert len(copies) == 1
|
||||
|
||||
|
||||
async def test_add_remove_readd_converges_on_one_live_copy(
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
test_workspace, sender = sample_data
|
||||
workspace_name = test_workspace.name
|
||||
scope_name = str(generate_nanoid())
|
||||
scope_peer = await _create_scope_peer(db_session, workspace_name, scope_name)
|
||||
session = await _create_session(db_session, workspace_name)
|
||||
await _join_scope(db_session, workspace_name, session.name, scope_peer.name)
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=sender.name, observed=sender.name
|
||||
)
|
||||
await _create_document(
|
||||
db_session,
|
||||
workspace_name,
|
||||
observer=sender.name,
|
||||
observed=sender.name,
|
||||
session_name=session.name,
|
||||
)
|
||||
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=sender.name
|
||||
)
|
||||
|
||||
backfill_payload = ScopeBackfillPayload(
|
||||
scope_peer=scope_peer.name, session_name=session.name
|
||||
)
|
||||
removal_payload = ScopeRemovalPayload(
|
||||
scope_peer=scope_peer.name, session_name=session.name
|
||||
)
|
||||
|
||||
# add
|
||||
await process_scope_backfill(backfill_payload, workspace_name)
|
||||
# remove
|
||||
await process_scope_removal(removal_payload, workspace_name)
|
||||
live = await _get_docs(
|
||||
db_session,
|
||||
workspace_name,
|
||||
observer=scope_peer.name,
|
||||
observed=sender.name,
|
||||
include_deleted=False,
|
||||
)
|
||||
assert live == []
|
||||
# re-add
|
||||
await process_scope_backfill(backfill_payload, workspace_name)
|
||||
|
||||
all_copies = await _get_docs(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=sender.name
|
||||
)
|
||||
live_copies = [d for d in all_copies if d.deleted_at is None]
|
||||
assert len(all_copies) == 1 # restored, not duplicated
|
||||
assert len(live_copies) == 1
|
||||
|
||||
|
||||
async def test_backfill_skips_a_session_that_left_the_scope(
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
"""A removal that lands first must not be undone by a queued backfill.
|
||||
|
||||
scope_backfill and scope_removal carry different work-unit keys, so nothing
|
||||
orders them: add-then-remove can leave a backfill queued after removal has
|
||||
already swept the scope.
|
||||
"""
|
||||
test_workspace, sender = sample_data
|
||||
workspace_name = test_workspace.name
|
||||
scope_name = str(generate_nanoid())
|
||||
scope_peer = await _create_scope_peer(db_session, workspace_name, scope_name)
|
||||
session = await _create_session(db_session, workspace_name)
|
||||
await _join_scope(db_session, workspace_name, session.name, scope_peer.name)
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=sender.name, observed=sender.name
|
||||
)
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=sender.name
|
||||
)
|
||||
await _create_document(
|
||||
db_session,
|
||||
workspace_name,
|
||||
observer=sender.name,
|
||||
observed=sender.name,
|
||||
session_name=session.name,
|
||||
)
|
||||
|
||||
# The session leaves the scope before the queued backfill is drained.
|
||||
await db_session.execute(
|
||||
update(models.SessionPeer)
|
||||
.where(
|
||||
models.SessionPeer.workspace_name == workspace_name,
|
||||
models.SessionPeer.session_name == session.name,
|
||||
models.SessionPeer.peer_name == scope_peer.name,
|
||||
)
|
||||
.values(left_at=func.now())
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
await process_scope_backfill(
|
||||
ScopeBackfillPayload(scope_peer=scope_peer.name, session_name=session.name),
|
||||
workspace_name,
|
||||
)
|
||||
|
||||
assert (
|
||||
await _get_docs(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=sender.name
|
||||
)
|
||||
== []
|
||||
)
|
||||
# No status entry either: removal cleared it, and a skipped backfill must
|
||||
# not resurrect the session in the scope's status map.
|
||||
# Names held as plain strings: expire_all() below would make reading them
|
||||
# off the ORM instances trigger a lazy reload mid-assertion.
|
||||
scope_peer_name_str, session_name = scope_peer.name, session.name
|
||||
db_session.expire_all()
|
||||
peer = await db_session.scalar(
|
||||
select(models.Peer)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name == scope_peer_name_str)
|
||||
)
|
||||
assert peer is not None
|
||||
assert session_name not in peer.internal_metadata.get("backfill_status", {})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Multi-peer session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_backfill_multi_peer_session_copies_into_right_collections(
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
test_workspace, peer_a = sample_data
|
||||
workspace_name = test_workspace.name
|
||||
peer_b = await _create_peer(db_session, workspace_name)
|
||||
scope_name = str(generate_nanoid())
|
||||
scope_peer = await _create_scope_peer(db_session, workspace_name, scope_name)
|
||||
session = await _create_session(db_session, workspace_name)
|
||||
await _join_scope(db_session, workspace_name, session.name, scope_peer.name)
|
||||
|
||||
for peer in (peer_a, peer_b):
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=peer.name, observed=peer.name
|
||||
)
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=peer.name
|
||||
)
|
||||
await _create_document(
|
||||
db_session,
|
||||
workspace_name,
|
||||
observer=peer.name,
|
||||
observed=peer.name,
|
||||
session_name=session.name,
|
||||
content=f"fact about {peer.name}",
|
||||
)
|
||||
|
||||
await process_scope_backfill(
|
||||
ScopeBackfillPayload(scope_peer=scope_peer.name, session_name=session.name),
|
||||
workspace_name,
|
||||
)
|
||||
|
||||
copies_a = await _get_docs(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=peer_a.name
|
||||
)
|
||||
copies_b = await _get_docs(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=peer_b.name
|
||||
)
|
||||
assert len(copies_a) == 1
|
||||
assert copies_a[0].content == f"fact about {peer_a.name}"
|
||||
assert len(copies_b) == 1
|
||||
assert copies_b[0].content == f"fact about {peer_b.name}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Removal cascade
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_removal_cascades_to_dependent_derived_docs_only(
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
test_workspace, sender = sample_data
|
||||
workspace_name = test_workspace.name
|
||||
scope_name = str(generate_nanoid())
|
||||
scope_peer = await _create_scope_peer(db_session, workspace_name, scope_name)
|
||||
session = await _create_session(db_session, workspace_name)
|
||||
await _join_scope(db_session, workspace_name, session.name, scope_peer.name)
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=sender.name, observed=sender.name
|
||||
)
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=sender.name
|
||||
)
|
||||
await _create_document(
|
||||
db_session,
|
||||
workspace_name,
|
||||
observer=sender.name,
|
||||
observed=sender.name,
|
||||
session_name=session.name,
|
||||
)
|
||||
|
||||
await process_scope_backfill(
|
||||
ScopeBackfillPayload(scope_peer=scope_peer.name, session_name=session.name),
|
||||
workspace_name,
|
||||
)
|
||||
[copy] = await _get_docs(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=sender.name
|
||||
)
|
||||
|
||||
# A derived doc resting on the copy's evidence -> must be cascaded.
|
||||
dependent = await _create_document(
|
||||
db_session,
|
||||
workspace_name,
|
||||
observer=scope_peer.name,
|
||||
observed=sender.name,
|
||||
session_name=None,
|
||||
content="deduction resting on removed evidence",
|
||||
level="deductive",
|
||||
source_ids=[copy.id],
|
||||
)
|
||||
# An unrelated derived doc in the same collection -> must survive.
|
||||
unrelated = await _create_document(
|
||||
db_session,
|
||||
workspace_name,
|
||||
observer=scope_peer.name,
|
||||
observed=sender.name,
|
||||
session_name=None,
|
||||
content="unrelated deduction",
|
||||
level="deductive",
|
||||
source_ids=["some-other-doc-id-not-removed"],
|
||||
)
|
||||
|
||||
copy_id, dependent_id, unrelated_id = copy.id, dependent.id, unrelated.id
|
||||
|
||||
await process_scope_removal(
|
||||
ScopeRemovalPayload(scope_peer=scope_peer.name, session_name=session.name),
|
||||
workspace_name,
|
||||
)
|
||||
|
||||
# process_scope_removal runs on a separate tracked_db session (a
|
||||
# different connection). Query raw columns rather than full ORM entities
|
||||
# so this session's identity map (holding the pre-removal `copy` /
|
||||
# `dependent` / `unrelated` instances) can't hand back stale, expired
|
||||
# attributes.
|
||||
result = await db_session.execute(
|
||||
select(models.Document.id, models.Document.deleted_at).where(
|
||||
models.Document.workspace_name == workspace_name,
|
||||
models.Document.observer == scope_peer.name,
|
||||
models.Document.observed == sender.name,
|
||||
)
|
||||
)
|
||||
deleted_at_by_id = {row[0]: row[1] for row in result.all()}
|
||||
assert deleted_at_by_id[copy_id] is not None
|
||||
assert deleted_at_by_id[dependent_id] is not None
|
||||
assert deleted_at_by_id[unrelated_id] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Dream enqueues
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_backfill_enqueues_manual_omni_dream(
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
test_workspace, sender = sample_data
|
||||
workspace_name = test_workspace.name
|
||||
scope_name = str(generate_nanoid())
|
||||
scope_peer = await _create_scope_peer(db_session, workspace_name, scope_name)
|
||||
session = await _create_session(db_session, workspace_name)
|
||||
await _join_scope(db_session, workspace_name, session.name, scope_peer.name)
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=sender.name, observed=sender.name
|
||||
)
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=sender.name
|
||||
)
|
||||
await _create_document(
|
||||
db_session,
|
||||
workspace_name,
|
||||
observer=sender.name,
|
||||
observed=sender.name,
|
||||
session_name=session.name,
|
||||
)
|
||||
|
||||
await process_scope_backfill(
|
||||
ScopeBackfillPayload(scope_peer=scope_peer.name, session_name=session.name),
|
||||
workspace_name,
|
||||
)
|
||||
|
||||
dreams = await _dream_items(db_session, workspace_name)
|
||||
assert len(dreams) == 1
|
||||
payload = dreams[0].payload
|
||||
assert payload["dream_type"] == DreamType.OMNI.value
|
||||
assert payload["observer"] == scope_peer.name
|
||||
assert payload["observed"] == sender.name
|
||||
assert payload["trigger_reason"] == "scope_backfill"
|
||||
assert payload.get("rebuild", False) is False
|
||||
|
||||
|
||||
async def test_removal_enqueues_card_refresh_rebuild_and_omni_dream(
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
"""Removal's own dream enqueues, isolated from backfill's.
|
||||
|
||||
The scope's copy is created directly (as if an earlier backfill already
|
||||
ran and its dream was drained by the deriver) rather than by calling
|
||||
process_scope_backfill first: enqueue_dream dedupes on work_unit_key, so
|
||||
a still-pending omni dream from an immediately-preceding backfill would
|
||||
silently swallow removal's own omni enqueue and make this test couple to
|
||||
that unrelated dedup behavior instead of testing removal in isolation.
|
||||
"""
|
||||
test_workspace, sender = sample_data
|
||||
workspace_name = test_workspace.name
|
||||
scope_name = str(generate_nanoid())
|
||||
scope_peer = await _create_scope_peer(db_session, workspace_name, scope_name)
|
||||
session = await _create_session(db_session, workspace_name)
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=sender.name
|
||||
)
|
||||
await _create_document(
|
||||
db_session,
|
||||
workspace_name,
|
||||
observer=scope_peer.name,
|
||||
observed=sender.name,
|
||||
session_name=session.name,
|
||||
internal_metadata={COPIED_FROM_KEY: "some-source-doc-id"},
|
||||
)
|
||||
|
||||
await process_scope_removal(
|
||||
ScopeRemovalPayload(scope_peer=scope_peer.name, session_name=session.name),
|
||||
workspace_name,
|
||||
)
|
||||
|
||||
dreams = await _dream_items(db_session, workspace_name)
|
||||
removal_dreams = [
|
||||
d for d in dreams if d.payload.get("trigger_reason") == "scope_removal"
|
||||
]
|
||||
assert len(removal_dreams) == 2
|
||||
|
||||
by_type = {d.payload["dream_type"]: d.payload for d in removal_dreams}
|
||||
assert DreamType.CARD_REFRESH.value in by_type
|
||||
assert DreamType.OMNI.value in by_type
|
||||
card_refresh_payload = by_type[DreamType.CARD_REFRESH.value]
|
||||
assert card_refresh_payload["rebuild"] is True
|
||||
assert card_refresh_payload["observer"] == scope_peer.name
|
||||
assert card_refresh_payload["observed"] == sender.name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Status endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_status_reflects_pending_then_completed(
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
test_workspace, sender = sample_data
|
||||
workspace_name = test_workspace.name
|
||||
scope_name = str(generate_nanoid())
|
||||
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{workspace_name}/scopes", json={"id": scope_name}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
scope_peer_full_name = scope_peer_name(scope_name)
|
||||
|
||||
session_name = str(generate_nanoid())
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{workspace_name}/sessions",
|
||||
json={"id": session_name, "peers": {sender.name: {}}},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
message = models.Message(
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
peer_name=sender.name,
|
||||
content="hello from before the scope existed",
|
||||
public_id=generate_nanoid(),
|
||||
seq_in_session=1,
|
||||
token_count=5,
|
||||
)
|
||||
db_session.add(message)
|
||||
await db_session.commit()
|
||||
|
||||
# The message's explicit document (normally produced by the deriver) —
|
||||
# created directly since the deriver isn't run in this test.
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=sender.name, observed=sender.name
|
||||
)
|
||||
await _create_document(
|
||||
db_session,
|
||||
workspace_name,
|
||||
observer=sender.name,
|
||||
observed=sender.name,
|
||||
session_name=session_name,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{workspace_name}/scopes/{scope_name}/sessions",
|
||||
json={"session_ids": [session_name]},
|
||||
)
|
||||
assert response.status_code == 204, response.text
|
||||
|
||||
# Destination collection: crud.get_or_create_collection is stubbed to an
|
||||
# unpersisted object by the autouse fixture, so it must pre-exist.
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=scope_peer_full_name, observed=sender.name
|
||||
)
|
||||
|
||||
status_url = f"/v3/workspaces/{workspace_name}/scopes/{scope_name}/status"
|
||||
response = client.get(status_url)
|
||||
assert response.status_code == 200, response.text
|
||||
backfill_status = response.json()["backfill_status"]
|
||||
assert backfill_status[session_name]["state"] == "pending"
|
||||
|
||||
# Simulate the deriver picking up the enqueued task.
|
||||
await process_scope_backfill(
|
||||
ScopeBackfillPayload(
|
||||
scope_peer=scope_peer_full_name, session_name=session_name
|
||||
),
|
||||
workspace_name,
|
||||
)
|
||||
|
||||
response = client.get(status_url)
|
||||
assert response.status_code == 200, response.text
|
||||
backfill_status = response.json()["backfill_status"]
|
||||
assert backfill_status[session_name]["state"] == "completed"
|
||||
assert backfill_status[session_name]["docs_copied"] == 1
|
||||
|
||||
|
||||
async def test_backfill_re_embeds_sources_with_null_embeddings(
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
"""Source rows carry no embedding on external-store deployments.
|
||||
|
||||
Phase 2 re-embeds those (embedding API only) and pairs results back with
|
||||
strict=True, so a mis-pairing would raise rather than silently mismatch.
|
||||
"""
|
||||
test_workspace, sender = sample_data
|
||||
workspace_name = test_workspace.name
|
||||
scope_name = str(generate_nanoid())
|
||||
scope_peer = await _create_scope_peer(db_session, workspace_name, scope_name)
|
||||
session = await _create_session(db_session, workspace_name)
|
||||
await _join_scope(db_session, workspace_name, session.name, scope_peer.name)
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=sender.name, observed=sender.name
|
||||
)
|
||||
await _create_collection(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=sender.name
|
||||
)
|
||||
source = await _create_document(
|
||||
db_session,
|
||||
workspace_name,
|
||||
observer=sender.name,
|
||||
observed=sender.name,
|
||||
session_name=session.name,
|
||||
content="fact whose vector lives in the external store",
|
||||
)
|
||||
source.embedding = None
|
||||
await db_session.commit()
|
||||
|
||||
await process_scope_backfill(
|
||||
ScopeBackfillPayload(scope_peer=scope_peer.name, session_name=session.name),
|
||||
workspace_name,
|
||||
)
|
||||
|
||||
[copy] = await _get_docs(
|
||||
db_session, workspace_name, observer=scope_peer.name, observed=sender.name
|
||||
)
|
||||
assert copy.embedding is not None
|
||||
assert len(copy.embedding) == _EMBEDDING_DIM
|
||||
|
||||
|
||||
async def test_backfill_failure_records_failed_status(
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
test_workspace, _ = sample_data
|
||||
workspace_name = test_workspace.name
|
||||
scope_name = str(generate_nanoid())
|
||||
scope_peer = await _create_scope_peer(db_session, workspace_name, scope_name)
|
||||
session = await _create_session(db_session, workspace_name)
|
||||
|
||||
# Plain strings: the ORM instances are expired below (see the same guard in
|
||||
# test_backfill_status_writes_preserve_the_scope_kind_flag).
|
||||
scope_peer_name_str, session_name = scope_peer.name, session.name
|
||||
|
||||
async def boom(*_args: Any, **_kwargs: Any) -> None:
|
||||
raise RuntimeError("copy phase blew up")
|
||||
|
||||
monkeypatch.setattr("src.deriver.scope_backfill._run_backfill", boom)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await process_scope_backfill(
|
||||
ScopeBackfillPayload(
|
||||
scope_peer=scope_peer_name_str, session_name=session_name
|
||||
),
|
||||
workspace_name,
|
||||
)
|
||||
|
||||
db_session.expire_all()
|
||||
peer = await db_session.scalar(
|
||||
select(models.Peer)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name == scope_peer_name_str)
|
||||
)
|
||||
assert peer is not None
|
||||
assert peer.internal_metadata["backfill_status"][session_name]["state"] == "failed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Route wiring: add-sessions enqueues backfill only when messages exist
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_add_sessions_enqueues_backfill_only_when_session_has_messages(
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
test_workspace, sender = sample_data
|
||||
workspace_name = test_workspace.name
|
||||
scope_name = str(generate_nanoid())
|
||||
assert (
|
||||
client.post(
|
||||
f"/v3/workspaces/{workspace_name}/scopes", json={"id": scope_name}
|
||||
).status_code
|
||||
== 201
|
||||
)
|
||||
|
||||
# Session with a pre-existing message.
|
||||
session_with_messages = str(generate_nanoid())
|
||||
assert (
|
||||
client.post(
|
||||
f"/v3/workspaces/{workspace_name}/sessions",
|
||||
json={"id": session_with_messages, "peers": {sender.name: {}}},
|
||||
).status_code
|
||||
== 201
|
||||
)
|
||||
message = models.Message(
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_with_messages,
|
||||
peer_name=sender.name,
|
||||
content="already said something",
|
||||
public_id=generate_nanoid(),
|
||||
seq_in_session=1,
|
||||
token_count=5,
|
||||
)
|
||||
db_session.add(message)
|
||||
await db_session.commit()
|
||||
|
||||
# Empty session, no messages.
|
||||
empty_session = str(generate_nanoid())
|
||||
assert (
|
||||
client.post(
|
||||
f"/v3/workspaces/{workspace_name}/sessions",
|
||||
json={"id": empty_session},
|
||||
).status_code
|
||||
== 201
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{workspace_name}/scopes/{scope_name}/sessions",
|
||||
json={"session_ids": [session_with_messages, empty_session]},
|
||||
)
|
||||
assert response.status_code == 204, response.text
|
||||
|
||||
result = await db_session.execute(
|
||||
select(models.QueueItem).where(
|
||||
models.QueueItem.workspace_name == workspace_name,
|
||||
models.QueueItem.task_type == "scope_backfill",
|
||||
)
|
||||
)
|
||||
backfill_items = list(result.scalars().all())
|
||||
assert len(backfill_items) == 1
|
||||
assert backfill_items[0].payload["session_name"] == session_with_messages
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The scope `kind` flag and the backfill status map share the scope peer's
|
||||
# internal_metadata. Every write to that column must be a JSONB merge scoped to
|
||||
# the backfill key; a wholesale assignment would drop the flag and silently turn
|
||||
# the peer back into an ordinary one — invisible until some later read stopped
|
||||
# recognising it as a scope.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_backfill_status_writes_preserve_the_scope_kind_flag(
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
"""Status writes must not clobber the authoritative kind flag.
|
||||
|
||||
Both live in internal_metadata, so this pins the one property that makes
|
||||
them able to coexist. Covers the whole lifecycle, because a wholesale write
|
||||
could be introduced at any single step: pending, completed, then cleared.
|
||||
"""
|
||||
test_workspace, _ = sample_data
|
||||
workspace_name = test_workspace.name
|
||||
scope_name = str(generate_nanoid())
|
||||
session_name = str(generate_nanoid())
|
||||
await _create_scope_peer(db_session, workspace_name, scope_name)
|
||||
# Held as a plain string: the ORM instance is expired below on every check,
|
||||
# so reading an attribute off it would trigger a reload mid-assertion.
|
||||
backing_peer = scope_peer_name(scope_name)
|
||||
|
||||
async def assert_still_a_scope(stage: str) -> dict[str, Any]:
|
||||
db_session.expire_all()
|
||||
refreshed = await db_session.scalar(
|
||||
select(models.Peer)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name == backing_peer)
|
||||
)
|
||||
assert refreshed is not None
|
||||
assert is_scope_peer(refreshed.name, refreshed.internal_metadata), (
|
||||
f"the peer stopped being a scope after {stage}: "
|
||||
f"internal_metadata={refreshed.internal_metadata!r}"
|
||||
)
|
||||
# And the facade still resolves it, which is what actually breaks:
|
||||
# get_scope_or_raise 404s on a peer that has lost the flag.
|
||||
resolved = await crud.get_scope_or_raise(db_session, workspace_name, scope_name)
|
||||
assert resolved.name == backing_peer
|
||||
return refreshed.internal_metadata
|
||||
|
||||
await assert_still_a_scope("creation")
|
||||
|
||||
await crud.update_scope_backfill_status(
|
||||
db_session,
|
||||
workspace_name,
|
||||
backing_peer,
|
||||
session_name,
|
||||
state="pending",
|
||||
)
|
||||
await db_session.commit()
|
||||
metadata = await assert_still_a_scope("a pending status write")
|
||||
assert metadata["backfill_status"][session_name]["state"] == "pending"
|
||||
|
||||
await crud.update_scope_backfill_status(
|
||||
db_session,
|
||||
workspace_name,
|
||||
backing_peer,
|
||||
session_name,
|
||||
state="completed",
|
||||
docs_copied=3,
|
||||
)
|
||||
await db_session.commit()
|
||||
metadata = await assert_still_a_scope("a completed status write")
|
||||
assert metadata["backfill_status"][session_name]["docs_copied"] == 3
|
||||
|
||||
await crud.clear_scope_backfill_status(
|
||||
db_session, workspace_name, backing_peer, session_name
|
||||
)
|
||||
await db_session.commit()
|
||||
metadata = await assert_still_a_scope("clearing the status")
|
||||
assert session_name not in metadata.get("backfill_status", {})
|
||||
|
|
@ -68,5 +68,5 @@ Coverage by provider:
|
|||
- OpenAI transport → OpenRouter non-reasoning models (e.g. `inception/mercury-2`): non-chat / diffusion architectures must stay on `max_tokens`, no `reasoning_effort`, tool-calling parameter-schema compatibility is the canary for exotic OR-served providers
|
||||
- Gemini 2.5/3.0 classes: structured outputs, cached-content reuse, thought signatures, multi-turn tool replay
|
||||
- Gemini 3.1 class: thinking and tool replay coverage by default; structured-output/caching coverage should only be added once Google documents support for that path
|
||||
- Embeddings (`test_live_embeddings.py`): single embed, batched embed, batch-vs-single alignment, chunk-to-id mapping, and oversize-truncate survival (`on_oversize="truncate"`) for every configured embedding model. `gemini-embedding-2*` is the reason this exists — those models collapse a list of bare strings into one document (#745), and only a live call catches it
|
||||
- Embeddings (`test_live_embeddings.py`): single embed, batched embed, batch-vs-single alignment, chunk-to-id mapping, and oversize-truncate survival (`on_oversize="truncate"`) for every configured embedding model. `gemini-embedding-2*` is the reason this exists — those models collapse a list of bare strings into one document (#745), and only a live call catches it. Also covers first-class `EmbeddingModelConfig.timeout` plumbing (one representative model per transport): configured timeout lands on the SDK client, and a near-zero timeout aborts before the provider answers
|
||||
- OpenAI-compatible embedding providers (e.g. OpenRouter's `google/gemini-embedding-001`): the #932 surface. Those providers reject a base64 embedding request outright (HTTP 400) or answer HTTP 200 with empty data, so the whole matrix fails without `encoding_format="float"`. Real OpenAI accepts base64 happily, so only a third-party provider catches it. Note that OpenRouter load-balances across upstreams, so the base64 failure is per-attempt rather than guaranteed: a retry can land on an endpoint that accepts it. `test_live_openai_float_encoding_matches_base64` covers the other side, that the float switch must not move vectors on real OpenAI
|
||||
|
|
|
|||
|
|
@ -66,14 +66,23 @@ def require_embedding_key(spec: LiveEmbeddingSpec) -> str:
|
|||
return key
|
||||
|
||||
|
||||
_EMBEDDING_CONFIG_OVERRIDE_KEYS = frozenset({"timeout", "max_batch_size"})
|
||||
|
||||
|
||||
def make_embedding_client(
|
||||
spec: LiveEmbeddingSpec, **overrides: Any
|
||||
) -> _EmbeddingClient:
|
||||
"""Build a live embedding client for one matrix entry.
|
||||
|
||||
Bypasses the `EmbeddingClient` singleton so each spec gets its own client
|
||||
without mutating global settings.
|
||||
without mutating global settings. `timeout` and `max_batch_size` land on
|
||||
`EmbeddingModelConfig`; remaining kwargs go to `_EmbeddingClient`.
|
||||
"""
|
||||
config_overrides = {
|
||||
key: overrides.pop(key)
|
||||
for key in _EMBEDDING_CONFIG_OVERRIDE_KEYS
|
||||
if key in overrides
|
||||
}
|
||||
kwargs: dict[str, Any] = {
|
||||
"vector_dimensions": spec.dimensions,
|
||||
"max_input_tokens": 2048,
|
||||
|
|
@ -90,6 +99,7 @@ def make_embedding_client(
|
|||
model=spec.model,
|
||||
api_key=require_embedding_key(spec),
|
||||
base_url=spec.base_url,
|
||||
**config_overrides,
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
import pytest
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from src.config import EmbeddingTransport
|
||||
|
||||
from .conftest import cosine_similarity, make_embedding_client
|
||||
from .embedding_matrix import LiveEmbeddingSpec, get_live_embedding_specs
|
||||
|
||||
|
|
@ -25,6 +30,52 @@ OPENAI_NATIVE_SPECS = tuple(
|
|||
spec for spec in ALL_SPECS if spec.family == "openai_embedding"
|
||||
)
|
||||
|
||||
GENEROUS_TIMEOUT_SECONDS = 120.0
|
||||
TIGHT_TIMEOUT_SECONDS = 0.01
|
||||
# Well under the client defaults; generous enough to absorb SDK retries.
|
||||
TIGHT_TIMEOUT_WALL_CLOCK_LIMIT_SECONDS = 30
|
||||
|
||||
EMBEDDING_TIMEOUT_EXCEPTIONS: dict[
|
||||
EmbeddingTransport, tuple[type[BaseException], ...]
|
||||
] = {
|
||||
"openai": (openai.APITimeoutError,),
|
||||
# google-genai raises httpx or aiohttp timeouts depending on its transport;
|
||||
# aiohttp surfaces as asyncio.TimeoutError (== builtins.TimeoutError).
|
||||
"gemini": (httpx.TimeoutException, TimeoutError),
|
||||
}
|
||||
|
||||
TRANSPORT_MARKS = {
|
||||
"openai": pytest.mark.requires_openai,
|
||||
"gemini": pytest.mark.requires_gemini,
|
||||
}
|
||||
|
||||
|
||||
def representative_embedding_specs() -> list[Any]:
|
||||
"""One spec per transport — timeout plumbing is client-level, not model-level."""
|
||||
params: list[Any] = []
|
||||
for transport in ("openai", "gemini"):
|
||||
specs = get_live_embedding_specs(transport=transport)
|
||||
if not specs:
|
||||
continue
|
||||
# Prefer the native family over openai-compatible proxies.
|
||||
family = f"{transport}_embedding"
|
||||
native = next((s for s in specs if s.family == family), specs[0])
|
||||
params.append(
|
||||
pytest.param(native, marks=TRANSPORT_MARKS[transport], id=native.id)
|
||||
)
|
||||
return params
|
||||
|
||||
|
||||
def assert_embedding_timeout_on_client(
|
||||
client: Any, transport: EmbeddingTransport, timeout_seconds: float
|
||||
) -> None:
|
||||
if transport == "gemini":
|
||||
http_options = client.client._api_client._http_options
|
||||
assert http_options.timeout == int(timeout_seconds * 1000)
|
||||
return
|
||||
openai_client = cast(AsyncOpenAI, client.client)
|
||||
assert openai_client.timeout == timeout_seconds
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("spec", ALL_SPECS, ids=lambda spec: spec.id)
|
||||
|
|
@ -180,3 +231,36 @@ async def test_live_gemini_batch_embed_survives_batch_split(
|
|||
|
||||
assert len(embeddings) == len(BATCH_TEXTS)
|
||||
assert len({tuple(embedding) for embedding in embeddings}) == len(BATCH_TEXTS)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("spec", representative_embedding_specs())
|
||||
async def test_live_embedding_timeout_reaches_the_client(
|
||||
spec: LiveEmbeddingSpec,
|
||||
) -> None:
|
||||
"""Configured embedding timeout lands on the provider SDK client."""
|
||||
client = make_embedding_client(spec, timeout=GENEROUS_TIMEOUT_SECONDS)
|
||||
|
||||
embedding = await client.embed(BATCH_TEXTS[0])
|
||||
|
||||
assert len(embedding) == spec.dimensions
|
||||
assert_embedding_timeout_on_client(client, spec.transport, GENEROUS_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("spec", representative_embedding_specs())
|
||||
async def test_live_tight_embedding_timeout_aborts_request(
|
||||
spec: LiveEmbeddingSpec,
|
||||
) -> None:
|
||||
"""A near-zero embedding timeout aborts before the provider can answer."""
|
||||
client = make_embedding_client(spec, timeout=TIGHT_TIMEOUT_SECONDS)
|
||||
|
||||
started = time.monotonic()
|
||||
with pytest.raises(EMBEDDING_TIMEOUT_EXCEPTIONS[spec.transport]):
|
||||
await client.embed(BATCH_TEXTS[0])
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
assert elapsed < TIGHT_TIMEOUT_WALL_CLOCK_LIMIT_SECONDS, (
|
||||
f"tight embedding timeout took {elapsed:.1f}s — client timeout "
|
||||
f"likely not applied"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -66,7 +66,13 @@ async def test_openai_embedding_client_uses_configured_model_and_dimensions(
|
|||
fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 8)
|
||||
|
||||
class FakeOpenAIClient:
|
||||
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None,
|
||||
base_url: str | None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
self.api_key: str | None = api_key
|
||||
self.base_url: str | None = base_url
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
|
||||
|
|
@ -105,7 +111,13 @@ async def test_openai_embedding_client_rejects_dimension_mismatch(
|
|||
fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 7)
|
||||
|
||||
class FakeOpenAIClient:
|
||||
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None,
|
||||
base_url: str | None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
|
||||
|
||||
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
|
||||
|
|
@ -221,6 +233,118 @@ async def test_gemini_embedding_client_keeps_timeout_without_base_url(
|
|||
assert gemini_client.http_options.timeout == 600_000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_embedding_client_forwards_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Configured embedding timeout reaches the OpenAI-compatible client."""
|
||||
|
||||
class FakeOpenAIClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None,
|
||||
base_url: str | None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
self.api_key: str | None = api_key
|
||||
self.base_url: str | None = base_url
|
||||
self.timeout: float | None = timeout
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = FakeOpenAIEmbeddingsAPI(
|
||||
[0.1] * 8
|
||||
)
|
||||
|
||||
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
transport="openai",
|
||||
model="text-embedding-3-small",
|
||||
api_key="test-key",
|
||||
timeout=45,
|
||||
),
|
||||
vector_dimensions=8,
|
||||
max_input_tokens=8192,
|
||||
max_tokens_per_request=300_000,
|
||||
send_dimensions=False,
|
||||
)
|
||||
|
||||
openai_client = cast(Any, client.client)
|
||||
assert openai_client.timeout == 45.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_embedding_client_omits_timeout_when_unset(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Unset timeout omits the kwarg so the OpenAI SDK keeps its default."""
|
||||
|
||||
missing = object()
|
||||
|
||||
class FakeOpenAIClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None,
|
||||
base_url: str | None,
|
||||
timeout: object = missing,
|
||||
) -> None:
|
||||
self.api_key: str | None = api_key
|
||||
self.base_url: str | None = base_url
|
||||
self.timeout: object = timeout
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = FakeOpenAIEmbeddingsAPI(
|
||||
[0.1] * 8
|
||||
)
|
||||
|
||||
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
transport="openai",
|
||||
model="text-embedding-3-small",
|
||||
api_key="test-key",
|
||||
),
|
||||
vector_dimensions=8,
|
||||
max_input_tokens=8192,
|
||||
max_tokens_per_request=300_000,
|
||||
send_dimensions=False,
|
||||
)
|
||||
|
||||
openai_client = cast(Any, client.client)
|
||||
assert openai_client.timeout is missing
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_embedding_client_forwards_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Configured embedding timeout reaches Gemini as milliseconds."""
|
||||
|
||||
class FakeGeminiClient:
|
||||
def __init__(self, *, api_key: str | None, http_options: Any) -> None:
|
||||
self.api_key: str | None = api_key
|
||||
self.http_options: Any = http_options
|
||||
self.aio: Any = SimpleNamespace(models=SimpleNamespace())
|
||||
|
||||
monkeypatch.setattr("google.genai.Client", FakeGeminiClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
transport="gemini",
|
||||
model="gemini-embedding-001",
|
||||
api_key="gemini-key",
|
||||
timeout=45,
|
||||
),
|
||||
vector_dimensions=8,
|
||||
max_input_tokens=4096,
|
||||
max_tokens_per_request=300_000,
|
||||
send_dimensions=False,
|
||||
)
|
||||
|
||||
gemini_client = cast(Any, client.client)
|
||||
assert gemini_client.http_options.timeout == 45_000
|
||||
|
||||
|
||||
def _build_openai_client(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
|
|
@ -234,7 +358,13 @@ def _build_openai_client(
|
|||
fake_embeddings = FakeOpenAIEmbeddingsAPI(embedding)
|
||||
|
||||
class FakeOpenAIClient:
|
||||
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None,
|
||||
base_url: str | None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
self.api_key: str | None = api_key
|
||||
self.base_url: str | None = base_url
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
|
||||
|
|
@ -708,7 +838,13 @@ async def test_simple_batch_embed_respects_token_budget_per_request(
|
|||
fake_embeddings = FakeOpenAIEmbeddingsAPI([0.5] * 4)
|
||||
|
||||
class FakeOpenAIClient:
|
||||
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None,
|
||||
base_url: str | None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
|
||||
|
||||
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
|
||||
|
|
@ -746,7 +882,13 @@ async def test_simple_batch_embed_rejects_oversized_input(
|
|||
fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 4)
|
||||
|
||||
class FakeOpenAIClient:
|
||||
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None,
|
||||
base_url: str | None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
|
||||
|
||||
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
|
||||
|
|
@ -901,7 +1043,13 @@ def test_prepare_chunks_returns_ordered_chunks(
|
|||
fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 4)
|
||||
|
||||
class FakeOpenAIClient:
|
||||
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None,
|
||||
base_url: str | None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
|
||||
|
||||
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
|
||||
|
|
@ -944,6 +1092,31 @@ def test_embedding_model_config_parses_max_batch_size_from_env(
|
|||
assert resolved.max_batch_size == 10
|
||||
|
||||
|
||||
def test_embedding_model_config_parses_timeout_from_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
s = _build_embedding_settings(
|
||||
{"EMBEDDING_MODEL_CONFIG__TIMEOUT": "90.0"},
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert s.MODEL_CONFIG.timeout == 90.0
|
||||
|
||||
resolved = resolve_embedding_model_config(s.MODEL_CONFIG)
|
||||
assert resolved.timeout == 90.0
|
||||
|
||||
|
||||
def test_embedding_model_config_rejects_invalid_timeout() -> None:
|
||||
with pytest.raises(
|
||||
ValueError, match=r"provider_params\.timeout must be a positive number"
|
||||
):
|
||||
EmbeddingModelConfig(
|
||||
transport="openai",
|
||||
model="text-embedding-3-small",
|
||||
timeout=-1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_process_batch_wraps_contents_as_content_part(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from sdks.python.src.honcho.client import Honcho
|
|||
from sdks.python.src.honcho.conclusions import (
|
||||
Conclusion,
|
||||
ConclusionCreateParams,
|
||||
ConclusionScope,
|
||||
ConclusionsView,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ async def test_observation_create_single(
|
|||
|
||||
# Get observation scope for observer -> target
|
||||
obs_scope = observer.conclusions_of(target)
|
||||
assert isinstance(obs_scope, ConclusionScope)
|
||||
assert isinstance(obs_scope, ConclusionsView)
|
||||
|
||||
# Create a single observation
|
||||
created = await obs_scope.aio.create(
|
||||
|
|
@ -68,7 +68,7 @@ async def test_observation_create_single(
|
|||
|
||||
# Get observation scope for observer -> target
|
||||
obs_scope = observer.conclusions_of(target)
|
||||
assert isinstance(obs_scope, ConclusionScope)
|
||||
assert isinstance(obs_scope, ConclusionsView)
|
||||
|
||||
# Create a single observation
|
||||
created = obs_scope.create(
|
||||
|
|
@ -422,7 +422,7 @@ async def test_self_observation_create(
|
|||
|
||||
# Get self-observation scope
|
||||
obs_scope = peer.conclusions
|
||||
assert isinstance(obs_scope, ConclusionScope)
|
||||
assert isinstance(obs_scope, ConclusionsView)
|
||||
assert obs_scope.observer == peer.id
|
||||
assert obs_scope.observed == peer.id
|
||||
|
||||
|
|
@ -443,7 +443,7 @@ async def test_self_observation_create(
|
|||
|
||||
# Get self-observation scope
|
||||
obs_scope = peer.conclusions
|
||||
assert isinstance(obs_scope, ConclusionScope)
|
||||
assert isinstance(obs_scope, ConclusionsView)
|
||||
assert obs_scope.observer == peer.id
|
||||
assert obs_scope.observed == peer.id
|
||||
|
||||
|
|
@ -796,7 +796,7 @@ async def test_list_rejects_reserved_scope_filter_keys(
|
|||
target = await honcho_client.aio.peer(id="test-obs-reserved-list-target")
|
||||
obs_scope = observer.conclusions_of(target)
|
||||
for key in reserved:
|
||||
with pytest.raises(ValueError, match="managed by this conclusion scope"):
|
||||
with pytest.raises(ValueError, match="managed by this conclusions view"):
|
||||
await obs_scope.aio.list(filters={key: "someone-else"})
|
||||
# A non-reserved filter (level) is allowed through.
|
||||
await obs_scope.aio.list(filters={"level": "explicit"})
|
||||
|
|
@ -805,7 +805,7 @@ async def test_list_rejects_reserved_scope_filter_keys(
|
|||
target = honcho_client.peer(id="test-obs-reserved-list-target")
|
||||
obs_scope = observer.conclusions_of(target)
|
||||
for key in reserved:
|
||||
with pytest.raises(ValueError, match="managed by this conclusion scope"):
|
||||
with pytest.raises(ValueError, match="managed by this conclusions view"):
|
||||
obs_scope.list(filters={key: "someone-else"})
|
||||
obs_scope.list(filters={"level": "explicit"})
|
||||
|
||||
|
|
@ -827,7 +827,7 @@ async def test_query_rejects_reserved_scope_filter_keys(
|
|||
target = await honcho_client.aio.peer(id="test-obs-reserved-query-target")
|
||||
obs_scope = observer.conclusions_of(target)
|
||||
for key in reserved:
|
||||
with pytest.raises(ValueError, match="managed by this conclusion scope"):
|
||||
with pytest.raises(ValueError, match="managed by this conclusions view"):
|
||||
await obs_scope.aio.query("q", filters={key: "someone-else"})
|
||||
# session_id is a normal filter for query (no dedicated param) — allowed.
|
||||
await obs_scope.aio.query("q", filters={"session_id": "some-session"})
|
||||
|
|
@ -836,6 +836,6 @@ async def test_query_rejects_reserved_scope_filter_keys(
|
|||
target = honcho_client.peer(id="test-obs-reserved-query-target")
|
||||
obs_scope = observer.conclusions_of(target)
|
||||
for key in reserved:
|
||||
with pytest.raises(ValueError, match="managed by this conclusion scope"):
|
||||
with pytest.raises(ValueError, match="managed by this conclusions view"):
|
||||
obs_scope.query("q", filters={key: "someone-else"})
|
||||
obs_scope.query("q", filters={"session_id": "some-session"})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,236 @@
|
|||
"""Unit tests for the SDK's scope / session-allowlist option handling.
|
||||
|
||||
Pure logic — no server, no database. These pin the wire translation the server
|
||||
expects, so a rename or a shape change fails here rather than as a 422 at runtime.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Add the SDK src to the path to allow imports
|
||||
sdk_src_path = Path(__file__).parent.parent.parent / "sdks" / "python" / "src"
|
||||
sys.path.insert(0, str(sdk_src_path))
|
||||
|
||||
from sdks.python.src.honcho.utils.scopes import ( # noqa: E402
|
||||
MAX_SCOPES_PER_OPTION,
|
||||
MAX_SESSION_ALLOWLIST_ENTRIES,
|
||||
MAX_SESSIONS_PER_ADD,
|
||||
resolve_scope_membership,
|
||||
resolve_scope_option,
|
||||
resolve_scope_session,
|
||||
scope_context_fields,
|
||||
scope_recall_fields,
|
||||
validate_scope_id,
|
||||
)
|
||||
|
||||
|
||||
def context_fields(**overrides: object) -> dict[str, object]:
|
||||
"""Call scope_context_fields with the neutral defaults filled in."""
|
||||
kwargs: dict[str, object] = {
|
||||
"scope": None,
|
||||
"sessions": None,
|
||||
"peer_target": "user",
|
||||
"peer_perspective": None,
|
||||
"limit_to_session": False,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return scope_context_fields(**kwargs) # pyright: ignore[reportArgumentType]
|
||||
|
||||
|
||||
class TestValidateScopeId:
|
||||
def test_accepts_a_plain_name(self):
|
||||
assert validate_scope_id("therapy") == "therapy"
|
||||
|
||||
def test_rejects_the_reserved_prefix_by_name(self):
|
||||
# 'scope.therapy' violates both the prefix rule and the charset. The
|
||||
# prefix message is the actionable one, so it must be the one raised.
|
||||
with pytest.raises(ValueError, match="reserved prefix"):
|
||||
validate_scope_id("scope.therapy")
|
||||
|
||||
def test_rejects_characters_outside_the_charset(self):
|
||||
with pytest.raises(ValueError, match="must match pattern"):
|
||||
validate_scope_id("my scope")
|
||||
|
||||
def test_rejects_empty(self):
|
||||
with pytest.raises(ValueError, match="between 1 and"):
|
||||
validate_scope_id("")
|
||||
|
||||
def test_rejects_a_name_that_leaves_no_room_for_the_prefix(self):
|
||||
# 512 - len("scope.") is the ceiling: the server stores the name prefixed
|
||||
# into a 512-character peer name.
|
||||
with pytest.raises(ValueError, match="between 1 and"):
|
||||
validate_scope_id("a" * 507)
|
||||
|
||||
|
||||
class TestResolveScopeOption:
|
||||
def test_a_single_scope_stays_a_string(self):
|
||||
# The shapes are not interchangeable to the server: one scope reads that
|
||||
# scope's own view, a list restricts to the union of member sessions.
|
||||
assert resolve_scope_option("therapy") == "therapy"
|
||||
|
||||
def test_a_sequence_becomes_a_list(self):
|
||||
assert resolve_scope_option(["therapy", "work"]) == ["therapy", "work"]
|
||||
|
||||
def test_rejects_an_empty_sequence(self):
|
||||
with pytest.raises(ValueError, match="at least one scope"):
|
||||
resolve_scope_option([])
|
||||
|
||||
def test_rejects_an_over_cap_sequence(self):
|
||||
with pytest.raises(ValueError, match="at most"):
|
||||
resolve_scope_option([f"s{i}" for i in range(MAX_SCOPES_PER_OPTION + 1)])
|
||||
|
||||
|
||||
class TestScopeRecallFields:
|
||||
def test_neither_option_contributes_nothing(self):
|
||||
assert scope_recall_fields(scope=None, sessions=None) == {}
|
||||
|
||||
def test_sessions_becomes_a_session_id_filter(self):
|
||||
# `sessions` is sugar. It must never reach the wire as its own key —
|
||||
# the server rejects unknown keys with a 422.
|
||||
fields = scope_recall_fields(scope=None, sessions=["a", "b"])
|
||||
assert fields == {"filters": {"session_id": ["a", "b"]}}
|
||||
assert "sessions" not in fields
|
||||
|
||||
def test_scope_passes_through_under_its_own_key(self):
|
||||
assert scope_recall_fields(scope="therapy", sessions=None) == {
|
||||
"scope": "therapy"
|
||||
}
|
||||
|
||||
def test_scope_and_sessions_are_mutually_exclusive(self):
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
scope_recall_fields(scope="therapy", sessions=["a"])
|
||||
|
||||
def test_scope_and_a_single_session_are_mutually_exclusive(self):
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
scope_recall_fields(scope="therapy", sessions=None, session_id="a")
|
||||
|
||||
def test_sessions_composes_with_a_single_session(self):
|
||||
# Unlike `scope`, an allowlist may accompany a session_id — the server
|
||||
# only requires that the session be inside the allowlist.
|
||||
assert scope_recall_fields(scope=None, sessions=["a", "b"], session_id="a") == {
|
||||
"filters": {"session_id": ["a", "b"]}
|
||||
}
|
||||
|
||||
def test_rejects_an_empty_allowlist(self):
|
||||
# An empty allowlist is fail-closed server-side (recalls nothing), which
|
||||
# is never what `sessions=[]` intends.
|
||||
with pytest.raises(ValueError, match="at least one session"):
|
||||
scope_recall_fields(scope=None, sessions=[])
|
||||
|
||||
def test_rejects_an_over_cap_allowlist(self):
|
||||
with pytest.raises(ValueError, match="at most"):
|
||||
scope_recall_fields(
|
||||
scope=None,
|
||||
sessions=[f"s{i}" for i in range(MAX_SESSION_ALLOWLIST_ENTRIES + 1)],
|
||||
)
|
||||
|
||||
def test_resolves_objects_with_an_id(self):
|
||||
class FakeSession:
|
||||
id: str = "session-a"
|
||||
|
||||
fields = scope_recall_fields(scope=None, sessions=[FakeSession()]) # pyright: ignore[reportArgumentType]
|
||||
assert fields == {"filters": {"session_id": ["session-a"]}}
|
||||
|
||||
|
||||
class TestScopeContextFields:
|
||||
"""The context route takes these as query params, not as a `filters` body."""
|
||||
|
||||
def test_neither_option_contributes_nothing(self):
|
||||
assert context_fields() == {}
|
||||
|
||||
def test_scope_passes_through(self):
|
||||
assert context_fields(scope="therapy") == {"scope": "therapy"}
|
||||
|
||||
def test_sessions_stays_a_plain_list(self):
|
||||
# Not wrapped in `filters` — this route reads a repeated query parameter.
|
||||
assert context_fields(sessions=["a", "b"]) == {"sessions": ["a", "b"]}
|
||||
|
||||
def test_scope_and_peer_perspective_are_mutually_exclusive(self):
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
context_fields(scope="therapy", peer_perspective="assistant")
|
||||
|
||||
def test_scope_and_sessions_are_mutually_exclusive(self):
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
context_fields(scope="therapy", sessions=["a"])
|
||||
|
||||
def test_sessions_and_limit_to_session_are_mutually_exclusive(self):
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
context_fields(sessions=["a"], limit_to_session=True)
|
||||
|
||||
@pytest.mark.parametrize("option", [{"scope": "therapy"}, {"sessions": ["a"]}])
|
||||
def test_either_option_requires_a_peer_target(self, option: dict[str, object]):
|
||||
# Both only reach the representation, and there is none without a target.
|
||||
# Refused rather than accepted and silently ignored.
|
||||
with pytest.raises(ValueError, match="peer_target"):
|
||||
context_fields(peer_target=None, **option)
|
||||
|
||||
def test_limit_to_session_alone_is_untouched(self):
|
||||
# The neutral case must not start emitting a scope/sessions key.
|
||||
assert context_fields(limit_to_session=True) == {}
|
||||
|
||||
|
||||
class TestResolveScopeMembership:
|
||||
def test_resolves_ids_and_objects_in_order(self):
|
||||
class FakeSession:
|
||||
id: str = "session-b"
|
||||
|
||||
assert resolve_scope_membership(["session-a", FakeSession()]) == [ # pyright: ignore[reportArgumentType]
|
||||
"session-a",
|
||||
"session-b",
|
||||
]
|
||||
|
||||
def test_rejects_empty(self):
|
||||
with pytest.raises(ValueError, match="At least one session"):
|
||||
resolve_scope_membership([])
|
||||
|
||||
def test_rejects_over_the_per_call_cap_rather_than_chunking(self):
|
||||
with pytest.raises(ValueError, match="At most"):
|
||||
resolve_scope_membership([f"s{i}" for i in range(MAX_SESSIONS_PER_ADD + 1)])
|
||||
|
||||
def test_rejects_a_malformed_id(self):
|
||||
with pytest.raises(ValueError, match="must match pattern"):
|
||||
resolve_scope_membership(["ok-session", "valid-session?typo"])
|
||||
|
||||
|
||||
class TestResolveScopeSession:
|
||||
"""Guards the ID that gets interpolated into a scope membership URL path."""
|
||||
|
||||
def test_resolves_a_plain_id(self):
|
||||
assert resolve_scope_session("session-a") == "session-a"
|
||||
|
||||
def test_resolves_an_object(self):
|
||||
class FakeSession:
|
||||
id: str = "session-a"
|
||||
|
||||
assert resolve_scope_session(FakeSession()) == "session-a" # pyright: ignore[reportArgumentType]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"malformed",
|
||||
[
|
||||
"valid-session?typo", # would address `valid-session` + a query string
|
||||
"valid-session/../other", # would climb the path
|
||||
"valid session",
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_rejects_ids_that_would_alter_the_request_path(self, malformed: str):
|
||||
# This value lands in a DELETE path. An unvalidated id silently changes
|
||||
# which session is removed, and removal triggers reconciliation against
|
||||
# whatever it hits.
|
||||
with pytest.raises(ValueError, match="Session ID"):
|
||||
resolve_scope_session(malformed)
|
||||
|
||||
|
||||
def test_deprecated_conclusion_scope_aliases_still_resolve():
|
||||
"""The rename keeps working for callers on the old name."""
|
||||
from sdks.python.src.honcho import (
|
||||
ConclusionScope,
|
||||
ConclusionScopeAio,
|
||||
ConclusionsView,
|
||||
ConclusionsViewAio,
|
||||
)
|
||||
|
||||
assert ConclusionScope is ConclusionsView
|
||||
assert ConclusionScopeAio is ConclusionsViewAio
|
||||
|
|
@ -175,6 +175,117 @@ async def test_polling_loop_idle_sleeps_once_per_cycle(
|
|||
assert sleeps == [1.0, 2.0, 4.0, 8.0, 8.0]
|
||||
|
||||
|
||||
def test_is_tenant_work_ignores_reconciler_only_batches() -> None:
|
||||
from src.deriver.queue_manager import QueueManager
|
||||
|
||||
assert not QueueManager._is_tenant_work(["reconciler:sync_vectors"]) # pyright: ignore[reportPrivateUsage]
|
||||
assert not QueueManager._is_tenant_work( # pyright: ignore[reportPrivateUsage]
|
||||
["reconciler:sync_vectors", "reconciler:cleanup_queue"]
|
||||
)
|
||||
# A mixed batch is tenant work: real work is present alongside housekeeping.
|
||||
assert QueueManager._is_tenant_work( # pyright: ignore[reportPrivateUsage]
|
||||
["reconciler:sync_vectors", "representation:ws:sess:peer"]
|
||||
)
|
||||
# Unparseable keys count as tenant work so an unknown key can't strand the
|
||||
# loop in a long sleep.
|
||||
assert QueueManager._is_tenant_work(["not-a-real-key"]) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconciler_work_does_not_reset_backoff(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The reconciler enqueues sweeps on its own timer. Claiming one must not
|
||||
look like a busy queue, or the backoff resets every cycle and the pooler
|
||||
never releases an idle tenant's connection."""
|
||||
monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_ENABLED", True)
|
||||
monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_INTERVAL_SECONDS", 1.0)
|
||||
monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_MULTIPLIER", 2.0)
|
||||
monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_MAX_INTERVAL_SECONDS", 64.0)
|
||||
monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.0)
|
||||
|
||||
import asyncio
|
||||
|
||||
from src.deriver import queue_manager as qm_mod
|
||||
|
||||
qm = qm_mod.QueueManager()
|
||||
sleeps: list[float] = []
|
||||
polls = {"n": 0}
|
||||
|
||||
async def fake_cleanup() -> None:
|
||||
return None
|
||||
|
||||
async def fake_claim() -> dict[str, str]:
|
||||
polls["n"] += 1
|
||||
if polls["n"] >= 5:
|
||||
qm.shutdown_event.set()
|
||||
# Third poll hands back a reconciler sweep; the rest are empty.
|
||||
return {"reconciler:sync_vectors": "aqs-1"} if polls["n"] == 3 else {}
|
||||
|
||||
async def fake_process(_work_unit_key: str, _worker_id: str) -> None:
|
||||
return None
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
sleeps.append(seconds)
|
||||
|
||||
monkeypatch.setattr(qm, "cleanup_stale_work_units", fake_cleanup)
|
||||
monkeypatch.setattr(qm, "get_and_claim_work_units", fake_claim)
|
||||
monkeypatch.setattr(qm, "process_work_unit", fake_process)
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
await qm.polling_loop()
|
||||
|
||||
# Polls 1 and 2 sleep 1 and 2. Poll 3 claims the sweep, so it neither sleeps
|
||||
# nor resets. Polls 4 and 5 resume the schedule at 4 -- not back at 1.
|
||||
assert sleeps == [1.0, 2.0, 4.0, 8.0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_work_still_resets_backoff(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Real tenant work must still snap the interval back for fast pickup."""
|
||||
monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_ENABLED", True)
|
||||
monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_INTERVAL_SECONDS", 1.0)
|
||||
monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_MULTIPLIER", 2.0)
|
||||
monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_MAX_INTERVAL_SECONDS", 64.0)
|
||||
monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.0)
|
||||
|
||||
import asyncio
|
||||
|
||||
from src.deriver import queue_manager as qm_mod
|
||||
|
||||
qm = qm_mod.QueueManager()
|
||||
sleeps: list[float] = []
|
||||
polls = {"n": 0}
|
||||
|
||||
async def fake_cleanup() -> None:
|
||||
return None
|
||||
|
||||
async def fake_claim() -> dict[str, str]:
|
||||
polls["n"] += 1
|
||||
if polls["n"] >= 5:
|
||||
qm.shutdown_event.set()
|
||||
return {"representation:ws:sess:peer": "aqs-1"} if polls["n"] == 3 else {}
|
||||
|
||||
async def fake_process(_work_unit_key: str, _worker_id: str) -> None:
|
||||
return None
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
sleeps.append(seconds)
|
||||
|
||||
monkeypatch.setattr(qm, "cleanup_stale_work_units", fake_cleanup)
|
||||
monkeypatch.setattr(qm, "get_and_claim_work_units", fake_claim)
|
||||
monkeypatch.setattr(qm, "process_work_unit", fake_process)
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
await qm.polling_loop()
|
||||
|
||||
# Poll 3 finds tenant work, so polls 4 and 5 start over from the base
|
||||
# interval rather than continuing from 4.
|
||||
assert sleeps == [1.0, 2.0, 1.0, 2.0]
|
||||
|
||||
|
||||
def test_inflight_gauge_no_drift(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings.METRICS, "NAMESPACE", "test")
|
||||
child: Any = db_queries_in_flight_gauge.labels(instance_type="api")
|
||||
|
|
|
|||
|
|
@ -601,6 +601,62 @@ class TestCreateObservations:
|
|||
batch_embed.assert_not_awaited()
|
||||
create_documents.assert_not_awaited()
|
||||
|
||||
@pytest.mark.parametrize("deduplicate_setting", [True, False])
|
||||
async def test_create_observations_honors_deduplicate_setting(
|
||||
self,
|
||||
tool_test_data: Any,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
deduplicate_setting: bool,
|
||||
):
|
||||
"""create_observations forwards settings.DERIVER.DEDUPLICATE to create_documents.
|
||||
|
||||
Guards against reintroducing a hardcoded deduplicate=True, which made
|
||||
DERIVER_DEDUPLICATE=false unable to disable dedup on this path (#989).
|
||||
"""
|
||||
workspace, peer1, peer2, session, _, _ = tool_test_data
|
||||
monkeypatch.setattr(settings.DERIVER, "DEDUPLICATE", deduplicate_setting)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_batch_embed(texts: list[str]) -> list[list[float]]:
|
||||
return [[0.1, 0.2, 0.3] for _ in texts]
|
||||
|
||||
async def fake_create_documents(
|
||||
_db: AsyncSession,
|
||||
documents: list[Any],
|
||||
workspace_name: str,
|
||||
*,
|
||||
observer: str,
|
||||
observed: str,
|
||||
deduplicate: bool = False,
|
||||
) -> crud.CreateDocumentsResult:
|
||||
_ = (workspace_name, observer, observed)
|
||||
captured["deduplicate"] = deduplicate
|
||||
return crud.CreateDocumentsResult(created_documents=documents)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.utils.agent_tools.embedding_client.simple_batch_embed",
|
||||
fake_batch_embed,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.utils.agent_tools.crud.create_documents", fake_create_documents
|
||||
)
|
||||
|
||||
result = await create_observations(
|
||||
observations=[
|
||||
schemas.ObservationInput(content="An observation", level="explicit"),
|
||||
],
|
||||
observer=peer1.name,
|
||||
observed=peer2.name,
|
||||
session_name=session.name,
|
||||
workspace_name=workspace.name,
|
||||
message_ids=[],
|
||||
message_created_at=str(datetime.now(timezone.utc)),
|
||||
)
|
||||
|
||||
assert isinstance(result, ObservationsCreatedResult)
|
||||
assert captured["deduplicate"] is deduplicate_setting
|
||||
|
||||
|
||||
class TestNormalizeObservationId:
|
||||
"""Unit tests for _normalize_observation_id."""
|
||||
|
|
|
|||
Loading…
Reference in New Issue