Merge origin/main into vineeth/dev-1695 (resolve conflicts)

This commit is contained in:
ajspig 2026-05-18 17:33:17 -04:00
commit 4460544b96
98 changed files with 7051 additions and 2547 deletions

View File

@ -102,7 +102,7 @@ Based on interview responses, implement the integration:
## Before You Start
1. **Check the latest SDK versions** at <https://docs.honcho.dev/changelog/introduction>
1. **Check the latest SDK versions** at <https://honcho.dev/docs/changelog/introduction>
- Python SDK: `honcho-ai`
- TypeScript SDK: `@honcho-ai/sdk`
@ -549,6 +549,6 @@ When integrating Honcho into an existing codebase:
## Resources
- Documentation: <https://docs.honcho.dev>
- Latest SDK versions: <https://docs.honcho.dev/changelog/introduction>
- API Reference: <https://docs.honcho.dev/v3/api-reference/introduction>
- Documentation: <https://honcho.dev/docs>
- Latest SDK versions: <https://honcho.dev/docs/changelog/introduction>
- API Reference: <https://honcho.dev/docs/v3/api-reference/introduction>

View File

@ -112,7 +112,8 @@ LLM_OPENAI_API_KEY=your-api-key-here
# DERIVER_DEDUPLICATE=true
# DERIVER_MODEL_CONFIG__MAX_OUTPUT_TOKENS=4096
# DERIVER_LOG_OBSERVATIONS=false
# DERIVER_MAX_INPUT_TOKENS=23000
# DERIVER_MAX_INPUT_TOKENS=25000
# DERIVER_MAX_CUSTOM_INSTRUCTIONS_TOKENS=2000
# DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=100
# DERIVER_REPRESENTATION_BATCH_MAX_TOKENS=1024
# DERIVER_FLUSH_ENABLED=false # Bypass batch token threshold, process work immediately
@ -142,11 +143,11 @@ LLM_OPENAI_API_KEY=your-api-key-here
# DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL=gpt-5.4-mini
# DIALECTIC_LEVELS__minimal__MAX_TOOL_ITERATIONS=1
# DIALECTIC_LEVELS__minimal__MAX_OUTPUT_TOKENS=250
# DIALECTIC_LEVELS__minimal__TOOL_CHOICE=any
# DIALECTIC_LEVELS__minimal__TOOL_CHOICE=auto
# DIALECTIC_LEVELS__low__MODEL_CONFIG__TRANSPORT=openai
# DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL=gpt-5.4-mini
# DIALECTIC_LEVELS__low__MAX_TOOL_ITERATIONS=5
# DIALECTIC_LEVELS__low__TOOL_CHOICE=any
# DIALECTIC_LEVELS__low__TOOL_CHOICE=auto
# DIALECTIC_LEVELS__medium__MODEL_CONFIG__TRANSPORT=openai
# DIALECTIC_LEVELS__medium__MODEL_CONFIG__MODEL=gpt-5.4-mini
# DIALECTIC_LEVELS__medium__MAX_TOOL_ITERATIONS=2
@ -278,8 +279,8 @@ VECTOR_STORE_MIGRATED=false
# - Messages: {NAMESPACE}.msg.{hash(workspace)}
# VECTOR_STORE_NAMESPACE=honcho # Inherits from NAMESPACE if not set
# Embedding dimensions (default: 1536 for OpenAI text-embedding-3-small)
# VECTOR_STORE_DIMENSIONS=1536
# Embedding dimensions are configured via EMBEDDING_VECTOR_DIMENSIONS (see top
# of this file). VECTOR_STORE_DIMENSIONS is deprecated and ignored.
# Turbopuffer-specific settings (required if TYPE is "turbopuffer")
# VECTOR_STORE_TURBOPUFFER_API_KEY=your-turbopuffer-api-key

242
CLAUDE.md
View File

@ -10,7 +10,7 @@ Honcho is an infrastructure layer for building AI agents with memory and social
- Imbuing agents with a sense of identity
- Personalizing user experiences through understanding user psychology
- Providing a Dialectic API that injects personal context just-in-time
- Providing a Chat Endpoint (the Dialectic agent) that injects personal context just-in-time
- Supporting development of LLM-powered applications that adapt to end users
- Enabling multi-peer sessions where multiple participants (users or agents) can interact
@ -32,23 +32,25 @@ Honcho uses a peer-based model where both users and agents are represented as "p
- **Peer** (formerly User): Any participant in the system (human or AI)
- **Session**: A conversation context that can involve multiple peers
- **Message**: Data units that can represent communication between peers OR arbitrary data ingested by a peer to enhance its global representation
- **Collections & Documents**: Internal vector storage for peer representations (not exposed via API)
- **Collections & Documents**: Internal vector storage for peer representations. Collections are keyed by `(observer, observed)` peer pairs. Collections/Documents are not directly exposed via API, but the observations stored within them are exposed as **Conclusions** (see `/v3/.../conclusions` endpoints).
## Architecture Overview
### API Structure
All API routes follow the pattern: `/v1/{resource}/{id}/{action}`
All API routes follow the pattern: `/v3/{resource}/{id}/{action}`. Most "list/search" endpoints are `POST` so they can accept rich filter bodies.
- **Workspaces**: Create, list, update, search
- **Peers**: Create, list, update, chat (dialectic), messages, representation
- **Sessions**: Create, list, update, delete, clone, manage peers, get context
- **Messages**: Create (batch up to 100), list, get, update
- **Messages**: Create (batch up to 100), upload (file), list, get, update
- **Conclusions**: Create, list, query (semantic search), delete — the API-facing name for observations stored in `(observer, observed)` collections
- **Keys**: Create scoped JWTs
- **Webhooks**: Register endpoint, list, delete, test
### Key Features
#### Dialectic API (`/peers/{peer_id}/chat`)
#### Chat Endpoint (Dialectic agent) (`/peers/{peer_id}/chat`)
- Provides bespoke responses informed by the representation
- Integrates long-term facts from vector storage
@ -115,127 +117,147 @@ cd sdks/typescript && bun run tsc --noEmit
- Docstrings: Use Google style docstrings
- **Never hold a DB session during external calls** (LLM, embedding, HTTP). If a function needs both a DB session and an external call result, compute the external result first and pass it as a parameter. This avoids tying up DB connections during slow network I/O. Use `tracked_db` for short-lived, DB-only operations; pass a shared session when multiple DB-only calls can reuse one connection.
### Runtime Architecture
Honcho runs as two cooperating processes that share a Postgres database and Redis cache:
- **API server** (`uv run fastapi dev src/main.py`) — handles HTTP, enqueues background work, returns immediately. Hosts the **Dialectic** agent inline (synchronous tool loop during chat requests).
- **Deriver worker** (`uv run python -m src.deriver`) — long-running queue consumer (uvloop). Runs the **Deriver**, **Summarizer**, and **Dreamer** off the queue. Can run multiple instances (`DERIVER_WORKERS`). Also hosts an in-process **Reconciler scheduler** (`src/reconciler/`) that periodically embeds messages with `sync_state='pending'` in `MessageEmbedding` and cleans up stale queue items — embedding generation is decoupled from message creation by design.
### Agent Architecture
Honcho uses three specialized LLM agents that work together to form memories and answer queries:
Honcho uses several specialized LLM agents. They share tool definitions and the LLM client abstraction in `src/utils/agent_tools.py` + `src/llm/`.
#### 1. Deriver Agent (`src/deriver/agent/`)
> **Terminology:** what users see as **conclusions** (the public API surface and the term we use in documentation) is called **observations** in code symbols — `create_observations`, `delete_observations`, `get_observation_context`, etc. Doc prose below uses "conclusions"; references to actual code symbols stay as "observations."
**Role**: Memory formation through content ingestion
#### 1. Deriver (`src/deriver/`)
The Deriver processes incoming messages and extracts observations about peers.
**Role**: Memory formation through content ingestion.
- **Trigger**: Messages created via API are enqueued for background processing
- **Tools**: `create_observations`, `update_peer_card`, `get_recent_history`, `search_memory`, `get_observation_context`, `search_messages`
- **Output**: Explicit observations (direct facts) and deductive observations (inferences)
- **Entry point**: `src/deriver/agent/worker.py``Agent.run_loop()`
The Deriver processes batches of incoming messages and extracts conclusions about peers. The current architecture is "minimal deriver" — a **single LLM call** per batch using structured output, not an agentic tool loop. This trades flexibility for cost and predictability.
#### 2. Dialectic Agent (`src/dialectic/agent/`)
- **Trigger**: Messages enqueued by `src/deriver/enqueue.py` on message create; consumed by `src/deriver/queue_manager.py``consumer.process_item()``deriver.process_representation_tasks_batch()`.
- **Output**: Explicit conclusions (direct facts) and deductive conclusions (inferences) saved to `(observer, observed)` collections.
- **Entry point**: `src/deriver/__main__.py``queue_manager.main()`.
- **Prompts**: `src/deriver/prompts.py` (`minimal_deriver_prompt`).
**Role**: Analysis and recall for answering queries
#### 2. Dialectic (`src/dialectic/`)
The Dialectic answers questions about peers by strategically gathering context from memory.
**Role**: Analysis and recall for answering queries.
- **Trigger**: API call to `/peers/{peer_id}/chat` with `agentic=true`
- **Tools**: `search_memory`, `get_recent_history`, `get_observation_context`, `search_messages`, `get_recent_observations`, `get_most_derived_observations`, `get_session_summary`, `get_peer_card`, `create_observations` (deductive only)
- **Output**: Natural language response grounded in gathered context
- **Entry point**: `src/dialectic/chat.py``agentic_chat()``DialecticAgent.answer()`
The Dialectic answers questions about peers by strategically gathering context from memory. It is the only tool-using agent on the synchronous request path — it loops over `DIALECTIC_TOOLS` until it has enough context to answer. (The Dreamer specialists also use tools, but run off the queue.)
#### 3. Dreamer Agent (`src/dreamer/agent.py`)
- **Trigger**: API call to `POST /v3/.../peers/{peer_id}/chat`.
- **Tools** (see `DIALECTIC_TOOLS` in `src/utils/agent_tools.py`): `search_memory`, `search_messages`, `get_observation_context`, `grep_messages`, `get_messages_by_date_range`, `search_messages_temporal`, `get_reasoning_chain`. At the `minimal` reasoning level, a reduced set (`DIALECTIC_TOOLS_MINIMAL`) is used: just `search_memory` + `search_messages`.
- **Reasoning levels**: 5 tiers — `minimal`, `low`, `medium`, `high`, `max` — each with its own model config (see `DialecticLevelSettings` in `src/config.py`).
- **Output**: Natural language response grounded in gathered context. Supports SSE streaming.
- **Entry point**: `src/dialectic/chat.py``agentic_chat()` / `agentic_chat_stream()``DialecticAgent` (in `src/dialectic/core.py`).
**Role**: Consolidation and self-improvement of memory
#### 3. Dreamer (`src/dreamer/`)
The Dreamer explores and consolidates observations to improve memory quality.
**Role**: Consolidation and self-improvement of memory.
- **Trigger**: Scheduled or explicit dream task via queue
- **Tools**: `get_recent_observations`, `get_most_derived_observations`, `search_memory`, `create_observations`, `delete_observations`, `update_peer_card`
- **Strategy**: Random walk exploration - start from recent/high-value observations, search for related content, consolidate redundancies
- **Output**: Consolidated observations, deleted redundancies
- **Entry point**: `src/dreamer/agent.py``DreamerAgent.consolidate()`
The Dreamer is an orchestrated multi-specialist system that runs during scheduled "dreams" to consolidate conclusions and build reasoning trees.
- **Trigger**: Scheduled via `DreamScheduler` (`src/dreamer/dream_scheduler.py`) or explicit dream task on the queue.
- **Strategy**: Surprisal-based prioritization (`src/dreamer/surprisal.py`) selects which conclusions to expand. The orchestrator (`orchestrator.run_dream`) runs two specialist phases:
1. **DeductionSpecialist** (`specialists.py`) — produces deductive conclusions from explicit conclusions. Tools: `get_recent_observations`, `search_memory`, `search_messages`, `create_observations_deductive`, `delete_observations`, `update_peer_card`.
2. **InductionSpecialist** — produces inductive conclusions from explicit + deductive conclusions. Tools: same discovery set + `create_observations_inductive`, `update_peer_card`.
- **Reasoning trees** (`src/dreamer/trees/`, migration `f1a2b3c4d5e6_add_reasoning_tree_columns`): each conclusion links to its premises and downstream conclusions, enabling `get_reasoning_chain` traversal at recall time.
- **Output**: Deductive/inductive conclusions, consolidated redundancies, updated peer cards.
- **Entry point**: `src/dreamer/orchestrator.py``process_dream()` (the package-level export from `src/dreamer/__init__.py`), which wraps `run_dream()`.
#### 4. Summarizer (`src/utils/summarizer.py`)
**Role**: Two-tier session summarization (direct LLM call — no agentic tools).
- **Trigger**: Runs as part of the queue pipeline alongside representation tasks.
- **Tiers**: short summary every `SUMMARY_MESSAGES_PER_SHORT_SUMMARY` messages (default 20); long summary every `SUMMARY_MESSAGES_PER_LONG_SUMMARY` (default 60). Token caps configurable via `SUMMARY_MAX_TOKENS_SHORT` / `SUMMARY_MAX_TOKENS_LONG`.
#### Shared Agent Infrastructure
All agents share common infrastructure in `src/utils/agent_tools.py`:
- **Tool definitions**: Unified tool schemas used by all agents
- **Tool executor**: `create_tool_executor()` factory creates context-aware executors
- **LLM client**: `honcho_llm_call()` handles tool calling loops with configurable iterations
- **Tool definitions** (`src/utils/agent_tools.py`): unified `TOOLS` dict; per-agent lists (`DIALECTIC_TOOLS`, `DIALECTIC_TOOLS_MINIMAL`, `DREAMER_TOOLS`, `DEDUCTION_SPECIALIST_TOOLS`, `INDUCTION_SPECIALIST_TOOLS`).
- **LLM subsystem** (`src/llm/`): provider-agnostic `honcho_llm_call()`. Backends in `src/llm/backends/` (`anthropic.py`, `gemini.py`, `openai.py`). Includes prompt caching (`caching.py`), structured output (`structured_output.py`), tool loop (`tool_loop.py`), history adapters for cross-provider message formats, and a model registry.
- **Per-agent model config**: each agent has its own `MODEL_CONFIG` in `src/config.py` with fallback chains (see `ConfiguredModelSettings`, `FallbackModelSettings`).
### Project Structure
```
src/
├── main.py # FastAPI app setup with middleware and exception handlers
├── models.py # SQLAlchemy ORM models with proper type annotations
├── schemas.py # Pydantic validation schemas for API
├── config.py # Configuration management
├── db.py # Database connection and session management
├── dependencies.py # Dependency injection (DB sessions)
├── exceptions.py # Custom exception types
├── main.py # FastAPI app: middleware, routers, lifespan, exception handlers
├── models.py # SQLAlchemy ORM models (Workspace/Peer/Session/Message/
│ # MessageEmbedding/Collection/Document/QueueItem/...)
├── config.py # Pydantic-settings configuration (very large; see README)
├── db.py # Engine + session/context management (request_context var)
├── dependencies.py # FastAPI DI (tracked_db, etc.)
├── exceptions.py # Custom exception types (HonchoException + subclasses)
├── security.py # JWT authentication
├── embedding_client.py # Embedding service client
├── crud/ # Database operations
│ ├── __init__.py
│ ├── collection.py # Collection CRUD operations
│ ├── deriver.py # Deriver-related CRUD operations
│ ├── document.py # Document CRUD operations
│ ├── message.py # Message CRUD operations
│ ├── peer.py # Peer CRUD operations
│ ├── peer_card.py # Peer Card CRUD operations
│ ├── representation.py # RepresentationManager and representation operations
│ ├── session.py # Session CRUD operations
│ ├── webhook.py # Webhook CRUD operations
│ └── workspace.py # Workspace CRUD operations
├── dialectic/ # Dialectic API implementation
│ ├── __init__.py
│ ├── chat.py # Chat functionality (standard + agentic)
│ ├── prompts.py # Prompt templates
│ └── agent/ # Agentic dialectic implementation
│ ├── __init__.py
│ ├── core.py # DialecticAgent class
│ └── prompts.py # Agent system prompts
├── routers/ # API endpoints
│ ├── workspaces.py
│ ├── peers.py
│ ├── sessions.py
│ ├── messages.py
│ ├── keys.py
│ └── webhooks.py # Webhook endpoints
├── deriver/ # Background processing system
│ ├── __init__.py
│ ├── __main__.py # Deriver entry point
│ ├── consumer.py # Message consumer
│ ├── enqueue.py # Queue operations
│ ├── queue_manager.py # Queue management
│ └── agent/ # Agentic deriver implementation
│ ├── __init__.py
│ ├── core.py # Agent class
│ ├── worker.py # Task processing
│ └── prompts.py # Agent system prompts
├── dreamer/ # Memory consolidation system
│ ├── __init__.py
│ ├── agent.py # DreamerAgent class + process_agent_dream
│ └── dreamer.py # Legacy dreamer (scheduled)
├── utils/ # Utilities
│ ├── __init__.py
│ ├── agent_tools.py # Shared agent tools and executor
│ ├── clients.py # LLM client abstraction
│ ├── files.py # File handling utilities
│ ├── filter.py # Query filtering utilities
│ ├── formatting.py # Message formatting utilities
│ ├── logging.py # Logging and metrics (Rich console output)
│ ├── search.py # Search functionality
│ ├── shared_models.py # Shared data models
│ ├── summarizer.py # Session summarization
│ └── types.py # Type definitions
└── webhooks/ # Webhook system
├── events.py # Webhook event definitions
├── webhook_delivery.py # Webhook delivery logic
└── README.md # Webhook documentation
├── embedding_client.py # Embedding provider client
├── schemas/ # Pydantic schemas
│ ├── api.py # Public API request/response schemas
│ ├── configuration.py # Per-resource configuration schemas
│ └── internal.py # Internal-only schemas (queue payloads, etc.)
├── crud/ # Per-resource DB operations
│ ├── collection.py, deriver.py, document.py, message.py
│ ├── peer.py, peer_card.py, representation.py (RepresentationManager)
│ ├── session.py, webhook.py, workspace.py
├── routers/ # FastAPI route handlers (all under /v3)
│ ├── workspaces.py, peers.py (dialectic /chat lives here), sessions.py
│ ├── messages.py, conclusions.py, keys.py, webhooks.py
├── dialectic/ # Dialectic agent — runs inline per chat request
│ ├── chat.py # agentic_chat() / agentic_chat_stream()
│ ├── core.py # DialecticAgent (the tool-loop driver)
│ └── prompts.py
├── deriver/ # Background queue consumer (separate process)
│ ├── __main__.py # `python -m src.deriver` entry point
│ ├── queue_manager.py # QueueManager + main() loop
│ ├── consumer.py # process_item dispatcher (representation / deletion / reconciler)
│ ├── deriver.py # "minimal deriver" — single-LLM-call batch processor
│ ├── enqueue.py # API → queue producer
│ └── prompts.py
├── dreamer/ # Memory consolidation (runs off the queue)
│ ├── orchestrator.py # run_dream() / process_dream()
│ ├── specialists.py # DeductionSpecialist + InductionSpecialist
│ ├── dream_scheduler.py
│ ├── surprisal.py # Surprisal-based conclusion prioritization
│ └── trees/ # Reasoning-tree primitives
├── reconciler/ # In-process scheduler hosted by the deriver worker
│ ├── scheduler.py # ReconcilerScheduler (started from queue_manager.py)
│ ├── sync_vectors.py # Embeds MessageEmbedding rows with sync_state='pending'
│ └── queue_cleanup.py # Removes stale queue items
├── llm/ # Provider-agnostic LLM client subsystem
│ ├── api.py, backend.py, executor.py, runtime.py, registry.py
│ ├── caching.py, structured_output.py, tool_loop.py, conversation.py
│ ├── history_adapters.py, request_builder.py, credentials.py, types.py
│ └── backends/ # anthropic.py, gemini.py, openai.py
├── cache/ # Redis cache abstraction (cashews-backed)
│ └── client.py
├── vector_store/ # Optional external vector stores (pgvector is default,
│ │ # implemented via MessageEmbedding/Document in models+crud)
│ ├── lancedb.py
│ └── turbopuffer.py
├── telemetry/ # Observability
│ ├── emitter.py # CloudEvents emitter
│ ├── logging.py # Logging helpers + route-template extraction
│ ├── metrics_collector.py, reasoning_traces.py, sentry.py
│ ├── events/ # Event type definitions
│ └── prometheus/ # Prometheus metric definitions
├── utils/ # Cross-cutting utilities
│ ├── agent_tools.py # Tool definitions + per-agent tool lists
│ ├── summarizer.py # Two-tier session summarizer
│ ├── representation.py # Representation formatting (distinct from crud/representation.py)
│ ├── search.py, filter.py, formatting.py
│ ├── tokens.py # tiktoken-based counting
│ ├── work_unit.py, queue_payload.py
│ ├── config_helpers.py, json_parser.py, files.py
│ └── types.py
└── webhooks/ # Webhook delivery
├── events.py
└── webhook_delivery.py
```
- Tests in pytest with fixtures in tests/conftest.py
- Use environment variables via python-dotenv (.env)
- Tests in pytest with fixtures in tests/conftest.py; subdirs mirror src/ (`tests/deriver/`, `tests/dialectic/`, etc.) plus `tests/bench/` (perf benchmarks), `tests/integration/`, `tests/live_llm/` (gated by `--live-llm`), and `tests/unified/` (the unified runner).
- Use environment variables via python-dotenv (.env). Config precedence: env > .env > config.toml > defaults.
### Database Design
@ -248,12 +270,18 @@ src/
### Key Architectural Decisions
1. **Multi-Peer Sessions**: Sessions can have multiple participants with different observation settings
3. **Background Processing**: Async queue system for expensive operations
4. **Provider Abstraction**: Model client supports multiple LLM providers
5. **Scoped Authentication**: JWTs can be scoped to workspace, peer, or session level
6. **Batch Operations**: Support for bulk message creation (up to 100 messages)
7. **Session History**: Two-tier summarization (short every 20 messages, long every 60)
1. **Peer Paradigm**: humans and AI agents are unified as "Peers"; many-to-many with Sessions. Internal vector storage (Collections/Documents) is keyed by `(observer, observed)` peer pairs — the same mechanism powers self-representation (`observer == observed`) and cross-peer modeling.
2. **Multi-Peer Sessions**: Sessions can have multiple participants with different observation settings.
3. **API server / worker split**: API enqueues, deriver worker process consumes. Never block HTTP on LLM work. The Reconciler runs as an in-process scheduler inside the deriver, handling async embedding sync and queue cleanup.
4. **"Minimal" deriver**: memory formation is a single structured-output LLM call per batch, not an agentic tool loop. Predictable cost, lower latency. The Dialectic is the one true tool-using agent.
5. **Provider-agnostic LLM layer** (`src/llm/`): all model calls go through `honcho_llm_call()`. Backends (`anthropic`, `gemini`, `openai`) sit behind a registry; per-agent `MODEL_CONFIG` with fallback chains is resolved at call time.
6. **Dialectic reasoning tiers**: 5 levels (`minimal` → `max`); each level has its own model config and tool set (`minimal` uses a reduced toolset).
7. **Hybrid search**: Postgres FTS (GIN index on `to_tsvector('english', content)`) + vector similarity (HNSW on `MessageEmbedding.embedding`). `MessageEmbedding` is a separate table from `Message` with its own `sync_state` so embedding is decoupled from message creation.
8. **Pluggable external vector stores**: defaults to pgvector inline; can swap to turbopuffer or lancedb (`VECTOR_STORE_*` config; `src/vector_store/`).
9. **Composite-FK multi-tenancy**: `workspace_name` participates in nearly every composite FK. Cross-workspace data leakage is structurally impossible at the schema level.
10. **Scoped Authentication**: JWTs can be scoped to workspace, peer, or session level.
11. **Batch Operations**: Bulk message creation up to 100 messages per request.
12. **Session History**: Two-tier summarization — short every `SUMMARY_MESSAGES_PER_SHORT_SUMMARY` (default 20), long every `SUMMARY_MESSAGES_PER_LONG_SUMMARY` (default 60).
### Error Handling

View File

@ -101,6 +101,45 @@ git commit -m "docs(readme): update installation instructions"
- Any relevant issue numbers (use "Closes #123" to auto-close issues)
- Screenshots or examples if applicable
## Pre-commit Hooks
Honcho uses pre-commit hooks to enforce code quality and consistency. They run linting, formatting, type checking, and security scans before each commit.
### Installation
```bash
uv add --dev pre-commit
uv run pre-commit install \
--hook-type pre-commit \
--hook-type commit-msg \
--hook-type pre-push
```
### What the hooks do
- **Code Quality** — Python linting and formatting (ruff), TypeScript linting (biome)
- **Type Checking** — Static analysis with basedpyright
- **Security** — Vulnerability scanning with bandit
- **Documentation** — Markdown linting and license header checks
- **Testing** — Automated test runs for Python and TypeScript
- **File Hygiene** — Trailing whitespace, line endings, file size checks
- **Commit Standards** — Conventional commit message validation
### Manual execution
Run against all files without committing:
```bash
uv run pre-commit run --all-files
```
Run a specific hook:
```bash
uv run pre-commit run ruff --all-files
uv run pre-commit run basedpyright --all-files
```
## Coding Standards
### Python Code Style

652
README.md
View File

@ -8,164 +8,303 @@
---
![Static Badge](https://img.shields.io/badge/Version-3.0.6-blue)
![Static Badge](https://img.shields.io/badge/Server-3.0.6-blue)
[![PyPI version](https://img.shields.io/pypi/v/honcho-ai.svg)](https://pypi.org/project/honcho-ai/)
[![NPM version](https://img.shields.io/npm/v/@honcho-ai/sdk.svg)](https://npmjs.org/package/@honcho-ai/sdk)
[![Discord](https://img.shields.io/discord/1016845111637839922?style=flat&logo=discord&logoColor=23ffffff&label=Plastic%20Labs&labelColor=235865F2)](https://discord.gg/honcho)
Honcho is an open source memory library with a managed service for building stateful
agents. Use it with any model, framework, or architecture. It enables agents to build
and maintain state about any entity--users, agents, groups, ideas, and more. And because
it's a continual learning system, it understands entities that change over time. Using
Honcho as your memory system will earn your agents higher retention, more trust, and
help you build data moats to out-compete incumbents.
**Honcho is memory infrastructure for building stateful agents that understand changing people, agents, groups, projects, and ideas over time.**
> Honcho has defined the Pareto Frontier of Agent Memory. Watch the [video](https://x.com/honchodotdev/status/2002090546521911703?s=20), check out our [evals page](https://evals.honcho.dev/), and read the [blog post](https://blog.plasticlabs.ai/research/Benchmarking-Honcho) for more detail.
Store messages and events, let Honcho reason in the background, then query peer representations, session context, search results, or natural-language insights from any model or framework. Use it managed at [api.honcho.dev](https://api.honcho.dev) or self-host the FastAPI server yourself.
## TL;DR - Getting Started
Using Honcho as your memory system will earn your agents higher retention, more trust, and help you build data moats to out-compete incumbents.
With Honcho you can easily setup your application's workflow, save your
interaction history, and leverage the reasoning it does to inform the behavior of
your agents
> Honcho has defined the Pareto Frontier of Agent Memory. Watch the [video](https://x.com/honchodotdev/status/2002090546521911703?s=20), check out our [evals page](https://honcho.dev/evals/), and read the [blog post](https://blog.plasticlabs.ai/research/Benchmarking-Honcho) for more detail.
> Typescript examples are available in our [docs](https://docs.honcho.dev).
## Contents
1. Install the SDK
```bash
# Python
pip install honcho-ai
uv add honcho-ai
poetry add honcho-ai
```
2. Setup your `Workspace`, `Peers`, `Session`, and send `Messages`
```python
from honcho import Honcho
# 1. Initialize your Honcho client
honcho = Honcho(workspace_id="my-app-testing")
# 2. Initialize peers
alice = honcho.peer("alice")
tutor = honcho.peer("tutor")
# 3. Create a session and add messages
session = honcho.session("session-1")
# Adding messages from a peer will automatically add them to the session
session.add_messages(
[
alice.message("Hey there — can you help me with my math homework?"),
tutor.message("Absolutely. Send me your first problem!"),
]
)
```
3. Leverage reasoning from Honcho to inform your agent's behavior
```python
### 1. Use the chat endpoint to ask questions about your users in natural language
response = alice.chat("What learning styles does the user respond to best?")
### 2. Use session context to continue a conversation with an LLM
context = session.context(summary=True, tokens=10_000)
# Convert to a format to send to OpenAI and get the next message
openai_messages = context.to_openai(assistant=tutor)
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4",
messages=openai_messages
)
### 3. Search for similar messages
results = alice.search("Math Homework")
### 4. Get a session-scoped representation of a peer
alice_representation = session.representation(alice)
```
This is a simple example of how you can use Honcho to build a chatbot and
leverage insights to personalize the agent's behavior.
Sign up at [app.honcho.dev](https://app.honcho.dev) to get started with a managed version of Honcho.
Learn more ways to use Honcho on our [developer docs](https://docs.honcho.dev).
Read about the design philosophy and history of the project on our [blog](https://blog.plasticlabs.ai/).
## Project Structure
- [Usage](#usage)
- [Local Development](#local-development)
- [Prerequisites and Dependencies](#prerequisites-and-dependencies)
- [Setup](#setup)
- [Docker](#docker)
- [Deploy on Fly](#deploy-on-fly)
- [Start Here](#start-here)
- [Why Honcho](#why-honcho)
- [The Honcho Loop](#the-honcho-loop)
- [Quickstart](#quickstart)
- [What Honcho Gives You](#what-honcho-gives-you)
- [Integrations](#integrations)
- [Core Concepts](#core-concepts)
- [Benchmarks & Evals](#benchmarks--evals)
- [Self-hosting](#self-hosting)
- [Configuration](#configuration)
- [Using config.toml](#using-configtoml)
- [Using Environment Variables](#using-environment-variables)
- [Configuration Priority](#configuration-priority)
- [Example](#example)
- [Architecture](#architecture)
- [Storage](#storage)
- [Reasoning](#reasoning)
- [Retrieving Data & Insights](#retrieving-data--insights)
- [SDKs](#sdks)
- [Learn More](#learn-more)
- [Contributing](#contributing)
- [License](#license)
The Honcho project is split between several repositories with this one hosting
the core service logic. This is implemented as a FastAPI server/API to store
data about an application's state.
The Honcho project is split between several repositories, with this one hosting the core service logic — implemented as a FastAPI server. Client SDKs for Python and TypeScript live in the [`sdks/`](./sdks) directory.
There are also client SDKs implemented in the `sdks/` directory with support
for Python and TypeScript.
## Start Here
- [Python](https://pypi.org/project/honcho-ai/)
- [TypeScript](https://www.npmjs.com/package/@honcho-ai/sdk)
| I want to... | Path | Get started |
|---|---|---|
| Give my coding agent persistent memory | Claude Code, OpenCode, OpenClaw, Hermes, or any MCP client | [Integrations](#integrations) |
| Add memory to my product | Python or TypeScript SDK | [Quickstart](#quickstart) |
| Self-host Honcho | Docker / local development | [Self-hosting](#self-hosting) |
Examples on how to use the SDK are located within each SDK folder and in the
[SDK Reference](https://docs.honcho.dev/v3/documentation/tutorial/SDK)
## Why Honcho
There are also documented examples of how to use the core SDKs in the
[API Reference](https://docs.honcho.dev/api-reference/introduction) section of
the documentation.
| Capability | What it means |
|---|---|
| Reasoning-first memory | Extracts conclusions from conversations and events, not just matching chunks. |
| Peer-centric model | Tracks users, agents, groups, projects, and ideas as entities that change over time. |
| Multi-peer perspective | Models what one peer knows about another when configured. |
| Managed or self-hosted | Use `api.honcho.dev` or run the FastAPI server yourself. |
| Agent-tool integrations | MCP, Claude Code, OpenCode, OpenClaw, Hermes, Cursor-compatible clients. |
## Usage
## The Honcho Loop
Sign up for an account at
[https://app.honcho.dev](https://app.honcho.dev) and get started with $100 free credits. When you sign up you'll be prompted to
join an organization which will have a dedicated instance of Honcho.
1. **Store** conversations, events, documents, or tool traces as messages on a session.
2. **Reason** — Honcho processes the queue in the background and updates peer representations.
3. **Query** — ask Honcho for context, search results, peer representations, or a natural-language answer.
4. **Inject** — drop the result into any LLM call or agent framework.
Provision API keys and change your base url to point to
[https://api.honcho.dev](https://api.honcho.dev)
Concretely: workspaces hold peers, peers participate in sessions, messages live on sessions, and Honcho builds a per-peer representation that you query through the [Chat Endpoint](https://honcho.dev/docs/v3/documentation/features/chat) or directly.
Additionally, Honcho can be self-hosted for testing and evaluation purposes. See
the [Local Development](#local-development) section below for details on how to set up a local
version of Honcho.
## Quickstart
## Local Development
Get an API key at [app.honcho.dev](https://app.honcho.dev) — when you sign up you'll be prompted to join an organization, which gets its own dedicated Honcho instance and $100 free credits. Or [self-host](#self-hosting) and run against `http://localhost:8000`.
Below is a guide on setting up a local environment for running the Honcho
Server.
### Python
> This guide was made using a M3 Macbook Pro. For any compatibility issues
> on different platforms, please raise an Issue.
```bash
pip install honcho-ai
# or: uv add honcho-ai
# or: poetry add honcho-ai
```
### Prerequisites and Dependencies
```python
import os
from honcho import Honcho
# Managed service uses api.honcho.dev by default. For self-hosted, pass
# base_url="http://localhost:8000" or set HONCHO_URL.
honcho = Honcho(
workspace_id="my-app-testing",
api_key=os.environ["HONCHO_API_KEY"],
)
# 1. Store: peers and messages on a session
alice = honcho.peer("alice")
tutor = honcho.peer("tutor")
session = honcho.session("session-1")
session.add_messages([
alice.message("Hey there — can you help me with my math homework?"),
tutor.message("Absolutely. Send me your first problem!"),
])
# 2. Reason: happens asynchronously in the background.
# 3. Query: ask Honcho what it knows, or pull prompt-ready context.
answer = alice.chat("What learning styles does the user respond to best?")
context = session.context(summary=True, tokens=10_000)
# 4. Inject: hand the context to your model of choice.
from openai import OpenAI
client = OpenAI()
completion = client.chat.completions.create(
model=os.environ.get("OPENAI_MODEL", "gpt-4o-mini"),
messages=context.to_openai(assistant=tutor),
)
```
### TypeScript
```bash
npm install @honcho-ai/sdk
# or: bun add @honcho-ai/sdk
```
```typescript
import { Honcho } from "@honcho-ai/sdk";
import OpenAI from "openai";
const honcho = new Honcho({
workspaceId: "my-app-testing",
apiKey: process.env.HONCHO_API_KEY,
});
const alice = await honcho.peer("alice");
const tutor = await honcho.peer("tutor");
const session = await honcho.session("session-1");
await session.addMessages([
alice.message("Hey there — can you help me with my math homework?"),
tutor.message("Absolutely. Send me your first problem!"),
]);
const answer = await alice.chat("What learning styles does the user respond to best?");
const context = await session.context({ summary: true, tokens: 10_000 });
const openai = new OpenAI();
const completion = await openai.chat.completions.create({
model: process.env.OPENAI_MODEL ?? "gpt-4o-mini",
messages: context.toOpenAI({ assistant: tutor }),
});
```
> **Note:** background reasoning is asynchronous. Newly-added messages may take a moment to be reflected in chat/representation responses; for low-latency reads, use the [`representation`](https://honcho.dev/docs/v3/documentation/features/representation) endpoint.
## What Honcho Gives You
| Need | API |
|---|---|
| Save interaction history | `session.add_messages(...)` |
| Ask what Honcho knows about a peer | `peer.chat(...)` |
| Get prompt-ready context | `session.context(...).to_openai(...)` / `.to_anthropic(...)` |
| Hybrid search (BM25 + vector) | `peer.search(...)`, `session.search(...)`, `honcho.search(...)` |
| Low-latency static representations | `peer.representation(...)`, `session.representation(...)` |
| Import documents | `session.upload_file(...)` |
| Inspect background processing | `honcho.queue_status(...)` |
See the full [SDK Reference](https://honcho.dev/docs/v3/documentation/reference/sdk) and [API Reference](https://honcho.dev/docs/v3/api-reference/introduction).
## Integrations
### Claude Code
Two ways, depending on how deep you want to go:
**Plugin (richer integration — recommended for Claude Code users):**
```text
/plugin marketplace add plastic-labs/claude-honcho
/plugin install honcho@honcho
```
**Raw MCP (works in any MCP client — Cursor, Cline, Windsurf, etc.):**
```bash
claude mcp add honcho \
--transport http \
--url "https://mcp.honcho.dev" \
--header "Authorization: Bearer hch-your-key-here" \
--header "X-Honcho-User-Name: YourName"
```
Details: [Claude Code guide](https://honcho.dev/docs/v3/guides/integrations/claude-code) · [MCP guide](https://honcho.dev/docs/v3/guides/integrations/mcp).
### OpenCode
```bash
opencode plugin "@honcho-ai/opencode-honcho" --global
```
Details: [OpenCode guide](https://honcho.dev/docs/v3/guides/integrations/opencode).
### OpenClaw
```bash
openclaw plugins install @honcho-ai/openclaw-honcho
openclaw honcho setup
openclaw gateway --force
```
`openclaw honcho setup` prompts for your API key, writes the config, and optionally migrates legacy `MEMORY.md` / `USER.md` / `IDENTITY.md` files into Honcho (non-destructive — originals are never deleted). Details: [OpenClaw guide](https://honcho.dev/docs/v3/guides/integrations/openclaw).
### Hermes
```bash
hermes memory setup # select "honcho", point at api.honcho.dev or your local server
```
Details: [Hermes guide](https://honcho.dev/docs/v3/guides/integrations/hermes).
### Add Honcho to your own codebase (agent skill)
For wiring the Honcho SDK into an existing application, install the integration skill — it explores your codebase, asks about integration preferences, generates the SDK setup, and verifies it works:
```bash
npx skills add plastic-labs/honcho
```
Then invoke `/honcho-integration` in Claude Code (or `/honcho-dev:integrate` via the plugin marketplace). Details: [agentic development guide](https://honcho.dev/docs/v3/documentation/introduction/vibecoding).
### Other MCP clients
The same `claude mcp add` form (or its client-specific equivalent) works in any MCP-compatible client. See [MCP guide](https://honcho.dev/docs/v3/guides/integrations/mcp).
## Core Concepts
Honcho organises everything around **peers** — humans and AI agents alike are first-class entities. The peer model enables:
- Multi-participant sessions with mixed human and AI agents
- Configurable observation settings (which peers observe which others)
- Flexible identity management for all participants
- Support for complex multi-agent interactions
Peers exchange messages within sessions; Honcho reasons over those messages to build a representation of each peer that you can query.
- **Workspace** (formerly App): top-level container; isolates data between use cases.
- **Peer** (formerly User): any participant — human user or AI agent.
- **Session**: a conversation context; many-to-many with peers.
- **Message**: an atomic data unit (peer-to-peer communication or ingested document chunk).
What you query out of Honcho:
- **Conclusions** — what Honcho has extracted about a peer (deductive and inductive). Exposed via the [conclusions API](https://honcho.dev/docs/v3/api-reference/introduction).
- **Representations** — static, low-latency snapshots of what Honcho knows about a peer (optionally session-scoped).
- **Peer Cards** — compact identity summaries.
- **Session context / summaries** — prompt-ready bundles for long-running conversations.
<!-- markdownlint-disable MD033 -->
<details>
<summary>Internal storage (Collections &amp; Documents)</summary>
Internally, Honcho stores peer-related observations in **collections** of vector-embedded **documents**. Collections are keyed by `(observer, observed)` peer pairs — the same mechanism powers self-representation (`observer == observed`) and cross-peer modelling (peer X's understanding of peer Y). These primitives are not exposed directly; the Conclusions API is the public surface.
</details>
<!-- markdownlint-enable MD033 -->
<!-- TODO(vineeth/marketing): write the "Honcho vs RAG / vector DB / memory-only" comparison.
Audit recommendation referenced; copy intentionally deferred to avoid inventing
positioning claims unsupported by primary sources. -->
## Benchmarks &amp; Evals
Honcho's evals span LongMemEval, LoCoMo, and other long-conversation benchmarks. See the [evals page](https://honcho.dev/evals/), the [research blog post](https://blog.plasticlabs.ai/research/Benchmarking-Honcho), and the [Pareto-frontier announcement video](https://x.com/honchodotdev/status/2002090546521911703?s=20) for methodology and reproducible results.
## Self-hosting
Honcho is open source under AGPL-3.0. You can run the full server locally with Docker, then point the SDKs at `http://localhost:8000`.
### Quick start (Docker)
```bash
git clone https://github.com/plastic-labs/honcho.git
cd honcho
cp docker-compose.yml.example docker-compose.yml
cp .env.template .env # fill in LLM_GEMINI_API_KEY / LLM_ANTHROPIC_API_KEY / LLM_OPENAI_API_KEY
docker compose up
```
Then point the SDKs at it:
```python
honcho = Honcho(workspace_id="my-app-testing", base_url="http://localhost:8000")
# or: export HONCHO_URL=http://localhost:8000
```
<!-- markdownlint-disable MD033 -->
<details>
<summary>Local development without Docker</summary>
Below is a guide on setting up a local environment for running the Honcho Server without Docker.
#### Prerequisites and Dependencies
Honcho is developed using [python](https://www.python.org/) and [uv](https://docs.astral.sh/uv/).
The minimum python version is `3.10`
The minimum uv version is `0.5.0`
### Setup
#### Setup
Once the dependencies are installed on the system run the following steps to get
the local project setup.
@ -286,108 +425,20 @@ In a separate terminal, run:
uv run python -m src.deriver
```
The deriver generates representation, summaries, peer cards, and manages dreaming tasks. You can increase the number of deriver's to improve runtime efficiency.
The deriver generates representations, summaries, peer cards, and manages dreaming tasks. You can increase the number of derivers to improve runtime efficiency.
### Pre-commit Hooks
</details>
<!-- markdownlint-enable MD033 -->
Honcho uses pre-commit hooks to ensure code quality and consistency across the project. These hooks automatically run checks on your code before each commit, including linting, formatting, type checking, and security scans.
#### Installation
To set up pre-commit hooks in your development environment:
1. **Install pre-commit using uv**
```bash
uv add --dev pre-commit
```
2. **Install the pre-commit hooks**
```bash
uv run pre-commit install \
--hook-type pre-commit \
--hook-type commit-msg \
--hook-type pre-push
```
This will install hooks for `pre-commit`, `commit-msg`, and `pre-push` stages.
#### What the hooks do
The pre-commit configuration includes:
- **Code Quality**: Python linting and formatting (ruff), TypeScript linting (biome)
- **Type Checking**: Static type analysis with basedpyright
- **Security**: Vulnerability scanning with bandit
- **Documentation**: Markdown linting and license header checks
- **Testing**: Automated test runs for Python and TypeScript code
- **File Hygiene**: Trailing whitespace, line endings, file size checks
- **Commit Standards**: Conventional commit message validation
#### Manual execution
You can run the hooks manually on all files without making a commit:
```bash
uv run pre-commit run --all-files
```
Or run specific hooks:
```bash
uv run pre-commit run ruff --all-files
uv run pre-commit run basedpyright --all-files
```
### Docker
As mentioned earlier a `docker-compose` template is included for running Honcho.
As an alternative to running Honcho locally it can also be run with the compose
template.
The docker-compose template is set to use an environment file called `.env`.
You can also copy the `.env.template` and fill with the appropriate values.
Copy the template and update the appropriate environment variables before
launching the service:
```bash
cd honcho
cp .env.template .env
# update the file with openai key and other wanted environment variables
cp docker-compose.yml.example docker-compose.yml
docker compose up
```
### Deploy on Fly
The API can also be deployed on fly.io. Follow the [Fly.io
Docs](https://fly.io/docs/getting-started/) to setup your environment and the
`flyctl`.
A sample `fly.toml` is included for convenience.
> Note: The fly.toml does not include launching a Postgres database. This must
> be configured separately
Once `flyctl` is set up use the following commands to launch the application:
```bash
cd honcho
flyctl launch --no-deploy # Follow the prompts and edit as you see fit
cat .env | flyctl secrets import # Load in your secrets
flyctl deploy # Deploy with appropriate environment variables
```
Contributors: see [`CONTRIBUTING.md`](./CONTRIBUTING.md) for pre-commit setup. Deploying to Fly.io: see [Self-hosting docs → Deploying on Fly.io](https://honcho.dev/docs/v3/contributing/self-hosting#deploying-on-fly-io).
## Configuration
Honcho uses a flexible configuration system that supports both TOML files and environment variables. Configuration values are loaded in the following priority order (highest to lowest):
Honcho uses a flexible configuration system that supports both TOML files and environment variables. Configuration values are loaded in priority order: **environment variables > `.env` file > `config.toml` > defaults**.
1. Environment variables
2. `.env` file (for local development)
3. `config.toml` file
4. Default values
<!-- markdownlint-disable MD033 -->
<details>
<summary>Full configuration reference</summary>
### Using config.toml
@ -406,7 +457,7 @@ Then modify the values as needed. The TOML file is organized into sections:
- `[llm]` - LLM provider API keys and general settings
- `[deriver]` - Background worker settings and representation configuration
- `[peer_card]` - Peer card generation settings
- `[dialectic]` - Dialectic API configuration with per-level reasoning settings
- `[dialectic]` - Chat Endpoint configuration with per-level reasoning settings
- `[summary]` - Session summarization settings
- `[dream]` - Dream processing configuration (including specialist models and surprisal settings)
- `[webhook]` - Webhook configuration
@ -434,21 +485,6 @@ Examples:
- `METRICS_ENABLED` - Enable Prometheus metrics
- `TELEMETRY_ENABLED` - Enable CloudEvents telemetry
### Configuration Priority
When a configuration value is set in multiple places, Honcho uses this priority:
1. **Environment variables** - Always take precedence
2. **.env file** - Loaded for local development
3. **config.toml** - Base configuration
4. **Default values** - Built-in defaults
This allows you to:
- Use `config.toml` for base configuration
- Override specific values with environment variables in production
- Use `.env` files for local development without modifying config.toml
### Example
If you have this in `config.toml`:
@ -467,28 +503,23 @@ export DB_CONNECTION_URI="postgresql+psycopg://prod-server/honcho_prod"
The application will use the production connection URI while keeping the pool size from config.toml.
</details>
<!-- markdownlint-enable MD033 -->
## Architecture
The functionality of Honcho can be split into two different services: Storage
and Insights.
Honcho splits into two services: **Storage** (workspaces, peers, sessions, messages, internal collections) and **Insights** (reasoning, conclusions, representations, summaries, the chat endpoint). Storage is synchronous via the API; Insights is asynchronous via a background queue consumed by the deriver worker process.
### Peer Paradigm
**Key features:**
Honcho uses an entity-centric model where both users and agents are represented as "[peers](https://blog.plasticlabs.ai/blog/Beyond-the-User-Assistant-Paradigm;-Introducing-Peers)". This unified approach enables:
- **Rich Reasoning System** — multiple implementation methods that extract conclusions from interactions and build comprehensive representations of peers
- **Chat Endpoint** — reasoning-informed responses that integrate conclusions with current context
- **Background Processing** — asynchronous processing pipeline for expensive operations like representation updates and session summarization
- **Multi-Provider Support** — configurable LLM providers for different use cases
- Multi-participant sessions with mixed human and AI agents
- Configurable observation settings (which peers observe which others)
- Flexible identity management for all participants
- Support for complex multi-agent interactions
#### Key Features
- **Rich Reasoning System**: Multiple implementation methods that extract conclusions from interactions and build comprehensive representations of peers
- **Chat API**: Provides reasoning-informed responses that integrate conclusions with current context
- **Background Processing**: Asynchronous processing pipeline for expensive operations like representation updates and session summarization
- **Multi-Provider Support**: Configurable LLM providers for different use cases
### Storage
<!-- markdownlint-disable MD033 MD001 -->
<details>
<summary>Storage primitives in detail</summary>
Honcho contains several different primitives used for storing application and
peer data. This data is used for managing conversations, modeling peer
@ -503,8 +534,7 @@ Below is a mapping of the different primitives and their relationships.
Workspaces
├── Peers ←──────────────────┐
│ ├── Sessions │
│ └── Collections │
│ └── Documents │
│ └── (internal collections, keyed by observer/observed peer pair)
│ │
│ │
└── Sessions ←───────────────┤ (many-to-many)
@ -514,12 +544,10 @@ Workspaces
**Relationship Details:**
- A **Workspace** contains multiple **Peers**
- **Peers** and **Sessions** have a many-to-many relationship (peers can participate in multiple sessions, sessions can have multiple peers)
- **Messages** can exist at two levels:
- **Session-level**: Communication between peers within a session
- **Collections** belong to specific **Peers**
- **Documents** are stored within **Collections**
- A **Workspace** contains multiple **Peers**.
- **Peers** and **Sessions** have a many-to-many relationship (peers can participate in multiple sessions, sessions can have multiple peers).
- **Messages** belong to a session and are labelled by their source peer.
- **Internal collections** of vector-embedded **documents** are keyed by `(observer, observed)` peer pairs. They are not directly exposed via the API; the observations stored in them are exposed as **Conclusions**.
Users familiar with APIs such as the OpenAI Assistants API will be familiar with
much of the mapping here.
@ -533,7 +561,7 @@ isolate data between use cases and provide multi-tenant capabilities.
#### Peers
Within a `Workspace` everything revolves around a `Peer`. The `Peer` object
represents any participant in the system - whether human users or AI agents.
represents any participant in the system whether human users or AI agents.
This unified model enables complex multi-participant interactions.
#### Sessions
@ -544,45 +572,39 @@ Sessions can involve multiple peers with configurable observation settings.
#### Messages
The `Message` represents an atomic data unit that can exist at two levels:
The `Message` represents an atomic data unit that exists at the session level:
communication between peers within a session context. All messages are labelled
by their source peer and can be processed asynchronously to update their
representations. This flexible design allows for both conversational interactions
and broader data ingestion for personality modelling.
- **Session-level Messages**: Communication between peers within a session context
</details>
<!-- markdownlint-enable MD033 MD001 -->
All messages are labeled by their source peer and can be processed
asynchronously to update their representations. This flexible design allows for
both conversational interactions and broader data ingestion for personality
modeling.
#### Collections
At a high level a `Collection` is a named group of `Documents`. Developers
familiar with RAG based applications will be familiar with these. `Collections`
store vector embedded data that developers and agents can retrieve against using
functions like cosine similarity.
Collections are also used internally by Honcho while creating representations of peers.
#### Documents
As stated before a `Document` is vector embedded data stored in a `Collection`.
### Reasoning
<!-- markdownlint-disable MD033 -->
<details>
<summary>Reasoning pipeline</summary>
The reasoning functionality of Honcho is built on top of the Storage service. As
`Messages` and `Sessions` are created for `Peers`, Honcho will asynchronously
reason about peer psychology to derive facts about them and store them
in reserved `Collections`.
in reserved internal collections.
A high level summary of the pipeline is as follows:
1. Messages are created via the API
2. Derivation Tasks are enqueued for background processing including:
- `representation`: To update representations of `Peers`
- `summary`: To create summaries of `Sessions`
3. Session-based queue processing ensures proper ordering
4. Results are stored internally
1. Messages are created via the API.
2. Derivation tasks are enqueued for background processing, including:
- `representation`: update representations of `Peers`.
- `summary`: create summaries of `Sessions`.
3. Session-based queue processing ensures proper ordering.
4. Results are stored internally and surfaced via the Conclusions API, Representations, Peer Cards, and the Chat Endpoint.
### Retrieving Data & Insights
</details>
<!-- markdownlint-enable MD033 -->
<!-- markdownlint-disable MD033 MD001 -->
<details>
<summary>Retrieving data and insights</summary>
Honcho exposes several different ways to retrieve data from the system to best
serve the needs of any given application.
@ -606,31 +628,33 @@ the results.
#### Chat API
The flagship interface for using these insights is through
the [`Chat` Endpoint](https://blog.plasticlabs.ai/archive/ARCHIVED;-Introducing-Honcho's-Dialectic-API).
The flagship interface for using these insights is the [Chat Endpoint](https://honcho.dev/docs/v3/documentation/features/chat) (`POST /peers/{peer_id}/chat`). It takes natural-language requests to get data about a peer and returns reasoning-grounded responses. Examples:
This is a regular API endpoint (`/peers/{peer_id}/chat`) that takes natural language requests to get data
about the `Peer`. This robust design lets us use this single endpoint for all
cases where extra personalization or information about the `Peer` is necessary.
A developer's application can treat Honcho as an oracle to the `Peer` and
consult it when necessary. Some examples of how to leverage the Dialectic
API include:
- Asking Honcho for a generic or specific insight about the `Peer`
- Asking Honcho to hydrate a prompt with data about the `Peer`s behavior
- Asking Honcho for a 2nd opinion or approach about how to respond to the Peer
- Getting personalized responses that incorporate long-term facts and context
- Asking Honcho for a generic or specific insight about the peer.
- Asking Honcho to hydrate a prompt with data about the peer's behaviour.
- Asking Honcho for a second opinion on how to respond.
- Getting personalised responses that incorporate long-term facts and context.
#### Representations
For low-latency use cases,
Honcho provides access to a `representation` endpoint that
returns a static document with insights about a `Peer` in the context of a
particular session.
For low-latency use cases, Honcho provides access to a `representation` endpoint that returns a static document with insights about a peer in the context of a particular session. Use this to quickly add context to a prompt without having to wait for an LLM response.
Use this to quickly add context to a prompt without having to wait for an LLM
response.
</details>
<!-- markdownlint-enable MD033 MD001 -->
## SDKs
- **Python** — [`honcho-ai`](https://pypi.org/project/honcho-ai/) on PyPI · source in [`sdks/python/`](./sdks/python)
- **TypeScript** — [`@honcho-ai/sdk`](https://www.npmjs.com/package/@honcho-ai/sdk) on npm · source in [`sdks/typescript/`](./sdks/typescript)
SDKs are versioned independently of the server. Current SDK versions track each other; the server badge above reflects the deployed server version.
See the [SDK Reference](https://honcho.dev/docs/v3/documentation/reference/sdk) for full API surface, the [API Reference](https://honcho.dev/docs/v3/api-reference/introduction) for the raw HTTP API, and per-SDK example folders for runnable demos.
## Learn More
- [Developer documentation](https://honcho.dev/docs/) — full API surface, guides, integrations.
- [Plastic Labs blog](https://blog.plasticlabs.ai/) — design philosophy and history of the project.
## Contributing
@ -638,4 +662,4 @@ We welcome contributions to Honcho! Please read our [Contributing Guide](./CONTR
## License
Honcho is licensed under the AGPL-3.0 License. Learn more at the [License file](./LICENSE)
Honcho is licensed under the AGPL-3.0 License. Learn more at the [License file](./LICENSE).

View File

@ -85,7 +85,8 @@ STALE_SESSION_TIMEOUT_MINUTES = 5
# QUEUE_ERROR_RETENTION_SECONDS = 2592000 # 30 days
DEDUPLICATE = true
LOG_OBSERVATIONS = false
MAX_INPUT_TOKENS = 23000
MAX_INPUT_TOKENS = 25000
MAX_CUSTOM_INSTRUCTIONS_TOKENS = 2000
WORKING_REPRESENTATION_MAX_OBSERVATIONS = 100
REPRESENTATION_BATCH_MAX_TOKENS = 1024
FLUSH_ENABLED = false # Bypass batch token threshold, process work immediately
@ -131,7 +132,7 @@ SESSION_HISTORY_MAX_TOKENS = 4096
[dialectic.levels.minimal]
MAX_TOOL_ITERATIONS = 1
MAX_OUTPUT_TOKENS = 250
TOOL_CHOICE = "any"
TOOL_CHOICE = "auto"
[dialectic.levels.minimal.model_config]
transport = "openai"
@ -139,7 +140,7 @@ model = "gpt-5.4-mini"
[dialectic.levels.low]
MAX_TOOL_ITERATIONS = 5
TOOL_CHOICE = "any"
TOOL_CHOICE = "auto"
[dialectic.levels.low.model_config]
transport = "openai"
@ -254,9 +255,7 @@ TYPE = "pgvector"
# Migration flag: set to true when migration from pgvector is complete
MIGRATED = false
NAMESPACE = "honcho"
# This should match embedding.vector_dimensions. pgvector and dual-write mode
# currently still require 1536 until a schema migration lands.
DIMENSIONS = 1536
# DIMENSIONS is deprecated; embedding.vector_dimensions is authoritative.
# TURBOPUFFER_API_KEY = "your-turbopuffer-api-key"
# TURBOPUFFER_REGION = "us-east-1"
LANCEDB_PATH = "./lancedb_data"

View File

@ -67,6 +67,10 @@ services:
- POSTGRES_DB=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
# Allow passwordless connections from the host (port is bound to 127.0.0.1).
# Lets the local test suite and ad-hoc tools connect without supplying a
# password. Do NOT use this in production.
- POSTGRES_HOST_AUTH_METHOD=trust
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql

View File

@ -19,21 +19,14 @@
},
"favicon": "/favicon.svg",
"contextual": {
"options": [
"copy",
"view",
"chatgpt",
"claude"
]
"options": ["copy", "view", "chatgpt", "claude"]
},
"navigation": {
"versions": [
{
"version": "v3.0.5",
"version": "v3.0.6",
"api": {
"openapi": [
"v3/openapi.json"
]
"openapi": ["v3/openapi.json"]
},
"tabs": [
{
@ -95,15 +88,14 @@
"groups": [
{
"group": "Overview",
"pages": [
"v3/guides/overview"
]
"pages": ["v3/guides/overview"]
},
{
"group": "Integrations",
"pages": [
"v3/guides/integrations/claude-code",
"v3/guides/integrations/opencode",
"v3/guides/integrations/vercel-ai-sdk",
"v3/guides/integrations/crewai",
"v3/guides/integrations/langgraph",
"v3/guides/integrations/mcp",
@ -134,9 +126,7 @@
},
{
"group": "Migrations",
"pages": [
"v3/guides/migrations/mem0"
]
"pages": ["v3/guides/migrations/mem0"]
}
]
},
@ -148,6 +138,7 @@
"pages": [
"v3/contributing/self-hosting",
"v3/contributing/configuration",
"v3/contributing/changing-embeddings",
"v3/contributing/troubleshooting"
]
},
@ -165,9 +156,7 @@
"groups": [
{
"group": "API Documentation",
"pages": [
"v3/api-reference/introduction"
]
"pages": ["v3/api-reference/introduction"]
},
{
"group": "workspaces",
@ -245,9 +234,7 @@
},
{
"group": "miscellaneous",
"pages": [
"v3/api-reference/endpoint/keys/create-key"
]
"pages": ["v3/api-reference/endpoint/keys/create-key"]
}
]
},
@ -268,9 +255,7 @@
{
"version": "v2.5.1",
"api": {
"openapi": [
"v2/openapi.json"
]
"openapi": ["v2/openapi.json"]
},
"tabs": [
{
@ -317,15 +302,11 @@
"groups": [
{
"group": "Getting Started",
"pages": [
"v2/guides/overview"
]
"pages": ["v2/guides/overview"]
},
{
"group": "Migrations",
"pages": [
"v2/migrations/from-mem0"
]
"pages": ["v2/migrations/from-mem0"]
},
{
"group": "Integrations",
@ -350,9 +331,7 @@
"groups": [
{
"group": "API Documentation",
"pages": [
"v2/api-reference/introduction"
]
"pages": ["v2/api-reference/introduction"]
},
{
"group": "workspaces",
@ -456,9 +435,7 @@
{
"version": "v1.1.0",
"api": {
"openapi": [
"openapi.json"
]
"openapi": ["openapi.json"]
},
"tabs": [
{
@ -488,23 +465,15 @@
"groups": [
{
"group": "Getting Started",
"pages": [
"v1/guides/overview",
"v1/guides/streaming-response"
]
"pages": ["v1/guides/overview", "v1/guides/streaming-response"]
},
{
"group": "Application Interfaces",
"pages": [
"v1/guides/discord",
"v1/guides/honcho-mcp"
]
"pages": ["v1/guides/discord", "v1/guides/honcho-mcp"]
},
{
"group": "Personal Memory",
"pages": [
"v1/guides/dialectic-endpoint"
]
"pages": ["v1/guides/dialectic-endpoint"]
}
]
},
@ -513,9 +482,7 @@
"groups": [
{
"group": "API Documentation",
"pages": [
"v1/api-reference/introduction"
]
"pages": ["v1/api-reference/introduction"]
},
{
"group": "apps",
@ -563,9 +530,7 @@
},
{
"group": "keys",
"pages": [
"v1/api-reference/endpoint/keys/create-key"
]
"pages": ["v1/api-reference/endpoint/keys/create-key"]
},
{
"group": "metamessages",

View File

@ -25,10 +25,10 @@ I want to start building with Honcho - a memory and personalization platform for
## Honcho Resources
**Documentation:**
- Main docs: https://docs.honcho.dev
- API Reference: https://docs.honcho.dev/v2/api-reference/introduction
- Quickstart: https://docs.honcho.dev/v2/documentation/introduction/quickstart
- Architecture: https://docs.honcho.dev/v2/documentation/reference/architecture
- Main docs: https://honcho.dev/docs
- API Reference: https://honcho.dev/docs/v2/api-reference/introduction
- Quickstart: https://honcho.dev/docs/v2/documentation/introduction/quickstart
- Architecture: https://honcho.dev/docs/v2/documentation/reference/architecture
**Code & Examples:**
- Core repo: https://github.com/plastic-labs/honcho

View File

@ -97,7 +97,7 @@ results = storage.search("query", filters={
})
```
For the full filter syntax including logical operators (AND, OR, NOT), comparison operators, and metadata filtering, see the [Using Filters](https://docs.honcho.dev/v2/documentation/core-concepts/features/using-filters) documentation.
For the full filter syntax including logical operators (AND, OR, NOT), comparison operators, and metadata filtering, see the [Using Filters](https://honcho.dev/docs/v2/documentation/core-concepts/features/using-filters) documentation.
<Note>
For comprehensive details about CrewAI's memory system, see the [official CrewAI Memory documentation](https://docs.crewai.com/en/concepts/memory).

View File

@ -0,0 +1,62 @@
---
title: "Changing Embeddings"
description: "How to switch embedding dimension or model on a Honcho deployment"
icon: "rotate"
---
## Short answer: you can't, in place.
The embedding dimension is **machine-enforced** as immutable for the life of a deployment. The embedding model is **operator-owned** as immutable by contract. The supported way to change either is:
1. Stand up a new deployment at the desired configuration.
2. Replay or re-embed your data into it out-of-band.
3. Cut traffic over to the new deployment.
The rest of this page explains why, and what the safety boundaries actually are.
## Why dimension is enforced and model is not
On boot, both the API (`src/main.py` lifespan) and the deriver (`src/deriver/__main__.py`) run the validator in `src/startup/embedding_validator.py`. It does a schema-qualified `pg_attribute` lookup against `documents.embedding` and `message_embeddings.embedding`, decodes the declared `atttypmod`, and compares it to `EMBEDDING_VECTOR_DIMENSIONS`. A mismatch crashes the process with an actionable error before any HTTP route is served or any queue task is processed.
There is no equivalent check for the model. The pgvector column does not record what model produced the vectors inside it, and this design intentionally avoids adding new persistent metadata fields. The runtime has no way to detect that you swapped `text-embedding-3-small` for a different model that emits the same dimension.
That last point is a real footgun:
<Warning>
Changing `EMBEDDING_MODEL_CONFIG__MODEL` to a different model at the **same dimension** (for example `text-embedding-3-small@1536` → `text-embedding-3-large` truncated to 1536) will silently succeed. New writes will use the new model; existing rows still hold vectors from the old model; recall quality will degrade with no startup or runtime warning.
Treat model identity as a contract you own. If you need to change it, follow the destroy + rebuild path below.
</Warning>
## Recipe: changing dim or model
Concretely, for either a dim change or a model change:
1. **Provision the new deployment** with the target environment.
```bash
# On the new deployment:
export EMBEDDING_VECTOR_DIMENSIONS=768
export EMBEDDING_MODEL_CONFIG__TRANSPORT=openai
export EMBEDDING_MODEL_CONFIG__MODEL=nomic-embed-text
export EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=http://your-ollama:11434/v1
alembic upgrade head
uv run python scripts/configure_embeddings.py --dry-run
uv run python scripts/configure_embeddings.py --yes
```
2. **Replay your source data** (messages, documents, ingested content) into the new deployment via your normal application path. Honcho's existing message-creation API will re-derive embeddings using the new configuration. There is no in-place re-embedding tool — that would be a separate spec covering atomicity, cost-per-token, and dialectic-during-migration semantics.
3. **Cut over** at your application layer (DNS, load balancer, feature flag — whatever you use). The old deployment can stay running until you are confident in the new one; this design does not require an atomic switch.
The startup validator on the new deployment will refuse to start if step 1's `configure_embeddings.py` did not run, so a misconfiguration cannot quietly write wrong-dim vectors into the new schema.
## Edge case: truncation at the default dimension
If you are using `text-embedding-3-large` but truncating to 1536 (the default), be aware that `EMBEDDING_MODEL_CONFIG__DIMENSIONS_MODE=auto` will **not** forward `dimensions=` to the API — `auto` interprets the default as "operator did not opt into a non-default dim." The provider will return native 3072, the response-dim validator will reject it, and the request will fail.
For this case, either set `EMBEDDING_VECTOR_DIMENSIONS=1536` explicitly (so `auto` knows the operator opted in), or set `EMBEDDING_MODEL_CONFIG__DIMENSIONS_MODE=always`.
## Backend swap (turbopuffer ↔ lancedb ↔ pgvector) is a different operation
Switching the *storage backend* at constant dim/model — for example moving from pgvector to Turbopuffer — is supported via `src/reconciler/sync_vectors.py` and `VECTOR_STORE_MIGRATED`. That flow is unchanged by the embedding-pipeline work and is documented separately. It is **not** the destroy + rebuild path described above.

View File

@ -218,8 +218,48 @@ EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=http://localhost:8000/v1
EMBEDDING_MODEL_CONFIG__OVERRIDES__API_KEY_ENV=EMBEDDING_CUSTOM_API_KEY
```
Current constraint:
- `EMBEDDING_VECTOR_DIMENSIONS` can be changed for fully migrated external vector stores, but pgvector and dual-write mode still require `1536` until the schema migration lands.
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).
- `always`: always forward, regardless of whether `EMBEDDING_VECTOR_DIMENSIONS` was set. Use for OpenAI-compatible self-hosted providers that require it. Do not pick `always` *just* for same-as-default truncation — `auto` handles that case correctly as long as you set `EMBEDDING_VECTOR_DIMENSIONS=1536` explicitly in your environment. `always` is the right answer when your config layer might strip explicit "default-valued" envs, or when you want defense-in-depth.
- `never`: never forward. Explicit opt-out for providers that reject the parameter (e.g. `text-embedding-ada-002` if it slips past the known-rejecting allowlist).
#### Bootstrapping non-default dimensions
`EMBEDDING_VECTOR_DIMENSIONS` is treated as immutable for the life of a deployment. The pgvector schema is dim-pinned by Alembic at `1536` by default; if you want a different dim, you must ALTER the empty columns once at bootstrap time.
Install order for a non-default dim:
```bash
# 1. Apply migrations (creates default vector(1536) schema)
alembic upgrade head
# 2. Set the dim you want
export EMBEDDING_VECTOR_DIMENSIONS=768
# 3. ALTER the empty columns to the target dim
uv run python scripts/configure_embeddings.py --dry-run # preview
uv run python scripts/configure_embeddings.py --yes # apply
# 4. Start API and deriver — both run the startup validator and refuse
# to serve traffic if the schema and EMBEDDING_VECTOR_DIMENSIONS disagree.
```
Existing deployments at 1536 with `text-embedding-3-small` need no action — step 3 detects matching dims and skips.
The script refuses to ALTER tables that already contain non-null embeddings. To switch dim or model on a populated deployment, stand up a new deployment at the new configuration and migrate data out of band; there is no in-place re-embedding affordance. See [Changing Embeddings](./changing-embeddings) for the destroy + rebuild recipe and the same-dim model-swap caveat.
External vector stores (Turbopuffer, LanceDB) do not need bootstrap setup. Namespaces are per-workspace and lazy-created on first write at whatever dim the embedding client returns. Use `--report` to inventory the existing namespaces against the configured dim:
```bash
uv run python scripts/configure_embeddings.py --report
```
The startup validator at `src/startup/embedding_validator.py` enforces the dim invariant at boot for both the API (`src/main.py` lifespan) and the deriver (`src/deriver/__main__.py`). A mismatch crashes the process with an actionable error before any HTTP route is served or any queue task is processed.
`VECTOR_STORE_DIMENSIONS` is **deprecated**. `EMBEDDING_VECTOR_DIMENSIONS` is the single source of truth; setting `VECTOR_STORE_DIMENSIONS` explicitly emits a startup warning and is otherwise ignored. The field will be removed in a future release; drop it from your `.env` to silence the warning.
The `VECTOR_STORE_MIGRATED` flag still exists and still controls dual-write / cutover semantics for legacy tenants moving between storage backends (pgvector ↔ turbopuffer ↔ lancedb). It is unrelated to dimension configuration after this release.
### Feature-Specific Model Configuration
@ -301,7 +341,8 @@ DERIVER_ENABLED=true
# LLM settings
DERIVER_MODEL_CONFIG__TRANSPORT=openai
DERIVER_MODEL_CONFIG__MODEL=gpt-5.4-mini
DERIVER_MAX_INPUT_TOKENS=23000
DERIVER_MAX_INPUT_TOKENS=25000
DERIVER_MAX_CUSTOM_INSTRUCTIONS_TOKENS=2000
# DERIVER_MODEL_CONFIG__THINKING_EFFORT=minimal
# DERIVER_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024
# DERIVER_MODEL_CONFIG__TEMPERATURE=0.7 # Optional temperature override
@ -458,7 +499,8 @@ WEBHOOK_MAX_WORKSPACE_LIMIT=10
VECTOR_STORE_TYPE=pgvector # Options: pgvector, turbopuffer, lancedb
VECTOR_STORE_MIGRATED=false
VECTOR_STORE_NAMESPACE=honcho
VECTOR_STORE_DIMENSIONS=1536
# Embedding dim is configured via EMBEDDING_VECTOR_DIMENSIONS — see the
# Embedding Configuration section. VECTOR_STORE_DIMENSIONS is deprecated.
# Turbopuffer-specific
VECTOR_STORE_TURBOPUFFER_API_KEY=your-turbopuffer-api-key
@ -636,4 +678,4 @@ uv run alembic revision --autogenerate -m "Description" # Create new migration
5. **Dialectic level issues** — Unset level fields inherit from the built-in defaults. For Anthropic, `THINKING_BUDGET_TOKENS` must be >= 1024 when enabled. For providers without budgeted thinking, omit it or set it to `0`. `MAX_OUTPUT_TOKENS` must exceed `THINKING_BUDGET_TOKENS`.
6. **Vector store issues** — For Turbopuffer, set the API key. Check `VECTOR_STORE_DIMENSIONS` matches your embedding model.
6. **Vector store issues** — For Turbopuffer, set the API key. Check that `EMBEDDING_VECTOR_DIMENSIONS` matches your embedding model — the startup validator will refuse to boot on a mismatch.

View File

@ -395,4 +395,20 @@ The default compose file is already production-oriented — ports bound to `127.
# Restore
cat backup.sql | docker compose exec -T database psql -U postgres postgres
```
## Deploying on Fly.io
The API can be deployed on [Fly.io](https://fly.io). Follow the [Fly.io getting-started docs](https://fly.io/docs/getting-started/) to set up your account and install `flyctl`. A sample `fly.toml` is included in the repo for convenience.
<Note>
The included `fly.toml` does not provision a PostgreSQL database. Stand one up separately (Fly Postgres, Supabase, Neon, or another managed provider) and set `DB_CONNECTION_URI` to point at it.
</Note>
Once `flyctl` is set up, from the repo root:
```bash
flyctl launch --no-deploy # follow the prompts and edit as you see fit
cat .env | flyctl secrets import # load secrets from .env
flyctl deploy # deploy
```
- Back up your `.env` or `config.toml` configuration files

View File

@ -7,7 +7,7 @@ sidebarTitle: "Overview"
Honcho is an open source memory library with a managed service for building stateful agents. Use it with any model, framework, or architecture. It enables agents to build and maintain state about any entity--users, agents, groups, ideas, and more. And because it's a continual learning system, it understands entities that change over time. Using Honcho as your memory system will earn your agents higher retention, more trust, and help you build data moats to out-compete incumbents.
<Note>
Honcho has defined the Pareto Frontier of Agent Memory. Watch the [video](https://x.com/honchodotdev/status/2002090546521911703?s=20), check out our [evals page](https://evals.honcho.dev/), and read the [blog post](https://blog.plasticlabs.ai/research/Benchmarking-Honcho) for more detail.
Honcho has defined the Pareto Frontier of Agent Memory. Watch the [video](https://x.com/honchodotdev/status/2002090546521911703?s=20), check out our [evals page](https://honcho.dev/evals/), and read the [blog post](https://blog.plasticlabs.ai/research/Benchmarking-Honcho) for more detail.
</Note>
<CardGroup cols={2}>

View File

@ -244,7 +244,7 @@ This is rich personal context for domain-specific agents to do what they want wi
- A productivity agent might see the same pattern and say "let's protect your weekend time for the finance app."
- A financial advisor agent might see it and ask "what runway would you need to make the leap?"
Honcho acts almost like a detective--it reasons about new and existing evidence in order to form conclusions that can be used to make a *case*. These conclusions wait to be composed dynamically based on how you, the ~~judge~~ developer, query it. This approach is what drives our [pareto-frontier](https://evals.honcho.dev) performance on memory benchmarks, and our custom models allow us to optimize speed and cost.
Honcho acts almost like a detective--it reasons about new and existing evidence in order to form conclusions that can be used to make a *case*. These conclusions wait to be composed dynamically based on how you, the ~~judge~~ developer, query it. This approach is what drives our [pareto-frontier](https://honcho.dev/evals) performance on memory benchmarks, and our custom models allow us to optimize speed and cost.
## Next Steps

View File

@ -167,10 +167,10 @@ I want to start building with Honcho - an open source memory library for buildin
## Honcho Resources
**Documentation:**
- Main docs: https://docs.honcho.dev
- API Reference: https://docs.honcho.dev/v3/api-reference/introduction
- Quickstart: https://docs.honcho.dev/v3/documentation/introduction/quickstart
- Architecture: https://docs.honcho.dev/v3/documentation/core-concepts/architecture
- Main docs: https://honcho.dev/docs
- API Reference: https://honcho.dev/docs/v3/api-reference/introduction
- Quickstart: https://honcho.dev/docs/v3/documentation/introduction/quickstart
- Architecture: https://honcho.dev/docs/v3/documentation/core-concepts/architecture
**Code & Examples:**
- Core repo: https://github.com/plastic-labs/honcho

View File

@ -18,32 +18,26 @@ Give OpenCode long-term memory that survives context wipes, session restarts, an
### Step 2: Install the Plugin
<Note>
This plugin requires [Bun](https://bun.sh) and the [OpenCode CLI](https://opencode.ai). If `opencode` isn't on your `PATH`, install it first, then restart your shell.
This plugin requires the [OpenCode CLI](https://opencode.ai). If `opencode` isn't on your `PATH`, install it first, then restart your shell.
</Note>
Run the installer:
Install the plugin:
```bash
bunx @honcho-ai/opencode-honcho install
opencode plugin "@honcho-ai/opencode-honcho" --global
```
<Accordion title="Windows install">
To update an existing plugin install:
Windows Command Prompt:
```cmd
git clone --branch main https://github.com/plastic-labs/opencode-honcho.git
cd opencode-honcho
bun install && bun run build && bun .\dist\cli.js install --plugin-spec "%CD%" --force
```bash
opencode plugin "@honcho-ai/opencode-honcho" --force
```
</Accordion>
The installer:
OpenCode:
- registers `@honcho-ai/opencode-honcho` with OpenCode
- enables both the native server and TUI plugin targets
- writes the Honcho command templates into your global OpenCode config
- resolves the package's native server and TUI plugin targets
- updates plugin entries in your global OpenCode config
- activates the plugin globally for every OpenCode project
### Step 3: Run Setup in OpenCode

View File

@ -0,0 +1,399 @@
---
title: "Vercel AI SDK"
icon: "triangle"
iconType: "solid"
description: "Add persistent user memory and reasoning to any Vercel AI SDK app with Honcho"
sidebarTitle: "Vercel AI SDK"
---
Integrate Honcho with the Vercel AI SDK to build AI apps that remember users across sessions. The [Vercel AI SDK](https://sdk.vercel.ai) is an open-source TypeScript toolkit for building AI-powered apps with a unified API across providers. This guide shows you how to wrap any `generateText` or `streamText` call with Honcho's memory middleware and reasoning tools.
<Note>
The full package source and examples are available on [GitHub](https://github.com/plastic-labs/vercel-ai-sdk-package).
</Note>
## What We're Building
We'll wire Honcho into a Vercel AI SDK app so the model receives context from past conversations and can query what it knows about the user mid-generation. Here's how the pieces fit together:
- **Vercel AI SDK** handles model calls and streaming
- **Honcho** stores messages and retrieves user context before each generation
- **Your model provider** can be Anthropic, OpenAI, Google, etc.
The key benefit: you don't manually manage conversation history across sessions. Honcho handles persistence and context injection — the model always has a rich picture of who it's talking to. (New to Honcho's primitives? See [peers and sessions](/v3/documentation/core-concepts/architecture).)
## Setup
Install the package:
<CodeGroup>
```bash npm
npm install @honcho-ai/vercel-ai-sdk
```
```bash pnpm
pnpm add @honcho-ai/vercel-ai-sdk
```
```bash yarn
yarn add @honcho-ai/vercel-ai-sdk
```
```bash bun
bun add @honcho-ai/vercel-ai-sdk
```
</CodeGroup>
Get your API key at [app.honcho.dev](https://app.honcho.dev).
```bash
HONCHO_API_KEY=your-api-key
HONCHO_WORKSPACE_ID=your-workspace-id
```
## Use the Skill
The package ships a Skill that can walk an agent through wiring Honcho into your Vercel AI SDK app automatically — it greps for your `generateText` / `streamText` call sites, asks where `userId` / `sessionId` come from, and applies the integration in place.
```bash
npx skills add plastic-labs/vercel-ai-sdk
```
Then invoke `/honcho-vercel-ai-sdk`.
<Accordion title="Alternative: manual symlink from npm package">
If you've already installed `@honcho-ai/vercel-ai-sdk` via npm, you can symlink the skill directly. Example shown is for Claude Code:
```bash
mkdir -p ~/.claude/skills/honcho-vercel-ai-sdk
ln -sf "$(pwd)/node_modules/@honcho-ai/vercel-ai-sdk/skills/honcho-vercel-ai-sdk/SKILL.md" \
~/.claude/skills/honcho-vercel-ai-sdk/SKILL.md
```
Restart the session, then invoke `/honcho-vercel-ai-sdk`.
</Accordion>
## Create a Provider Instance
`createHoncho()` is the entry point. It reads your API key and workspace from environment variables and returns a provider object with `middleware()`, `tools()`, and `send()`.
```typescript
import { createHoncho } from '@honcho-ai/vercel-ai-sdk';
const honcho = createHoncho();
```
You can set a stable `defaultAssistantId` on the provider to identify the AI peer across all calls:
```typescript
const honcho = createHoncho({
defaultAssistantId: 'my-assistant',
});
```
## Add Middleware
`honcho.middleware()` is compatible with `wrapLanguageModel`. Two things happen on each call:
1. **Before generation** — Honcho fetches the user's representation, peer card, session summary, and recent messages and injects them into the system prompt
2. **After generation** — the user message and assistant response are stored back in Honcho with correct peer attribution
```typescript
import { createHoncho } from '@honcho-ai/vercel-ai-sdk';
import { wrapLanguageModel, generateText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
const honcho = createHoncho();
const model = wrapLanguageModel({
model: anthropic('claude-sonnet-4-6'),
middleware: honcho.middleware({
userId: 'user-abc',
sessionId: 'session-123',
}),
});
const { text } = await generateText({
model,
prompt: 'What should I focus on today?',
});
```
Pass `userId` and `sessionId` per request — no session handles to construct. Both default to lazily generated IDs if omitted, which is fine for local scripts but not for multi-user server traffic.
## Add Tools
`honcho.tools()` gives the model six tools it can call mid-generation to query or update what it knows about the user:
| Tool | What it does |
| --- | --- |
| `honcho_chat` | Dialectic reasoning — ask natural-language questions about the user; answers synthesized from full interaction history |
| `honcho_context` | Short summary of recent context within the session |
| `honcho_search` | Semantic search over stored conversation messages |
| `honcho_search_conclusions` | Query derived conclusions: personality traits, preferences, behavioral patterns |
| `honcho_get_representation` | Full synthesized profile of the user |
| `honcho_save_conclusion` | Persist an observation about the user for future sessions |
Pass the same `userId` and `sessionId` to `honcho.tools()` so tool calls bind to the same peers as the middleware:
```typescript
import { generateText, stepCountIs } from 'ai';
const { text } = await generateText({
model,
tools: honcho.tools({
userId: 'user-abc',
sessionId: 'session-123',
}),
stopWhen: stepCountIs(3),
prompt: 'Based on our conversations, what do I care about most?',
});
```
## Complete Example
Here's a full working example combining middleware and tools.
Want a runnable end-to-end version? See the [Full Script](#full-script).
```typescript
import { createHoncho } from '@honcho-ai/vercel-ai-sdk';
import { wrapLanguageModel, generateText, stepCountIs } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
const honcho = createHoncho({
defaultAssistantId: 'assistant',
});
const userId = 'user-abc';
const sessionId = 'session-123';
const model = wrapLanguageModel({
model: anthropic('claude-sonnet-4-6'),
middleware: honcho.middleware({ userId, sessionId }),
});
const { text } = await generateText({
model,
tools: honcho.tools({ userId, sessionId }),
stopWhen: stepCountIs(3),
prompt: 'What should we work on today?',
});
console.log(text);
```
## Streaming
`streamText` works the same way — middleware handles persistence after the stream completes:
```typescript
import { createHoncho } from '@honcho-ai/vercel-ai-sdk';
import { wrapLanguageModel, streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
const honcho = createHoncho();
const userId = 'user-abc';
const sessionId = 'session-456';
const model = wrapLanguageModel({
model: openai('gpt-4o'),
middleware: honcho.middleware({ userId, sessionId }),
});
const result = streamText({
model,
tools: honcho.tools({ userId, sessionId }),
prompt: 'What should we work on today?',
});
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}
```
## Using with `messages`
If your app already manages conversation history and passes a `messages` array directly, set `injectHistory: false` to prevent Honcho from prepending duplicate history:
```typescript
honcho.middleware({
userId,
sessionId,
injectHistory: false, // don't prepend history — we're passing messages directly
})
```
Honcho still injects the user's representation and peer card into the system prompt, and still persists messages after generation. With `injectHistory: false` you must pass a `messages` array — without either `messages` or `prompt`, the Vercel AI SDK throws `Invalid prompt: prompt or messages must be defined`.
## Verifying the Integration
### 1. Isolate Honcho's Contribution
Let's confirm the memory is actually coming from Honcho and not your app's existing conversation history.
Two ways to check: 1) through a developer method 2) through the UI.
**Token delta (developer check).** On a session with a few prior turns, run the same prompt twice — once with `injectHistory: false` and once without.
Compare `result.usage.inputTokens`:
```typescript
const baseline = await generateText({
model: wrapLanguageModel({
model: anthropic('claude-sonnet-4-6'),
middleware: honcho.middleware({ userId, sessionId, injectHistory: false }),
}),
prompt: 'What do you know about my preferences?',
});
const injected = await generateText({
model: wrapLanguageModel({
model: anthropic('claude-sonnet-4-6'),
middleware: honcho.middleware({ userId, sessionId }),
}),
prompt: 'What do you know about my preferences?',
});
console.log(injected.usage.inputTokens - baseline.usage.inputTokens);
```
A positive delta is Honcho's representation, peer card, and session summary being injected into the system prompt. Expect ~0 on a fresh peer — the deriver runs asynchronously after messages persist, so injected context only populates after a few prior turns.
**Dashboard (UI check).** Open [app.honcho.dev/explore](https://app.honcho.dev/explore), select your workspace, and confirm your peer and session appear under the Peers and Sessions tables.
With Honcho's contribution isolated, the rest of this section shows what the integration feels like in practice.
### 2. First turn
Send any message. The model responds normally — nothing is stored yet. Context injection returns empty on the first turn.
### 3. Build memory across turns
Have a multi-turn conversation and share something about yourself:
```text
I prefer concise answers and I mostly work in TypeScript.
```
After a few turns, ask:
```text
What do you know about my preferences?
```
If the model references TypeScript and concise answers without being told again in this session, memory is working.
### 4. Cross-session recall
Start a new session (new `sessionId`) with the same `userId`. Ask:
```text
Call your honcho_search tool with the query 'TypeScript' and quote the exact verbatim message that contained TypeScript. Do not paraphrase.
```
If the search returns a message from the prior session word-for-word, peer-scoped retrieval is crossing session boundaries. `honcho_search` queries the user's messages across all their sessions and doesn't depend on the deriver, so it works regardless of how short the prior session was.
To confirm the tool actually fired, inspect `result.steps[i].toolCalls`:
```typescript
const toolFires = result.steps?.flatMap((step, i) =>
(step.toolCalls ?? []).map((tc) => ({ step: i, tool: tc.toolName, input: tc.input }))
) ?? [];
console.log(toolFires);
// [{ step: 0, tool: "honcho_search", input: { query: "TypeScript", limit: 10 } }]
```
When the model takes more than one turn (call a tool, see the result, then answer), the top-level `result.toolCalls` is empty — check inside each `step`.
## Full Script
<Accordion title="honcho_vercel_chat.ts">
```typescript
/**
* Multi-turn chat with Honcho memory + Vercel AI SDK.
*
* Prerequisites:
* 1. Install dependencies:
* npm install @honcho-ai/vercel-ai-sdk ai @ai-sdk/anthropic dotenv
* 2. Set environment variables in `.env`:
* HONCHO_API_KEY=your-honcho-api-key
* HONCHO_WORKSPACE_ID=your-workspace-id
* ANTHROPIC_API_KEY=your-anthropic-api-key
* 3. Run with: npx tsx honcho_vercel_chat.ts
*
* Pass a stable userId from your auth system and a sessionId for the conversation
* thread; Honcho handles persistence and context injection on every turn.
*/
import 'dotenv/config';
import { createHoncho } from '@honcho-ai/vercel-ai-sdk';
import { wrapLanguageModel, generateText, stepCountIs } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import * as readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
const honcho = createHoncho({
defaultAssistantId: 'assistant',
});
const userId = process.env.USER_ID ?? 'demo-user';
const sessionId = process.env.SESSION_ID ?? `session-${Date.now()}`;
const model = wrapLanguageModel({
model: anthropic('claude-sonnet-4-6'),
middleware: honcho.middleware({ userId, sessionId }),
});
async function chat(prompt: string): Promise<string> {
const { text } = await generateText({
model,
tools: honcho.tools({ userId, sessionId }),
stopWhen: stepCountIs(3),
prompt,
});
return text;
}
async function main() {
const rl = readline.createInterface({ input, output });
console.log(`Honcho session: ${sessionId} (user: ${userId})`);
console.log('Type a message, or "exit" to quit.\n');
while (true) {
const userMessage = (await rl.question('you > ')).trim();
if (!userMessage || userMessage === 'exit') break;
const reply = await chat(userMessage);
console.log(`bot > ${reply}\n`);
}
rl.close();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
</Accordion>
## Next Steps
<CardGroup cols={2}>
<Card title="Github Repository" icon="github" href="https://github.com/plastic-labs/vercel-ai-sdk-package">
Source, tests, and full API reference for @honcho-ai/vercel-ai-sdk.
</Card>
<Card title="Honcho Architecture" icon="sitemap" href="/v3/documentation/core-concepts/architecture">
Learn about peers, sessions, and dialectic reasoning.
</Card>
<Card title="Self-Hosting Guide" icon="server" href="/v3/contributing/self-hosting">
Run Honcho locally with your Vercel AI SDK app.
</Card>
<Card title="Vercel AI SDK Docs" icon="book" href="https://sdk.vercel.ai">
wrapLanguageModel, middleware, and tool use reference.
</Card>
</CardGroup>

View File

@ -64,8 +64,19 @@ print(result.raw)
## Documentation
For guides and API reference, visit [docs.honcho.dev](https://docs.honcho.dev/v3/guides/integrations/crewai).
For comprehensive guides, examples, and API reference, visit:
**[https://honcho.dev/docs/v3/integrations/crewai](https://honcho.dev/docs/v3/integrations/crewai)**
## Examples
Check out complete examples in the [GitHub repository](https://github.com/plastic-labs/honcho/tree/main/examples/crewai/python/examples).
## License
AGPL-3.0-or-later
## Support
- Report issues: [GitHub Issues](https://github.com/plastic-labs/honcho/issues)
- Documentation: [honcho.dev/docs](https://honcho.dev/docs)
- Website: [honcho.dev](https://honcho.dev)

View File

@ -41,7 +41,7 @@ dependencies = [
[project.urls]
Homepage = "https://honcho.dev"
Documentation = "https://docs.honcho.dev/v3/guides/integrations/crewai"
Documentation = "https://honcho.dev/docs/v3/integrations/crewai"
Repository = "https://github.com/plastic-labs/honcho"
"Bug Tracker" = "https://github.com/plastic-labs/honcho/issues"
Changelog = "https://github.com/plastic-labs/honcho/blob/main/CHANGELOG.md"

View File

@ -464,7 +464,30 @@ class HonchoStorage(LegacyStorage):
score_threshold: float = 0.5,
filters: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Search session messages and return CrewAI external-memory records."""
"""
Search for relevant messages using semantic search.
This method uses Honcho's semantic vector search to find messages most
relevant to the query.
Args:
query: Search query used for semantic matching
limit: Maximum number of messages to retrieve
score_threshold: Minimum relevance score (not currently used by Honcho API)
filters: Optional filters to scope the search. Supports Honcho's filter syntax
including logical operators (AND, OR, NOT), comparison operators
(gt, gte, lt, lte, eq, ne), and metadata filtering.
Example: {"peer_id": "user123"} or {"metadata": {"type": "important"}}
See: https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters
Returns:
List of message dictionaries in CrewAI expected format.
Each dict contains:
- content: The message content
- memory: The message content (required by CrewAI)
- context: The message content (for compatibility)
- metadata: Message metadata including peer_id, created_at, and custom metadata
"""
try:
_ = score_threshold
messages = self.session.search(query=query, filters=filters, limit=limit)

View File

@ -38,7 +38,7 @@ dependencies = [
"cashews[redis]==7.4.4",
"scikit-learn>=1.6.0",
"prometheus_client>=0.21.0",
"cloudevents>=1.12.0",
"cloudevents>=1.12.0,<2.0",
]
[dependency-groups]
dev = [

View File

@ -0,0 +1,475 @@
"""Configure pgvector schema dim to match EMBEDDING_VECTOR_DIMENSIONS.
Usage::
uv run python scripts/configure_embeddings.py # interactive
uv run python scripts/configure_embeddings.py --dry-run # print intent, no DB write
uv run python scripts/configure_embeddings.py --yes # apply without prompt
uv run python scripts/configure_embeddings.py --report # full external-store inventory
The bootstrap sequence for a self-hosted install is:
1. alembic upgrade head # creates default vector(1536) schema
2. uv run python scripts/configure_embeddings.py # ALTER columns to target dim
3. start the API and deriver # validators refuse to start on mismatch
Existing 1536 deployments need no action step 2 is a no-op when settings
already match the schema.
This script never creates or modifies external-store namespaces. Turbopuffer
and LanceDB namespaces are per-workspace and lazy-created on first write by
application code; their dim is implicitly pinned at that point. Use
``--report`` to enumerate existing namespaces against the configured dim.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import re
import sys
from dataclasses import dataclass
# Match the path-shim convention used by the other scripts in this directory
# so `src.*` imports resolve when the script is run directly.
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
from sqlalchemy import select, text # noqa: E402
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine # noqa: E402
from src.config import settings # noqa: E402
from src.db import engine # noqa: E402
from src.models import Collection, Workspace # noqa: E402
from src.vector_store import VectorStore # noqa: E402
logger = logging.getLogger(__name__)
_EMBEDDING_TABLES: tuple[str, ...] = ("documents", "message_embeddings")
# Defense-in-depth for the dynamic SQL paths in this script. The values we
# interpolate (schema from settings, index names from pg_indexes, table
# names from a hardcoded constant) are not user input under any threat
# model we currently care about, but validating once at the top of the
# pipeline keeps the constraint explicit and the surface tight.
_SAFE_IDENT_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def _validate_identifier(name: str, *, kind: str) -> None:
if not _SAFE_IDENT_PATTERN.fullmatch(name):
raise SystemExit(
f"error: refusing to interpolate {kind} {name!r} into SQL —"
+ " expected a SQL identifier of the form [A-Za-z_][A-Za-z0-9_]*."
+ " Reconfigure your environment and re-run."
)
# ---------------------------------------------------------------------------
# Result types
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class _PgvectorPlan:
target_dim: int
schema: str
current_dims: dict[str, int]
needs_alter: bool
@dataclass(frozen=True)
class _NamespaceRecord:
"""A single row in the --report output."""
namespace: str
status: str # one of: "ok", "missing", "mismatch"
actual_dim: int | None
target_dim: int
# ---------------------------------------------------------------------------
# pgvector phase
# ---------------------------------------------------------------------------
async def _introspect_pgvector(conn: AsyncConnection, schema: str) -> dict[str, int]:
"""Return ``{table_name: atttypmod}`` for embedding columns in ``schema``.
Tables not present in the result dict are absent from the schema.
pgvector stores the declared dim directly in ``atttypmod`` (no VARHDRSZ).
"""
query = text(
"""
SELECT c.relname AS table_name, a.atttypmod AS typmod
FROM pg_attribute a
JOIN pg_class c ON a.attrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE n.nspname = :schema
AND c.relname = ANY(:tables)
AND a.attname = 'embedding'
"""
)
result = await conn.execute(
query,
{"schema": schema, "tables": list(_EMBEDDING_TABLES)},
)
return {row.table_name: row.typmod for row in result}
async def _build_pgvector_plan(
engine: AsyncEngine, target_dim: int, schema: str
) -> _PgvectorPlan:
"""Build a plan describing what (if anything) the script will change."""
async with engine.connect() as conn:
current = await _introspect_pgvector(conn, schema)
missing = set(_EMBEDDING_TABLES) - current.keys()
if missing:
listing = ", ".join(sorted(f"{schema}.{t}.embedding" for t in missing))
raise SystemExit(
f"error: required vector columns missing: {listing}."
+ " Run `alembic upgrade head` first."
)
for table, typmod in current.items():
if typmod == -1:
raise SystemExit(
f"error: {schema}.{table}.embedding has no declared vector"
+ " dimension (unbounded typmod). Drop and recreate the column"
+ " or restore from a versioned migration before re-running."
)
needs_alter = any(typmod != target_dim for typmod in current.values())
return _PgvectorPlan(
target_dim=target_dim,
schema=schema,
current_dims=current,
needs_alter=needs_alter,
)
async def _count_non_null_embeddings(
conn: AsyncConnection, schema: str, table: str
) -> int:
query = text(
f'SELECT COUNT(*) AS n FROM "{schema}"."{table}" WHERE embedding IS NOT NULL'
)
result = await conn.execute(query)
row = result.first()
return int(row.n) if row is not None else 0
async def _fetch_hnsw_index_defs(
conn: AsyncConnection, schema: str
) -> list[tuple[str, str]]:
"""Return ``(index_name, CREATE INDEX ...)`` for HNSW indices on the
embedding columns. We re-CREATE them after the ALTER using these exact
definitions, preserving operator-set params (m, ef_construction, etc.)."""
query = text(
"""
SELECT indexname AS name, indexdef AS ddl
FROM pg_indexes
WHERE schemaname = :schema
AND tablename = ANY(:tables)
AND indexdef ILIKE '%USING hnsw%'
"""
)
result = await conn.execute(
query, {"schema": schema, "tables": list(_EMBEDDING_TABLES)}
)
return [(row.name, row.ddl) for row in result]
async def _apply_pgvector_alter(engine: AsyncEngine, plan: _PgvectorPlan) -> None:
"""ALTER the embedding columns to ``plan.target_dim`` in a single
transaction. Refuses to proceed if any non-null embeddings exist.
Sequence (inside the transaction):
1. LOCK TABLE ... IN ACCESS EXCLUSIVE MODE closes the TOCTOU window
between the population check and the ALTER.
2. SELECT COUNT(embedding IS NOT NULL) per table refuse if any > 0.
3. Save HNSW index definitions, then DROP them (cannot ALTER under HNSW).
4. ALTER ... ALTER COLUMN embedding TYPE vector(N) USING NULL.
5. Recreate HNSW indices from saved definitions.
"""
async with engine.begin() as conn:
# Step 1: lock both tables for the duration of the transaction.
for table in _EMBEDDING_TABLES:
await conn.execute(
text(f'LOCK TABLE "{plan.schema}"."{table}" IN ACCESS EXCLUSIVE MODE')
)
# Step 2: population check.
counts: dict[str, int] = {}
for table in _EMBEDDING_TABLES:
counts[table] = await _count_non_null_embeddings(conn, plan.schema, table)
populated = {t: n for t, n in counts.items() if n > 0}
if populated:
detail = ", ".join(f"{t}: {n} rows" for t, n in sorted(populated.items()))
raise SystemExit(
f"error: refusing to ALTER populated embedding tables ({detail})."
+ " This script only configures empty tables. Re-embed out-of-band"
+ " into a fresh deployment, then cut over."
)
# Step 3: snapshot + drop HNSW indices.
index_defs = await _fetch_hnsw_index_defs(conn, plan.schema)
for index_name, _ddl in index_defs:
_validate_identifier(index_name, kind="HNSW index name")
logger.info("dropping HNSW index %s", index_name)
await conn.execute(text(f'DROP INDEX "{plan.schema}"."{index_name}"'))
# Step 4: ALTER columns.
for table in _EMBEDDING_TABLES:
logger.info(
"altering %s.%s.embedding to vector(%d)",
plan.schema,
table,
plan.target_dim,
)
await conn.execute(
text(
f'ALTER TABLE "{plan.schema}"."{table}"'
+ f" ALTER COLUMN embedding TYPE vector({plan.target_dim})"
+ " USING NULL"
)
)
# Step 5: recreate HNSW indices from the saved definitions.
for index_name, ddl in index_defs:
logger.info("recreating HNSW index %s", index_name)
await conn.execute(text(ddl))
# ---------------------------------------------------------------------------
# External-store report
# ---------------------------------------------------------------------------
async def _enumerate_workspaces(conn: AsyncConnection) -> list[str]:
"""All workspace names, ordered by creation. Uses the ORM so
``Base.metadata.schema`` (configured from ``DB.SCHEMA``) is honored
non-public schema deployments must not sample the wrong table."""
stmt = select(Workspace.name).order_by(Workspace.created_at)
result = await conn.execute(stmt)
return [row[0] for row in result]
async def _enumerate_collections(
conn: AsyncConnection,
) -> list[tuple[str, str, str]]:
"""Every (workspace_name, observer, observed) triple that has a row in
the collections table these are the document namespaces that could
exist in an external store."""
stmt = select(Collection.workspace_name, Collection.observer, Collection.observed)
result = await conn.execute(stmt)
return [(row[0], row[1], row[2]) for row in result]
async def _build_external_namespace_inventory(
engine: AsyncEngine,
) -> list[tuple[str, str]]:
"""Return ``(namespace_type, namespace_name)`` pairs for every namespace
that should exist based on the application DB. Message namespaces are
derived per workspace, document namespaces per collection row.
"""
from src.vector_store import get_external_vector_store
store = get_external_vector_store()
if store is None:
return []
async with engine.connect() as conn:
workspace_names = await _enumerate_workspaces(conn)
collection_keys = await _enumerate_collections(conn)
pairs: list[tuple[str, str]] = []
for workspace_name in workspace_names:
pairs.append(("message", store.get_vector_namespace("message", workspace_name)))
for workspace_name, observer, observed in collection_keys:
pairs.append(
(
"document",
store.get_vector_namespace(
"document", workspace_name, observer=observer, observed=observed
),
)
)
return pairs
async def _probe_namespace_dim(store: VectorStore, namespace: str) -> int | None:
"""Return the namespace's declared dim, or ``None`` if the namespace
does not exist yet. Delegates to the store-specific probe."""
return await store.probe_namespace_dim(namespace)
async def _emit_report(
engine: AsyncEngine, target_dim: int, *, is_report_mode: bool
) -> int:
"""Print the per-namespace inventory and return an exit code. 0 on a
clean report (all matching or missing); non-zero on any mismatch.
``is_report_mode=True`` means the operator explicitly invoked ``--report``
only then do we print the "no effect with pgvector" notice. Implicit
post-apply calls from interactive/dry-run/yes mode stay silent when the
deployment is on pgvector.
"""
if settings.VECTOR_STORE.TYPE == "pgvector":
if is_report_mode:
print("--report has no effect with VECTOR_STORE_TYPE=pgvector")
return 0
inventory = await _build_external_namespace_inventory(engine)
if not inventory:
print(
"no external namespaces to inventory"
+ " (no workspaces/collections exist yet, or no external store configured)"
)
return 0
from src.vector_store import get_external_vector_store
store = get_external_vector_store()
if store is None:
print("no external store configured; nothing to report")
return 0
records: list[_NamespaceRecord] = []
for _ns_type, namespace in inventory:
actual = await _probe_namespace_dim(store, namespace)
if actual is None:
# Namespace has not been written to yet (lazy-create model).
status = "missing"
elif actual == target_dim:
status = "ok"
else:
status = "mismatch"
records.append(
_NamespaceRecord(
namespace=namespace,
status=status,
actual_dim=actual,
target_dim=target_dim,
)
)
width = max(len(r.namespace) for r in records)
print(f"{'namespace'.ljust(width)} status dim")
print(f"{'-' * width} --------- ------")
for r in records:
dim_str = "?" if r.actual_dim is None else str(r.actual_dim)
print(f"{r.namespace.ljust(width)} {r.status:<9} {dim_str}")
mismatches = [r for r in records if r.status == "mismatch"]
if mismatches:
print(
f"\nerror: {len(mismatches)} namespace(s) have dim != {target_dim}",
file=sys.stderr,
)
return 1
return 0
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="configure_embeddings",
description=(
"Configure pgvector schema dim to match EMBEDDING_VECTOR_DIMENSIONS."
),
)
mode = parser.add_mutually_exclusive_group()
mode.add_argument(
"--dry-run",
action="store_true",
help="print intended changes and exit without touching the DB",
)
mode.add_argument(
"--yes",
action="store_true",
help="apply changes without an interactive prompt",
)
mode.add_argument(
"--report",
action="store_true",
help="print external-store namespace inventory and exit",
)
return parser
def _confirm(prompt: str) -> bool:
response = input(f"{prompt} [y/N]: ").strip().lower()
return response in {"y", "yes"}
async def _async_main(args: argparse.Namespace) -> int:
try:
return await _run_pipeline(args)
finally:
# Dispose inside the same event loop so cleanup doesn't spin up a
# second loop just to await engine.dispose().
await engine.dispose()
async def _run_pipeline(args: argparse.Namespace) -> int:
target_dim = settings.EMBEDDING.VECTOR_DIMENSIONS
schema = settings.DB.SCHEMA
_validate_identifier(schema, kind="DB.SCHEMA")
if args.report:
return await _emit_report(engine, target_dim, is_report_mode=True)
plan = await _build_pgvector_plan(engine, target_dim, schema)
if not plan.needs_alter:
print(
f"pgvector: {schema}.documents.embedding and"
+ f" {schema}.message_embeddings.embedding already at dim {target_dim},"
+ " skipping ALTER"
)
return await _emit_report(engine, target_dim, is_report_mode=False)
current_summary = ", ".join(
f"{schema}.{t}.embedding={plan.current_dims[t]}" for t in _EMBEDDING_TABLES
)
print(f"target dim: {target_dim}")
print(f"current: {current_summary}")
print("planned operations (single transaction):")
print(f" - LOCK TABLE {schema}.documents IN ACCESS EXCLUSIVE MODE")
print(f" - LOCK TABLE {schema}.message_embeddings IN ACCESS EXCLUSIVE MODE")
print(" - refuse if any non-null embeddings exist")
print(" - DROP existing HNSW indices on the embedding columns")
print(
f" - ALTER COLUMN embedding TYPE vector({target_dim}) USING NULL"
+ " on both tables"
)
print(" - CREATE HNSW indices from snapshotted definitions")
if args.dry_run:
print("\n--dry-run: no changes applied")
return 0
if not args.yes and not _confirm("apply?"):
print("aborted")
return 1
await _apply_pgvector_alter(engine, plan)
print(f"\npgvector schema is now at dim {target_dim}")
return await _emit_report(engine, target_dim, is_report_mode=False)
def main(argv: list[str] | None = None) -> int:
logging.basicConfig(level=logging.INFO, format="%(message)s")
parser = _build_parser()
args = parser.parse_args(argv)
return asyncio.run(_async_main(args))
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -21,8 +21,7 @@ import sys
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, project_root)
import tiktoken # noqa: E402
from sqlalchemy import select # noqa: E402
from sqlalchemy import func, select # noqa: E402
from sqlalchemy.ext.asyncio import AsyncSession # noqa: E402
from src import models # noqa: E402
@ -56,6 +55,7 @@ async def get_messages_without_embeddings(
models.Message.public_id == models.MessageEmbedding.message_id,
)
.where(models.MessageEmbedding.message_id.is_(None)) # No embedding exists
.where(func.length(func.trim(models.Message.content)) > 0)
.order_by(models.Message.id)
)
@ -90,20 +90,16 @@ async def create_embeddings_for_messages(
if not messages:
return 0
# Initialize tiktoken encoding (same as used in MessageCreate schema)
encoding = tiktoken.get_encoding("o200k_base")
# Prepare data for batch embedding with proper token encoding
id_resource_dict = {
message.public_id: (
message.content,
encoding.encode(message.content), # Properly encode the content
)
message.public_id: message.content
for message in messages
if message.content and message.content.strip()
}
# Generate embeddings
embedding_dict = await embedding_client.batch_embed(id_resource_dict)
embedding_dict = (
await embedding_client.batch_embed(id_resource_dict) if id_resource_dict else {}
)
# Create MessageEmbedding objects
embedding_objects: list[models.MessageEmbedding] = []

View File

@ -159,6 +159,6 @@ Apache 2.0 - see [LICENSE](../../LICENSE) for details.
## Support
- [Documentation](https://docs.honcho.dev)
- [Documentation](https://honcho.dev/docs)
- [GitHub Issues](https://github.com/plastic-labs/honcho/issues)
- [Discord Community](https://discord.gg/honcho)

View File

@ -140,7 +140,7 @@ class WorkspaceCreateParams(BaseModel):
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
id: str = Field(min_length=1, max_length=100)
id: str = Field(min_length=1, max_length=512)
metadata: dict[str, Any] = Field(default_factory=dict)
configuration: WorkspaceConfiguration = Field(
default_factory=WorkspaceConfiguration
@ -186,7 +186,7 @@ class PeerCreateParams(BaseModel):
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
id: str = Field(min_length=1, max_length=100)
id: str = Field(min_length=1, max_length=512)
metadata: dict[str, Any] | None = None
configuration: PeerConfig | None = None
@ -268,7 +268,7 @@ class SessionCreateParams(BaseModel):
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
id: str = Field(min_length=1, max_length=100)
id: str = Field(min_length=1, max_length=512)
metadata: dict[str, Any] | None = None
peers: dict[str, SessionPeerConfig] | None = None
configuration: SessionConfiguration | None = None

View File

@ -553,7 +553,7 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
Args:
query: The search query to use
filters: Filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
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)
Returns:

View File

@ -370,7 +370,7 @@ class AsyncHonchoHTTPClient:
from datetime import datetime
from email.utils import parsedate_to_datetime
dt: datetime = cast(datetime, parsedate_to_datetime(header))
dt: datetime = parsedate_to_datetime(header)
timestamp: float = dt.timestamp()
return max(0.0, timestamp - time.time())
except Exception:

View File

@ -367,7 +367,7 @@ class HonchoHTTPClient:
from datetime import datetime
from email.utils import parsedate_to_datetime
dt: datetime = cast(datetime, parsedate_to_datetime(header))
dt: datetime = parsedate_to_datetime(header)
timestamp: float = dt.timestamp()
return max(0.0, timestamp - time.time())
except Exception:

View File

@ -473,7 +473,7 @@ class Peer(PeerBase, MetadataConfigMixin):
Args:
query: The search query to use
filters: Filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
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)
Returns:

View File

@ -771,7 +771,7 @@ class Session(SessionBase, MetadataConfigMixin):
Args:
query: The search query to use
filters: Filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
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)
Returns:

View File

@ -416,7 +416,7 @@ export class Honcho {
*
* @param options - Either a legacy raw filter object or an options object with
* `filters`, `page`, `size`, and `reverse`. See
* [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters).
* @returns Promise resolving to a Page of Peer objects representing all peers in the workspace
*/
async peers(
@ -537,7 +537,7 @@ export class Honcho {
*
* @param options - Either a legacy raw filter object or an options object with
* `filters`, `page`, `size`, and `reverse`. See
* [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters).
* @returns Promise resolving to a Page of Session objects representing all sessions
* in the workspace. Returns an empty page if no sessions exist
*/
@ -692,7 +692,7 @@ export class Honcho {
*
* @param options - Either a legacy raw filter object or an options object with
* `filters`, `page`, and `size`. See
* [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters).
* @returns Promise resolving to a Page of workspace ID strings. Returns an empty
* page if no workspaces are accessible or none exist
*/
@ -751,7 +751,7 @@ export class Honcho {
* Makes an API call to search for messages in the current workspace.
*
* @param query - The search query to use
* @param filters - Optional filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* @param filters - Optional filters to scope the search. See [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters).
* @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.

View File

@ -480,7 +480,7 @@ export class Peer {
*
* @param options - Either a legacy raw filter object or an options object with
* `filters`, `page`, `size`, and `reverse`. See
* [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters).
* @returns Promise resolving to a paginated list of Session objects this peer belongs to.
* Returns an empty list if the peer is not a member of any sessions
*/
@ -684,7 +684,7 @@ export class Peer {
* Makes an API call to search endpoint.
*
* @param query The search query to use
* @param filters - Optional filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* @param filters - Optional filters to scope the search. See [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters).
* @param limit - Optional limit on the number of results to return.
* @returns Promise resolving to an array of Message objects representing the search results.
* Returns an empty array if no messages are found.

View File

@ -585,7 +585,7 @@ export class Session {
*
* @param options - Either a legacy raw filter object or an options object with
* `filters`, `page`, `size`, and `reverse`. See
* [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters).
* @returns Promise resolving to a paginated Page of Message objects
*/
async messages(
@ -843,7 +843,7 @@ export class Session {
* @param query - The search query to use
* @param options - Search options
* @param options.filters - Optional filters to scope the search. See
* [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters).
* @param options.limit - Number of results to return (1-100, default: 10)
* @returns Promise resolving to an array of Message objects matching the query
*/

View File

@ -17,7 +17,7 @@ export const WorkspaceIdSchema = z
/^[a-zA-Z0-9_-]+$/,
'Workspace ID may only contain letters, numbers, underscores, and hyphens'
)
.max(100, 'Workspace ID can be at most 100 characters')
.max(512, 'Workspace ID can be at most 512 characters')
/**
* Schema for Honcho client configuration options.
@ -69,7 +69,7 @@ export const PeerIdSchema = z
/^[a-zA-Z0-9_-]+$/,
'Peer ID may only contain letters, numbers, underscores, and hyphens'
)
.max(100, 'Peer ID can be at most 100 characters')
.max(512, 'Peer ID can be at most 512 characters')
/**
* Strict helper: peer ID as object.
@ -152,7 +152,7 @@ export const SessionIdSchema = z
/^[a-zA-Z0-9_-]+$/,
'Session ID may only contain letters, numbers, underscores, and hyphens'
)
.max(100, 'Session ID can be at most 100 characters')
.max(512, 'Session ID can be at most 512 characters')
/**
* Strict helper: session ID as object.

View File

@ -24,6 +24,12 @@ logger = logging.getLogger(__name__)
ModelTransport = Literal["anthropic", "openai", "gemini"]
EmbeddingTransport = Literal["openai", "gemini"]
EmbeddingDimensionsMode = Literal["auto", "always", "never"]
# OpenAI-compatible models that reject the `dimensions=` request parameter.
_EMBEDDING_KNOWN_REJECTING_MODELS: frozenset[str] = frozenset(
{"text-embedding-ada-002"}
)
def _default_embedding_model_for_transport(transport: EmbeddingTransport) -> str:
@ -294,6 +300,7 @@ class ConfiguredEmbeddingModelSettings(BaseModel):
model: str = "text-embedding-3-small"
transport: EmbeddingTransport = "openai"
overrides: ModelOverrideSettings = Field(default_factory=ModelOverrideSettings)
dimensions_mode: EmbeddingDimensionsMode = "auto"
@model_validator(mode="before")
@classmethod
@ -648,6 +655,12 @@ class LLMSettings(HonchoSettings):
OPENAI_API_KEY: str | None = None
GEMINI_API_KEY: str | None = None
# Base URLs for LLM providers (for OpenAI-compatible proxies like
# OpenRouter, vLLM, Together, Anyscale, self-hosted, etc.)
ANTHROPIC_BASE_URL: str | None = None
OPENAI_BASE_URL: str | None = None
GEMINI_BASE_URL: str | None = None
# General LLM settings
DEFAULT_MAX_TOKENS: Annotated[int, Field(default=1000, gt=0, le=100_000)] = 2500
@ -695,6 +708,23 @@ class EmbeddingSettings(HonchoSettings):
)
return data # pyright: ignore[reportUnknownVariableType]
def resolve_send_dimensions(self) -> bool:
"""Decide whether OpenAI embedding calls should forward ``dimensions=``.
Lives on the settings instance because ``auto`` mode needs access to
``self.model_fields_set`` to tell whether the operator explicitly set
``VECTOR_DIMENSIONS`` a standalone resolver over
``ConfiguredEmbeddingModelSettings`` cannot see that.
"""
mode = self.MODEL_CONFIG.dimensions_mode
if mode == "always":
return True
if mode == "never":
return False
if self.MODEL_CONFIG.model in _EMBEDDING_KNOWN_REJECTING_MODELS:
return False
return "VECTOR_DIMENSIONS" in self.model_fields_set
class DeriverSettings(HonchoSettings):
model_config = SettingsConfigDict( # pyright: ignore
@ -731,7 +761,10 @@ class DeriverSettings(HonchoSettings):
LOG_OBSERVATIONS: bool = False
MAX_INPUT_TOKENS: Annotated[int, Field(default=23000, gt=0, le=23000)] = 23000
MAX_INPUT_TOKENS: Annotated[int, Field(default=25000, gt=0, le=25000)] = 25000
MAX_CUSTOM_INSTRUCTIONS_TOKENS: Annotated[
int, Field(default=2000, ge=0, le=2000)
] = 2000
# Maximum number of observations to return in working representation
# This is applied to both explicit and deductive observations
@ -837,12 +870,12 @@ def _default_dialectic_levels() -> dict[ReasoningLevel, DialecticLevelSettings]:
MODEL_CONFIG=_default_model_config(),
MAX_TOOL_ITERATIONS=1,
MAX_OUTPUT_TOKENS=250,
TOOL_CHOICE="any",
TOOL_CHOICE="auto",
),
"low": DialecticLevelSettings(
MODEL_CONFIG=_default_model_config(),
MAX_TOOL_ITERATIONS=5,
TOOL_CHOICE="any",
TOOL_CHOICE="auto",
),
"medium": DialecticLevelSettings(
MODEL_CONFIG=_default_model_config(),
@ -926,6 +959,10 @@ class DialecticSettings(HonchoSettings):
del base_mc[k]
level_override[mc_key] = {**base_mc, **override_mc}
levels_raw[level_name] = {**base, **level_override}
# Backfill any reasoning levels the operator didn't explicitly set with the default values.
for default_level_name, default_level in defaults.items():
if default_level_name not in levels_raw:
levels_raw[default_level_name] = default_level.model_dump(by_alias=True)
return data # pyright: ignore[reportUnknownVariableType]
@model_validator(mode="after")
@ -1259,25 +1296,27 @@ class AppSettings(HonchoSettings):
self.CACHE.NAMESPACE = self.NAMESPACE
if "NAMESPACE" not in self.VECTOR_STORE.model_fields_set:
self.VECTOR_STORE.NAMESPACE = self.NAMESPACE
if "DIMENSIONS" not in self.VECTOR_STORE.model_fields_set:
self.VECTOR_STORE.DIMENSIONS = self.EMBEDDING.VECTOR_DIMENSIONS
elif self.VECTOR_STORE.DIMENSIONS != self.EMBEDDING.VECTOR_DIMENSIONS:
raise ValueError(
"VECTOR_STORE.DIMENSIONS must match EMBEDDING.VECTOR_DIMENSIONS"
if "DIMENSIONS" in self.VECTOR_STORE.model_fields_set:
# VECTOR_STORE_DIMENSIONS is deprecated: EMBEDDING_VECTOR_DIMENSIONS
# is the single source of truth. Log a runtime-visible warning
# so operators see it (DeprecationWarning is filtered by Python's
# default config outside __main__/tests) and also raise the stdlib
# warning so tests can assert on it.
import warnings
message = (
"VECTOR_STORE_DIMENSIONS is deprecated; "
"EMBEDDING_VECTOR_DIMENSIONS is authoritative. "
"Drop VECTOR_STORE_DIMENSIONS from your .env."
)
logger.warning(message)
warnings.warn(message, DeprecationWarning, stacklevel=2)
self.VECTOR_STORE.DIMENSIONS = self.EMBEDDING.VECTOR_DIMENSIONS
if "NAMESPACE" not in self.TELEMETRY.model_fields_set:
self.TELEMETRY.NAMESPACE = self.NAMESPACE
if "NAMESPACE" not in self.METRICS.model_fields_set:
self.METRICS.NAMESPACE = self.NAMESPACE
if self.EMBEDDING.VECTOR_DIMENSIONS != 1536 and (
self.VECTOR_STORE.TYPE == "pgvector" or not self.VECTOR_STORE.MIGRATED
):
raise ValueError(
"EMBEDDING.VECTOR_DIMENSIONS must remain 1536 while pgvector is "
+ "active or vector-store migration is incomplete"
)
return self

View File

@ -9,6 +9,7 @@ from .document import (
create_observations,
delete_document,
delete_document_by_id,
delete_documents,
fetch_documents_by_ids,
get_all_documents,
get_child_observations,
@ -95,6 +96,7 @@ __all__ = [
"query_external_vector_document_ids",
"delete_document",
"delete_document_by_id",
"delete_documents",
# Message
"create_messages",
"get_messages",

View File

@ -81,6 +81,7 @@ async def get_collection(
*,
observer: str,
observed: str,
with_for_update: bool = False,
) -> models.Collection:
"""
Get a collection by observer/observed for a workspace.
@ -90,6 +91,11 @@ async def get_collection(
workspace_name: Name of the workspace
observer: Name of the observing peer (owns the collection)
observed: Name of the observed peer
with_for_update: If True, acquire a row-level lock (SELECT ... FOR UPDATE)
on the collection. Bypasses the cache so the lock is actually held
by the current transaction. Callers using this flag must wrap the
read and subsequent write in the same transaction (the lock is
released on commit/rollback).
Returns:
The collection if found
@ -97,6 +103,22 @@ async def get_collection(
Raises:
ResourceNotFoundException: If the collection does not exist
"""
if with_for_update:
# Row-lock path: go direct to DB (skip cache) so the FOR UPDATE lock
# is actually acquired on the row in the current transaction. The
# cached dict path would return without issuing SELECT ... FOR UPDATE.
stmt = (
select(models.Collection)
.where(models.Collection.workspace_name == workspace_name)
.where(models.Collection.observer == observer)
.where(models.Collection.observed == observed)
.with_for_update()
)
collection = await db.scalar(stmt)
if collection is None:
raise ResourceNotFoundException("Collection not found")
return collection
data = await _fetch_collection(db, workspace_name, observer, observed)
if data is None:
raise ResourceNotFoundException("Collection not found")

View File

@ -242,6 +242,7 @@ async def query_external_vector_document_ids(
top_k=top_k,
max_distance=max_distance,
filters=vector_filters if vector_filters else None,
include_attributes=False,
)
if not vector_results:
@ -661,6 +662,48 @@ async def delete_document(
await db.commit()
async def delete_documents(
db: AsyncSession,
workspace_name: str,
document_ids: Sequence[str],
*,
observer: str,
observed: str,
session_name: str | None = None,
) -> list[tuple[str, str]]:
"""
Soft-delete multiple documents in a single UPDATE ... RETURNING statement.
Returns (id, level) tuples for rows that actually got deleted i.e. rows
that matched the workspace/observer/observed filter and were not already
soft-deleted. IDs that didn't match are silently skipped; callers can diff
the returned ids against the input to detect misses.
"""
if not document_ids:
return []
conditions = [
models.Document.id.in_(document_ids),
models.Document.workspace_name == workspace_name,
models.Document.observer == observer,
models.Document.observed == observed,
models.Document.deleted_at.is_(None),
]
if session_name is not None:
conditions.append(models.Document.session_name == session_name)
stmt = (
update(models.Document)
.where(*conditions)
.values(deleted_at=func.now())
.returning(models.Document.id, models.Document.level)
)
result = await db.execute(stmt)
rows = result.all()
await db.commit()
return [(row.id, row.level) for row in rows]
async def delete_document_by_id(
db: AsyncSession,
workspace_name: str,

View File

@ -140,13 +140,12 @@ async def _build_merged_snippets(
for msg in matched_messages:
session_matches.setdefault(msg.session_name, []).append(msg)
snippets: list[tuple[list[models.Message], list[models.Message]]] = []
# Build merged ranges per session, then issue a single batched query
session_ranges: dict[str, list[tuple[int, int, list[models.Message]]]] = {}
for sess_name, matches in session_matches.items():
matches.sort(key=lambda m: m.seq_in_session)
merged_ranges: list[tuple[int, int, list[models.Message]]] = []
for match in matches:
start = match.seq_in_session - context_window
end = match.seq_in_session + context_window
@ -161,25 +160,42 @@ async def _build_merged_snippets(
else:
merged_ranges.append((start, end, [match]))
# Batch all ranges into a single query using OR conditions.
# NOTE: If callers ever pass a very high limit (many disjoint ranges),
# consider chunking to avoid oversized SQL / planner issues.
range_conditions = [
models.Message.seq_in_session.between(start_seq, end_seq)
for start_seq, end_seq, _ in merged_ranges
]
context_stmt = (
select(models.Message)
.where(models.Message.workspace_name == workspace_name)
.where(models.Message.session_name == sess_name)
.where(or_(*range_conditions))
.order_by(models.Message.seq_in_session.asc())
session_ranges[sess_name] = merged_ranges
# One OR-of-ANDs predicate covers every (session, range) pair
session_predicates = [
and_(
models.Message.session_name == sess_name,
or_(
*(
models.Message.seq_in_session.between(start_seq, end_seq)
for start_seq, end_seq, _ in merged_ranges
)
),
)
for sess_name, merged_ranges in session_ranges.items()
]
context_result = await db.execute(context_stmt)
all_context_messages = list(context_result.scalars().all())
context_stmt = (
select(models.Message)
.where(models.Message.workspace_name == workspace_name)
.where(or_(*session_predicates))
.order_by(
models.Message.session_name.asc(),
models.Message.seq_in_session.asc(),
)
)
# Partition results back into their respective ranges
context_result = await db.execute(context_stmt)
by_session: dict[str, list[models.Message]] = {}
for msg in context_result.scalars().all():
by_session.setdefault(msg.session_name, []).append(msg)
snippets: list[
tuple[list[models.Message], list[models.Message]]
] = [] # list of tuples, each containing query matches and context messages
for sess_name, merged_ranges in session_ranges.items():
all_context_messages = by_session.get(sess_name, [])
for start_seq, end_seq, range_matches in merged_ranges:
context_messages = [
msg
@ -262,18 +278,16 @@ async def create_messages(
await db.commit()
try:
if settings.EMBED_MESSAGES:
encoded_message_lookup = {
msg.public_id: orig_msg.encoded_message
for msg, orig_msg in zip(message_objects, messages, strict=True)
}
id_resource_dict = {
message.public_id: (
message.content,
encoded_message_lookup[message.public_id],
)
message.public_id: message.content
for message in message_objects
if message.content and message.content.strip()
}
embedding_dict = await embedding_client.batch_embed(id_resource_dict)
embedding_dict = (
await embedding_client.batch_embed(id_resource_dict)
if id_resource_dict
else {}
)
external_vector_store = get_external_vector_store()
@ -680,6 +694,7 @@ async def _search_messages_external(
query_embedding,
top_k=limit * oversample,
filters=vector_filters if vector_filters else None,
include_attributes=["message_id"],
)
if not vector_results:

View File

@ -26,6 +26,21 @@ from src.utils.representation import (
logger = logging.getLogger(__name__)
def _observation_text(obs: ExplicitObservation | DeductiveObservation) -> str:
"""Return the canonical text payload for an explicit or deductive observation."""
return obs.conclusion if isinstance(obs, DeductiveObservation) else obs.content
def _normalized_observation(
obs: ExplicitObservation | DeductiveObservation,
) -> ExplicitObservation | DeductiveObservation:
"""Return an observation with its persisted/embed text normalized."""
text = _observation_text(obs).strip()
if isinstance(obs, DeductiveObservation):
return obs.model_copy(update={"conclusion": text})
return obs.model_copy(update={"content": text})
class RepresentationManager:
"""Unified manager for representation and document queries."""
@ -67,15 +82,19 @@ class RepresentationManager:
logger.debug("No observations to save")
return new_documents
all_observations = representation.deductive + representation.explicit
all_observations = [
_normalized_observation(obs)
for obs in representation.deductive + representation.explicit
if _observation_text(obs).strip()
]
if not all_observations:
logger.debug("No non-empty observations to save")
return new_documents
# Batch embed all observations
batch_embed_start = time.perf_counter()
observation_texts = [
obs.conclusion if isinstance(obs, DeductiveObservation) else obs.content
for obs in all_observations
]
observation_texts = [_observation_text(obs) for obs in all_observations]
try:
embeddings = await embedding_client.simple_batch_embed(observation_texts)
except ValueError as e:

View File

@ -6,6 +6,8 @@ import uvloop
from prometheus_client import start_http_server
from src.config import settings
from src.db import engine
from src.startup import validate_embedding_schema
from src.telemetry import initialize_telemetry_async, shutdown_telemetry
from .queue_manager import main
@ -56,7 +58,12 @@ async def run_deriver():
"""Run the deriver with proper telemetry lifecycle management."""
# Initialize async telemetry (CloudEvents emitter)
await initialize_telemetry_async()
try:
# Fail fast if the embedding schema does not match settings — same
# gate the API runs in its lifespan. Inside the try block so the
# telemetry buffer is still flushed if validation raises.
await validate_embedding_schema(engine)
await main()
finally:
# Shutdown telemetry (flush CloudEvents buffer)

View File

@ -22,7 +22,7 @@ from src.utils.formatting import format_new_turn_with_timestamp
from src.utils.representation import PromptRepresentation, Representation
from src.utils.tokens import track_deriver_input_tokens
from .prompts import estimate_minimal_deriver_prompt_tokens, minimal_deriver_prompt
from .prompts import estimate_deriver_prompt_tokens, minimal_deriver_prompt
logger = logging.getLogger(__name__)
@ -78,6 +78,8 @@ async def process_representation_tasks_batch(
if message_level_configuration.reasoning.enabled is False:
return
custom_instructions = message_level_configuration.reasoning.custom_instructions
accumulate_metric(
f"minimal_deriver_{latest_message.id}_{observed}",
"starting_message_id",
@ -98,7 +100,7 @@ async def process_representation_tasks_batch(
)
# Track token usage - count only tokens from messages being processed
prompt_tokens = estimate_minimal_deriver_prompt_tokens()
prompt_tokens = estimate_deriver_prompt_tokens(custom_instructions)
queue_item_message_ids_set = set(queue_item_message_ids)
messages_tokens = sum(
msg.token_count for msg in messages if msg.id in queue_item_message_ids_set
@ -112,7 +114,11 @@ async def process_representation_tasks_batch(
)
# Build prompt
prompt = minimal_deriver_prompt(peer_id=observed, messages=formatted_messages)
prompt = minimal_deriver_prompt(
peer_id=observed,
messages=formatted_messages,
custom_instructions=custom_instructions,
)
context_prep_duration = (time.perf_counter() - overall_start) * 1000
accumulate_metric(

View File

@ -1,5 +1,4 @@
import logging
from datetime import datetime, timezone
from typing import Any, Literal
from sqlalchemy import exists, insert, select
@ -436,27 +435,26 @@ async def enqueue_dream(
observer: str,
observed: str,
dream_type: schemas.DreamType,
document_count: int,
session_name: str | None = None,
) -> None:
"""
Enqueue a dream task for immediate processing by the deriver.
Does not touch collection.internal_metadata["dream"] both guard fields
are written atomically in process_dream on successful completion.
Deduplication: If a dream with the same work_unit_key is already in-progress
(has an ActiveQueueSession), the enqueue is skipped to prevent running
multiple dreams concurrently for the same collection.
(has an ActiveQueueSession) or pending in the queue, the enqueue is skipped.
Args:
workspace_name: Name of the workspace
observer: Name of the observer peer
observed: Name of the observed peer
dream_type: Type of dream to execute
document_count: Current document count for metadata update
session_name: Name of the session to scope the dream to if specified
"""
async with tracked_db("dream_enqueue") as db_session:
try:
# Create the dream queue record
dream_record = create_dream_record(
workspace_name,
observer=observer,
@ -467,11 +465,6 @@ async def enqueue_dream(
work_unit_key = dream_record["work_unit_key"]
# Check if a dream with this work_unit_key is currently in progress
# (has an ActiveQueueSession, meaning a worker is processing it)
# We only block on in-progress dreams, not pending ones - if there's
# a pending dream, we don't need to add another one anyway since
# the queue processor will pick it up.
in_progress_check = select(
exists(
select(models.ActiveQueueSession.id).where(
@ -491,7 +484,6 @@ async def enqueue_dream(
)
return
# Check if there's already a pending dream with the same work_unit_key
pending_check = select(
exists(
select(QueueItem.id).where(
@ -512,25 +504,9 @@ async def enqueue_dream(
)
return
# Insert into queue
stmt = insert(QueueItem).returning(QueueItem)
await db_session.execute(stmt, [dream_record])
# Update collection metadata (CRUD handles cache invalidation)
now_iso = datetime.now(timezone.utc).isoformat()
await crud.update_collection_internal_metadata(
db_session,
workspace_name,
observer,
observed,
update_data={
"dream": {
"last_dream_document_count": document_count,
"last_dream_at": now_iso,
}
},
)
# update_collection_internal_metadata commits already
await db_session.commit()
logger.info(
"Enqueued dream task for %s/%s/%s (type: %s)",

View File

@ -11,9 +11,33 @@ from inspect import cleandoc as c
from src.utils.tokens import estimate_tokens
def _normalized_custom_instructions(custom_instructions: str | None) -> str | None:
"""Return stripped custom instructions, if any."""
if custom_instructions is None:
return None
normalized = custom_instructions.strip()
return normalized or None
def _custom_instructions_section(custom_instructions: str | None) -> str:
"""Render optional custom instructions for the deriver prompt."""
normalized_custom_instructions = _normalized_custom_instructions(custom_instructions)
if normalized_custom_instructions is None:
return ""
return c(
f"""
CUSTOM INSTRUCTIONS:
{normalized_custom_instructions}
"""
)
def minimal_deriver_prompt(
peer_id: str,
messages: str,
custom_instructions: str | None = None,
) -> str:
"""
Generate minimal prompt for fast observation extraction.
@ -25,6 +49,7 @@ def minimal_deriver_prompt(
Returns:
Formatted prompt string for observation extraction.
"""
custom_instructions_section = _custom_instructions_section(custom_instructions)
return c(
f"""
Analyze messages from {peer_id} to extract **explicit atomic facts** about them.
@ -45,6 +70,8 @@ EXAMPLES:
- EXPLICIT: "I took my dog for a walk in NYC" "{peer_id} has a dog", "{peer_id} lives in NYC"
- EXPLICIT: "{peer_id} attended college" + general knowledge "{peer_id} completed high school or equivalent"
{custom_instructions_section}
Messages to analyze:
<messages>
{messages}
@ -55,12 +82,24 @@ Messages to analyze:
@cache
def estimate_minimal_deriver_prompt_tokens() -> int:
"""Estimate base prompt tokens (cached)."""
try:
prompt = minimal_deriver_prompt(
peer_id="",
messages="",
)
return estimate_tokens(prompt)
except Exception:
return 300
"""Estimate the static minimal deriver prompt without custom instructions."""
prompt = minimal_deriver_prompt(
peer_id="",
messages="",
custom_instructions=None,
)
return estimate_tokens(prompt)
def estimate_deriver_prompt_tokens(custom_instructions: str | None) -> int:
"""Estimate minimal deriver prompt tokens, including custom instructions if present."""
normalized_custom_instructions = _normalized_custom_instructions(custom_instructions)
if normalized_custom_instructions is None:
return estimate_minimal_deriver_prompt_tokens()
prompt = minimal_deriver_prompt(
peer_id="",
messages="",
custom_instructions=normalized_custom_instructions,
)
return estimate_tokens(prompt)

View File

@ -4,7 +4,7 @@ from datetime import datetime, timezone
from logging import getLogger
import sentry_sdk
from sqlalchemy import func, select
from sqlalchemy import exists, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src import models
@ -160,13 +160,11 @@ class DreamScheduler:
observer: str,
observed: str,
) -> None:
"""Execute the dream by enqueueing it and updating collection metadata."""
# Import here to avoid circular dependency
"""Execute the dream by enqueueing it."""
from src import crud
from src.deriver.enqueue import enqueue_dream
from src.utils.config_helpers import get_configuration
# Find the most recent session and get current document count
async with tracked_db("dream_session_lookup") as db:
stmt = (
select(models.Document.session_name)
@ -174,6 +172,7 @@ class DreamScheduler:
models.Document.workspace_name == workspace_name,
models.Document.observer == observer,
models.Document.observed == observed,
models.Document.level == "explicit",
)
.order_by(models.Document.created_at.desc())
.limit(1)
@ -186,14 +185,6 @@ class DreamScheduler:
)
return
# Get current document count at execution time (not stale from scheduling)
count_stmt = select(func.count(models.Document.id)).where(
models.Document.workspace_name == workspace_name,
models.Document.observer == observer,
models.Document.observed == observed,
)
current_document_count = int(await db.scalar(count_stmt) or 0)
session = await crud.get_session(
db, workspace_name=workspace_name, session_name=session_name
)
@ -212,7 +203,6 @@ class DreamScheduler:
observer=observer,
observed=observed,
dream_type=dream_type,
document_count=current_document_count,
session_name=session_name,
)
@ -231,13 +221,18 @@ async def check_and_schedule_dream(
collection: models.Collection,
) -> bool:
"""
Check if a collection has reached the document threshold and schedule a timer-based dream.
From the moment a dream is scheduled until it completes or fails, no second
dream may be enqueued for the same (workspace, observer, observed) and the
baseline count advances only when consolidation actually happened.
Check if a collection has reached the explicit-observation threshold and schedule a timer-based dream.
This function only schedules a timer-based dream if:
1. Dreams are enabled
2. Document threshold is reached
2. Explicit-observation threshold is reached (dreamer output does not count)
3. Minimum hours between dreams have passed
4. No dream is already scheduled for this collection
4. No dream is already pending in the queue for this collection (in-flight check)
5. No dream is already scheduled for this collection
Args:
db: Database session
@ -249,21 +244,21 @@ async def check_and_schedule_dream(
if not settings.DREAM.ENABLED:
return False
# Get dream metadata from internal_metadata
dream_metadata = collection.internal_metadata.get("dream", {})
last_dream_document_count = dream_metadata.get("last_dream_document_count", 0)
last_dream_at = dream_metadata.get("last_dream_at")
# Count current documents in the collection
# Count explicit-level docs only: dreamer output (deductive/inductive/
# contradiction) would inflate the threshold and create a feedback loop.
count_stmt = select(func.count(models.Document.id)).where(
models.Document.workspace_name == collection.workspace_name,
models.Document.observer == collection.observer,
models.Document.observed == collection.observed,
models.Document.level == "explicit",
)
current_document_count = int(await db.scalar(count_stmt) or 0)
current_explicit_count = int(await db.scalar(count_stmt) or 0)
# Calculate documents added since last dream
documents_since_last_dream = current_document_count - last_dream_document_count
documents_since_last_dream = current_explicit_count - last_dream_document_count
logger.debug(
"Dream check",
@ -271,16 +266,14 @@ async def check_and_schedule_dream(
"workspace_name": collection.workspace_name,
"observer": collection.observer,
"observed": collection.observed,
"current_document_count": current_document_count,
"current_explicit_count": current_explicit_count,
"last_dream_document_count": last_dream_document_count,
"documents_since_last_dream": documents_since_last_dream,
"document_threshold": settings.DREAM.DOCUMENT_THRESHOLD,
},
)
# Only schedule timer if document threshold is reached
if documents_since_last_dream >= settings.DREAM.DOCUMENT_THRESHOLD:
# Check if we're within minimum hours between dreams
if last_dream_at:
try:
last_dream_time = datetime.fromisoformat(last_dream_at)
@ -299,11 +292,43 @@ async def check_and_schedule_dream(
f"Invalid last_dream_at timestamp: {last_dream_at}, error: {e}"
)
# Queue is source of truth for in-flight dreams; mirrors
# uq_queue_dream_pending_work_unit_key.
enabled_dream_types = settings.DREAM.ENABLED_TYPES
pending_keys = [
construct_work_unit_key(
collection.workspace_name,
{
"task_type": "dream",
"observer": collection.observer,
"observed": collection.observed,
"dream_type": dream_type,
},
)
for dream_type in enabled_dream_types
]
pending_exists = await db.scalar(
select(
exists(
select(models.QueueItem.id).where(
models.QueueItem.task_type == "dream",
models.QueueItem.processed == False, # noqa: E712
models.QueueItem.work_unit_key.in_(pending_keys),
)
)
)
)
if pending_exists:
logger.info(
"Skipping dream schedule for %s/%s: pending dream already in queue",
collection.observer,
collection.observed,
)
return False
dream_scheduler = get_dream_scheduler()
if dream_scheduler:
enabled_dream_types = settings.DREAM.ENABLED_TYPES
for dream_type in enabled_dream_types:
# Include dream_type in key so each dream type can be tracked independently
dream_work_unit_key = construct_work_unit_key(
collection.workspace_name,
{

View File

@ -17,11 +17,13 @@ import logging
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
import sentry_sdk
from sqlalchemy import func, select
from src import crud
from src import crud, models
from src.config import settings
from src.dependencies import tracked_db
from src.dreamer.specialists import SPECIALISTS, SpecialistResult
@ -323,6 +325,34 @@ DREAM: {payload.dream_type} documents for {workspace_name}/{payload.observer}/{p
+ f"duration={result.total_duration_ms:.0f}ms"
)
# Both guard fields advance together only on successful consolidation.
now_iso = datetime.now(timezone.utc).isoformat()
async with tracked_db("dream.guard_pair_write") as db:
collection = await crud.get_collection(
db,
workspace_name,
observer=payload.observer,
observed=payload.observed,
with_for_update=True,
)
count_stmt = select(func.count(models.Document.id)).where(
models.Document.workspace_name == workspace_name,
models.Document.observer == payload.observer,
models.Document.observed == payload.observed,
models.Document.level == "explicit",
)
current_explicit_count = int(await db.scalar(count_stmt) or 0)
dream_meta = dict(collection.internal_metadata.get("dream", {}))
dream_meta["last_dream_at"] = now_iso
dream_meta["last_dream_document_count"] = current_explicit_count
await crud.update_collection_internal_metadata(
db,
workspace_name,
payload.observer,
payload.observed,
update_data={"dream": dream_meta},
)
except Exception as e:
logger.error(
f"Error processing dream task {payload.dream_type} for {payload.observer}/{payload.observed}: {str(e)}",

View File

@ -2,7 +2,7 @@ import asyncio
import logging
import threading
from collections import defaultdict
from typing import NamedTuple
from typing import Any, NamedTuple
import tiktoken
from google import genai
@ -34,10 +34,12 @@ class _EmbeddingClient:
vector_dimensions: int,
max_input_tokens: int,
max_tokens_per_request: int,
send_dimensions: bool,
):
self.transport: str = config.transport
self.model: str = config.model
self.vector_dimensions: int = vector_dimensions
self.send_dimensions: bool = send_dimensions
if self.transport == "gemini":
if not config.api_key:
@ -65,7 +67,10 @@ class _EmbeddingClient:
self.max_embedding_tokens = max_input_tokens
self.max_batch_size = 2048 # OpenAI batch limit
self.encoding: tiktoken.Encoding = tiktoken.get_encoding("o200k_base")
try:
self.encoding: tiktoken.Encoding = tiktoken.encoding_for_model(self.model)
except KeyError:
self.encoding = tiktoken.get_encoding("cl100k_base")
self.max_embedding_tokens_per_request: int = max_tokens_per_request
@property
@ -98,9 +103,10 @@ class _EmbeddingClient:
raise ValueError("No embedding returned from Gemini API")
return self._validate_embedding_dimensions(response.embeddings[0].values)
else: # openai
response = await self.client.embeddings.create(
model=self.model, input=[query]
)
openai_kwargs: dict[str, Any] = {"model": self.model, "input": [query]}
if self.send_dimensions:
openai_kwargs["dimensions"] = self.vector_dimensions
response = await self.client.embeddings.create(**openai_kwargs)
return self._validate_embedding_dimensions(response.data[0].embedding)
async def simple_batch_embed(self, texts: list[str]) -> list[list[float]]:
@ -135,10 +141,13 @@ class _EmbeddingClient:
self._validate_embedding_dimensions(emb.values)
)
else: # openai
response = await self.client.embeddings.create(
input=batch,
model=self.model,
)
openai_kwargs: dict[str, Any] = {
"input": batch,
"model": self.model,
}
if self.send_dimensions:
openai_kwargs["dimensions"] = self.vector_dimensions
response = await self.client.embeddings.create(**openai_kwargs)
embeddings.extend(
[
self._validate_embedding_dimensions(data.embedding)
@ -156,13 +165,13 @@ class _EmbeddingClient:
return embeddings
async def batch_embed(
self, id_resource_dict: dict[str, tuple[str, list[int]]]
self, id_resource_dict: dict[str, str]
) -> dict[str, list[list[float]]]:
"""
Embed multiple texts, chunking long ones and batching API calls.
Args:
id_resource_dict: Maps text IDs to (text, encoded_tokens) tuples
id_resource_dict: Maps text IDs to text content
Returns:
Maps text IDs to lists of embedding vectors (one per chunk)
@ -185,27 +194,29 @@ class _EmbeddingClient:
return self._accumulate_embeddings(batch_results)
def _prepare_chunks(
self, id_resource_dict: dict[str, tuple[str, list[int]]]
self, id_resource_dict: dict[str, str]
) -> dict[str, list[tuple[str, int]]]:
"""
Chunk texts that exceed token limits.
Args:
id_resource_dict: Maps text IDs to (text, encoded_tokens) tuples
id_resource_dict: Maps text IDs to text content. We tokenize with
the embedding client's own encoding so token IDs match the
decoder vocabulary used by the target embedding API.
Returns:
Maps text IDs to lists of (chunk_text, token_count) tuples
"""
return {
text_id: (
_chunk_text_with_tokens(
text, encoded_tokens, self.max_embedding_tokens, self.encoding
out: dict[str, list[tuple[str, int]]] = {}
for text_id, text in id_resource_dict.items():
tokens = self.encoding.encode(text)
if len(tokens) > self.max_embedding_tokens:
out[text_id] = _chunk_text_with_tokens(
text, tokens, self.max_embedding_tokens, self.encoding
)
if len(encoded_tokens) > self.max_embedding_tokens
else [(text, len(encoded_tokens))]
)
for text_id, (text, encoded_tokens) in id_resource_dict.items()
}
else:
out[text_id] = [(text, len(tokens))]
return out
def _create_batches(
self, text_chunks: dict[str, list[tuple[str, int]]]
@ -282,9 +293,13 @@ class _EmbeddingClient:
)
)
else: # openai
response = await self.client.embeddings.create(
model=self.model, input=[item.text for item in batch]
)
openai_kwargs: dict[str, Any] = {
"model": self.model,
"input": [item.text for item in batch],
}
if self.send_dimensions:
openai_kwargs["dimensions"] = self.vector_dimensions
response = await self.client.embeddings.create(**openai_kwargs)
for item, embedding_data in zip(batch, response.data, strict=True):
result[item.text_id][item.chunk_index] = (
self._validate_embedding_dimensions(
@ -406,6 +421,7 @@ class EmbeddingClient:
vector_dimensions=settings.EMBEDDING.VECTOR_DIMENSIONS,
max_input_tokens=settings.EMBEDDING.MAX_INPUT_TOKENS,
max_tokens_per_request=settings.EMBEDDING.MAX_TOKENS_PER_REQUEST,
send_dimensions=settings.EMBEDDING.resolve_send_dimensions(),
)
self._instance_signature = signature
logger.debug(
@ -429,6 +445,7 @@ class EmbeddingClient:
settings.EMBEDDING.VECTOR_DIMENSIONS,
settings.EMBEDDING.MAX_INPUT_TOKENS,
settings.EMBEDDING.MAX_TOKENS_PER_REQUEST,
settings.EMBEDDING.resolve_send_dimensions(),
)
async def embed(self, query: str) -> list[float]:
@ -440,7 +457,7 @@ class EmbeddingClient:
return await self._get_client().simple_batch_embed(texts)
async def batch_embed(
self, id_resource_dict: dict[str, tuple[str, list[int]]]
self, id_resource_dict: dict[str, str]
) -> dict[str, list[list[float]]]:
"""Embed multiple texts, chunking long ones and batching API calls."""
return await self._get_client().batch_embed(id_resource_dict)

View File

@ -38,6 +38,7 @@ def get_anthropic_client() -> AsyncAnthropic:
"""Default Anthropic client built from settings.LLM.ANTHROPIC_API_KEY."""
return AsyncAnthropic(
api_key=settings.LLM.ANTHROPIC_API_KEY,
base_url=settings.LLM.ANTHROPIC_BASE_URL,
timeout=600.0,
)
@ -47,13 +48,19 @@ def get_openai_client() -> AsyncOpenAI:
"""Default OpenAI client built from settings.LLM.OPENAI_API_KEY."""
return AsyncOpenAI(
api_key=settings.LLM.OPENAI_API_KEY,
base_url=settings.LLM.OPENAI_BASE_URL,
)
@lru_cache(maxsize=1)
def get_gemini_client() -> genai.Client:
"""Default Gemini client built from settings.LLM.GEMINI_API_KEY."""
return genai.Client(api_key=settings.LLM.GEMINI_API_KEY)
http_options = (
genai_types.HttpOptions(base_url=settings.LLM.GEMINI_BASE_URL)
if settings.LLM.GEMINI_BASE_URL
else None
)
return genai.Client(api_key=settings.LLM.GEMINI_API_KEY, http_options=http_options)
# Bounded cache — in practice the (base_url, api_key) key space is small
@ -91,17 +98,25 @@ CLIENTS: dict[ModelTransport, ProviderClient] = {}
if settings.LLM.ANTHROPIC_API_KEY:
CLIENTS["anthropic"] = AsyncAnthropic(
api_key=settings.LLM.ANTHROPIC_API_KEY,
base_url=settings.LLM.ANTHROPIC_BASE_URL,
timeout=600.0,
)
if settings.LLM.OPENAI_API_KEY:
CLIENTS["openai"] = AsyncOpenAI(
api_key=settings.LLM.OPENAI_API_KEY,
base_url=settings.LLM.OPENAI_BASE_URL,
)
if settings.LLM.GEMINI_API_KEY:
CLIENTS["gemini"] = genai.client.Client(
http_options = (
genai_types.HttpOptions(base_url=settings.LLM.GEMINI_BASE_URL)
if settings.LLM.GEMINI_BASE_URL
else None
)
CLIENTS["gemini"] = genai.Client(
api_key=settings.LLM.GEMINI_API_KEY,
http_options=http_options,
)

View File

@ -28,6 +28,7 @@ from src.routers import (
webhooks,
workspaces,
)
from src.startup import validate_embedding_schema
from src.telemetry import (
initialize_telemetry_async,
metrics_endpoint,
@ -125,6 +126,12 @@ async def lifespan(_: FastAPI):
# Initialize CloudEvents telemetry
await initialize_telemetry_async()
# Validate embedding schema before serving any traffic. Fails closed: if
# the configured EMBEDDING_VECTOR_DIMENSIONS does not match the physical
# pgvector columns, the process refuses to start rather than silently
# writing wrong-dim vectors.
await validate_embedding_schema(engine)
try:
await init_cache()
except Exception as e:

View File

@ -25,12 +25,15 @@ from sqlalchemy.orm import Mapped, MappedColumn, mapped_column, relationship
from sqlalchemy.sql import func
from typing_extensions import override
from src.config import settings
from src.utils.types import DocumentLevel, TaskType, VectorSyncState
from .db import Base
load_dotenv(override=True)
_VECTOR_DIM: int = settings.EMBEDDING.VECTOR_DIMENSIONS
logger = getLogger(__name__)
@ -278,7 +281,7 @@ class MessageEmbedding(Base):
BigInteger, Identity(), primary_key=True, autoincrement=True
)
content: Mapped[str] = mapped_column(TEXT)
embedding: MappedColumn[Any] = mapped_column(Vector(1536), nullable=True)
embedding: MappedColumn[Any] = mapped_column(Vector(_VECTOR_DIM), nullable=True)
message_id: Mapped[str] = mapped_column(
ForeignKey("messages.public_id", ondelete="CASCADE"), nullable=False, index=True
)
@ -386,7 +389,7 @@ class Document(Base):
times_derived: Mapped[int] = mapped_column(
Integer, nullable=False, server_default=text("1")
)
embedding: MappedColumn[Any] = mapped_column(Vector(1536), nullable=True)
embedding: MappedColumn[Any] = mapped_column(Vector(_VECTOR_DIM), nullable=True)
source_ids: Mapped[list[str] | None] = mapped_column(
JSONB, nullable=True, server_default=text("NULL")
)

View File

@ -11,8 +11,10 @@ import time
from dataclasses import dataclass
from typing import cast
from sqlalchemy import and_, delete, select, update
from sqlalchemy import and_, delete, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlalchemy.sql import ColumnElement
from sqlalchemy.sql.functions import func
from src import models
@ -27,7 +29,20 @@ logger = logging.getLogger(__name__)
# Constants
RECONCILIATION_BATCH_SIZE = 50
RECONCILIATION_TIME_BUDGET_SECONDS = 240 # Leave headroom for other maintenance work
MAX_SYNC_ATTEMPTS = 5 # After this many failures, mark as failed
MAX_SYNC_ATTEMPTS = 20 # After this many failures, mark as failed
# Flat wait between sync attempts. With MAX_SYNC_ATTEMPTS=20 this gives ~3 hours
# of outage headroom before a row is marked failed.
SYNC_BACKOFF = datetime.timedelta(minutes=10)
def _backoff_eligible(
last_sync_at: InstrumentedAttribute[datetime.datetime | None],
) -> ColumnElement[bool]:
"""Rows are eligible for sync if never attempted or past the backoff window."""
return or_(
last_sync_at.is_(None),
last_sync_at < func.now() - SYNC_BACKOFF,
)
@dataclass
@ -73,6 +88,7 @@ async def _get_documents_needing_sync(
and_(
models.Document.deleted_at.is_(None),
models.Document.sync_state == "pending", # Only pending items
_backoff_eligible(models.Document.last_sync_at),
)
)
.order_by(models.Document.last_sync_at.asc().nullsfirst())
@ -101,7 +117,12 @@ async def _get_message_embeddings_needing_sync(
"""
stmt = (
select(models.MessageEmbedding)
.where(models.MessageEmbedding.sync_state == "pending")
.where(
and_(
models.MessageEmbedding.sync_state == "pending",
_backoff_eligible(models.MessageEmbedding.last_sync_at),
)
)
.order_by(models.MessageEmbedding.last_sync_at.asc().nullsfirst())
.limit(batch_size)
.with_for_update(skip_locked=True)
@ -494,19 +515,7 @@ async def _reconcile_message_embeddings_batch(
if not embs:
return False
try:
synced, failed = await _sync_message_embeddings(
db, embs, external_vector_store
)
except Exception:
logger.exception(
"Message embedding reconciliation failed for %s embeddings",
len(embs),
)
await _bump_message_embedding_sync_attempts(db, embs)
synced = 0
failed = len(embs)
synced, failed = await _sync_message_embeddings(db, embs, external_vector_store)
metrics.message_embeddings_synced += synced
metrics.message_embeddings_failed += failed
await db.commit()

View File

@ -3,10 +3,9 @@ import logging
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import apaginate
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models, schemas
from src import crud, schemas
from src.config import settings
from src.dependencies import db
from src.deriver.enqueue import enqueue_deletion, enqueue_dream
@ -201,7 +200,6 @@ async def schedule_dream(
request: schemas.ScheduleDreamRequest = Body(
..., description="Dream scheduling parameters"
),
db: AsyncSession = db,
):
"""
Manually schedule a dream task for a specific collection.
@ -224,21 +222,11 @@ async def schedule_dream(
observed = request.observed if request.observed is not None else request.observer
dream_type = request.dream_type
# Count documents in the collection
count_stmt = select(func.count(models.Document.id)).where(
models.Document.workspace_name == workspace_id,
models.Document.observer == observer,
models.Document.observed == observed,
)
document_count = int(await db.scalar(count_stmt) or 0)
# Enqueue the dream task for immediate processing
await enqueue_dream(
workspace_id,
observer=observer,
observed=observed,
dream_type=dream_type,
document_count=document_count,
session_name=request.session_id,
)

View File

@ -97,7 +97,7 @@ class WorkspaceBase(BaseModel):
class WorkspaceCreate(WorkspaceBase):
name: Annotated[
str,
Field(alias="id", min_length=1, max_length=100, pattern=RESOURCE_NAME_PATTERN),
Field(alias="id", min_length=1, max_length=512, pattern=RESOURCE_NAME_PATTERN),
]
metadata: _SanitizedMetadata = {}
configuration: WorkspaceConfiguration = Field(
@ -141,7 +141,7 @@ class PeerBase(BaseModel):
class PeerCreate(PeerBase):
name: Annotated[
str,
Field(alias="id", min_length=1, max_length=100, pattern=RESOURCE_NAME_PATTERN),
Field(alias="id", min_length=1, max_length=512, pattern=RESOURCE_NAME_PATTERN),
]
metadata: _SanitizedMetadata | None = None
configuration: dict[str, Any] | None = None
@ -322,7 +322,7 @@ class SessionBase(BaseModel):
class SessionCreate(SessionBase):
name: Annotated[
str,
Field(alias="id", min_length=1, max_length=100, pattern=RESOURCE_NAME_PATTERN),
Field(alias="id", min_length=1, max_length=512, pattern=RESOURCE_NAME_PATTERN),
]
metadata: _SanitizedMetadata | None = None
peer_names: dict[str, SessionPeerConfig] | None = Field(default=None, alias="peers")

View File

@ -7,7 +7,10 @@ the fully-resolved variants used at runtime.
from enum import Enum
from typing import Any, Self, cast
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from src.config import settings
from src.utils.tokens import estimate_tokens
class DreamType(str, Enum):
@ -23,9 +26,14 @@ class ReasoningConfiguration(BaseModel):
)
custom_instructions: str | None = Field(
default=None,
description="TODO: currently unused. Custom instructions to use for the reasoning system on this workspace/session/message.",
description="Optional custom instructions for the reasoning system on this workspace/session/message. Rejected if they exceed the deriver custom-instruction token cap.",
)
@field_validator("custom_instructions")
@classmethod
def validate_custom_instructions(cls, value: str | None) -> str | None:
return _validate_custom_instructions_budget(value)
class PeerCardConfiguration(BaseModel):
use: bool | None = Field(
@ -75,6 +83,26 @@ class DreamConfiguration(BaseModel):
)
def _validate_custom_instructions_budget(
custom_instructions: str | None,
) -> str | None:
if custom_instructions is None:
return None
if not custom_instructions.strip():
return custom_instructions
max_tokens = settings.DERIVER.MAX_CUSTOM_INSTRUCTIONS_TOKENS
if max_tokens <= 0:
raise ValueError("custom_instructions are not enabled for this deployment")
if estimate_tokens(custom_instructions) > max_tokens:
raise ValueError(
f"custom_instructions exceeds DERIVER.MAX_CUSTOM_INSTRUCTIONS_TOKENS ({max_tokens} tokens)"
)
return custom_instructions
class WorkspaceConfiguration(BaseModel):
"""
The set of options that can be in a workspace DB-level configuration dictionary.
@ -127,6 +155,12 @@ class MessageConfiguration(BaseModel):
class ResolvedReasoningConfiguration(BaseModel):
enabled: bool
custom_instructions: str | None = None
@field_validator("custom_instructions")
@classmethod
def validate_custom_instructions(cls, value: str | None) -> str | None:
return _validate_custom_instructions_budget(value)
class ResolvedPeerCardConfiguration(BaseModel):

8
src/startup/__init__.py Normal file
View File

@ -0,0 +1,8 @@
"""Startup-time validators that gate API/deriver boot."""
from src.startup.embedding_validator import (
StartupValidationError,
validate_embedding_schema,
)
__all__ = ("StartupValidationError", "validate_embedding_schema")

View File

@ -0,0 +1,267 @@
"""Startup validator for the embedding pipeline.
Crashes the process at boot if the configured EMBEDDING_VECTOR_DIMENSIONS does
not match the physical pgvector schema. Replaces an earlier config-time guard
that forbade non-1536 dims unless the operator asserted a VECTOR_STORE.MIGRATED
flag the schema introspection here is more accurate because it inspects
actual state instead of operator-asserted state.
For external stores (turbopuffer, lancedb) the check is best-effort: namespaces
are per-workspace and lazy-created, so this validator can only sample existing
ones. Full enumeration is available via `uv run python scripts/configure_embeddings.py --report`.
"""
from __future__ import annotations
import logging
from sqlalchemy import select, text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncEngine
from tenacity import (
AsyncRetrying,
RetryError,
before_sleep_log,
retry_if_exception_type,
stop_after_attempt,
wait_fixed,
)
from src.config import AppSettings, settings
from src.exceptions import HonchoException
from src.models import Collection, Workspace
from src.vector_store import VectorStore
logger = logging.getLogger(__name__)
# Embedding tables that must exist with matching dim.
_EMBEDDING_TABLES: tuple[str, ...] = ("documents", "message_embeddings")
# Retry budget for transient introspection failures. Total wall time is
# bounded so a sick DB does not hang readiness; fail-closed after exhaustion.
_RETRY_ATTEMPTS = 3
_RETRY_BACKOFF_SECONDS = 1.0
# Best-effort external sampler bounds.
_EXTERNAL_SAMPLE_LIMIT = 10
class StartupValidationError(HonchoException):
"""Raised when the embedding configuration cannot be reconciled with the
physical schema. Always surfaced before any HTTP route is served or any
queue task is processed.
Inherits from ``HonchoException`` (status_code=500) so the project's
exception handlers recognize it consistently. Startup-time failure, not
a per-request validation error ``ValidationException``'s 422 semantics
would be misleading.
"""
async def validate_embedding_schema(
engine: AsyncEngine,
*,
app_settings: AppSettings | None = None,
) -> None:
"""Validate that the embedding schema matches the configured dimension.
Run after the DB pool is initialized and before the embedding client is
constructed. Fails closed: any unrecoverable introspection error raises
rather than letting the process serve traffic with an unknown state.
"""
s = app_settings if app_settings is not None else settings
target_dim = s.EMBEDDING.VECTOR_DIMENSIONS
schema = s.DB.SCHEMA
dims = await _introspect_pgvector_dims_with_retry(engine, schema)
_assert_pgvector_dims_match(dims, schema=schema, target_dim=target_dim)
if s.VECTOR_STORE.TYPE in ("turbopuffer", "lancedb"):
await _sample_external_namespaces(engine, target_dim=target_dim)
async def _introspect_pgvector_dims_with_retry(
engine: AsyncEngine, schema: str
) -> dict[str, int]:
"""Schema-qualified pg_attribute introspection with bounded retries.
Returns a mapping of table name -> raw ``atttypmod`` for the embedding
columns. Fails closed on the last attempt uncertainty is not a green
light to serve traffic.
"""
try:
async for attempt in AsyncRetrying(
stop=stop_after_attempt(_RETRY_ATTEMPTS),
wait=wait_fixed(_RETRY_BACKOFF_SECONDS),
retry=retry_if_exception_type(SQLAlchemyError),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=False,
):
with attempt:
return await _introspect_pgvector_dims_once(engine, schema)
except RetryError as e:
underlying = e.last_attempt.exception()
raise StartupValidationError(
f"could not validate embedding schema: {underlying}"
) from underlying
# Unreachable: AsyncRetrying either returns from inside the loop or raises.
raise StartupValidationError("embedding schema introspection did not run")
async def _introspect_pgvector_dims_once(
engine: AsyncEngine, schema: str
) -> dict[str, int]:
"""Single-shot schema-qualified pg_attribute lookup.
The join through ``pg_class``/``pg_namespace`` lets us respect
``DB.SCHEMA`` rather than relying on the ambient search_path.
"""
query = text(
"""
SELECT c.relname AS table_name, a.atttypmod AS typmod
FROM pg_attribute a
JOIN pg_class c ON a.attrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE n.nspname = :schema
AND c.relname = ANY(:tables)
AND a.attname = 'embedding'
"""
)
async with engine.connect() as conn:
result = await conn.execute(
query,
{"schema": schema, "tables": list(_EMBEDDING_TABLES)},
)
return {row.table_name: row.typmod for row in result}
def _assert_pgvector_dims_match(
dims: dict[str, int], *, schema: str, target_dim: int
) -> None:
expected = set(_EMBEDDING_TABLES)
missing = expected - dims.keys()
if missing:
listing = ", ".join(sorted(f"{schema}.{t}.embedding" for t in missing))
raise StartupValidationError(
f"Required vector columns missing: {listing}."
+ " Run `alembic upgrade head` first."
)
for table in sorted(expected):
atttypmod = dims[table]
if atttypmod == -1:
raise StartupValidationError(
f"{schema}.{table}.embedding has no declared vector dimension"
+ " (unbounded typmod). Run"
+ " `uv run python scripts/configure_embeddings.py`."
)
# pgvector stores the declared dim directly in atttypmod (no VARHDRSZ).
actual = atttypmod
if actual != target_dim:
raise StartupValidationError(
f"{schema}.{table}.embedding dim ({actual}) does not match"
+ f" EMBEDDING_VECTOR_DIMENSIONS ({target_dim}). Run"
+ " `uv run python scripts/configure_embeddings.py`"
+ " or fix EMBEDDING_VECTOR_DIMENSIONS."
)
async def _sample_external_namespaces(engine: AsyncEngine, *, target_dim: int) -> None:
"""Best-effort dim check across existing external-store namespaces.
External stores in this codebase are per-workspace and lazy-created on
first write (see ``src.vector_store.get_vector_namespace``), so there is
no canonical deployment-wide namespace to introspect. We enumerate up to
``_EXTERNAL_SAMPLE_LIMIT`` of each namespace category from the application
DB and probe each:
- Message namespaces one per workspace.
- Document namespaces one per existing ``(workspace, observer, observed)``
collection triple.
Missing namespaces are OK; mismatched dims crash startup. Run
``configure_embeddings --report`` for full enumeration when a hard
guarantee is needed.
"""
workspace_names = await _sample_workspace_names(engine, _EXTERNAL_SAMPLE_LIMIT)
collection_keys = await _sample_collection_keys(engine, _EXTERNAL_SAMPLE_LIMIT)
if not workspace_names and not collection_keys:
logger.info(
"External-store validator: no workspaces or collections exist yet,"
+ " skipping sample"
)
return
# Import lazily to avoid pulling in vector store deps when not configured.
from src.vector_store import get_external_vector_store
store = get_external_vector_store()
if store is None:
# Settings said TYPE != pgvector but the store could not be created.
# That is its own problem and not for this validator to swallow.
return
candidates: list[str] = []
for workspace_name in workspace_names:
candidates.append(store.get_vector_namespace("message", workspace_name))
for workspace_name, observer, observed in collection_keys:
candidates.append(
store.get_vector_namespace(
"document",
workspace_name,
observer=observer,
observed=observed,
)
)
mismatches: list[tuple[str, int]] = []
for namespace in candidates:
actual_dim = await _probe_namespace_dim(store, namespace)
if actual_dim is not None and actual_dim != target_dim:
mismatches.append((namespace, actual_dim))
if mismatches:
formatted = ", ".join(f"{ns} (dim={d})" for ns, d in mismatches)
raise StartupValidationError(
f"Existing external-store namespaces have dim != {target_dim}:"
+ f" {formatted}. Run"
+ " `uv run python scripts/configure_embeddings.py --report`."
)
async def _sample_workspace_names(engine: AsyncEngine, limit: int) -> list[str]:
"""Pull up to ``limit`` workspace names ordered by creation time.
Uses the ORM ``Workspace`` model so ``Base.metadata.schema`` (configured
from ``settings.DB.SCHEMA`` in ``src/db.py``) is honored automatically
a non-public schema deployment must not silently sample the wrong table.
"""
stmt = select(Workspace.name).order_by(Workspace.created_at.desc()).limit(limit)
async with engine.connect() as conn:
result = await conn.execute(stmt)
return [row[0] for row in result]
async def _sample_collection_keys(
engine: AsyncEngine, limit: int
) -> list[tuple[str, str, str]]:
"""Pull up to ``limit`` ``(workspace_name, observer, observed)`` triples,
one per existing collection row. Each triple corresponds to a document
namespace that may exist in the external store."""
stmt = (
select(Collection.workspace_name, Collection.observer, Collection.observed)
.order_by(Collection.created_at.desc())
.limit(limit)
)
async with engine.connect() as conn:
result = await conn.execute(stmt)
return [(row[0], row[1], row[2]) for row in result]
async def _probe_namespace_dim(store: VectorStore, namespace: str) -> int | None:
"""Return the namespace's declared dim, or ``None`` if not present.
Delegates to the store's own ``probe_namespace_dim`` implementation
(lancedb opens the table, turbopuffer reads the schema). ``None`` means
"lazy-create namespace, nothing to validate against."
"""
return await store.probe_namespace_dim(namespace)

View File

@ -121,7 +121,7 @@ class AgentToolConclusionsDeletedEvent(BaseEvent):
"""
_event_type: ClassVar[str] = "agent.tool.conclusions.deleted"
_schema_version: ClassVar[int] = 1
_schema_version: ClassVar[int] = 2
_category: ClassVar[str] = "agent"
# Run identification
@ -141,6 +141,10 @@ class AgentToolConclusionsDeletedEvent(BaseEvent):
# What was deleted
conclusion_count: int = Field(..., description="Number of conclusions deleted")
levels: list[str] = Field(
default_factory=list,
description="Level of each deleted conclusion (e.g., ['explicit', 'deductive', 'deductive'])",
)
def get_resource_id(self) -> str:
"""Resource ID includes run_id and iteration for uniqueness."""

View File

@ -33,6 +33,13 @@ logger = logging.getLogger(__name__)
MAX_PEER_CARD_FACTS = 40
def _normalized_observation_input(
obs: schemas.ObservationInput,
) -> schemas.ObservationInput:
"""Return an observation input with content normalized for persistence/embedding."""
return obs.model_copy(update={"content": obs.content.strip()})
def _base_observation_properties() -> dict[str, Any]:
return {
"content": {
@ -800,6 +807,13 @@ async def create_observations(
logger.warning("create_observations called with empty list")
return ObservationsCreatedResult(created_count=0, created_levels=[], failed=[])
normalized_observations = [
_normalized_observation_input(obs) for obs in observations if obs.content.strip()
]
if not normalized_observations:
logger.info("No non-empty observations to create")
return ObservationsCreatedResult(created_count=0, created_levels=[], failed=[])
# Phase 1: Ensure collection exists (short DB scope)
async with tracked_db("create_observations.collection") as db:
await crud.get_or_create_collection(
@ -810,12 +824,12 @@ async def create_observations(
)
# Phase 2: Compute embeddings (no DB needed)
contents = [obs.content for obs in observations]
contents = [obs.content for obs in normalized_observations]
embeddings_by_index: dict[int, list[float]] | None = None
try:
embeddings = await embedding_client.simple_batch_embed(contents)
embeddings_by_index = dict(
zip(range(len(observations)), embeddings, strict=True)
zip(range(len(normalized_observations)), embeddings, strict=True)
)
except Exception as e:
logger.warning(
@ -826,7 +840,7 @@ async def create_observations(
# Build document objects with pre-computed embeddings
documents: list[schemas.DocumentCreate] = []
failed: list[ObservationFailure] = []
for i, obs in enumerate(observations):
for i, obs in enumerate(normalized_observations):
embedding: list[float]
if embeddings_by_index is not None:
embedding = embeddings_by_index[i]
@ -1812,22 +1826,24 @@ async def _handle_delete_observations(
if not observation_ids:
return "ERROR: observation_ids list is empty"
deleted_count = 0
async with ctx.db_lock, tracked_db("tool.delete_observations") as db:
for obs_id in observation_ids:
try:
await crud.delete_document(
db,
workspace_name=ctx.workspace_name,
document_id=obs_id,
observer=ctx.observer,
observed=ctx.observed,
)
deleted_count += 1
except Exception as e:
logger.warning("Failed to delete observation %s: %s", obs_id, e)
deleted = await crud.delete_documents(
db,
workspace_name=ctx.workspace_name,
document_ids=observation_ids,
observer=ctx.observer,
observed=ctx.observed,
)
# Emit telemetry event if context is available
deleted_ids = {doc_id for doc_id, _ in deleted}
for obs_id in observation_ids:
if obs_id not in deleted_ids:
logger.warning(
"Failed to delete observation %s (not found, already deleted, or wrong scope)",
obs_id,
)
deleted_count = len(deleted)
if deleted_count > 0 and ctx.run_id and ctx.agent_type and ctx.parent_category:
emit(
AgentToolConclusionsDeletedEvent(
@ -1839,6 +1855,7 @@ async def _handle_delete_observations(
observer=ctx.observer,
observed=ctx.observed,
conclusion_count=deleted_count,
levels=[level for _, level in deleted],
)
)

View File

@ -101,7 +101,10 @@ def get_configuration(
"""
# Start with defaults
config_dict: dict[str, Any] = {
"reasoning": {"enabled": settings.DERIVER.ENABLED},
"reasoning": {
"enabled": settings.DERIVER.ENABLED,
"custom_instructions": None,
},
"peer_card": {
"use": settings.PEER_CARD.ENABLED,
"create": settings.PEER_CARD.ENABLED,

View File

@ -100,6 +100,7 @@ async def query_external_vector_message_ids(
embedding_query,
top_k=limit * 3,
filters=vector_filters if vector_filters else None,
include_attributes=["message_id"],
)
if not vector_results:

View File

@ -134,6 +134,7 @@ class VectorStore(ABC):
top_k: int = 10,
filters: dict[str, Any] | None = None,
max_distance: float | None = None,
include_attributes: bool | list[str] = True,
) -> list[VectorQueryResult]:
"""
Query for similar vectors.
@ -144,6 +145,8 @@ class VectorStore(ABC):
top_k: Maximum number of results to return
filters: Optional metadata filters
max_distance: Optional maximum distance threshold (cosine distance)
include_attributes: Attributes to return with each result. Use False when
callers only need IDs/scores, or a list for selected metadata.
Returns:
List of VectorQueryResult objects, ordered by similarity (most similar first)
@ -180,6 +183,17 @@ class VectorStore(ABC):
"""
...
@abstractmethod
async def probe_namespace_dim(self, namespace: str) -> int | None:
"""
Return the declared vector dimension of an existing namespace.
Returns ``None`` if the namespace does not exist yet (lazy-create
model: not an error). Raises only when the SDK reports the
namespace exists but its schema is unreadable.
"""
...
def _create_store_by_type(store_type: str) -> VectorStore:
"""Create a vector store instance by type name."""

View File

@ -99,7 +99,7 @@ class LanceDBVectorStore(VectorStore):
fields.extend(self._metadata_fields_for_namespace(namespace))
schema = pa.schema(fields)
try:
table = await db.create_table(namespace, schema=schema) # pyright: ignore[reportUnknownArgumentType]
table = await db.create_table(namespace, schema=schema)
return table
except Exception:
# Table may have been created by another worker, try to open it
@ -197,6 +197,7 @@ class LanceDBVectorStore(VectorStore):
top_k: int = 10,
filters: dict[str, Any] | None = None,
max_distance: float | None = None,
include_attributes: bool | list[str] = True,
) -> list[VectorQueryResult]:
"""
Query for similar vectors in LanceDB.
@ -207,6 +208,8 @@ class LanceDBVectorStore(VectorStore):
top_k: Maximum number of results to return
filters: Optional metadata filters
max_distance: Optional maximum distance threshold (cosine distance)
include_attributes: Attributes to return with each result. False returns
no metadata; a list returns only those metadata fields.
Returns:
List of VectorQueryResult objects, ordered by similarity (most similar first)
@ -217,9 +220,15 @@ class LanceDBVectorStore(VectorStore):
return []
try:
# Build query
query = table.vector_search(embedding).distance_type("cosine").limit(top_k)
if include_attributes is False:
# Caller only needs id/score. Don't fetch any metadata or the vector.
query = query.select(["id"])
elif isinstance(include_attributes, list):
projection = ["id", *(c for c in include_attributes if c != "id")]
query = query.select(projection)
# Apply filters if provided
if filters:
where_clause = self._build_where_clause(filters)
@ -369,3 +378,28 @@ class LanceDBVectorStore(VectorStore):
self._db.close()
self._db = None
logger.debug("LanceDB connection closed")
async def probe_namespace_dim(self, namespace: str) -> int | None:
"""Inspect a LanceDB table's vector column to recover its declared dim.
Returns ``None`` only when the table does not exist (lazy-create
model, expected case). When the table exists but its schema does
not include a ``vector`` field with a fixed ``list_size``, raises
``VectorStoreError`` that is a malformed table, not a missing one,
and silently bucketing it as "missing" would let real corruption
through the startup validator.
"""
db = await self._get_db()
table_names = await db.table_names()
if namespace not in table_names:
return None
table = await db.open_table(namespace)
schema = await table.schema()
for field in schema:
if field.name == "vector" and hasattr(field.type, "list_size"):
return int(field.type.list_size)
raise VectorStoreError(
f"LanceDB table {namespace!r} exists but has no 'vector' field"
+ " with a fixed dimension; cannot probe dim. Schema may be"
+ " corrupted — inspect with `lancedb` CLI before retrying."
)

View File

@ -5,6 +5,7 @@ This module provides a Turbopuffer-based implementation of the VectorStore inter
"""
import logging
import re
from collections.abc import Sequence
from typing import Any, Literal, cast
@ -87,7 +88,7 @@ class TurbopufferVectorStore(VectorStore):
try:
await ns.write(
upsert_rows=rows,
upsert_rows=cast(Any, rows),
distance_metric=DISTANCE_METRIC,
)
return
@ -116,6 +117,7 @@ class TurbopufferVectorStore(VectorStore):
top_k: int = 10,
filters: dict[str, Any] | None = None,
max_distance: float | None = None,
include_attributes: bool | list[str] = True,
) -> list[VectorQueryResult]:
"""
Query for similar vectors in Turbopuffer.
@ -126,6 +128,8 @@ class TurbopufferVectorStore(VectorStore):
top_k: Maximum number of results to return
filters: Optional metadata filters
max_distance: Optional maximum distance threshold (cosine distance)
include_attributes: Attributes to include in the response. Passing False
avoids parsing unused row attributes.
Returns:
List of VectorQueryResult objects, ordered by similarity (most similar first)
@ -149,7 +153,7 @@ class TurbopufferVectorStore(VectorStore):
"rank_by": rank_by,
"top_k": top_k,
"distance_metric": DISTANCE_METRIC,
"include_attributes": True,
"include_attributes": include_attributes,
}
if filter_condition is not None:
query_kwargs["filters"] = filter_condition
@ -307,3 +311,46 @@ class TurbopufferVectorStore(VectorStore):
"""Close the Turbopuffer client and release resources."""
await self.tpuf.close()
logger.debug("Turbopuffer client closed")
async def probe_namespace_dim(self, namespace: str) -> int | None:
"""Inspect a Turbopuffer namespace schema to recover the vector dim.
Turbopuffer namespaces are lazy-created; ``namespace.exists()`` returns
False before the first write. The schema response maps attribute name
to ``AttributeSchemaConfig``; the vector field's ``type`` string is
a bracket-prefixed dim with a width suffix, e.g. ``"[768]f32"``,
``"[1536]f16"``, ``"[256]i8"``.
Returns ``None`` only when the namespace does not exist yet
(NotFoundError or ``exists() == False``). When the namespace
exists but its schema lacks a parseable ``vector`` attribute,
raises ``VectorStoreError`` silently bucketing that as "missing"
would let a corrupt namespace pass the startup validator.
"""
ns = self._get_namespace(namespace)
try:
if not await ns.exists():
return None
except NotFoundError:
return None
try:
schema = await ns.schema()
except NotFoundError:
return None
vector_attr = schema.get("vector")
if vector_attr is None:
raise VectorStoreError(
f"Turbopuffer namespace {namespace!r} exists but its schema"
+ " has no 'vector' attribute; cannot probe dim."
)
type_str = str(vector_attr.type)
match = re.search(r"\[(\d+)\]", type_str)
if match is None:
raise VectorStoreError(
f"Turbopuffer namespace {namespace!r} has an unparseable"
+ f" vector type {type_str!r}; expected `[<dim>]<width>`"
+ " (e.g. `[768]f32`). SDK format may have changed."
)
return int(match.group(1))

View File

@ -321,6 +321,7 @@ async def fake_cache(fake_cache_session: FakeAsyncRedis):
async def client(
db_session: AsyncSession,
fake_cache_session: FakeAsyncRedis, # pyright: ignore[reportUnusedParameter]
monkeypatch: pytest.MonkeyPatch,
) -> AsyncGenerator[TestClient, Any]:
"""Create a FastAPI TestClient for the scope of a single test function"""
@ -338,6 +339,18 @@ async def client(
yield db_session
app.dependency_overrides[get_db] = override_get_db
# No-op the startup embedding-schema validator inside the lifespan. The
# global `engine` it would inspect points to a DB that isn't migrated in
# CI (per-worker test DBs are migrated separately by db_engine), and we
# don't want the validator to dispose the test engine via the lifespan
# finally block either. The validator has its own dedicated coverage in
# tests/startup/test_embedding_validator.py against db_engine directly.
async def _skip_validate(_engine: object) -> None:
return None
monkeypatch.setattr("src.main.validate_embedding_schema", _skip_validate)
with TestClient(app) as c:
if settings.AUTH.USE_AUTH:
# give the test client the admin JWT
@ -481,11 +494,11 @@ def mock_openai_embeddings(request: pytest.FixtureRequest):
# Mock the batch_embed method to return content-dependent embeddings
async def mock_batch_embed_func(
id_resource_dict: dict[str, tuple[str, list[int]]],
id_resource_dict: dict[str, str],
) -> dict[str, list[list[float]]]:
return {
text_id: [_content_to_embedding(resource[0])]
for text_id, resource in id_resource_dict.items()
text_id: [_content_to_embedding(content)]
for text_id, content in id_resource_dict.items()
}
mock_batch_embed.side_effect = mock_batch_embed_func
@ -509,7 +522,7 @@ def mock_vector_store(request: pytest.FixtureRequest):
from src.vector_store import (
VectorQueryResult,
VectorRecord,
_hash_namespace_components, # pyright: ignore[reportPrivateUsage]
_hash_namespace_components,
)
# Create a mock vector store that stores vectors in memory
@ -799,6 +812,7 @@ def mock_tracked_db(request: pytest.FixtureRequest):
patch("src.utils.search.tracked_db", mock_tracked_db_context),
patch("src.crud.document.tracked_db", mock_tracked_db_context),
patch("src.crud.message.tracked_db", mock_tracked_db_context),
patch("src.reconciler.sync_vectors.tracked_db", mock_tracked_db_context),
patch("src.dialectic.core.tracked_db", mock_tracked_db_context),
patch("src.dreamer.specialists.tracked_db", mock_tracked_db_context),
patch("src.dreamer.surprisal.tracked_db", mock_tracked_db_context),

View File

@ -1,3 +1,8 @@
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from nanoid import generate as generate_nanoid
from sqlalchemy import func, update
@ -5,6 +10,26 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.crud.representation import RepresentationManager
from src.utils.representation import (
DeductiveObservation,
ExplicitObservation,
Representation,
)
@asynccontextmanager
async def _fake_tracked_db(_name: str):
yield object()
def _saved_observations(mock_save: AsyncMock):
call = mock_save.await_args
assert call is not None, "mock was not awaited"
if "all_observations" in call.kwargs:
return call.kwargs["all_observations"]
if len(call.args) > 1:
return call.args[1]
raise AssertionError("missing all_observations in await args")
class TestRepresentationManagerSoftDelete:
@ -134,3 +159,166 @@ class TestRepresentationManagerSoftDelete:
result_ids = [doc.id for doc in results]
assert doc_live.id in result_ids
assert doc_deleted.id not in result_ids
class TestRepresentationManagerSave:
@pytest.mark.asyncio
async def test_save_representation_filters_blank_observations_before_embedding(
self,
):
manager = RepresentationManager(
"workspace",
observer="observer",
observed="observed",
)
representation = Representation(
explicit=[
ExplicitObservation(
content=" ",
created_at=datetime.now(timezone.utc),
message_ids=[1],
session_name="session",
),
ExplicitObservation(
content=" useful observation ",
created_at=datetime.now(timezone.utc),
message_ids=[1],
session_name="session",
),
]
)
with (
patch("src.crud.representation.tracked_db", _fake_tracked_db),
patch(
"src.crud.representation.embedding_client.simple_batch_embed",
new=AsyncMock(return_value=[[0.1]]),
) as mock_embed,
patch.object(
manager,
"_save_representation_internal",
new=AsyncMock(return_value=1),
) as mock_save,
):
saved = await manager.save_representation(
representation,
message_ids=[1],
session_name="session",
message_created_at=datetime.now(timezone.utc),
message_level_configuration=SimpleNamespace( # pyright: ignore[reportArgumentType]
dream=SimpleNamespace(enabled=False)
),
)
assert saved == 1
mock_embed.assert_awaited_once_with(["useful observation"])
saved_observations = _saved_observations(mock_save)
assert len(saved_observations) == 1
assert saved_observations[0].content == "useful observation"
@pytest.mark.asyncio
async def test_save_representation_filters_blank_deductive_observations(self):
manager = RepresentationManager(
"workspace",
observer="observer",
observed="observed",
)
representation = Representation(
deductive=[
DeductiveObservation(
conclusion=" ",
premises=["premise a"],
source_ids=["doc-a"],
created_at=datetime.now(timezone.utc),
message_ids=[1],
session_name="session",
),
DeductiveObservation(
conclusion=" inferred conclusion ",
premises=["premise b"],
source_ids=["doc-b"],
created_at=datetime.now(timezone.utc),
message_ids=[1],
session_name="session",
),
]
)
with (
patch("src.crud.representation.tracked_db", _fake_tracked_db),
patch(
"src.crud.representation.embedding_client.simple_batch_embed",
new=AsyncMock(return_value=[[0.2]]),
) as mock_embed,
patch.object(
manager,
"_save_representation_internal",
new=AsyncMock(return_value=1),
) as mock_save,
):
saved = await manager.save_representation(
representation,
message_ids=[1],
session_name="session",
message_created_at=datetime.now(timezone.utc),
message_level_configuration=SimpleNamespace( # pyright: ignore[reportArgumentType]
dream=SimpleNamespace(enabled=False)
),
)
assert saved == 1
mock_embed.assert_awaited_once_with(["inferred conclusion"])
saved_observations = _saved_observations(mock_save)
assert len(saved_observations) == 1
assert isinstance(saved_observations[0], DeductiveObservation)
assert saved_observations[0].conclusion == "inferred conclusion"
@pytest.mark.asyncio
async def test_save_representation_skips_all_blank_observations(self):
manager = RepresentationManager(
"workspace",
observer="observer",
observed="observed",
)
representation = Representation(
explicit=[
ExplicitObservation(
content="",
created_at=datetime.now(timezone.utc),
message_ids=[1],
session_name="session",
),
ExplicitObservation(
content="\n\t ",
created_at=datetime.now(timezone.utc),
message_ids=[1],
session_name="session",
),
]
)
with (
patch("src.crud.representation.tracked_db", _fake_tracked_db),
patch(
"src.crud.representation.embedding_client.simple_batch_embed",
new=AsyncMock(),
) as mock_embed,
patch.object(
manager,
"_save_representation_internal",
new=AsyncMock(),
) as mock_save,
):
saved = await manager.save_representation(
representation,
message_ids=[1],
session_name="session",
message_created_at=datetime.now(timezone.utc),
message_level_configuration=SimpleNamespace( # pyright: ignore[reportArgumentType]
dream=SimpleNamespace(enabled=False)
),
)
assert saved == 0
mock_embed.assert_not_awaited()
mock_save.assert_not_awaited()

View File

@ -66,6 +66,69 @@ class TestDeriverProcessing:
assert kwargs["model_config"].stop_sequences == expected_config.stop_sequences
assert "llm_settings" not in kwargs
async def test_process_representation_tasks_batch_passes_custom_instructions_into_prompt(
self,
) -> None:
message = Mock(
id=1,
public_id="msg_1",
session_name="session-1",
workspace_name="workspace-1",
peer_name="alice",
content="hello",
token_count=5,
created_at=datetime.now(timezone.utc),
)
configuration = Mock()
configuration.reasoning.enabled = True
configuration.reasoning.custom_instructions = (
"Prefer explicit facts with dates."
)
mock_response = HonchoLLMCallResponse(
content=PromptRepresentation(explicit=[]),
input_tokens=10,
output_tokens=5,
finish_reasons=["STOP"],
)
with (
patch(
"src.deriver.deriver.estimate_deriver_prompt_tokens",
return_value=123,
) as mock_estimate_prompt_tokens,
patch(
"src.deriver.deriver.minimal_deriver_prompt",
return_value="prompt",
) as mock_prompt,
patch(
"src.deriver.deriver.honcho_llm_call",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_llm_call,
):
await process_representation_tasks_batch(
messages=[message],
message_level_configuration=configuration,
observers=["bob"],
observed="alice",
queue_item_message_ids=[1],
)
mock_estimate_prompt_tokens.assert_called_once_with(
"Prefer explicit facts with dates."
)
mock_prompt.assert_called_once()
assert (
mock_prompt.call_args.kwargs["custom_instructions"]
== "Prefer explicit facts with dates."
)
await_args = mock_llm_call.await_args
if await_args is None:
raise AssertionError("Expected deriver LLM call")
assert await_args.kwargs["prompt"] == "prompt"
async def test_work_unit_key_generation(
self,
sample_session_with_peers: tuple[models.Session, list[models.Peer]],

View File

@ -0,0 +1,59 @@
"""Regression tests for `enqueue_dream` metadata write shape.
Loop 4 (PR #573): `enqueue_dream` no longer touches collection.internal_metadata
at all. Both guard fields (last_dream_at and last_dream_document_count) are
written atomically in `process_dream` on successful completion this preserves
the invariant that the baseline advances only when consolidation actually
happened, and prevents the in-flight stampede from false-advancing a guard.
"""
from unittest.mock import AsyncMock, patch
import pytest
from src import schemas
from src.deriver.enqueue import enqueue_dream
class TestEnqueueDreamMetadataShape:
@pytest.mark.asyncio
async def test_enqueue_does_not_touch_collection_metadata(self):
"""`enqueue_dream` must not call update_collection_internal_metadata."""
with (
patch(
"src.deriver.enqueue.crud.update_collection_internal_metadata",
new_callable=AsyncMock,
) as mock_update,
patch(
"src.deriver.enqueue.crud.get_collection",
new_callable=AsyncMock,
) as mock_get_collection,
patch(
"src.deriver.enqueue.tracked_db",
) as mock_db_ctx,
):
mock_session = AsyncMock()
mock_session.scalar = AsyncMock(return_value=False)
mock_session.execute = AsyncMock()
mock_session.commit = AsyncMock()
mock_db_ctx.return_value.__aenter__.return_value = mock_session
await enqueue_dream(
workspace_name="test_workspace",
observer="alice",
observed="bob",
dream_type=schemas.DreamType.OMNI,
session_name=None,
)
assert not mock_update.called, (
"enqueue_dream must not write to collection.internal_metadata; "
"guard fields advance atomically in process_dream on success."
)
assert not mock_get_collection.called, (
"enqueue_dream must not need to load the collection — it no "
"longer touches dream metadata."
)
assert (
mock_session.execute.called
), "enqueue_dream must still insert the QueueItem row."

View File

@ -0,0 +1,53 @@
from unittest.mock import patch
import pytest
from src.deriver.prompts import (
estimate_deriver_prompt_tokens,
estimate_minimal_deriver_prompt_tokens,
minimal_deriver_prompt,
)
def test_minimal_deriver_prompt_includes_custom_instructions_when_present() -> None:
prompt = minimal_deriver_prompt(
peer_id="alice",
messages="alice: hello",
custom_instructions="Prefer concrete timeline facts.",
)
assert "CUSTOM INSTRUCTIONS:" in prompt
assert "Prefer concrete timeline facts." in prompt
def test_minimal_deriver_prompt_omits_custom_instructions_when_absent() -> None:
prompt = minimal_deriver_prompt(
peer_id="alice",
messages="alice: hello",
custom_instructions=None,
)
assert "CUSTOM INSTRUCTIONS:" not in prompt
def test_estimate_deriver_prompt_tokens_increases_with_custom_instructions() -> None:
base_tokens = estimate_minimal_deriver_prompt_tokens()
custom_tokens = estimate_deriver_prompt_tokens(
"Prefer explicit facts with absolute dates and keep the subject precise."
)
assert custom_tokens > base_tokens
def test_estimate_deriver_prompt_tokens_propagates_token_estimation_errors() -> None:
estimate_minimal_deriver_prompt_tokens.cache_clear()
with patch(
"src.deriver.prompts.estimate_tokens",
side_effect=RuntimeError("tokenizer unavailable"),
):
with pytest.raises(RuntimeError, match="tokenizer unavailable"):
estimate_deriver_prompt_tokens(None)
with pytest.raises(RuntimeError, match="tokenizer unavailable"):
estimate_deriver_prompt_tokens("Prefer concrete facts.")

View File

@ -20,6 +20,7 @@ from src.reconciler.sync_vectors import (
ReconciliationMetrics,
_get_documents_needing_sync, # pyright: ignore[reportPrivateUsage]
_get_message_embeddings_needing_sync, # pyright: ignore[reportPrivateUsage]
_reconcile_message_embeddings_batch, # pyright: ignore[reportPrivateUsage]
_sync_documents, # pyright: ignore[reportPrivateUsage]
_sync_message_embeddings, # pyright: ignore[reportPrivateUsage]
run_vector_reconciliation_cycle,
@ -27,7 +28,7 @@ from src.reconciler.sync_vectors import (
from src.vector_store import (
VectorRecord,
VectorStore,
_hash_namespace_components, # pyright: ignore[reportPrivateUsage]
_hash_namespace_components,
)
@ -377,6 +378,60 @@ class TestBatchProcessing:
# Verify batch size respected
assert len(batch) == 100
async def test_documents_respect_retry_backoff(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
) -> None:
"""Pending documents should only be fetched once their backoff has elapsed."""
workspace, peer1 = sample_data
collection = models.Collection(
workspace_name=workspace.name,
observer=peer1.name,
observed=peer1.name,
)
db_session.add(collection)
await db_session.commit()
session = models.Session(
name=str(generate_nanoid()), workspace_name=workspace.name
)
db_session.add(session)
await db_session.commit()
now = datetime.datetime.now(datetime.timezone.utc)
ineligible_doc = models.Document(
content="too soon",
workspace_name=workspace.name,
observer=peer1.name,
observed=peer1.name,
session_name=session.name,
sync_state="pending",
sync_attempts=1,
last_sync_at=now - datetime.timedelta(minutes=9, seconds=59),
embedding=[1.0] * 1536,
)
eligible_doc = models.Document(
content="ready",
workspace_name=workspace.name,
observer=peer1.name,
observed=peer1.name,
session_name=session.name,
sync_state="pending",
sync_attempts=1,
last_sync_at=now - datetime.timedelta(minutes=10, seconds=1),
embedding=[2.0] * 1536,
)
db_session.add_all([ineligible_doc, eligible_doc])
await db_session.commit()
pending = await _get_documents_needing_sync(db_session)
pending_ids = {doc.id for doc in pending}
assert eligible_doc.id in pending_ids
assert ineligible_doc.id not in pending_ids
@pytest.mark.asyncio
class TestReEmbedding:
@ -705,6 +760,33 @@ class TestMessageEmbeddings:
pending = await _get_message_embeddings_needing_sync(db_session)
assert any(emb.id == pending_emb.id for emb in pending)
async def test_message_embeddings_respect_retry_backoff(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
) -> None:
"""Pending embeddings should only be fetched once their backoff has elapsed."""
workspace, peer = sample_data
ineligible_emb = await self._create_pending_message_embedding(
db_session, workspace, peer
)
eligible_emb = await self._create_pending_message_embedding(
db_session, workspace, peer
)
now = datetime.datetime.now(datetime.timezone.utc)
ineligible_emb.sync_attempts = 1
ineligible_emb.last_sync_at = now - datetime.timedelta(minutes=9, seconds=59)
eligible_emb.sync_attempts = 1
eligible_emb.last_sync_at = now - datetime.timedelta(minutes=10, seconds=1)
await db_session.commit()
pending = await _get_message_embeddings_needing_sync(db_session)
pending_ids = {emb.id for emb in pending}
assert eligible_emb.id in pending_ids
assert ineligible_emb.id not in pending_ids
async def test_missing_embeddings_reembedded_and_synced(
self,
db_session: AsyncSession,
@ -764,6 +846,33 @@ class TestMessageEmbeddings:
assert pending_emb.sync_state in {"pending", "failed"}
assert pending_emb.sync_attempts == 1
async def test_unexpected_batch_exception_does_not_bump_unattempted_rows(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
mock_vector_store: VectorStore,
) -> None:
"""Unexpected wrapper-level failures should not penalize the whole batch."""
workspace, peer = sample_data
pending_emb = await self._create_pending_message_embedding(
db_session, workspace, peer
)
metrics = ReconciliationMetrics()
with (
patch(
"src.reconciler.sync_vectors._sync_message_embeddings",
side_effect=RuntimeError("unexpected"),
),
pytest.raises(RuntimeError, match="unexpected"),
):
await _reconcile_message_embeddings_batch(mock_vector_store, metrics)
await db_session.refresh(pending_emb)
assert pending_emb.sync_state == "pending"
assert pending_emb.sync_attempts == 0
assert pending_emb.last_sync_at is None
@pytest.mark.asyncio
class TestEndToEndReconciliation:

View File

@ -4,8 +4,14 @@ from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from src.dreamer.dream_scheduler import DreamScheduler, set_dream_scheduler
from src import models
from src.dreamer.dream_scheduler import (
DreamScheduler,
check_and_schedule_dream,
set_dream_scheduler,
)
from src.schemas import DreamType
from src.utils.work_unit import construct_work_unit_key
@ -279,136 +285,140 @@ class TestCancelDreamsForObserved:
assert key_ws2 in dream_scheduler.pending_dreams
class TestDocumentCountAtExecutionTime:
"""Regression tests for Bug #2: Stale document count used in metadata update.
class TestThresholdFilter:
"""Regression tests for Finding 2: threshold must count only explicit-level docs.
Previously, the document count was captured when the dream was scheduled
(at check_and_schedule_dream time), then used 60 minutes later when the
dream actually executed. This caused incorrect metadata if documents were
added during the wait period.
Now, execute_dream queries the current document count at execution time.
Previously the threshold counted all documents in a collection, including
dreamer output (deductive/inductive/contradiction). This created a feedback
loop where each dream's output inflated the trigger for the next dream.
The fix filters the count to `level == "explicit"` only.
"""
@pytest.mark.asyncio
async def test_execute_dream_queries_document_count_at_execution(
self, dream_scheduler: DreamScheduler
):
"""execute_dream should query current document count, not use a stale value.
@pytest.fixture(autouse=True)
def _pin_dream_config(self):
"""Pin DOCUMENT_THRESHOLD=50 and ENABLED_TYPES=['omni'] for this class.
This test verifies that execute_dream fetches the document count fresh
from the database at execution time rather than using a pre-captured value.
The key architectural change was:
- OLD: schedule_dream(document_count) -> _delayed_dream(document_count) -> execute_dream(document_count)
- NEW: schedule_dream() -> _delayed_dream() -> execute_dream() queries count internally
We verify this by mocking the database to return a specific count and
checking that enqueue_dream receives that count.
These tests assume the default thresholds; a developer's local env
(e.g. DREAM_DOCUMENT_THRESHOLD=5 for faster manual testing) would
otherwise invalidate the 30/60/10 fixtures below. Scoped to this
class only do NOT widen; other tests may have different assumptions.
"""
from contextlib import asynccontextmanager
from unittest.mock import MagicMock
with (
patch("src.dreamer.dream_scheduler.settings.DREAM.DOCUMENT_THRESHOLD", 50),
patch("src.dreamer.dream_scheduler.settings.DREAM.ENABLED_TYPES", ["omni"]),
):
yield
from src import models
from src.schemas import (
ResolvedConfiguration,
ResolvedDreamConfiguration,
ResolvedPeerCardConfiguration,
ResolvedReasoningConfiguration,
ResolvedSummaryConfiguration,
async def _make_collection(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
) -> models.Collection:
"""Helper: create a Collection in the test workspace with no dream metadata."""
workspace, peer = sample_data
collection = models.Collection(
observer=peer.name,
observed=peer.name,
workspace_name=workspace.name,
internal_metadata={},
)
db_session.add(collection)
await db_session.commit()
return collection
async def _insert_doc(
self,
db_session: AsyncSession,
collection: models.Collection,
level: str,
) -> None:
"""Helper: insert one Document at the given level."""
db_session.add(
models.Document(
content="test",
level=level,
workspace_name=collection.workspace_name,
observer=collection.observer,
observed=collection.observed,
)
)
workspace_name = "test_workspace"
observer = "bob"
observed = "bob"
session_name = "test_session"
@pytest.mark.asyncio
async def test_mixed_levels_below_explicit_threshold(
self,
dream_scheduler: DreamScheduler,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""30 explicit + 40 deductive + 10 inductive → should NOT trigger.
# The document count that the database will return
CURRENT_DOC_COUNT = 42
Total doc count = 80 (would trigger under the buggy unfiltered count),
but explicit count = 30 < threshold 50, so the correct behavior is to
NOT schedule a dream. This is the core regression: the fix must reject
this scenario.
"""
collection = await self._make_collection(db_session, sample_data)
for _ in range(30):
await self._insert_doc(db_session, collection, "explicit")
for _ in range(40):
await self._insert_doc(db_session, collection, "deductive")
for _ in range(10):
await self._insert_doc(db_session, collection, "inductive")
await db_session.commit()
# Track what document_count is passed to enqueue_dream
captured_document_count: int | None = None
with patch.object(dream_scheduler, "schedule_dream", new_callable=AsyncMock):
scheduled = await check_and_schedule_dream(db_session, collection)
async def capture_enqueue_dream(
_ws_name: str,
observer: str, # pyright: ignore[reportUnusedParameter]
observed: str, # pyright: ignore[reportUnusedParameter]
dream_type: Any, # pyright: ignore[reportUnusedParameter]
document_count: int,
session_name: str, # pyright: ignore[reportUnusedParameter]
) -> None:
nonlocal captured_document_count
captured_document_count = document_count
assert scheduled is False, (
"Threshold should filter on explicit level only — dreamer output "
"(deductive/inductive) must not count toward the trigger."
)
# Create mock database session that returns our test data
mock_session = MagicMock()
mock_workspace = MagicMock(spec=models.Workspace)
mock_db_session = MagicMock(spec=models.Session)
@pytest.mark.asyncio
async def test_explicit_only_at_threshold(
self,
dream_scheduler: DreamScheduler,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""60 explicit + 0 derived → should trigger (60 ≥ threshold 50)."""
collection = await self._make_collection(db_session, sample_data)
for _ in range(60):
await self._insert_doc(db_session, collection, "explicit")
await db_session.commit()
# Mock scalar to return session_name for first call, document count for second
scalar_call_count = 0
with patch.object(
dream_scheduler, "schedule_dream", new_callable=AsyncMock
) as mock_schedule:
scheduled = await check_and_schedule_dream(db_session, collection)
async def mock_scalar(_stmt: Any) -> str | int:
nonlocal scalar_call_count
scalar_call_count += 1
if scalar_call_count == 1:
return session_name # First call gets session_name from documents
else:
return CURRENT_DOC_COUNT # Second call gets document count
assert scheduled is True
assert mock_schedule.called, "schedule_dream should fire when threshold met"
mock_session.scalar = mock_scalar
@pytest.mark.asyncio
async def test_contradiction_excluded_from_count(
self,
dream_scheduler: DreamScheduler,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Contradiction-level docs are dreamer output — must not count.
@asynccontextmanager
async def mock_tracked_db(_: str | None = None):
yield mock_session
100 contradictions + 10 explicit explicit=10 < threshold=50, no trigger.
Confirms the positive `== "explicit"` filter excludes contradiction by
construction (same as deductive/inductive).
"""
collection = await self._make_collection(db_session, sample_data)
for _ in range(100):
await self._insert_doc(db_session, collection, "contradiction")
for _ in range(10):
await self._insert_doc(db_session, collection, "explicit")
await db_session.commit()
with (
patch(
"src.dreamer.dream_scheduler.tracked_db",
mock_tracked_db,
),
patch(
"src.deriver.enqueue.enqueue_dream",
side_effect=capture_enqueue_dream,
),
patch(
"src.crud.get_session",
return_value=mock_db_session,
),
patch(
"src.crud.get_workspace",
return_value=mock_workspace,
),
patch(
"src.utils.config_helpers.get_configuration",
return_value=ResolvedConfiguration(
reasoning=ResolvedReasoningConfiguration(enabled=True),
peer_card=ResolvedPeerCardConfiguration(use=True, create=True),
summary=ResolvedSummaryConfiguration(
enabled=True,
messages_per_short_summary=10,
messages_per_long_summary=20,
),
dream=ResolvedDreamConfiguration(enabled=True),
),
),
):
# Execute the dream
await dream_scheduler.execute_dream(
workspace_name,
DreamType.OMNI,
observer=observer,
observed=observed,
)
with patch.object(dream_scheduler, "schedule_dream", new_callable=AsyncMock):
scheduled = await check_and_schedule_dream(db_session, collection)
# Verify that execute_dream queried the document count (2 scalar calls)
assert (
scalar_call_count == 2
), "Should have queried session_name and document count"
# Verify that enqueue_dream received the CURRENT document count (42),
# proving that execute_dream queries the count at execution time
assert captured_document_count == CURRENT_DOC_COUNT
assert scheduled is False
class TestEnqueueCancelsDreamsCorrectly:

View File

@ -0,0 +1,598 @@
"""Integration tests for the dream completion write.
Finding 3 (code-level) relocates `last_dream_at` from enqueue time to
dream-completion time (in `process_dream`). These tests exercise the real
Postgres JSONB merge via `tracked_db` to verify the write lands in the
collection's internal_metadata on successful dreams — and critically,
does NOT land on failures or exceptions.
"""
from datetime import datetime, timedelta
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.deriver.enqueue import enqueue_dream
from src.dreamer.dream_scheduler import (
DreamScheduler,
check_and_schedule_dream,
set_dream_scheduler,
)
from src.dreamer.orchestrator import DreamResult, process_dream
from src.schemas import (
DreamType,
ResolvedConfiguration,
ResolvedDreamConfiguration,
ResolvedPeerCardConfiguration,
ResolvedReasoningConfiguration,
ResolvedSummaryConfiguration,
)
from src.utils.queue_payload import DreamPayload
@pytest_asyncio.fixture
async def seeded_collection(
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
) -> models.Collection:
"""Create a Collection with an empty dream metadata dict."""
workspace, peer = sample_data
collection = models.Collection(
observer=peer.name,
observed=peer.name,
workspace_name=workspace.name,
internal_metadata={},
)
db_session.add(collection)
await db_session.commit()
await db_session.refresh(collection)
return collection
def _make_dream_result() -> DreamResult:
"""Build a minimal non-null DreamResult for happy-path tests."""
return DreamResult(
run_id="test_run_01",
specialists_run=["deduction", "induction"],
deduction_success=True,
induction_success=True,
surprisal_enabled=False,
surprisal_conclusion_count=0,
total_iterations=3,
total_duration_ms=1234.5,
input_tokens=100,
output_tokens=50,
)
async def _get_dream_metadata(
db_session: AsyncSession, collection: models.Collection
) -> dict[str, Any]:
"""Re-fetch collection and return its internal_metadata['dream'] dict (or {})."""
await db_session.refresh(collection)
stmt = select(models.Collection).where(models.Collection.id == collection.id)
refreshed = (await db_session.execute(stmt)).scalar_one()
dream_meta: dict[str, Any] = refreshed.internal_metadata.get("dream", {})
return dream_meta
class TestLastDreamAtCompletionWrite:
"""Regression tests for Finding 3: `last_dream_at` written at completion."""
@pytest.mark.asyncio
async def test_happy_path_writes_last_dream_at(
self,
db_session: AsyncSession,
seeded_collection: models.Collection,
):
"""Non-null DreamResult → `last_dream_at` is set in internal_metadata."""
payload = DreamPayload(
dream_type=DreamType.OMNI,
observer=seeded_collection.observer,
observed=seeded_collection.observed,
)
with patch(
"src.dreamer.orchestrator.run_dream",
new=AsyncMock(return_value=_make_dream_result()),
):
await process_dream(payload, seeded_collection.workspace_name)
dream_meta = await _get_dream_metadata(db_session, seeded_collection)
assert (
"last_dream_at" in dream_meta
), "process_dream must write last_dream_at when run_dream returns a result"
# Must be a tz-aware UTC ISO timestamp. A naive datetime.now().isoformat()
# would pass a loose "T in string" check but corrupt the 8h guard math
# against tz-aware now() comparisons downstream.
parsed = datetime.fromisoformat(dream_meta["last_dream_at"])
assert (
parsed.tzinfo is not None
), f"last_dream_at must be timezone-aware, got {dream_meta['last_dream_at']!r}"
assert parsed.utcoffset() == timedelta(
0
), f"last_dream_at must be UTC, got offset {parsed.utcoffset()}"
@pytest.mark.asyncio
async def test_failure_path_leaves_last_dream_at_null(
self,
db_session: AsyncSession,
seeded_collection: models.Collection,
):
"""run_dream returns None → `last_dream_at` stays absent.
Lenient success criteria: the guard only advances on completion of a
non-null DreamResult. Failed runs (None return) must not count.
"""
payload = DreamPayload(
dream_type=DreamType.OMNI,
observer=seeded_collection.observer,
observed=seeded_collection.observed,
)
with patch(
"src.dreamer.orchestrator.run_dream",
new=AsyncMock(return_value=None),
):
await process_dream(payload, seeded_collection.workspace_name)
dream_meta = await _get_dream_metadata(db_session, seeded_collection)
assert "last_dream_at" not in dream_meta, (
"last_dream_at must NOT be written when run_dream returns None "
"(failed dream). The guard should not falsely advance."
)
@pytest.mark.asyncio
async def test_completion_writes_guard_pair_atomically(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Completion writes last_dream_at AND last_dream_document_count together.
Both guard fields advance only on successful consolidation, recomputed
inside the row-locked RMW block so the pair stays coherent. Baseline
reflects the actual explicit-doc count at completion, not a stale
enqueue-time snapshot.
"""
workspace, peer = sample_data
collection = models.Collection(
observer=peer.name,
observed=peer.name,
workspace_name=workspace.name,
internal_metadata={},
)
db_session.add(collection)
for i in range(7):
db_session.add(
models.Document(
content=f"explicit {i}",
level="explicit",
workspace_name=workspace.name,
observer=peer.name,
observed=peer.name,
)
)
await db_session.commit()
await db_session.refresh(collection)
payload = DreamPayload(
dream_type=DreamType.OMNI,
observer=collection.observer,
observed=collection.observed,
)
with patch(
"src.dreamer.orchestrator.run_dream",
new=AsyncMock(return_value=_make_dream_result()),
):
await process_dream(payload, collection.workspace_name)
dream_meta = await _get_dream_metadata(db_session, collection)
assert "last_dream_at" in dream_meta, "last_dream_at must be written"
assert dream_meta.get("last_dream_document_count") == 7, (
"last_dream_document_count must equal the current explicit-doc count "
"at completion time; both guard fields advance together."
)
@pytest.mark.asyncio
async def test_exception_path_leaves_last_dream_at_null(
self,
db_session: AsyncSession,
seeded_collection: models.Collection,
):
"""run_dream raises → `last_dream_at` stays absent.
`process_dream` catches exceptions (logs + marks task processed without
re-raising) so the queue worker doesn't get stuck retrying. The guard
write must not happen in the exception path it's inside the
`if result is not None` block, which never executes if an exception
bypassed the assignment.
"""
payload = DreamPayload(
dream_type=DreamType.OMNI,
observer=seeded_collection.observer,
observed=seeded_collection.observed,
)
with patch(
"src.dreamer.orchestrator.run_dream",
new=AsyncMock(side_effect=RuntimeError("simulated specialist crash")),
):
# process_dream swallows exceptions internally; no re-raise expected
await process_dream(payload, seeded_collection.workspace_name)
dream_meta = await _get_dream_metadata(db_session, seeded_collection)
assert "last_dream_at" not in dream_meta, (
"last_dream_at must NOT be written when run_dream raises. "
"process_dream swallows the exception but the guard write must "
"not occur."
)
class TestEnqueueDreamLeavesMetadataAlone:
"""enqueue_dream must not touch collection.internal_metadata["dream"].
After the Loop 4 fix, the guard fields advance only on successful
completion in process_dream. enqueue_dream should preserve whatever
metadata is already on the collection (e.g. a prior completion's
timestamp and baseline) and add nothing of its own.
"""
@pytest.mark.asyncio
async def test_enqueue_does_not_modify_dream_metadata(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
workspace, peer = sample_data
prior_metadata = {
"dream": {
"last_dream_at": "2026-04-17T12:00:00+00:00",
"last_dream_document_count": 99,
}
}
collection = models.Collection(
observer=peer.name,
observed=peer.name,
workspace_name=workspace.name,
internal_metadata=prior_metadata,
)
db_session.add(collection)
await db_session.commit()
await db_session.refresh(collection)
await enqueue_dream(
workspace_name=workspace.name,
observer=collection.observer,
observed=collection.observed,
dream_type=DreamType.OMNI,
session_name=None,
)
dream_meta = await _get_dream_metadata(db_session, collection)
assert dream_meta == prior_metadata["dream"], (
"enqueue_dream must leave dream metadata untouched; the guard fields "
"advance only at completion."
)
class TestExecuteDreamSessionFilter:
"""Regression test for the session lookup asymmetry in execute_dream.
The session_name lookup filters to `level == "explicit"`, symmetric with
check_and_schedule_dream's count query. Otherwise a derived doc could win
ORDER BY created_at DESC and the dream would be scoped to a session that
wasn't in the triggering document cohort.
"""
@pytest.mark.asyncio
async def test_session_name_picked_from_latest_explicit_doc(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Latest explicit session wins even when a newer deductive doc exists.
Seeds:
- Session A (older): one explicit-level Document
- Session B (newer): one deductive-level Document (dreamer output)
Without the explicit filter on the session lookup, the newer deductive
doc's session_name (B) would be returned. With the filter, A is
returned matching the explicit-only count query in
check_and_schedule_dream.
"""
workspace, peer = sample_data
# Pre-create the collection so crud.get_collection inside enqueue_dream
# finds something (process_dream's baseline write is not exercised here;
# we're only asserting the kwargs passed to enqueue_dream).
collection = models.Collection(
observer=peer.name,
observed=peer.name,
workspace_name=workspace.name,
internal_metadata={},
)
db_session.add(collection)
# Two sessions: A (older), B (newer). Insert A first so its created_at
# is strictly earlier than B's.
session_a = models.Session(name="session_a", workspace_name=workspace.name)
db_session.add(session_a)
await db_session.commit()
await db_session.refresh(session_a)
session_b = models.Session(name="session_b", workspace_name=workspace.name)
db_session.add(session_b)
await db_session.commit()
await db_session.refresh(session_b)
# Older explicit doc in session A.
explicit_doc = models.Document(
content="explicit observation",
level="explicit",
workspace_name=workspace.name,
observer=peer.name,
observed=peer.name,
session_name=session_a.name,
)
db_session.add(explicit_doc)
await db_session.commit()
# Newer deductive doc in session B. Without the explicit filter on the
# session lookup, this doc's session_name (B) would win on ORDER BY
# created_at DESC — even though the count query ignores it.
deductive_doc = models.Document(
content="deductive observation",
level="deductive",
workspace_name=workspace.name,
observer=peer.name,
observed=peer.name,
session_name=session_b.name,
)
db_session.add(deductive_doc)
await db_session.commit()
captured_kwargs: dict[str, Any] = {}
async def capture_enqueue_dream(
workspace_name: str,
*,
observer: str,
observed: str,
dream_type: Any,
session_name: str,
) -> None:
captured_kwargs.update(
{
"workspace_name": workspace_name,
"observer": observer,
"observed": observed,
"dream_type": dream_type,
"session_name": session_name,
}
)
# Fresh scheduler instance; ENABLED patched so execute_dream runs.
DreamScheduler.reset_singleton()
scheduler = DreamScheduler()
set_dream_scheduler(scheduler)
try:
with (
patch("src.dreamer.dream_scheduler.settings.DREAM.ENABLED", True),
patch(
"src.deriver.enqueue.enqueue_dream",
side_effect=capture_enqueue_dream,
),
patch(
"src.utils.config_helpers.get_configuration",
return_value=ResolvedConfiguration(
reasoning=ResolvedReasoningConfiguration(enabled=True),
peer_card=ResolvedPeerCardConfiguration(use=True, create=True),
summary=ResolvedSummaryConfiguration(
enabled=True,
messages_per_short_summary=10,
messages_per_long_summary=20,
),
dream=ResolvedDreamConfiguration(enabled=True),
),
),
):
await scheduler.execute_dream(
workspace.name,
DreamType.OMNI,
observer=peer.name,
observed=peer.name,
)
finally:
DreamScheduler.reset_singleton()
assert captured_kwargs, (
"enqueue_dream must be called — execute_dream returned early, "
"likely because the session lookup returned no rows (check that "
"the explicit filter matches at least one doc in the fixture)."
)
assert captured_kwargs["session_name"] == session_a.name, (
f"Session lookup must filter to level=='explicit' to match the "
f"baseline count query. Got session_name="
f"{captured_kwargs['session_name']!r}, expected {session_a.name!r} "
f"(the older session with the only explicit doc). Picking "
f"{session_b.name!r} means the session came from a derived doc "
f"that the count query ignores — the dream would be scoped to a "
f"session that wasn't in the triggering cohort."
)
class TestGuardPairCoherence:
"""Loop 4 coherence tests for the invariant preserved by the atomic pair
write and the in-flight stampede defense.
Invariant: From the moment a dream is scheduled until it completes or
fails, no second dream may be enqueued for the same
(workspace, observer, observed) and the baseline count advances only
when consolidation actually happened.
"""
@pytest_asyncio.fixture
async def _scheduler(self):
DreamScheduler.reset_singleton()
scheduler = DreamScheduler()
set_dream_scheduler(scheduler)
with (
patch("src.dreamer.dream_scheduler.settings.DREAM.ENABLED", True),
patch("src.dreamer.dream_scheduler.settings.DREAM.DOCUMENT_THRESHOLD", 50),
patch("src.dreamer.dream_scheduler.settings.DREAM.ENABLED_TYPES", ["omni"]),
):
yield scheduler
DreamScheduler.reset_singleton()
@pytest.mark.asyncio
async def test_pending_queue_item_blocks_second_schedule(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
_scheduler: DreamScheduler,
):
"""In-flight window: pending QueueItem must block a second schedule.
Walks the stampede timeline: enqueue fires a dream, more explicit
docs arrive past the threshold again, but check_and_schedule_dream
sees the pending queue row and returns False no second QueueItem.
"""
workspace, peer = sample_data
collection = models.Collection(
observer=peer.name,
observed=peer.name,
workspace_name=workspace.name,
internal_metadata={},
)
db_session.add(collection)
for i in range(50):
db_session.add(
models.Document(
content=f"explicit {i}",
level="explicit",
workspace_name=workspace.name,
observer=peer.name,
observed=peer.name,
)
)
await db_session.commit()
await db_session.refresh(collection)
await enqueue_dream(
workspace_name=workspace.name,
observer=peer.name,
observed=peer.name,
dream_type=DreamType.OMNI,
session_name=None,
)
pending_q = select(models.QueueItem).where(
models.QueueItem.task_type == "dream",
models.QueueItem.processed == False, # noqa: E712
models.QueueItem.workspace_name == workspace.name,
)
pending_rows = (await db_session.execute(pending_q)).scalars().all()
assert len(pending_rows) == 1, (
"enqueue_dream must insert exactly one pending dream QueueItem "
"(baseline for the stampede test)."
)
for i in range(50, 100):
db_session.add(
models.Document(
content=f"explicit {i}",
level="explicit",
workspace_name=workspace.name,
observer=peer.name,
observed=peer.name,
)
)
await db_session.commit()
await db_session.refresh(collection)
scheduled = await check_and_schedule_dream(db_session, collection)
assert scheduled is False, (
"check_and_schedule_dream must return False while a dream is "
"pending in the queue — the in-flight window must not admit a "
"second schedule regardless of how many explicit docs arrive."
)
pending_rows_after = (await db_session.execute(pending_q)).scalars().all()
assert len(pending_rows_after) == 1, (
"No second QueueItem may be inserted while the first is pending. "
f"Found {len(pending_rows_after)} pending rows."
)
@pytest.mark.asyncio
async def test_silent_failure_allows_retry_on_same_corpus(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
_scheduler: DreamScheduler,
):
"""Failed dream (run_dream returns None) leaves both guard fields
untouched, so check_and_schedule_dream re-schedules on the same
corpus instead of silently consuming the baseline.
"""
workspace, peer = sample_data
collection = models.Collection(
observer=peer.name,
observed=peer.name,
workspace_name=workspace.name,
internal_metadata={},
)
db_session.add(collection)
for i in range(50):
db_session.add(
models.Document(
content=f"explicit {i}",
level="explicit",
workspace_name=workspace.name,
observer=peer.name,
observed=peer.name,
)
)
await db_session.commit()
await db_session.refresh(collection)
payload = DreamPayload(
dream_type=DreamType.OMNI,
observer=peer.name,
observed=peer.name,
)
with patch(
"src.dreamer.orchestrator.run_dream",
new=AsyncMock(return_value=None),
):
await process_dream(payload, workspace.name)
dream_meta = await _get_dream_metadata(db_session, collection)
assert dream_meta.get("last_dream_document_count", 0) == 0, (
"Failed dream must not advance last_dream_document_count; "
"pre-Loop-4 the baseline was consumed at enqueue time and a "
"silent failure would lock out retries on the same corpus."
)
assert (
"last_dream_at" not in dream_meta
), "Failed dream must not advance last_dream_at either."
with patch.object(
_scheduler, "schedule_dream", new_callable=AsyncMock
) as mock_schedule:
scheduled = await check_and_schedule_dream(db_session, collection)
assert scheduled is True, (
"After a silent failure both guards should still allow the "
"same-corpus retry — 50 explicit docs ≥ threshold, no prior "
"last_dream_at, no pending queue item."
)
assert mock_schedule.called, "schedule_dream must be invoked on the retry path."

View File

@ -17,11 +17,50 @@ from src import models
from src.config import settings
from src.crud import create_messages
from src.crud import message as message_crud
from src.models import Peer, Workspace
from src.models import Message, Peer, Workspace
from src.schemas import MessageCreate
from src.utils.search import search
class _FakeScalarResult:
def __init__(self, rows: list[models.Message]):
self._rows: list[Message] = rows
def all(self) -> list[models.Message]:
return self._rows
class _FakeResult:
def __init__(self, rows: list[models.Message]):
self._rows: list[Message] = rows
def scalars(self) -> _FakeScalarResult:
return _FakeScalarResult(self._rows)
class _CountingDb:
def __init__(self, rows: list[models.Message]):
self._rows: list[Message] = rows
self.execute_count: int = 0
async def execute(self, _stmt: Any) -> _FakeResult:
self.execute_count += 1
return _FakeResult(self._rows)
def _message(session_name: str, seq_in_session: int) -> models.Message:
return models.Message(
workspace_name="workspace",
session_name=session_name,
peer_name="peer",
content=f"{session_name}:{seq_in_session}",
public_id=generate_nanoid(),
seq_in_session=seq_in_session,
token_count=1,
created_at=datetime.now(timezone.utc),
)
@pytest.mark.asyncio
async def test_message_embedding_created_when_setting_enabled(
db_session: AsyncSession,
@ -77,6 +116,68 @@ async def test_message_embedding_created_when_setting_enabled(
assert embedding_record.peer_name == test_peer.name
@pytest.mark.asyncio
async def test_blank_messages_are_not_sent_for_embedding(
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
monkeypatch: pytest.MonkeyPatch,
mock_openai_embeddings: dict[str, Any],
):
"""Blank messages should be persisted but excluded from embedding batches."""
monkeypatch.setattr("src.config.settings.EMBED_MESSAGES", True)
test_workspace, test_peer = sample_data
test_session = models.Session(
workspace_name=test_workspace.name, name=str(generate_nanoid())
)
db_session.add(test_session)
await db_session.commit()
blank_content = " "
nonblank_content = "This message should be embedded"
messages = [
MessageCreate(
content=blank_content,
peer_id=test_peer.name,
metadata={"test": "blank_embedding"},
),
MessageCreate(
content=nonblank_content,
peer_id=test_peer.name,
metadata={"test": "blank_embedding"},
),
]
created_messages = await create_messages(
db=db_session,
messages=messages,
workspace_name=test_workspace.name,
session_name=test_session.name,
)
assert [message.content for message in created_messages] == [
blank_content,
nonblank_content,
]
mock_openai_embeddings["batch_embed"].assert_awaited_once()
batch_arg = mock_openai_embeddings["batch_embed"].await_args.args[0]
assert batch_arg == {created_messages[1].public_id: nonblank_content}
stmt = select(models.MessageEmbedding).where(
models.MessageEmbedding.message_id.in_(
[message.public_id for message in created_messages]
)
)
result = await db_session.execute(stmt)
embedding_records = list(result.scalars().all())
assert len(embedding_records) == 1
assert embedding_records[0].message_id == created_messages[1].public_id
assert embedding_records[0].content == nonblank_content
@pytest.mark.asyncio
async def test_message_embedding_not_created_when_setting_disabled(
db_session: AsyncSession,
@ -260,6 +361,46 @@ async def test_semantic_search_when_embeddings_enabled(
assert created_message.public_id in found_message_ids
@pytest.mark.asyncio
async def test_build_merged_snippets_batches_context_query_across_sessions():
"""Context expansion should not issue one DB query per matched session."""
matched_messages = [
_message("session_a", 10),
_message("session_b", 20),
_message("session_c", 30),
]
context_messages = [
_message("session_a", 9),
_message("session_a", 10),
_message("session_a", 11),
_message("session_a", 99),
_message("session_b", 19),
_message("session_b", 20),
_message("session_b", 21),
_message("session_c", 29),
_message("session_c", 30),
_message("session_c", 31),
]
db = _CountingDb(context_messages)
snippets = await message_crud._build_merged_snippets( # pyright: ignore[reportPrivateUsage]
db, # pyright: ignore[reportArgumentType]
workspace_name="workspace",
matched_messages=matched_messages,
context_window=1,
)
assert db.execute_count == 1
assert [len(matches) for matches, _ in snippets] == [1, 1, 1]
assert [
[msg.content for msg in context_messages] for _, context_messages in snippets
] == [
["session_a:9", "session_a:10", "session_a:11"],
["session_b:19", "session_b:20", "session_b:21"],
["session_c:29", "session_c:30", "session_c:31"],
]
@pytest.mark.asyncio
async def test_search_messages_external_lookup_happens_before_tracked_db(
monkeypatch: pytest.MonkeyPatch,
@ -492,7 +633,7 @@ async def test_message_chunking_creates_multiple_embeddings(
test_message_content = "This is a very long message that should be chunked into multiple pieces because it exceeds the token limit that we set for testing purposes. This message contains many words and should definitely be split into multiple chunks."
def mock_batch_embed_chunked(
id_resource_dict: dict[str, tuple[str, list[int]]],
id_resource_dict: dict[str, str],
) -> dict[str, list[list[float]]]:
return {
text_id: [[0.1] * 1536, [0.2] * 1536, [0.3] * 1536] # 3 chunks per message

View File

@ -246,6 +246,7 @@ def create_conclusions_deleted_event(
observer="assistant",
observed="user_peer",
conclusion_count=3,
levels=["explicit", "deductive", "explicit"],
)
@ -651,6 +652,7 @@ class TestAllEventTypes:
received = mock_transport.received_events[0]
assert received["type"] == "agent.tool.conclusions.deleted"
assert received["data"]["conclusion_count"] == 3
assert received["data"]["levels"] == ["explicit", "deductive", "explicit"]
@pytest.mark.asyncio
async def test_peer_card_updated_event(

View File

@ -12,8 +12,16 @@ class FakeOpenAIEmbeddingsAPI:
self.embedding: list[float] = embedding
self.calls: list[dict[str, Any]] = []
async def create(self, *, model: str, input: str | list[str]) -> SimpleNamespace:
self.calls.append({"model": model, "input": input})
async def create(
self,
*,
model: str,
input: str | list[str],
**kwargs: Any,
) -> SimpleNamespace:
call: dict[str, Any] = {"model": model, "input": input}
call.update(kwargs)
self.calls.append(call)
if isinstance(input, list):
data = [SimpleNamespace(embedding=self.embedding) for _ in input]
else:
@ -45,6 +53,7 @@ async def test_openai_embedding_client_uses_configured_model_and_dimensions(
vector_dimensions=8,
max_input_tokens=8192,
max_tokens_per_request=300_000,
send_dimensions=False,
)
embedding = await client.embed("hello world")
@ -76,6 +85,7 @@ async def test_openai_embedding_client_rejects_dimension_mismatch(
vector_dimensions=8,
max_input_tokens=8192,
max_tokens_per_request=300_000,
send_dimensions=False,
)
with pytest.raises(ValueError, match="Embedding dimension mismatch"):
@ -125,6 +135,7 @@ async def test_gemini_embedding_client_uses_output_dimensionality(
vector_dimensions=12,
max_input_tokens=4096,
max_tokens_per_request=300_000,
send_dimensions=False,
)
embedding = await client.embed("hello world")
@ -137,3 +148,194 @@ async def test_gemini_embedding_client_uses_output_dimensionality(
"config": {"output_dimensionality": 12},
}
]
def _build_openai_client(
monkeypatch: pytest.MonkeyPatch,
*,
embedding: list[float],
model: str,
send_dimensions: bool,
vector_dimensions: int,
) -> tuple[_EmbeddingClient, FakeOpenAIEmbeddingsAPI]:
fake_embeddings = FakeOpenAIEmbeddingsAPI(embedding)
class FakeOpenAIClient:
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
self.api_key: str | None = api_key
self.base_url: str | None = base_url
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
monkeypatch.setattr("src.embedding_client.AsyncOpenAI", FakeOpenAIClient)
client = _EmbeddingClient(
EmbeddingModelConfig(
transport="openai",
model=model,
api_key="test-key",
),
vector_dimensions=vector_dimensions,
max_input_tokens=8192,
max_tokens_per_request=300_000,
send_dimensions=send_dimensions,
)
return client, fake_embeddings
@pytest.mark.asyncio
async def test_openai_embed_forwards_dimensions_when_send_dimensions_true(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, fake = _build_openai_client(
monkeypatch,
embedding=[0.1] * 768,
model="text-embedding-3-small",
send_dimensions=True,
vector_dimensions=768,
)
await client.embed("hello")
assert fake.calls == [
{
"model": "text-embedding-3-small",
"input": ["hello"],
"dimensions": 768,
}
]
@pytest.mark.asyncio
async def test_openai_embed_omits_dimensions_when_send_dimensions_false(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, fake = _build_openai_client(
monkeypatch,
embedding=[0.1] * 1536,
model="text-embedding-3-small",
send_dimensions=False,
vector_dimensions=1536,
)
await client.embed("hello")
assert fake.calls == [{"model": "text-embedding-3-small", "input": ["hello"]}]
@pytest.mark.asyncio
async def test_openai_simple_batch_embed_forwards_dimensions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, fake = _build_openai_client(
monkeypatch,
embedding=[0.1] * 768,
model="text-embedding-3-small",
send_dimensions=True,
vector_dimensions=768,
)
await client.simple_batch_embed(["a", "b"])
assert len(fake.calls) == 1
assert fake.calls[0]["dimensions"] == 768
assert fake.calls[0]["input"] == ["a", "b"]
@pytest.mark.asyncio
async def test_openai_batch_embed_forwards_dimensions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, fake = _build_openai_client(
monkeypatch,
embedding=[0.1] * 768,
model="text-embedding-3-small",
send_dimensions=True,
vector_dimensions=768,
)
await client.batch_embed({"a": "hello", "b": "world"})
assert len(fake.calls) == 1
assert fake.calls[0]["dimensions"] == 768
def _build_embedding_settings(
env: dict[str, str],
monkeypatch: pytest.MonkeyPatch,
) -> Any:
"""Construct a fresh EmbeddingSettings from the given env, isolated from os.environ."""
from src.config import EmbeddingSettings
for key in (
"EMBEDDING_VECTOR_DIMENSIONS",
"EMBEDDING_MODEL_CONFIG__MODEL",
"EMBEDDING_MODEL_CONFIG__TRANSPORT",
"EMBEDDING_MODEL_CONFIG__DIMENSIONS_MODE",
):
monkeypatch.delenv(key, raising=False)
for key, value in env.items():
monkeypatch.setenv(key, value)
return EmbeddingSettings()
def test_resolve_send_dimensions_auto_default_dim_returns_false(
monkeypatch: pytest.MonkeyPatch,
) -> None:
s = _build_embedding_settings({}, monkeypatch)
assert s.resolve_send_dimensions() is False
def test_resolve_send_dimensions_auto_explicit_dim_returns_true(
monkeypatch: pytest.MonkeyPatch,
) -> None:
s = _build_embedding_settings({"EMBEDDING_VECTOR_DIMENSIONS": "768"}, monkeypatch)
assert s.resolve_send_dimensions() is True
def test_resolve_send_dimensions_auto_ada_002_returns_false(
monkeypatch: pytest.MonkeyPatch,
) -> None:
s = _build_embedding_settings(
{
"EMBEDDING_VECTOR_DIMENSIONS": "1536",
"EMBEDDING_MODEL_CONFIG__MODEL": "text-embedding-ada-002",
},
monkeypatch,
)
assert s.resolve_send_dimensions() is False
def test_resolve_send_dimensions_always_returns_true_regardless(
monkeypatch: pytest.MonkeyPatch,
) -> None:
s = _build_embedding_settings(
{"EMBEDDING_MODEL_CONFIG__DIMENSIONS_MODE": "always"},
monkeypatch,
)
assert s.resolve_send_dimensions() is True
def test_resolve_send_dimensions_always_overrides_ada_rejecting_allowlist(
monkeypatch: pytest.MonkeyPatch,
) -> None:
s = _build_embedding_settings(
{
"EMBEDDING_MODEL_CONFIG__DIMENSIONS_MODE": "always",
"EMBEDDING_MODEL_CONFIG__MODEL": "text-embedding-ada-002",
},
monkeypatch,
)
assert s.resolve_send_dimensions() is True
def test_resolve_send_dimensions_never_returns_false_regardless(
monkeypatch: pytest.MonkeyPatch,
) -> None:
s = _build_embedding_settings(
{
"EMBEDDING_MODEL_CONFIG__DIMENSIONS_MODE": "never",
"EMBEDDING_VECTOR_DIMENSIONS": "768",
},
monkeypatch,
)
assert s.resolve_send_dimensions() is False

View File

@ -1,5 +1,4 @@
import os
import re
from pathlib import Path
from typing import Any, cast
@ -251,14 +250,14 @@ def test_app_settings_propagate_embedding_dimensions_to_vector_store() -> None:
assert settings.VECTOR_STORE.DIMENSIONS == 2048
def test_app_settings_require_matching_embedding_and_vector_store_dimensions() -> None:
with pytest.raises(
ValueError,
match=re.escape(
"VECTOR_STORE.DIMENSIONS must match EMBEDDING.VECTOR_DIMENSIONS"
),
):
AppSettings(
def test_app_settings_explicit_vector_store_dimensions_warns_and_overrides() -> None:
"""VECTOR_STORE.DIMENSIONS is deprecated: EMBEDDING.VECTOR_DIMENSIONS wins
and the operator gets a DeprecationWarning if they set it explicitly."""
import warnings
with warnings.catch_warnings(record=True) as captured:
warnings.simplefilter("always")
settings = AppSettings(
EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048),
VECTOR_STORE=VectorStoreSettings(
TYPE="lancedb",
@ -266,28 +265,47 @@ def test_app_settings_require_matching_embedding_and_vector_store_dimensions() -
DIMENSIONS=1536,
),
)
messages = [
str(w.message) for w in captured if issubclass(w.category, DeprecationWarning)
]
assert any(
"VECTOR_STORE_DIMENSIONS is deprecated" in m for m in messages
), f"expected deprecation warning, got {messages!r}"
assert settings.EMBEDDING.VECTOR_DIMENSIONS == 2048
assert settings.VECTOR_STORE.DIMENSIONS == 2048, (
"EMBEDDING.VECTOR_DIMENSIONS should always overwrite the operator-supplied "
"VECTOR_STORE.DIMENSIONS value"
)
def test_app_settings_reject_non_1536_dimensions_while_pgvector_or_dual_write_active() -> (
None
):
with pytest.raises(
ValueError,
match=re.escape("EMBEDDING.VECTOR_DIMENSIONS must remain 1536"),
):
AppSettings(
EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048),
VECTOR_STORE=VectorStoreSettings(TYPE="pgvector", MIGRATED=True),
)
with pytest.raises(
ValueError,
match=re.escape("EMBEDDING.VECTOR_DIMENSIONS must remain 1536"),
):
AppSettings(
EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048),
VECTOR_STORE=VectorStoreSettings(TYPE="lancedb", MIGRATED=False),
def test_app_settings_accepts_non_1536_with_any_vector_store_configuration() -> None:
"""The dim-vs-MIGRATED guard was removed; the runtime startup schema
validator (src/startup/embedding_validator.py) is the new safety net.
Construction must succeed for every combination at config time."""
from typing import Literal
combos: list[tuple[Literal["pgvector", "turbopuffer", "lancedb"], bool]] = [
("pgvector", True),
("pgvector", False),
("lancedb", True),
("lancedb", False),
("turbopuffer", True),
("turbopuffer", False),
]
for store_type, migrated in combos:
# Turbopuffer's model_validator requires TURBOPUFFER_API_KEY whenever
# TYPE="turbopuffer"; supply a dummy value so the test exercises the
# dim-acceptance path rather than the api-key guard.
vs_kwargs: dict[str, Any] = {"TYPE": store_type, "MIGRATED": migrated}
if store_type == "turbopuffer":
vs_kwargs["TURBOPUFFER_API_KEY"] = "test-key"
settings = AppSettings(
EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=768),
VECTOR_STORE=VectorStoreSettings(**vs_kwargs),
)
assert settings.EMBEDDING.VECTOR_DIMENSIONS == 768
assert store_type == settings.VECTOR_STORE.TYPE
assert settings.VECTOR_STORE.MIGRATED is migrated
def test_config_toml_example_uses_nested_model_config_sections() -> None:
@ -300,6 +318,9 @@ def test_config_toml_example_uses_nested_model_config_sections() -> None:
minimal_level = DialecticLevelSettings.model_validate(
config_data["dialectic"]["levels"]["minimal"]
)
low_level = DialecticLevelSettings.model_validate(
config_data["dialectic"]["levels"]["low"]
)
max_level = DialecticLevelSettings.model_validate(
config_data["dialectic"]["levels"]["max"]
)
@ -331,6 +352,8 @@ def test_config_toml_example_uses_nested_model_config_sections() -> None:
assert deriver_config.thinking_budget_tokens is None
assert minimal_level.MODEL_CONFIG.model == "gpt-5.4-mini"
assert minimal_level.MODEL_CONFIG.transport == "openai"
assert minimal_level.TOOL_CHOICE == "auto"
assert low_level.TOOL_CHOICE == "auto"
assert max_level.MODEL_CONFIG.model == "gpt-5.4-mini"
assert max_level.MODEL_CONFIG.transport == "openai"
assert max_level.MODEL_CONFIG.thinking_budget_tokens is None
@ -350,6 +373,8 @@ def test_env_template_uses_nested_model_config_keys() -> None:
assert "EMBEDDING_VECTOR_DIMENSIONS" in env_template
assert "DERIVER_MODEL_CONFIG__MODEL" in env_template
assert "DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL" in env_template
assert "DIALECTIC_LEVELS__minimal__TOOL_CHOICE=auto" in env_template
assert "DIALECTIC_LEVELS__low__TOOL_CHOICE=auto" in env_template
assert "SUMMARY_MODEL_CONFIG__MODEL" in env_template
assert "DREAM_DEDUCTION_MODEL_CONFIG__MODEL" in env_template
@ -508,3 +533,50 @@ def test_dialectic_level_transport_override_drops_default_thinking_params(
assert minimal_mc["model"] == "gpt-4.1-mini"
assert "thinking_budget_tokens" not in minimal_mc
assert "thinking_effort" not in minimal_mc
def test_dialectic_settings_backfills_missing_levels(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Operators only need to override the levels they care about.
Env-var overrides replace the LEVELS dict wholesale (bypassing the
default_factory), so without a backfill the unmentioned levels would be
dropped and _validate_all_levels_present would fail.
"""
from src.config import (
DialecticSettings,
_default_dialectic_levels, # pyright: ignore[reportPrivateUsage]
)
for key in list(os.environ):
if key.startswith("DIALECTIC_LEVELS"):
monkeypatch.delenv(key)
settings = DialecticSettings(
LEVELS={ # pyright: ignore[reportArgumentType]
"low": {
"MODEL_CONFIG": {
"transport": "anthropic",
"model": "claude-haiku-4-5-20251001",
"thinking_budget_tokens": 1024,
},
"MAX_OUTPUT_TOKENS": 2500,
}
}
)
assert set(settings.LEVELS.keys()) == {"minimal", "low", "medium", "high", "max"}
assert settings.LEVELS["low"].MODEL_CONFIG.transport == "anthropic"
assert settings.LEVELS["low"].MODEL_CONFIG.model == "claude-haiku-4-5-20251001"
assert settings.LEVELS["low"].MAX_OUTPUT_TOKENS == 2500
# Backfilled levels come from _default_dialectic_levels()
defaults = _default_dialectic_levels()
assert (
settings.LEVELS["minimal"].MAX_TOOL_ITERATIONS
== defaults["minimal"].MAX_TOOL_ITERATIONS
)
assert (
settings.LEVELS["max"].MAX_TOOL_ITERATIONS
== defaults["max"].MAX_TOOL_ITERATIONS
)

View File

@ -1001,8 +1001,10 @@ async def test_create_message_without_timestamp_uses_default(
db_session.add(test_session)
await db_session.commit()
# Record time before request
before_request = datetime.datetime.now(datetime.timezone.utc)
# Pad the window to absorb client/Postgres clock skew under Docker.
before_request = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
seconds=1
)
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages",
@ -1017,8 +1019,9 @@ async def test_create_message_without_timestamp_uses_default(
},
)
# Record time after request
after_request = datetime.datetime.now(datetime.timezone.utc)
after_request = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
seconds=1
)
assert response.status_code == 201
data = response.json()
@ -1053,8 +1056,9 @@ async def test_create_batch_messages_with_mixed_timestamps(
timestamp1 = datetime.datetime(2023, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
timestamp2 = datetime.datetime(2023, 1, 2, 12, 0, 0, tzinfo=datetime.timezone.utc)
# Record time before request for default timestamp
before_request = datetime.datetime.now(datetime.timezone.utc)
before_request = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
seconds=1
)
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages",
@ -1081,7 +1085,9 @@ async def test_create_batch_messages_with_mixed_timestamps(
},
)
after_request = datetime.datetime.now(datetime.timezone.utc)
after_request = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
seconds=1
)
assert response.status_code == 201
data = response.json()
@ -1124,8 +1130,9 @@ async def test_create_message_with_null_timestamp(
db_session.add(test_session)
await db_session.commit()
# Record time before request
before_request = datetime.datetime.now(datetime.timezone.utc)
before_request = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
seconds=1
)
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages",
@ -1141,7 +1148,9 @@ async def test_create_message_with_null_timestamp(
},
)
after_request = datetime.datetime.now(datetime.timezone.utc)
after_request = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
seconds=1
)
assert response.status_code == 201
data = response.json()

View File

@ -18,11 +18,11 @@ def test_workspace_validations_api(client: TestClient):
assert error["type"] == "string_too_short"
# Test name too long
response = client.post("/v3/workspaces", json={"name": "a" * 101, "metadata": {}})
response = client.post("/v3/workspaces", json={"name": "a" * 513, "metadata": {}})
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "name"]
assert error["msg"] == "String should have at most 100 characters"
assert error["msg"] == "String should have at most 512 characters"
assert error["type"] == "string_too_long"
# Test invalid metadata type
@ -51,12 +51,12 @@ def test_peer_validations_api(client: TestClient, sample_data: tuple[Workspace,
# Test name too long
response = client.post(
f"/v3/workspaces/{test_workspace.name}/peers",
json={"name": "a" * 101, "metadata": {}},
json={"name": "a" * 513, "metadata": {}},
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "name"]
assert error["msg"] == "String should have at most 100 characters"
assert error["msg"] == "String should have at most 512 characters"
assert error["type"] == "string_too_long"

View File

@ -1,9 +1,12 @@
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from nanoid import generate as generate_nanoid
from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.models import Peer, Workspace
@ -569,3 +572,59 @@ def test_delete_workspace_after_session_deletion(client: TestClient):
# Now workspace deletion should succeed
response = client.delete(f"/v3/workspaces/{workspace_name}")
assert response.status_code == 202
@pytest.mark.asyncio
async def test_schedule_dream_invokes_enqueue_dream(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""POST /schedule_dream forwards observer/observed/dream_type to enqueue_dream.
After Loop 4, the manual schedule_dream route no longer touches the
baseline count the orchestrator writes both guard fields atomically on
successful completion. The route's job shrinks to forwarding the dream
request.
"""
workspace, peer = sample_data
collection = models.Collection(
observer=peer.name,
observed=peer.name,
workspace_name=workspace.name,
internal_metadata={},
)
db_session.add(collection)
await db_session.commit()
captured: dict[str, Any] = {}
async def fake_enqueue_dream(*args: Any, **kwargs: Any) -> None:
captured["args"] = args
captured["kwargs"] = kwargs
with (
patch("src.routers.workspaces.settings.DREAM.ENABLED", True),
patch(
"src.routers.workspaces.enqueue_dream",
new=AsyncMock(side_effect=fake_enqueue_dream),
),
):
response = client.post(
f"/v3/workspaces/{workspace.name}/schedule_dream",
json={
"observer": peer.name,
"observed": peer.name,
"dream_type": "omni",
},
)
assert response.status_code == 204, response.text
assert "kwargs" in captured, "enqueue_dream was not called"
assert captured["kwargs"]["observer"] == peer.name
assert captured["kwargs"]["observed"] == peer.name
assert "document_count" not in captured["kwargs"], (
"Loop 4: enqueue_dream no longer accepts document_count; the baseline "
"is written atomically with last_dream_at in process_dream."
)

View File

View File

@ -0,0 +1,178 @@
"""Phase 3: configure_embeddings script tests."""
from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine
from scripts.configure_embeddings import (
_apply_pgvector_alter, # pyright: ignore[reportPrivateUsage]
_build_pgvector_plan, # pyright: ignore[reportPrivateUsage]
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@asynccontextmanager
async def _restore_schema_to(db_engine: AsyncEngine, dim: int) -> AsyncGenerator[None]:
"""ALTER both embedding columns back to ``dim`` on exit so this test
leaves the shared test DB in a consistent state for subsequent tests."""
try:
yield
finally:
async with db_engine.begin() as conn:
for table in ("documents", "message_embeddings"):
await conn.execute(
text(
f"ALTER TABLE {table} ALTER COLUMN embedding"
+ f" TYPE vector({dim}) USING NULL"
)
)
async def _current_dims(db_engine: AsyncEngine) -> dict[str, int]:
async with db_engine.connect() as conn:
result = await conn.execute(
text(
"""
SELECT c.relname AS table_name, a.atttypmod AS typmod
FROM pg_attribute a
JOIN pg_class c ON a.attrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE n.nspname = 'public'
AND c.relname = ANY(:tables)
AND a.attname = 'embedding'
"""
),
{"tables": ["documents", "message_embeddings"]},
)
return {row.table_name: row.typmod for row in result}
async def _hnsw_indexes(db_engine: AsyncEngine) -> set[str]:
async with db_engine.connect() as conn:
result = await conn.execute(
text(
"""
SELECT indexname
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename IN ('documents', 'message_embeddings')
AND indexdef ILIKE '%USING hnsw%'
"""
)
)
return {row.indexname for row in result}
# ---------------------------------------------------------------------------
# Plan
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_plan_no_alter_needed_when_dims_already_match(
db_engine: AsyncEngine,
) -> None:
plan = await _build_pgvector_plan(db_engine, target_dim=1536, schema="public")
assert plan.needs_alter is False
assert plan.current_dims == {"documents": 1536, "message_embeddings": 1536}
@pytest.mark.asyncio
async def test_plan_needs_alter_when_target_differs(db_engine: AsyncEngine) -> None:
plan = await _build_pgvector_plan(db_engine, target_dim=768, schema="public")
assert plan.needs_alter is True
@pytest.mark.asyncio
async def test_plan_raises_on_missing_column(db_engine: AsyncEngine) -> None:
with pytest.raises(SystemExit, match="required vector columns missing"):
await _build_pgvector_plan(db_engine, target_dim=1536, schema="no_such_schema")
# ---------------------------------------------------------------------------
# Apply
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_apply_alters_dims_and_recreates_hnsw_indexes(
db_engine: AsyncEngine,
) -> None:
# 768 is the canonical "small" dim used in non-1536 deployments and is
# well below pgvector's 2000-dim HNSW limit.
target = 768
async with _restore_schema_to(db_engine, dim=1536):
before_indexes = await _hnsw_indexes(db_engine)
assert before_indexes, "test fixture should have HNSW indexes pre-alter"
plan = await _build_pgvector_plan(db_engine, target_dim=target, schema="public")
assert plan.needs_alter is True
await _apply_pgvector_alter(db_engine, plan)
after_dims = await _current_dims(db_engine)
assert after_dims == {"documents": target, "message_embeddings": target}
after_indexes = await _hnsw_indexes(db_engine)
assert (
after_indexes == before_indexes
), "HNSW indexes should be recreated with the same names"
@pytest.mark.asyncio
async def test_apply_refuses_when_embeddings_populated(
db_engine: AsyncEngine,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""ALTER ... USING NULL would silently wipe non-null embeddings, so the
pre-check must abort the transaction before any destructive action.
We patch the count helper to simulate populated tables rather than wire
up the full FK chain of workspace/peer/collection/document just to land
one vector row.
"""
async def fake_count(_conn: object, _schema: str, table: str) -> int:
return 7 if table == "documents" else 0
monkeypatch.setattr(
"scripts.configure_embeddings._count_non_null_embeddings",
fake_count,
)
async with _restore_schema_to(db_engine, dim=1536):
plan = await _build_pgvector_plan(db_engine, target_dim=768, schema="public")
with pytest.raises(
SystemExit, match="refusing to ALTER populated embedding tables"
):
await _apply_pgvector_alter(db_engine, plan)
# The SystemExit aborts the transaction; nothing should have changed.
dims_after_refuse = await _current_dims(db_engine)
assert dims_after_refuse == {
"documents": 1536,
"message_embeddings": 1536,
}
# ---------------------------------------------------------------------------
# Idempotency
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_idempotent_apply_is_a_noop(db_engine: AsyncEngine) -> None:
"""Build plan twice with the matching dim — second call should still
return needs_alter=False without raising or making any changes."""
plan_a = await _build_pgvector_plan(db_engine, target_dim=1536, schema="public")
plan_b = await _build_pgvector_plan(db_engine, target_dim=1536, schema="public")
assert plan_a.needs_alter is False
assert plan_b.needs_alter is False
assert plan_a.current_dims == plan_b.current_dims

View File

@ -96,16 +96,29 @@ def ts_test_server(
app.dependency_overrides[get_db] = override_get_db
# No-op the lifespan's startup embedding-schema validator — same
# reasoning as the `client` fixture in tests/conftest.py: the module-
# level `engine` it inspects points to an unmigrated DB in CI, and the
# validator has dedicated coverage in tests/startup/. Use a manual
# MonkeyPatch since this fixture is module-scoped (the built-in
# `monkeypatch` fixture is function-scoped only).
async def _skip_validate(_engine: object) -> None:
return None
mp = pytest.MonkeyPatch()
mp.setattr("src.main.validate_embedding_schema", _skip_validate)
# Start the server
server = TestServer(app, port)
server.start()
yield f"http://127.0.0.1:{port}"
# Cleanup
server.stop()
app.dependency_overrides.clear()
_ts_session_factory = None
try:
server.start()
yield f"http://127.0.0.1:{port}"
finally:
# Cleanup
server.stop()
app.dependency_overrides.clear()
_ts_session_factory = None
mp.undo()
@pytest.fixture(autouse=True)

View File

View File

@ -0,0 +1,205 @@
"""Phase 2: startup embedding-schema validator + VECTOR_STORE_DIMENSIONS deprecation."""
from __future__ import annotations
import os
import subprocess
import sys
import warnings
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncEngine
from src.startup.embedding_validator import (
StartupValidationError,
_assert_pgvector_dims_match, # pyright: ignore[reportPrivateUsage]
validate_embedding_schema,
)
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
# pgvector stores the declared dim directly in atttypmod (no VARHDRSZ offset).
def _typmod(dim: int) -> int:
return dim
# ---------------------------------------------------------------------------
# Pure-function unit tests for the dim assertion
# ---------------------------------------------------------------------------
def test_assert_pgvector_dims_match_passes_when_all_dims_align() -> None:
_assert_pgvector_dims_match(
{"documents": _typmod(1536), "message_embeddings": _typmod(1536)},
schema="public",
target_dim=1536,
)
def test_assert_pgvector_dims_match_raises_on_dim_mismatch() -> None:
with pytest.raises(StartupValidationError, match="dim .* does not match"):
_assert_pgvector_dims_match(
{"documents": _typmod(1536), "message_embeddings": _typmod(768)},
schema="public",
target_dim=1536,
)
def test_assert_pgvector_dims_match_lists_all_missing_columns() -> None:
with pytest.raises(StartupValidationError) as excinfo:
_assert_pgvector_dims_match(
{"documents": _typmod(1536)},
schema="public",
target_dim=1536,
)
msg = str(excinfo.value)
assert "message_embeddings" in msg
assert "alembic upgrade head" in msg
def test_assert_pgvector_dims_match_raises_on_unbounded_typmod() -> None:
with pytest.raises(StartupValidationError, match="unbounded typmod"):
_assert_pgvector_dims_match(
{"documents": -1, "message_embeddings": _typmod(1536)},
schema="public",
target_dim=1536,
)
def test_assert_pgvector_dims_match_respects_non_public_schema() -> None:
with pytest.raises(StartupValidationError, match="my_schema.documents"):
_assert_pgvector_dims_match(
{},
schema="my_schema",
target_dim=1536,
)
# ---------------------------------------------------------------------------
# Fail-closed retry behavior
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_validator_fails_closed_when_introspection_keeps_failing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""After the retry budget exhausts, the validator crashes — uncertainty
is not a green light to serve traffic."""
call_count = 0
async def always_raise(_engine: AsyncEngine, _schema: str) -> dict[str, int]:
nonlocal call_count
call_count += 1
raise OperationalError("SELECT 1", {}, Exception("DB unreachable"))
monkeypatch.setattr(
"src.startup.embedding_validator._introspect_pgvector_dims_once",
always_raise,
)
# Make backoff effectively instant for the test.
monkeypatch.setattr("src.startup.embedding_validator._RETRY_BACKOFF_SECONDS", 0.0)
with pytest.raises(StartupValidationError, match="could not validate"):
await validate_embedding_schema(engine=AsyncMock())
assert call_count == 3, "should exhaust the retry budget before failing"
# ---------------------------------------------------------------------------
# Integration: real test DB
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_validator_passes_against_test_database(
db_engine: AsyncEngine,
) -> None:
"""The test DB is provisioned at the default dim (1536); the validator
should accept it without raising."""
await validate_embedding_schema(db_engine)
@pytest.mark.asyncio
async def test_validator_raises_when_schema_dim_diverges_from_settings(
db_engine: AsyncEngine,
) -> None:
"""ALTER one of the embedding columns to a non-1536 dim and confirm the
validator raises with an actionable message."""
async with db_engine.begin() as conn:
await conn.execute(
text(
"ALTER TABLE documents"
+ " ALTER COLUMN embedding TYPE vector(768) USING NULL"
)
)
try:
with pytest.raises(StartupValidationError, match="dim .* does not match"):
await validate_embedding_schema(db_engine)
finally:
async with db_engine.begin() as conn:
await conn.execute(
text(
"ALTER TABLE documents"
+ " ALTER COLUMN embedding TYPE vector(1536) USING NULL"
)
)
# ---------------------------------------------------------------------------
# VECTOR_STORE_DIMENSIONS deprecation + dim-vs-MIGRATED guard removal
# ---------------------------------------------------------------------------
def test_vector_store_dimensions_explicit_set_warns(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Setting VECTOR_STORE_DIMENSIONS explicitly should trigger a deprecation
warning. EMBEDDING_VECTOR_DIMENSIONS remains authoritative."""
monkeypatch.setenv("PYTHON_DOTENV_DISABLED", "1")
monkeypatch.setenv("VECTOR_STORE_DIMENSIONS", "1536")
with warnings.catch_warnings(record=True) as captured:
warnings.simplefilter("always")
from src.config import AppSettings
AppSettings()
messages = [
str(w.message) for w in captured if issubclass(w.category, DeprecationWarning)
]
assert any(
"VECTOR_STORE_DIMENSIONS is deprecated" in m for m in messages
), f"expected deprecation warning, got {messages!r}"
def test_non_1536_pgvector_without_migrated_no_longer_raises_at_config_time() -> None:
"""Phase 2 removed the dim-vs-MIGRATED guard. Constructing AppSettings
with non-1536 + default pgvector + MIGRATED=false should now succeed
(the runtime schema validator at startup is the new safety net)."""
env = {
**os.environ,
"PYTHON_DOTENV_DISABLED": "1",
"EMBEDDING_VECTOR_DIMENSIONS": "768",
}
# Use a subprocess so the global settings singleton in this test
# process is not perturbed and is re-evaluated freshly in the child.
snippet = (
"from src.config import AppSettings\n"
"s = AppSettings()\n"
"print(s.EMBEDDING.VECTOR_DIMENSIONS, s.VECTOR_STORE.TYPE, s.VECTOR_STORE.MIGRATED)\n"
)
result = subprocess.run(
[sys.executable, "-c", snippet],
env=env,
cwd=str(_PROJECT_ROOT),
check=True,
capture_output=True,
text=True,
)
last_line = result.stdout.strip().splitlines()[-1]
assert last_line == "768 pgvector False"

View File

@ -184,6 +184,7 @@ def sample_conclusions_deleted_event(
observer="assistant",
observed="user_peer",
conclusion_count=3,
levels=["explicit", "deductive", "explicit"],
)

View File

@ -422,7 +422,7 @@ class TestAgentToolConclusionsDeletedEvent:
def test_schema_version(self):
"""schema_version() returns correct value."""
assert AgentToolConclusionsDeletedEvent.schema_version() == 1
assert AgentToolConclusionsDeletedEvent.schema_version() == 2
def test_category(self):
"""category() returns correct value."""

38
tests/test_config.py Normal file
View File

@ -0,0 +1,38 @@
import pytest
from src.config import ConfiguredModelSettings, DeriverSettings
def _make_deriver_settings(
*,
MAX_INPUT_TOKENS: int = 25000,
MAX_CUSTOM_INSTRUCTIONS_TOKENS: int = 2000,
REPRESENTATION_BATCH_MAX_TOKENS: int = 1024,
) -> DeriverSettings:
return DeriverSettings(
MODEL_CONFIG=ConfiguredModelSettings(
model="gpt-5.4-mini",
transport="openai",
),
MAX_INPUT_TOKENS=MAX_INPUT_TOKENS,
MAX_CUSTOM_INSTRUCTIONS_TOKENS=MAX_CUSTOM_INSTRUCTIONS_TOKENS,
REPRESENTATION_BATCH_MAX_TOKENS=REPRESENTATION_BATCH_MAX_TOKENS,
)
def test_deriver_defaults_enable_custom_instructions_at_supported_cap() -> None:
settings = _make_deriver_settings()
assert settings.MAX_INPUT_TOKENS == 25000
assert settings.MAX_CUSTOM_INSTRUCTIONS_TOKENS == 2000
def test_custom_instructions_tokens_can_be_disabled_with_zero() -> None:
settings = _make_deriver_settings(MAX_CUSTOM_INSTRUCTIONS_TOKENS=0)
assert settings.MAX_CUSTOM_INSTRUCTIONS_TOKENS == 0
def test_custom_instructions_tokens_cannot_exceed_supported_cap() -> None:
with pytest.raises(ValueError, match="less than or equal to 2000"):
_make_deriver_settings(MAX_CUSTOM_INSTRUCTIONS_TOKENS=2001)

View File

@ -497,7 +497,7 @@ class TestErrorHandlingAndRecovery:
# parse_datetime_iso should raise appropriate errors
if error_case is None or not isinstance(error_case, str):
with pytest.raises(ValueError):
parse_datetime_iso(error_case)
parse_datetime_iso(error_case) # pyright: ignore[reportArgumentType]
# Filter validation should return None for invalid types
if isinstance(error_case, str) or error_case is None:

View File

@ -0,0 +1,58 @@
"""Phase 1: verify src/models.py honors EMBEDDING_VECTOR_DIMENSIONS at import time."""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
def _run_in_fresh_interpreter(env_overrides: dict[str, str]) -> dict[str, int]:
"""Import src.models in a fresh interpreter and return the vector dims.
A subprocess is required because src.models reads
settings.EMBEDDING.VECTOR_DIMENSIONS at module import time to construct
SQLAlchemy column types reloading the module in-process would conflict
with the existing Base.registry from earlier imports.
PYTHON_DOTENV_DISABLED=1 prevents config.py:20 from reloading the
developer's .env file (which calls load_dotenv with override=True)
and clobbering our test overrides.
"""
env: dict[str, str] = {
**os.environ,
"PYTHON_DOTENV_DISABLED": "1",
**env_overrides,
}
snippet = (
"import json\n"
"from src.models import Document, MessageEmbedding\n"
"print(json.dumps({\n"
" 'message_embedding_dim': MessageEmbedding.__table__.c.embedding.type.dim,\n"
" 'document_dim': Document.__table__.c.embedding.type.dim,\n"
"}))\n"
)
result = subprocess.run(
[sys.executable, "-c", snippet],
env=env,
cwd=str(_PROJECT_ROOT),
check=True,
capture_output=True,
text=True,
)
parsed: dict[str, int] = json.loads(result.stdout.strip().splitlines()[-1])
return parsed
def test_models_uses_default_1536_when_no_env_override() -> None:
dims = _run_in_fresh_interpreter({})
assert dims == {"message_embedding_dim": 1536, "document_dim": 1536}
def test_models_honors_explicit_embedding_vector_dimensions() -> None:
dims = _run_in_fresh_interpreter({"EMBEDDING_VECTOR_DIMENSIONS": "768"})
assert dims == {"message_embedding_dim": 768, "document_dim": 768}

View File

@ -3,11 +3,13 @@ from typing import Any
import pytest
from pydantic import ValidationError
from src.config import settings
from src.schemas import (
DocumentCreate,
DocumentMetadata,
MessageCreate,
PeerCreate,
ReasoningConfiguration,
ResolvedConfiguration,
SessionCreate,
WorkspaceCreate,
@ -28,7 +30,7 @@ class TestWorkspaceValidations:
def test_app_name_too_long(self):
with pytest.raises(ValidationError) as exc_info:
WorkspaceCreate(name="a" * 101, metadata={})
WorkspaceCreate(name="a" * 513, metadata={})
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "string_too_long"
@ -62,7 +64,7 @@ class TestPeerValidations:
def test_peer_name_too_long(self):
with pytest.raises(ValidationError) as exc_info:
PeerCreate(name="a" * 101, metadata={})
PeerCreate(name="a" * 513, metadata={})
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "string_too_long"
@ -203,3 +205,73 @@ class TestResolvedConfigurationMigration:
ResolvedConfiguration.model_validate(payload)
assert any(e["loc"] == ("reasoning",) for e in exc_info.value.errors())
class TestReasoningCustomInstructionsValidation:
def test_nonblank_custom_instructions_rejected_when_cap_is_zero(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings.DERIVER, "MAX_CUSTOM_INSTRUCTIONS_TOKENS", 0)
with pytest.raises(ValidationError) as exc_info:
ReasoningConfiguration(custom_instructions="Prefer concrete facts.")
errors = exc_info.value.errors()
assert any(
error["loc"] == ("custom_instructions",)
and "custom_instructions are not enabled for this deployment"
in error["msg"]
for error in errors
)
def test_reasoning_configuration_rejects_oversized_custom_instructions(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings.DERIVER, "MAX_CUSTOM_INSTRUCTIONS_TOKENS", 1)
with pytest.raises(ValidationError) as exc_info:
ReasoningConfiguration(
custom_instructions="repeat repeat repeat repeat repeat"
)
assert any(
error["loc"] == ("custom_instructions",)
for error in exc_info.value.errors()
)
def test_oversized_custom_instructions_are_rejected(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings.DERIVER, "MAX_CUSTOM_INSTRUCTIONS_TOKENS", 1)
payload = {
"reasoning": {
"enabled": True,
"custom_instructions": "repeat repeat repeat repeat repeat",
},
"peer_card": {"use": True, "create": True},
"summary": {
"enabled": True,
"messages_per_short_summary": 20,
"messages_per_long_summary": 60,
},
"dream": {"enabled": False},
}
with pytest.raises(ValidationError) as exc_info:
ResolvedConfiguration.model_validate(payload)
assert any(
error["loc"] == ("reasoning", "custom_instructions")
for error in exc_info.value.errors()
)
@pytest.mark.parametrize("custom_instructions", ["", " \n\t "])
def test_blank_custom_instructions_do_not_require_token_cap(
self, monkeypatch: pytest.MonkeyPatch, custom_instructions: str
) -> None:
monkeypatch.setattr(settings.DERIVER, "MAX_CUSTOM_INSTRUCTIONS_TOKENS", 0)
configuration = ReasoningConfiguration(custom_instructions=custom_instructions)
assert configuration.custom_instructions == custom_instructions

View File

@ -12,13 +12,13 @@ from tests.unified.runner import UnifiedTestRunner
async def main():
parser = argparse.ArgumentParser(description="Run Unified Honcho Tests")
parser.add_argument(
target_group = parser.add_mutually_exclusive_group()
target_group.add_argument(
"--test-dir",
type=str,
default="tests/unified/test_cases",
help="Directory containing JSON test files",
)
parser.add_argument(
target_group.add_argument(
"--test-file",
type=str,
help="Path to a single JSON test file to run",
@ -32,10 +32,8 @@ async def main():
args = parser.parse_args()
# Validate mutually exclusive args
if args.test_file and args.test_dir != "tests/unified/test_cases":
print("Error: Cannot specify both --test-file and --test-dir")
sys.exit(1)
if args.test_file is None and args.test_dir is None:
args.test_dir = "tests/unified/test_cases"
if args.test_file:
test_path = Path(args.test_file)

View File

@ -4,6 +4,7 @@ import asyncio
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from typing import Any
from unittest.mock import AsyncMock
import pytest
from nanoid import generate as generate_nanoid
@ -140,6 +141,9 @@ def make_tool_context(tool_test_data: Any) -> Callable[..., ToolContext]:
include_observation_ids: bool = False,
history_token_limit: int = 8192,
session_name: str | None = None,
run_id: str | None = None,
agent_type: str | None = None,
parent_category: str | None = None,
) -> ToolContext:
return ToolContext(
workspace_name=workspace.name,
@ -150,6 +154,9 @@ def make_tool_context(tool_test_data: Any) -> Callable[..., ToolContext]:
include_observation_ids=include_observation_ids,
history_token_limit=history_token_limit,
db_lock=shared_lock,
run_id=run_id,
agent_type=agent_type,
parent_category=parent_category,
)
return _make_context
@ -371,6 +378,96 @@ class TestCreateObservations:
assert len(created_documents) == 1
assert created_documents[0].content == "Embeds fine"
async def test_create_observations_filters_blank_content_before_embedding(
self,
tool_test_data: Any,
monkeypatch: pytest.MonkeyPatch,
):
"""Blank or whitespace-only observations are dropped before embedding/persistence."""
workspace, peer1, peer2, session, _, _ = tool_test_data
created_documents: list[Any] = []
async def fake_batch_embed(texts: list[str]) -> list[list[float]]:
assert texts == ["trimmed observation"]
return [[0.4, 0.5, 0.6]]
async def fake_create_documents(
_db: AsyncSession,
documents: list[Any],
workspace_name: str,
*,
observer: str,
observed: str,
deduplicate: bool = False,
) -> list[Any]:
_ = (workspace_name, observer, observed, deduplicate)
created_documents.extend(documents)
return 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=" ", level="explicit"),
schemas.ObservationInput(content=" trimmed 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 result.created_count == 1
assert len(result.failed) == 0
assert len(created_documents) == 1
assert created_documents[0].content == "trimmed observation"
async def test_create_observations_skips_all_blank_content(
self,
tool_test_data: Any,
monkeypatch: pytest.MonkeyPatch,
):
"""All-blank observations short-circuit without embedding or persistence."""
workspace, peer1, peer2, session, _, _ = tool_test_data
batch_embed = AsyncMock()
create_documents = AsyncMock()
monkeypatch.setattr(
"src.utils.agent_tools.embedding_client.simple_batch_embed",
batch_embed,
)
monkeypatch.setattr(
"src.utils.agent_tools.crud.create_documents", create_documents
)
result = await create_observations(
observations=[
schemas.ObservationInput(content=" ", level="explicit"),
schemas.ObservationInput(content="\n\t", level="explicit"),
],
observer=peer1.name,
observed=peer2.name,
session_name=session.name,
workspace_name=workspace.name,
message_ids=[],
message_created_at=str(datetime.now(timezone.utc)),
)
assert isinstance(result, ObservationsCreatedResult)
assert result.created_count == 0
assert len(result.failed) == 0
batch_embed.assert_not_awaited()
create_documents.assert_not_awaited()
@pytest.mark.asyncio
class TestDeleteObservations:
@ -412,6 +509,82 @@ class TestDeleteObservations:
# Should report 0 deleted (graceful handling)
assert "Deleted 0 observations" in result
async def test_delete_batch_emits_levels_for_successful_only(
self,
db_session: AsyncSession,
tool_test_data: Any,
make_tool_context: Callable[..., ToolContext],
monkeypatch: pytest.MonkeyPatch,
):
"""Batch delete with mixed levels emits levels only for rows actually deleted."""
workspace, peer1, peer2, session, _messages, documents = tool_test_data
# Add two extra documents with non-explicit levels so the batch spans levels.
deductive_doc = models.Document(
workspace_name=workspace.name,
observer=peer1.name,
observed=peer2.name,
content="Works in tech",
embedding=[0.42] * 1536,
session_name=session.name,
level="deductive",
metadata={},
)
inductive_doc = models.Document(
workspace_name=workspace.name,
observer=peer1.name,
observed=peer2.name,
content="Tends to be an early riser",
embedding=[0.43] * 1536,
session_name=session.name,
level="inductive",
metadata={},
)
db_session.add_all([deductive_doc, inductive_doc])
await db_session.flush()
await db_session.refresh(deductive_doc)
await db_session.refresh(inductive_doc)
await db_session.commit()
# Capture emitted telemetry events.
from src.telemetry.events import AgentToolConclusionsDeletedEvent
from src.telemetry.events.base import BaseEvent
from src.utils import agent_tools as agent_tools_module
captured: list[BaseEvent] = []
def _capture(event: BaseEvent) -> None:
captured.append(event)
monkeypatch.setattr(agent_tools_module, "emit", _capture)
ctx = make_tool_context(
include_observation_ids=True,
run_id="test_run",
agent_type="deduction",
parent_category="dream",
)
explicit_doc_id = documents[0].id
ids_to_delete = [
explicit_doc_id,
deductive_doc.id,
inductive_doc.id,
"nonexistent_id_12345",
]
result = await _handle_delete_observations(
ctx, {"observation_ids": ids_to_delete}
)
assert "Deleted 3 observations" in result
assert len(captured) == 1
event = captured[0]
assert isinstance(event, AgentToolConclusionsDeletedEvent)
assert event.conclusion_count == 3
# RETURNING order is not guaranteed; compare as multiset.
assert sorted(event.levels) == sorted(["explicit", "deductive", "inductive"])
@pytest.mark.asyncio
class TestGetRecentObservations:

View File

@ -0,0 +1,74 @@
from typing import Any
import pytest
from src import models
from src.config import settings
from src.schemas import MessageConfiguration, ReasoningConfiguration
from src.utils.config_helpers import get_configuration
def _workspace(configuration: dict[str, Any]) -> models.Workspace:
return models.Workspace(name="workspace", configuration=configuration)
def _session(configuration: dict[str, Any]) -> models.Session:
return models.Session(
name="session",
workspace_name="workspace",
configuration=configuration,
)
def test_preserves_workspace_custom_instructions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings.DERIVER, "MAX_CUSTOM_INSTRUCTIONS_TOKENS", 100)
workspace = _workspace(
{
"reasoning": {
"custom_instructions": "Use the workspace-specific guidance.",
}
}
)
configuration = get_configuration(None, None, workspace)
assert (
configuration.reasoning.custom_instructions
== "Use the workspace-specific guidance."
)
def test_message_custom_instructions_override_session_and_workspace(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings.DERIVER, "MAX_CUSTOM_INSTRUCTIONS_TOKENS", 100)
workspace = _workspace(
{
"reasoning": {
"custom_instructions": "Use the workspace-specific guidance.",
}
}
)
session = _session(
{
"reasoning": {
"custom_instructions": "Use the session-specific guidance.",
}
}
)
message = MessageConfiguration(
reasoning=ReasoningConfiguration(
custom_instructions="Use the message-specific guidance.",
),
)
configuration = get_configuration(message, session, workspace)
assert (
configuration.reasoning.custom_instructions
== "Use the message-specific guidance."
)

View File

View File

@ -0,0 +1,151 @@
"""Tests for LanceDBVectorStore query projection behavior."""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.vector_store.lancedb import LanceDBVectorStore
def _build_query_chain(rows: list[dict[str, Any]]) -> MagicMock:
"""Build a chainable mock that mirrors LanceDB's async query builder."""
chain = MagicMock()
chain.distance_type.return_value = chain
chain.limit.return_value = chain
chain.select.return_value = chain
chain.where.return_value = chain
chain.to_list = AsyncMock(return_value=rows)
return chain
def _patch_table(
store: LanceDBVectorStore, rows: list[dict[str, Any]]
) -> tuple[MagicMock, MagicMock]:
"""Patch _get_table to return a mock whose vector_search yields the chain."""
chain = _build_query_chain(rows)
table = MagicMock()
table.vector_search = MagicMock(return_value=chain)
store._get_table = AsyncMock(return_value=table) # pyright: ignore[reportPrivateUsage]
return table, chain
@pytest.fixture
def store() -> LanceDBVectorStore:
return LanceDBVectorStore()
@pytest.mark.asyncio
async def test_query_returns_empty_when_table_missing(
store: LanceDBVectorStore,
) -> None:
store._get_table = AsyncMock(return_value=None) # pyright: ignore[reportPrivateUsage]
results = await store.query("honcho.msg.missing", [0.1, 0.2, 0.3, 0.4])
assert results == []
@pytest.mark.asyncio
async def test_query_default_does_not_project(store: LanceDBVectorStore) -> None:
_table, chain = _patch_table(store, rows=[])
await store.query("honcho.msg.test", [0.1, 0.2, 0.3, 0.4])
chain.select.assert_not_called()
@pytest.mark.asyncio
async def test_query_with_include_attributes_false_selects_only_id(
store: LanceDBVectorStore,
) -> None:
_table, chain = _patch_table(store, rows=[])
await store.query(
"honcho.doc.test",
[0.1, 0.2, 0.3, 0.4],
include_attributes=False,
)
chain.select.assert_called_once_with(["id"])
@pytest.mark.asyncio
async def test_query_with_attribute_list_projects_id_plus_listed(
store: LanceDBVectorStore,
) -> None:
_table, chain = _patch_table(store, rows=[])
await store.query(
"honcho.msg.test",
[0.1, 0.2, 0.3, 0.4],
include_attributes=["message_id"],
)
chain.select.assert_called_once_with(["id", "message_id"])
@pytest.mark.asyncio
async def test_query_attribute_list_dedupes_explicit_id(
store: LanceDBVectorStore,
) -> None:
_table, chain = _patch_table(store, rows=[])
await store.query(
"honcho.msg.test",
[0.1, 0.2, 0.3, 0.4],
include_attributes=["id", "message_id"],
)
chain.select.assert_called_once_with(["id", "message_id"])
@pytest.mark.asyncio
async def test_query_converts_rows_to_results_with_score_and_metadata(
store: LanceDBVectorStore,
) -> None:
rows: list[dict[str, Any]] = [
{
"id": "vec_1",
"_distance": 0.12,
"vector": [0.0, 0.0, 0.0, 0.0],
"message_id": "msg_1",
"session_name": "sess_a",
},
{
"id": "vec_2",
"_distance": 0.34,
"message_id": "msg_2",
},
]
_patch_table(store, rows=rows)
results = await store.query("honcho.msg.test", [0.1, 0.2, 0.3, 0.4])
assert [r.id for r in results] == ["vec_1", "vec_2"]
assert [r.score for r in results] == [0.12, 0.34]
# id, vector, _distance must not leak into metadata
assert results[0].metadata == {
"message_id": "msg_1",
"session_name": "sess_a",
}
assert results[1].metadata == {"message_id": "msg_2"}
@pytest.mark.asyncio
async def test_query_filters_by_max_distance(store: LanceDBVectorStore) -> None:
rows: list[dict[str, Any]] = [
{"id": "vec_close", "_distance": 0.05, "message_id": "msg_1"},
{"id": "vec_far", "_distance": 0.9, "message_id": "msg_2"},
]
_patch_table(store, rows=rows)
results = await store.query(
"honcho.msg.test",
[0.1, 0.2, 0.3, 0.4],
max_distance=0.5,
)
assert [r.id for r in results] == ["vec_close"]

View File

@ -0,0 +1,72 @@
"""Per-store namespace dim probe tests.
LanceDB has an embedded driver we can spin up in a tmp dir, so we exercise
the real probe end-to-end. Turbopuffer needs a network + API key, so it is
covered only by static analysis + the parsing test below.
"""
from __future__ import annotations
import re
import pyarrow as pa
import pytest
from src.vector_store.lancedb import LanceDBVectorStore
@pytest.mark.asyncio
async def test_lancedb_probe_returns_declared_dim(
monkeypatch: pytest.MonkeyPatch, tmp_path: object
) -> None:
"""Create a real LanceDB table at dim 768, confirm the probe recovers it."""
monkeypatch.setattr("src.config.settings.VECTOR_STORE.LANCEDB_PATH", str(tmp_path))
store = LanceDBVectorStore()
try:
db = await store._get_db() # pyright: ignore[reportPrivateUsage]
schema = pa.schema(
[
pa.field("id", pa.string()),
pa.field("vector", pa.list_(pa.float32(), 768)),
]
)
await db.create_table("probe_test", schema=schema)
dim = await store.probe_namespace_dim("probe_test")
assert dim == 768
finally:
await store.close()
@pytest.mark.asyncio
async def test_lancedb_probe_returns_none_for_missing_namespace(
monkeypatch: pytest.MonkeyPatch, tmp_path: object
) -> None:
"""Lazy-create model: probing a nonexistent table is not an error."""
monkeypatch.setattr("src.config.settings.VECTOR_STORE.LANCEDB_PATH", str(tmp_path))
store = LanceDBVectorStore()
try:
dim = await store.probe_namespace_dim("does_not_exist")
assert dim is None
finally:
await store.close()
def test_turbopuffer_vector_dim_regex_extracts_dim_from_type_string() -> None:
"""Turbopuffer's attribute type for a vector column is a bracket-prefixed
dim with a width suffix: ``[768]f32``, ``[1536]f16``, ``[256]i8``. The
probe extracts the integer inside the brackets. Lock the format here so
an SDK change is loud."""
pattern = re.compile(r"\[(\d+)\]")
cases = {
"[768]f32": "768",
"[1536]f16": "1536",
"[256]i8": "256",
}
for type_str, expected in cases.items():
match = pattern.search(type_str)
assert match is not None, f"failed to match {type_str!r}"
assert match.group(1) == expected
assert pattern.search("string") is None

View File

@ -79,3 +79,41 @@ async def test_upsert_many_succeeds_without_raising(
assert result is None
namespace_mock.write.assert_awaited_once()
@pytest.mark.asyncio
async def test_query_passes_requested_include_attributes(
store: TurbopufferVectorStore,
) -> None:
namespace_mock = MagicMock()
namespace_mock.query = AsyncMock(return_value=MagicMock(rows=[]))
store._get_namespace = MagicMock(return_value=namespace_mock) # pyright: ignore[reportPrivateUsage]
await store.query(
"honcho.msg.test",
[0.1, 0.2, 0.3, 0.4],
include_attributes=["message_id"],
)
namespace_mock.query.assert_awaited_once()
assert namespace_mock.query.await_args.kwargs["include_attributes"] == [
"message_id"
]
@pytest.mark.asyncio
async def test_query_can_skip_attributes(
store: TurbopufferVectorStore,
) -> None:
namespace_mock = MagicMock()
namespace_mock.query = AsyncMock(return_value=MagicMock(rows=[]))
store._get_namespace = MagicMock(return_value=namespace_mock) # pyright: ignore[reportPrivateUsage]
await store.query(
"honcho.doc.test",
[0.1, 0.2, 0.3, 0.4],
include_attributes=False,
)
namespace_mock.query.assert_awaited_once()
assert namespace_mock.query.await_args.kwargs["include_attributes"] is False

3293
uv.lock

File diff suppressed because it is too large Load Diff