Merge branch 'plastic-labs:main' into main

This commit is contained in:
Anush 2026-05-19 09:55:56 +05:30 committed by GitHub
commit d369e77391
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
76 changed files with 5674 additions and 3654 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

@ -279,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

@ -255,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,9 +88,7 @@
"groups": [
{
"group": "Overview",
"pages": [
"v3/guides/overview"
]
"pages": ["v3/guides/overview"]
},
{
"group": "Integrations",
@ -135,9 +126,7 @@
},
{
"group": "Migrations",
"pages": [
"v3/guides/migrations/mem0"
]
"pages": ["v3/guides/migrations/mem0"]
}
]
},
@ -149,6 +138,7 @@
"pages": [
"v3/contributing/self-hosting",
"v3/contributing/configuration",
"v3/contributing/changing-embeddings",
"v3/contributing/troubleshooting"
]
},
@ -166,9 +156,7 @@
"groups": [
{
"group": "API Documentation",
"pages": [
"v3/api-reference/introduction"
]
"pages": ["v3/api-reference/introduction"]
},
{
"group": "workspaces",
@ -246,9 +234,7 @@
},
{
"group": "miscellaneous",
"pages": [
"v3/api-reference/endpoint/keys/create-key"
]
"pages": ["v3/api-reference/endpoint/keys/create-key"]
}
]
},
@ -269,9 +255,7 @@
{
"version": "v2.5.1",
"api": {
"openapi": [
"v2/openapi.json"
]
"openapi": ["v2/openapi.json"]
},
"tabs": [
{
@ -318,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",
@ -351,9 +331,7 @@
"groups": [
{
"group": "API Documentation",
"pages": [
"v2/api-reference/introduction"
]
"pages": ["v2/api-reference/introduction"]
},
{
"group": "workspaces",
@ -457,9 +435,7 @@
{
"version": "v1.1.0",
"api": {
"openapi": [
"openapi.json"
]
"openapi": ["openapi.json"]
},
"tabs": [
{
@ -489,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"]
}
]
},
@ -514,9 +482,7 @@
"groups": [
{
"group": "API Documentation",
"pages": [
"v1/api-reference/introduction"
]
"pages": ["v1/api-reference/introduction"]
},
{
"group": "apps",
@ -564,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
@ -459,7 +499,8 @@ WEBHOOK_MAX_WORKSPACE_LIMIT=10
VECTOR_STORE_TYPE=pgvector # Options: pgvector, turbopuffer, lancedb, qdrant
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
@ -646,4 +687,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

@ -5,28 +5,24 @@ description: "Build AI agents with persistent memory using CrewAI and Honcho"
sidebarTitle: 'CrewAI'
---
Integrate Honcho with CrewAI to build AI agents that maintain memory across sessions. This guide shows you how to use Honcho's memory layer with CrewAI's agent orchestration framework.
Integrate Honcho with CrewAI to build agents that maintain memory across sessions. This guide uses CrewAI's unified `Memory` API with Honcho as a custom storage backend.
<Note>
The full code is available on [GitHub](https://github.com/plastic-labs/honcho/tree/main/examples/crewai) with examples in [Python](https://github.com/plastic-labs/honcho/tree/main/examples/crewai/python/examples)
The full code is available on [GitHub](https://github.com/plastic-labs/honcho/tree/main/examples/crewai) with examples in [Python](https://github.com/plastic-labs/honcho/tree/main/examples/crewai/python/examples).
</Note>
## What We're Building
We'll create AI agents that remember and reason over past conversations. Here's how the pieces fit together:
- **CrewAI** orchestrates agent behavior and task execution
- **Honcho** stores messages and retrieves relevant context
The key benefit: CrewAI automatically retrieves relevant conversation history from Honcho without you needing to manually manage context, token limits, or message formatting.
- **CrewAI** orchestrates agents, tasks, and memory recall.
- **Honcho** persists CrewAI memory records and exposes additional context, search, and reasoning tools.
<Note>
This tutorial demonstrates single-agent setup to show how Honcho integrates with CrewAI. For production applications, you can extend this to multi-agent crews with shared or individual memory using Honcho's `peer` system.
CrewAI currently supports Python `>=3.10,<3.14`; use one of those interpreters when installing this integration.
</Note>
## Setup
Install required packages:
Install the packages:
<CodeGroup>
```bash Python (uv)
@ -38,243 +34,153 @@ pip install honcho-crewai crewai python-dotenv
```
</CodeGroup>
Use any LLM provider for your Crew. Create a `.env` file with your API keys:
Set your model provider keys and Honcho configuration:
```bash
OPENAI_API_KEY=your_openai_key
HONCHO_API_KEY=your_honcho_key
HONCHO_WORKSPACE_ID=crewai-demo
```
<Note>
This tutorial uses the Honcho demo server at https://demo.honcho.dev which runs a small instance of Honcho on the latest version. For production, get your Honcho API key at [app.honcho.dev](https://app.honcho.dev). For local development, use `environment="local"`.
</Note>
For local development, initialize the Honcho client with `environment="local"`.
## CrewAI Honcho Storage
## CrewAI Memory Storage
The `honcho_crewai` package provides `HonchoStorage`, a storage provider that implements CrewAI's `Storage` interface using Honcho's session-based memory.
<Note>
Before proceeding, it's important to understand Honcho's core concepts (`Peers` and `Sessions`). Review the [Honcho Architecture](/v3/documentation/core-concepts/architecture) to familiarize yourself with these primitives.
</Note>
`HonchoStorage` implements CrewAI's `Storage` interface using Honcho's `peer` and `session` primitives.
`HonchoMemoryStorage` implements CrewAI's current `StorageBackend` protocol and can be passed directly to `Memory(storage=...)`.
```python
storage = HonchoStorage(
user_id="demo-user", # Required: Honcho `peer` ID for the user
session_id=None, # Optional: Specific `session` ID (auto-generated UUID if None)
honcho_client=None, # Optional: Pre-configured Honcho client instance
from crewai import Memory
from honcho import Honcho
from honcho_crewai import HonchoMemoryStorage
honcho = Honcho(workspace_id="crewai-demo")
storage = HonchoMemoryStorage(
peer_id="user-123",
session_id="session-123",
honcho_client=honcho,
)
memory = Memory(storage=storage)
```
CrewAI embeds memory records before storing them. The Honcho backend stores those records as Honcho messages, keeps CrewAI metadata in message metadata, and performs vector search over the stored embeddings.
```python
memory.remember(
"The user is learning Python and wants to build web applications.",
scope="/users/user-123",
categories=["preferences"],
metadata={"source": "onboarding"},
)
```
The `HonchoStorage` class implements three key methods:
- **`save()`** - Stores messages in Honcho's `session`, associating them with the appropriate `peer` (user or assistant)
- **`search()`** - Performs semantic vector search using `session.search()` to find messages most relevant to the query. Supports optional `filters` parameter for fine-grained scoping.
- **`reset()`** - Creates a new `session` to start fresh conversations
CrewAI automatically calls these methods when agents need to store or retrieve memory, creating a seamless integration.
### Search with Filters
The `search()` method supports an optional `filters` parameter for fine-grained scoping of search results:
```python
# Search with peer_id filter (only messages from a specific peer)
results = storage.search("query", filters={"peer_id": "user123"})
# Search with metadata filter
results = storage.search("query", filters={"metadata": {"priority": "high"}})
# Search with time range filter
results = storage.search("query", filters={"created_at": {"gte": "2024-01-01"}})
# Complex filter with logical operators
results = storage.search("query", filters={
"AND": [
{"peer_id": "user123"},
{"metadata": {"topic": "python"}}
]
})
```
For the full filter syntax including logical operators (AND, OR, NOT), comparison operators, and metadata filtering, see the [Using Filters](https://docs.honcho.dev/v3/documentation/features/advanced/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).
</Note>
Let's create a basic example showing how CrewAI agents use Honcho's memory automatically:
Use the memory instance with a crew:
```python Python
from dotenv import load_dotenv
from crewai import Agent, Task, Crew, Process
from crewai.memory.external.external_memory import ExternalMemory
from honcho_crewai import HonchoStorage
load_dotenv()
storage = HonchoStorage(user_id="simple-demo-user")
external_memory = ExternalMemory(storage=storage)
messages = [
("user", "I'm learning Python programming"),
("assistant", "Great! Python is an excellent language to learn."),
("user", "I'm particularly interested in web development"),
]
for role, message in messages:
external_memory.save(message, metadata={"agent": role})
from crewai import Agent, Crew, Process, Task
agent = Agent(
role="Programming Mentor",
goal="Help users learn programming by remembering their interests and progress",
backstory=(
"You are a patient programming mentor who remembers what students "
"have told you about their learning journey and interests."
),
verbose=True,
allow_delegation=False
backstory="You are a patient programming mentor.",
)
task = Task(
description=(
"Based on what you know about the user's interests, "
"suggest a simple web development project they could build to practice Python."
),
expected_output="A specific project suggestion with brief explanation",
agent=agent
description="Suggest a Python web project that matches the user's interests.",
expected_output="A specific project suggestion with a brief explanation",
agent=agent,
)
crew = Crew(
agents=[agent],
tasks=[task],
process=Process.sequential,
external_memory=external_memory,
verbose=True
memory=memory,
verbose=True,
)
result = crew.kickoff()
print(result.raw)
```
<Note>
`HonchoStorage` is still available as a compatibility adapter for older CrewAI `ExternalMemory` integrations, but new projects should use `HonchoMemoryStorage`.
</Note>
## CrewAI Tool Integration
Honcho provides specialized tools that give CrewAI agents explicit control over memory retrieval:
Honcho also provides tools that let agents explicitly retrieve memory:
- **`HonchoGetContextTool`** - Retrieves comprehensive conversation history with token limits. Use for tasks needing broad conversation understanding.
- **`HonchoDialecticTool`** - Queries representations about `peer`s. Use for understanding user preferences and characteristics without full message history.
- **`HonchoSearchTool`** - Performs semantic search for specific information. Supports optional `filters` parameter for fine-grained scoping. Use for targeted queries like "what did the user say about budget?"
<Tip>
Agents can use multiple tools in sequence: search for topics, query dialectic for preferences, then get full context for generation.
</Tip>
Here's an example demonstrating all three tools:
- **`HonchoGetContextTool`** retrieves session context with token limits.
- **`HonchoDialecticTool`** queries Honcho's representation of a peer.
- **`HonchoSearchTool`** performs semantic search over session messages.
```python Python
from dotenv import load_dotenv
from crewai import Agent, Task, Crew, Process
from crewai import Agent, Crew, Process, Task
from honcho import Honcho
from honcho_crewai import (
HonchoGetContextTool,
HonchoDialecticTool,
HonchoGetContextTool,
HonchoSearchTool,
)
load_dotenv()
honcho = Honcho()
user_id = "demo-user-45"
honcho = Honcho(workspace_id="crewai-demo")
user_id = "demo-user"
session_id = "tools-demo-session"
user = honcho.peer(user_id)
session = honcho.session(session_id)
messages = [
for message in [
"I'm planning a trip to Japan in March",
"I love trying authentic local cuisine, especially ramen and sushi",
"I love authentic local cuisine, especially ramen and sushi",
"My budget is around $3000 for a 10-day trip",
"I'm interested in visiting both Tokyo and Kyoto",
"I prefer staying in traditional ryokans over hotels",
]
for msg in messages:
session.add_messages([user.message(msg)])
]:
session.add_messages([user.message(message)])
context_tool = HonchoGetContextTool(
honcho=honcho, session_id=session_id, peer_id=user_id
honcho=honcho,
session_id=session_id,
peer_id=user_id,
)
dialectic_tool = HonchoDialecticTool(
honcho=honcho, session_id=session_id, peer_id=user_id
honcho=honcho,
session_id=session_id,
peer_id=user_id,
)
search_tool = HonchoSearchTool(honcho=honcho, session_id=session_id)
# Note: The search tool supports optional filters for fine-grained scoping
# Agents can use filters like {"peer_id": "user123"} or {"metadata": {"priority": "high"}}
travel_agent = Agent(
role="Travel Planning Specialist",
goal="Create personalized travel recommendations using memory tools",
backstory=(
"You are an expert travel planner with access to conversation memory tools. "
"Use the tools to understand the user's preferences before making recommendations."
),
backstory="You are an expert travel planner with access to memory tools.",
tools=[context_tool, dialectic_tool, search_tool],
verbose=True,
allow_delegation=False
allow_delegation=False,
)
task = Task(
description=(
"Create a personalized 3-day Tokyo itinerary. "
"Use the memory tools to understand:\n"
" • Food preferences (use search_tool for 'cuisine' or 'food')\n"
" • Travel style and budget (use dialectic_tool to query user knowledge)\n"
" • Recent context (use context_tool to get conversation history)\n"
"Then create a detailed plan matching their interests."
),
expected_output=(
"A 3-day Tokyo itinerary with:\n"
" • Daily activities matching user interests\n"
" • Restaurant recommendations\n"
" • Accommodation suggestions\n"
" • Budget considerations"
),
agent=travel_agent
description="Create a personalized 3-day Tokyo itinerary using the memory tools.",
expected_output="A 3-day Tokyo itinerary with activities, restaurants, and budget notes",
agent=travel_agent,
)
crew = Crew(
agents=[travel_agent],
tasks=[task],
process=Process.sequential,
verbose=True
verbose=True,
)
crew.kickoff()
```
## Tool-Based vs Automatic Memory
## When To Use Each
**Use `HonchoStorage`** for automatic memory - CrewAI handles everything transparently. Best for simple conversational flows.
Use `HonchoMemoryStorage` when you want CrewAI to handle recall automatically through the unified memory system.
**Use Honcho Tools** for strategic control - agents decide when and how to query memory. Best for multi-step reasoning, when different query types are needed, or multi-agent systems.
Use the Honcho tools when the agent should decide when and how to query memory, search messages, or ask Honcho for a peer-level representation.
You can combine both: automatic memory for baseline context, tools for specific queries. See the [hybrid memory example](https://github.com/plastic-labs/honcho/blob/main/examples/crewai/python/examples/hybrid_memory_example.py) for a complete implementation.
<Note>
**Multi-Agent Memory:** Use Honcho tools with different `peer_id` values to give each agent distinct memory and identity.
</Note>
## Next Steps
Now that you have a working CrewAI integration with Honcho, you can:
- **Create specialized agents** with domain-specific memory and context
- **Use CrewAI's advanced features** like hierarchical processes, tool delegation, and conditional task execution
- **Leverage logical reasoning** via the Dialectic API for deep `peer` understanding
- **Implement custom tools** to give agents explicit control over memory retrieval
You can combine both: unified memory for baseline context, tools for targeted retrieval. See the [hybrid memory example](https://github.com/plastic-labs/honcho/blob/main/examples/crewai/python/examples/hybrid_memory_example.py) for a complete implementation.
## Related Resources
@ -286,7 +192,7 @@ Now that you have a working CrewAI integration with Honcho, you can:
Learn about retrieving and formatting conversation context
</Card>
<Card title="Chat API" icon="brain" href="/v3/documentation/features/chat">
Query `peer` representations for deeper understanding
Query peer representations for deeper understanding
</Card>
<Card title="LangGraph Integration" icon="diagram-project" href="/v3/guides/integrations/langgraph">
Build stateful agents with LangGraph and Honcho

View File

@ -5,47 +5,67 @@ Build CrewAI agents with persistent memory and reasoning capabilities powered by
## Installation
```bash
pip install honcho-crewai
uv add honcho-crewai crewai python-dotenv
```
CrewAI currently supports Python `>=3.10,<3.14`; this package follows the same range.
## Quick Start
```python
from crewai import Agent, Task, Crew, Process
from crewai.memory.external.external_memory import ExternalMemory
from honcho_crewai import HonchoStorage
from crewai import Agent, Crew, Memory, Process, Task
from honcho import Honcho
from honcho_crewai import HonchoMemoryStorage
# Initialize Honcho storage
storage = HonchoStorage(user_id="user-123")
external_memory = ExternalMemory(storage=storage)
honcho = Honcho(workspace_id="crewai-demo")
storage = HonchoMemoryStorage(
peer_id="user-123",
session_id="session-123",
honcho_client=honcho,
)
memory = Memory(storage=storage)
# Create agent with memory
agent = Agent(
role="AI Assistant",
goal="Help users with persistent memory",
backstory="You remember past conversations.",
memory.remember(
"The user is learning Python and wants to build web applications.",
scope="/users/user-123",
categories=["preferences"],
metadata={"source": "onboarding"},
)
agent = Agent(
role="Programming Mentor",
goal="Help users learn programming by remembering their interests and progress",
backstory="You are a patient programming mentor.",
)
task = Task(
description="Suggest a Python web project that matches the user's interests.",
expected_output="A specific project suggestion with a brief explanation",
agent=agent,
)
# Create crew with external memory
crew = Crew(
agents=[agent],
tasks=[task],
external_memory=external_memory
process=Process.sequential,
memory=memory,
)
result = crew.kickoff()
print(result.raw)
```
## Features
- **Automatic Memory**: CrewAI agents automatically store and retrieve conversation context
- **Semantic Search**: Find relevant past messages using vector similarity
- **Logical Reasoning**: Query what the system knows about users via the Dialectic API
- **Multi-Agent Support**: Give each agent distinct memory and identity
- **Tools Integration**: `HonchoGetContextTool`, `HonchoDialecticTool`, and `HonchoSearchTool` for explicit memory control
- `HonchoMemoryStorage`: CrewAI unified `Memory` storage backend.
- `HonchoStorage`: compatibility adapter for older CrewAI `ExternalMemory` usage.
- `HonchoGetContextTool`, `HonchoDialecticTool`, and `HonchoSearchTool` for explicit Honcho memory retrieval.
- Lazy Honcho peer/session handles, matching the latest Honcho SDK get-or-create behavior.
## Documentation
For comprehensive guides, examples, and API reference, visit:
**[https://docs.honcho.dev/v3/integrations/crewai](https://docs.honcho.dev/v3/integrations/crewai)**
**[https://honcho.dev/docs/v3/integrations/crewai](https://honcho.dev/docs/v3/integrations/crewai)**
## Examples
@ -58,5 +78,5 @@ AGPL-3.0-or-later
## Support
- Report issues: [GitHub Issues](https://github.com/plastic-labs/honcho/issues)
- Documentation: [docs.honcho.dev](https://docs.honcho.dev)
- Documentation: [honcho.dev/docs](https://honcho.dev/docs)
- Website: [honcho.dev](https://honcho.dev)

View File

@ -1,18 +1,17 @@
"""
Hybrid Memory Example: Combining Automatic Memory + Explicit Tools
Demonstrates combining automatic memory (HonchoStorage) with explicit memory tools.
Demonstrates combining automatic memory (HonchoMemoryStorage) with explicit memory tools.
The agent gets baseline context automatically but can also make targeted queries.
"""
from crewai import Agent, Crew, Memory, Process, Task
from dotenv import load_dotenv
from crewai import Agent, Task, Crew, Process
from crewai.memory.external.external_memory import ExternalMemory
from honcho import Honcho
from honcho_crewai import (
HonchoStorage,
HonchoSearchTool,
HonchoDialecticTool,
HonchoMemoryStorage,
HonchoSearchTool,
)
load_dotenv()
@ -25,13 +24,13 @@ def main():
user_id = "hybrid-demo-user"
session_id = "hybrid-demo-session"
# Setup automatic memory
storage = HonchoStorage(
user_id=user_id,
# Setup unified CrewAI memory
storage = HonchoMemoryStorage(
peer_id=user_id,
session_id=session_id,
honcho_client=honcho
honcho_client=honcho,
)
external_memory = ExternalMemory(storage=storage)
memory = Memory(storage=storage)
# Add conversation history
messages = [
@ -45,7 +44,12 @@ def main():
]
for role, message in messages:
external_memory.save(message, metadata={"agent": role})
memory.remember(
message,
scope=f"/users/{user_id}/conversation",
categories=["conversation"],
metadata={"role": role},
)
# Create memory tools for targeted queries
search_tool = HonchoSearchTool(honcho=honcho, session_id=session_id)
@ -63,7 +67,7 @@ def main():
),
tools=[search_tool, dialectic_tool],
verbose=True,
allow_delegation=False
allow_delegation=False,
)
# Create task
@ -75,7 +79,7 @@ def main():
"Then create a personalized itinerary with activities and restaurant recommendations."
),
expected_output="A 3-day Tokyo itinerary with daily activities and dining suggestions",
agent=travel_agent
agent=travel_agent,
)
# Execute with hybrid memory: automatic baseline + explicit tools
@ -83,8 +87,8 @@ def main():
agents=[travel_agent],
tasks=[task],
process=Process.sequential,
external_memory=external_memory, # Automatic memory!
verbose=True
memory=memory,
verbose=True,
)
result = crew.kickoff()

View File

@ -6,11 +6,9 @@ CrewAI for agent orchestration, OpenAI for the AI model, and Honcho for memory
management via the honcho_crewai package.
"""
from typing import Optional
from crewai import Agent, Crew, Memory, Process, Task
from dotenv import load_dotenv
from crewai import Agent, Task, Crew, Process
from crewai.memory.external.external_memory import ExternalMemory
from honcho_crewai import HonchoStorage
from honcho_crewai import HonchoMemoryStorage
load_dotenv()
@ -18,9 +16,9 @@ load_dotenv()
def run_conversation_turn(
user_id: str,
user_input: str,
session_id: Optional[str] = None,
storage: Optional[HonchoStorage] = None
) -> tuple[str, HonchoStorage]:
session_id: str | None = None,
storage: HonchoMemoryStorage | None = None,
) -> tuple[str, HonchoMemoryStorage]:
"""
Run a single conversation turn with the CrewAI agent.
@ -28,7 +26,7 @@ def run_conversation_turn(
user_id: Unique identifier for the user
user_input: User's message
session_id: Optional session ID for conversation continuity
storage: Optional existing HonchoStorage instance
storage: Optional existing HonchoMemoryStorage instance
Returns:
Tuple of (agent_response, storage_instance)
@ -37,13 +35,17 @@ def run_conversation_turn(
if storage is None:
if not session_id:
session_id = f"session_{user_id}"
storage = HonchoStorage(user_id=user_id, session_id=session_id)
storage = HonchoMemoryStorage(peer_id=user_id, session_id=session_id)
# Create ExternalMemory wrapper for automatic context retrieval
external_memory = ExternalMemory(storage=storage)
memory = Memory(storage=storage)
# Save user input to memory
external_memory.save(user_input, metadata={"agent": "user"})
memory.remember(
user_input,
scope=f"/users/{user_id}/conversation",
categories=["conversation"],
metadata={"role": "user"},
)
# Create an agent with memory
agent = Agent(
@ -54,23 +56,23 @@ def run_conversation_turn(
"You use context from previous interactions to provide personalized and relevant responses."
),
verbose=False,
allow_delegation=False
allow_delegation=False,
)
# Create task for the agent
task = Task(
description=f"Respond to the user's message: {user_input}",
expected_output="A helpful and contextually relevant response that considers conversation history",
agent=agent
agent=agent,
)
# Create crew with external memory - enables automatic context retrieval
# Create crew with unified memory - enables automatic context retrieval
crew = Crew(
agents=[agent],
tasks=[task],
process=Process.sequential,
external_memory=external_memory,
verbose=False
memory=memory,
verbose=False,
)
# Execute - CrewAI automatically retrieves relevant context from Honcho
@ -78,7 +80,12 @@ def run_conversation_turn(
# Save assistant response back to memory
response_text = str(result.raw)
external_memory.save(response_text, metadata={"agent": "assistant"})
memory.remember(
response_text,
scope=f"/users/{user_id}/conversation",
categories=["conversation"],
metadata={"role": "assistant"},
)
return response_text, storage
@ -93,7 +100,7 @@ def main():
while True:
user_input = input("You: ")
if user_input.lower() in ['quit', 'exit']:
if user_input.lower() in ["quit", "exit"]:
print("Goodbye!")
break
@ -104,7 +111,7 @@ def main():
response, storage = run_conversation_turn(
user_id=user_id,
user_input=user_input,
storage=storage
storage=storage,
)
print(f"Assistant: {response}\n")
except Exception as e:

View File

@ -1,23 +1,27 @@
"""
Simple Honcho + CrewAI Example
A minimal example showing how to use Honcho's ExternalMemory with CrewAI agents.
A minimal example showing how to use Honcho-backed unified Memory with CrewAI agents.
This demonstrates the basic pattern for persistent conversation memory.
"""
from crewai import Agent, Crew, Memory, Process, Task
from dotenv import load_dotenv
from crewai import Agent, Task, Crew, Process
from crewai.memory.external.external_memory import ExternalMemory
from honcho_crewai import HonchoStorage
from honcho_crewai import HonchoMemoryStorage
load_dotenv()
def main():
"""Simple example of CrewAI agent with Honcho memory."""
# Initialize Honcho storage
storage = HonchoStorage(user_id="simple-demo-user")
external_memory = ExternalMemory(storage=storage)
user_id = "simple-demo-user"
# Initialize CrewAI unified memory backed by Honcho
storage = HonchoMemoryStorage(
peer_id=user_id,
session_id="simple-demo-session",
)
memory = Memory(storage=storage)
# Add some conversation history
messages = [
@ -27,7 +31,12 @@ def main():
]
for role, message in messages:
external_memory.save(message, metadata={"agent": role})
memory.remember(
message,
scope=f"/users/{user_id}/conversation",
categories=["conversation"],
metadata={"role": role},
)
# Create agent with memory
agent = Agent(
@ -38,7 +47,7 @@ def main():
"have told you about their learning journey and interests."
),
verbose=True,
allow_delegation=False
allow_delegation=False,
)
# Create task
@ -48,16 +57,16 @@ def main():
"suggest a simple web development project they could build to practice Python."
),
expected_output="A specific project suggestion with brief explanation",
agent=agent
agent=agent,
)
# Execute with memory - CrewAI automatically retrieves relevant context!
# Execute with memory - CrewAI automatically retrieves relevant context.
crew = Crew(
agents=[agent],
tasks=[task],
process=Process.sequential,
external_memory=external_memory,
verbose=True
memory=memory,
verbose=True,
)
result = crew.kickoff()

View File

@ -7,15 +7,15 @@ Demonstrates how to equip CrewAI agents with Honcho's memory tools:
- HonchoSearchTool: Perform semantic search across session messages
These tools give agents explicit control over memory retrieval, beyond the
automatic memory provided by ExternalMemory.
automatic memory provided by CrewAI's unified Memory API.
"""
from crewai import Agent, Crew, Process, Task
from dotenv import load_dotenv
from crewai import Agent, Task, Crew, Process
from honcho import Honcho
from honcho_crewai import (
HonchoGetContextTool,
HonchoDialecticTool,
HonchoGetContextTool,
HonchoSearchTool,
)
@ -81,7 +81,7 @@ def main():
),
tools=[context_tool, dialectic_tool, search_tool],
verbose=True,
allow_delegation=False
allow_delegation=False,
)
print(" ✓ Agent created with 3 Honcho tools\n")
@ -104,7 +104,7 @@ def main():
" • Accommodation suggestions\n"
" • Budget considerations"
),
agent=travel_agent
agent=travel_agent,
)
print(" ✓ Task created\n")
@ -116,10 +116,11 @@ def main():
agents=[travel_agent],
tasks=[task],
process=Process.sequential,
verbose=True
verbose=True,
)
crew.kickoff()
if __name__ == "__main__":
main()

View File

@ -1,9 +1,9 @@
[project]
name = "honcho-crewai"
version = "0.2.0"
version = "0.3.0"
description = "CrewAI integration with Honcho for persistent agent memory"
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.10,<3.14"
license = {text = "AGPL-3.0-or-later"}
authors = [
{name = "Plastic Labs", email = "hello@plasticlabs.ai"}
@ -33,15 +33,25 @@ classifiers = [
"Framework :: Pydantic",
]
dependencies = [
"crewai>=0.134.0",
"honcho-ai>=2.0.0",
"crewai>=1.14.3,<2.0.0",
"honcho-ai>=2.1.1,<3.0.0",
"openai>=1.0.0",
"python-dotenv>=1.0.0",
]
[project.urls]
Homepage = "https://honcho.dev"
Documentation = "https://docs.honcho.dev/v3/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"
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]

View File

@ -6,15 +6,18 @@ enabling AI agents to maintain persistent memory across conversations.
Example:
```python
from honcho_crewai import HonchoStorage, HonchoSearchTool, HonchoGetContextTool, HonchoDialecticTool
from crewai.memory.external.external_memory import ExternalMemory
from crewai import Agent, Task, Crew
from honcho_crewai import HonchoMemoryStorage, HonchoSearchTool, HonchoGetContextTool, HonchoDialecticTool
from crewai import Agent, Task, Crew, Memory
from honcho import Honcho
# Initialize Honcho client and storage
# Initialize Honcho client and CrewAI memory
honcho = Honcho()
storage = HonchoStorage(user_id="user123", honcho_client=honcho)
external_memory = ExternalMemory(storage=storage)
storage = HonchoMemoryStorage(
peer_id="user123",
session_id="session123",
honcho_client=honcho,
)
memory = Memory(storage=storage)
# Create tools for agents
search_tool = HonchoSearchTool(honcho=honcho, session_id=storage.session_id)
@ -36,28 +39,29 @@ Example:
agent=agent,
)
# Create crew with external memory
# Create crew with unified memory
crew = Crew(
agents=[agent],
tasks=[task],
external_memory=external_memory
memory=memory
)
```
"""
from honcho_crewai.exceptions import HonchoDependencyError
from honcho_crewai.storage import HonchoStorage
from honcho_crewai.storage import HonchoMemoryStorage, HonchoStorage
from honcho_crewai.tools import (
HonchoDialecticTool,
HonchoGetContextTool,
HonchoSearchTool,
)
__version__ = "0.2.0"
__version__ = "0.3.0"
__all__ = [
"HonchoDependencyError",
"HonchoDialecticTool",
"HonchoGetContextTool",
"HonchoMemoryStorage",
"HonchoSearchTool",
"HonchoStorage",
]

View File

@ -1,104 +1,457 @@
"""
Honcho Storage for CrewAI External Memory
Honcho storage adapters for CrewAI memory.
This module provides a Honcho-backed storage provider for CrewAI's external memory
system, enabling AI agents to maintain persistent conversation memory across sessions.
`HonchoMemoryStorage` implements CrewAI's current unified memory
`StorageBackend` protocol. `HonchoStorage` is kept as a compatibility adapter
for older CrewAI `ExternalMemory` usage.
"""
from __future__ import annotations
import asyncio
import logging
import math
import uuid
from typing import Any, Optional
from collections.abc import Iterable
from datetime import UTC, datetime
from typing import Any
from crewai.memory.storage.interface import Storage
from honcho import Honcho
from honcho_crewai.exceptions import HonchoDependencyError
try: # CrewAI <= 1.9 external memory interface.
from crewai.memory.storage.interface import Storage as LegacyStorage
except ModuleNotFoundError: # CrewAI >= 1.10 unified memory only.
class LegacyStorage: # type: ignore[no-redef]
pass
try: # CrewAI >= 1.10 unified memory types.
from crewai.memory.types import MemoryRecord, ScopeInfo
except ModuleNotFoundError:
MemoryRecord = None # type: ignore[assignment]
ScopeInfo = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
_MEMORY_KIND = "crewai_memory_record"
_KIND_KEY = "honcho_crewai_kind"
_DELETED_KEY = "honcho_crewai_deleted"
_RECORD_ID_KEY = "crewai_record_id"
_SCOPE_KEY = "crewai_scope"
_CATEGORIES_KEY = "crewai_categories"
_MEMORY_METADATA_KEY = "crewai_metadata"
_IMPORTANCE_KEY = "crewai_importance"
_CREATED_AT_KEY = "crewai_created_at"
_LAST_ACCESSED_KEY = "crewai_last_accessed"
_EMBEDDING_KEY = "crewai_embedding"
_SOURCE_KEY = "crewai_source"
_PRIVATE_KEY = "crewai_private"
class HonchoStorage(Storage):
def _require_unified_memory() -> None:
if MemoryRecord is None or ScopeInfo is None:
raise HonchoDependencyError("CrewAI unified memory", "uv add crewai>=1.14.3")
def _iso(value: datetime | None) -> str | None:
return value.isoformat() if value else None
def _parse_datetime(value: Any, fallback: datetime | None = None) -> datetime:
if isinstance(value, datetime):
return value
if isinstance(value, str):
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
logger.debug("Could not parse datetime %r", value)
return fallback or datetime.now(UTC)
def _scope_matches(scope: str, scope_prefix: str | None) -> bool:
if scope_prefix in (None, "", "/"):
return True
normalized = scope_prefix.rstrip("/")
return scope == normalized or scope.startswith(f"{normalized}/")
def _category_matches(
record_categories: list[str], categories: list[str] | None
) -> bool:
if not categories:
return True
return bool(set(record_categories).intersection(categories))
def _metadata_matches(
metadata: dict[str, Any], metadata_filter: dict[str, Any] | None
) -> bool:
if not metadata_filter:
return True
return all(metadata.get(key) == value for key, value in metadata_filter.items())
def _cosine_similarity(left: list[float] | None, right: list[float] | None) -> float:
if not left or not right or len(left) != len(right):
return 0.0
dot_product = sum(a * b for a, b in zip(left, right, strict=True))
left_norm = math.sqrt(sum(a * a for a in left))
right_norm = math.sqrt(sum(b * b for b in right))
if left_norm == 0.0 or right_norm == 0.0:
return 0.0
return dot_product / (left_norm * right_norm)
class HonchoMemoryStorage:
"""
Honcho-backed storage provider for CrewAI external memory.
CrewAI unified memory storage backend backed by Honcho messages.
Implements CrewAI's Storage interface using Honcho's session-based memory,
allowing agents to maintain context across conversations.
CrewAI's current memory system embeds records before passing them to custom
storage. This adapter stores those embeddings in Honcho message metadata and
performs vector search locally over the session's active memory records.
"""
Attributes:
honcho: The Honcho client instance
user: Peer representing the user
assistant: Peer representing the AI assistant
session: The conversation session
session_id: Unique identifier for the session
def __init__(
self,
*,
session_id: str | None = None,
peer_id: str = "crewai-memory",
honcho_client: Honcho | None = None,
) -> None:
_require_unified_memory()
self.honcho = honcho_client or Honcho()
self.session_id = session_id or str(uuid.uuid4())
self.peer_id = peer_id
self._session: Any | None = None
self._peer: Any | None = None
Example:
```python
from honcho_crewai import HonchoStorage
from crewai.memory.external.external_memory import ExternalMemory
@property
def session(self) -> Any:
if self._session is None:
self._session = self.honcho.session(self.session_id)
return self._session
# Initialize storage
storage = HonchoStorage(user_id="user123")
@property
def peer(self) -> Any:
if self._peer is None:
self._peer = self.honcho.peer(self.peer_id)
return self._peer
# Use with CrewAI's external memory
external_memory = ExternalMemory(storage=storage)
```
def save(self, records: list[Any]) -> None:
"""Save CrewAI memory records to Honcho."""
if not records:
return
messages = [
self.peer.message(
record.content,
metadata=self._record_metadata(record),
created_at=record.created_at,
)
for record in records
]
self.session.add_messages(messages)
def search(
self,
query_embedding: list[float],
scope_prefix: str | None = None,
categories: list[str] | None = None,
metadata_filter: dict[str, Any] | None = None,
limit: int = 10,
min_score: float = 0.0,
) -> list[tuple[Any, float]]:
"""Search records by cosine similarity over CrewAI-provided embeddings."""
matches: list[tuple[Any, float]] = []
for _, record in self._active_record_messages():
if not self._record_matches(
record, scope_prefix, categories, metadata_filter
):
continue
score = _cosine_similarity(query_embedding, record.embedding)
if score >= min_score:
matches.append((record, score))
matches.sort(key=lambda item: item[1], reverse=True)
return matches[:limit]
def delete(
self,
scope_prefix: str | None = None,
categories: list[str] | None = None,
record_ids: list[str] | None = None,
older_than: datetime | None = None,
metadata_filter: dict[str, Any] | None = None,
) -> int:
"""Tombstone records that match the delete criteria."""
deleted = 0
record_id_set = set(record_ids or [])
for message, record in self._active_record_messages():
if record_id_set and record.id not in record_id_set:
continue
if not self._record_matches(
record, scope_prefix, categories, metadata_filter
):
continue
if older_than is not None and record.created_at >= older_than:
continue
metadata = dict(message.metadata)
metadata[_DELETED_KEY] = True
self.session.update_message(message, metadata=metadata)
deleted += 1
return deleted
def update(self, record: Any) -> None:
"""Replace an existing record by tombstoning old copies and saving the new one."""
self.delete(record_ids=[record.id])
self.save([record])
def get_record(self, record_id: str) -> Any | None:
"""Return the newest active record with the given ID."""
records = [
record
for _, record in self._active_record_messages()
if record.id == record_id
]
if not records:
return None
return max(records, key=lambda record: record.created_at)
def list_records(
self,
scope_prefix: str | None = None,
limit: int = 200,
offset: int = 0,
) -> list[Any]:
"""List active records, newest first."""
records = [
record
for _, record in self._active_record_messages()
if _scope_matches(record.scope, scope_prefix)
]
records.sort(key=lambda record: record.created_at, reverse=True)
return records[offset : offset + limit]
def get_scope_info(self, scope: str) -> Any:
"""Build CrewAI scope metadata from active Honcho-backed records."""
_require_unified_memory()
records = self.list_records(scope_prefix=scope, limit=10_000)
categories = sorted(
{category for record in records for category in record.categories}
)
created_at_values = [record.created_at for record in records]
return ScopeInfo( # type: ignore[operator]
path=scope,
record_count=len(records),
categories=categories,
oldest_record=min(created_at_values) if created_at_values else None,
newest_record=max(created_at_values) if created_at_values else None,
child_scopes=self.list_scopes(scope),
)
def list_scopes(self, parent: str = "/") -> list[str]:
"""List immediate child scopes below `parent`."""
children: set[str] = set()
parent = parent.rstrip("/") or "/"
for record in self.list_records(scope_prefix=parent, limit=10_000):
scope = record.scope.rstrip("/") or "/"
if scope == parent:
continue
if parent == "/":
parts = [part for part in scope.split("/") if part]
if parts:
children.add(f"/{parts[0]}")
else:
remainder = scope.removeprefix(parent).strip("/")
if remainder:
children.add(f"{parent}/{remainder.split('/')[0]}")
return sorted(children)
def list_categories(self, scope_prefix: str | None = None) -> dict[str, int]:
"""Count categories in active records."""
counts: dict[str, int] = {}
for record in self.list_records(scope_prefix=scope_prefix, limit=10_000):
for category in record.categories:
counts[category] = counts.get(category, 0) + 1
return counts
def count(self, scope_prefix: str | None = None) -> int:
"""Count active records in a scope."""
return len(self.list_records(scope_prefix=scope_prefix, limit=10_000))
def reset(self, scope_prefix: str | None = None) -> None:
"""Tombstone all records in a scope, or all records when no scope is given."""
self.delete(scope_prefix=scope_prefix)
async def asave(self, records: list[Any]) -> None:
await asyncio.to_thread(self.save, records)
async def asearch(
self,
query_embedding: list[float],
scope_prefix: str | None = None,
categories: list[str] | None = None,
metadata_filter: dict[str, Any] | None = None,
limit: int = 10,
min_score: float = 0.0,
) -> list[tuple[Any, float]]:
return await asyncio.to_thread(
self.search,
query_embedding,
scope_prefix,
categories,
metadata_filter,
limit,
min_score,
)
async def adelete(
self,
scope_prefix: str | None = None,
categories: list[str] | None = None,
record_ids: list[str] | None = None,
older_than: datetime | None = None,
metadata_filter: dict[str, Any] | None = None,
) -> int:
return await asyncio.to_thread(
self.delete,
scope_prefix,
categories,
record_ids,
older_than,
metadata_filter,
)
def _record_metadata(self, record: Any) -> dict[str, Any]:
return {
_KIND_KEY: _MEMORY_KIND,
_DELETED_KEY: False,
_RECORD_ID_KEY: record.id,
_SCOPE_KEY: record.scope,
_CATEGORIES_KEY: list(record.categories),
_MEMORY_METADATA_KEY: dict(record.metadata),
_IMPORTANCE_KEY: record.importance,
_CREATED_AT_KEY: _iso(record.created_at),
_LAST_ACCESSED_KEY: _iso(record.last_accessed),
_EMBEDDING_KEY: record.embedding,
_SOURCE_KEY: record.source,
_PRIVATE_KEY: record.private,
}
def _active_record_messages(self) -> Iterable[tuple[Any, Any]]:
for message in self._record_messages():
metadata = message.metadata or {}
if metadata.get(_DELETED_KEY):
continue
yield message, self._message_to_record(message)
def _record_messages(self) -> Iterable[Any]:
filters = {"metadata": {_KIND_KEY: _MEMORY_KIND}}
for message in self.session.messages(filters=filters, size=100, reverse=True):
if (message.metadata or {}).get(_KIND_KEY) == _MEMORY_KIND:
yield message
def _message_to_record(self, message: Any) -> Any:
_require_unified_memory()
metadata = message.metadata or {}
return MemoryRecord( # type: ignore[operator]
id=metadata[_RECORD_ID_KEY],
content=message.content,
scope=metadata.get(_SCOPE_KEY, "/"),
categories=list(metadata.get(_CATEGORIES_KEY) or []),
metadata=dict(metadata.get(_MEMORY_METADATA_KEY) or {}),
importance=metadata.get(_IMPORTANCE_KEY, 0.5),
created_at=_parse_datetime(
metadata.get(_CREATED_AT_KEY), message.created_at
),
last_accessed=_parse_datetime(
metadata.get(_LAST_ACCESSED_KEY), message.created_at
),
embedding=metadata.get(_EMBEDDING_KEY),
source=metadata.get(_SOURCE_KEY),
private=bool(metadata.get(_PRIVATE_KEY, False)),
)
def _record_matches(
self,
record: Any,
scope_prefix: str | None,
categories: list[str] | None,
metadata_filter: dict[str, Any] | None,
) -> bool:
return (
_scope_matches(record.scope, scope_prefix)
and _category_matches(record.categories, categories)
and _metadata_matches(record.metadata, metadata_filter)
)
class HonchoStorage(LegacyStorage):
"""
Backwards-compatible Honcho storage for CrewAI `ExternalMemory`.
New CrewAI projects should prefer `HonchoMemoryStorage` with
`crewai.Memory(storage=...)`.
"""
def __init__(
self,
user_id: str,
session_id: Optional[str] = None,
honcho_client: Optional[Honcho] = None,
session_id: str | None = None,
honcho_client: Honcho | None = None,
assistant_id: str = "assistant",
) -> None:
"""
Initialize Honcho storage for a specific user and session.
Args:
user_id: Unique identifier for the user
session_id: Optional session ID. If not provided, a UUID will be generated
honcho_client: Optional Honcho client instance. If not provided, creates one
using the demo environment (https://demo.honcho.dev)
"""
self.honcho = honcho_client or Honcho()
self.user_id = user_id
self.assistant_id = assistant_id
self.session_id = session_id or str(uuid.uuid4())
self._user: Any | None = None
self._assistant: Any | None = None
self._session: Any | None = None
# Initialize user and assistant peers
self.user = self.honcho.peer(user_id)
self.assistant = self.honcho.peer("assistant")
@property
def user(self) -> Any:
if self._user is None:
self._user = self.honcho.peer(self.user_id)
return self._user
# Create or use existing session
if not session_id:
session_id = str(uuid.uuid4())
self.session = self.honcho.session(session_id)
self.session_id = session_id
@property
def assistant(self) -> Any:
if self._assistant is None:
self._assistant = self.honcho.peer(self.assistant_id)
return self._assistant
@property
def session(self) -> Any:
if self._session is None:
self._session = self.honcho.session(self.session_id)
return self._session
def save(self, value: Any, metadata: dict[str, Any]) -> None:
"""
Save a message to Honcho session.
This method is called by CrewAI to store messages and context. Messages
are associated with the appropriate peer (user or assistant) based on
the metadata.
Args:
value: Message content to save
metadata: Metadata dict that may contain 'role', 'agent', or 'type' info
Common keys: 'role', 'agent', 'type'
"""
"""Save a CrewAI external-memory message to a Honcho session."""
try:
# Determine if this is from user or assistant based on metadata
# Check various metadata keys that might indicate the role
role = metadata.get("role", metadata.get("agent", "assistant"))
is_user = role == "user"
peer = self.user if is_user else self.assistant
content_str = str(value)
# Add message to session
self.session.add_messages([peer.message(content_str, metadata=metadata)])
logger.debug(
"Saved message from %s: %s...",
metadata.get("name", role),
content_str[:100],
role = str(metadata.get("role", metadata.get("agent", "assistant"))).lower()
peer = (
self.user if role in {"user", "human", self.user_id} else self.assistant
)
content = str(value)
self.session.add_messages([peer.message(content, metadata=metadata)])
logger.debug("Saved %s message to Honcho session %s", role, self.session_id)
except Exception:
logger.exception("Error saving to Honcho")
@ -109,7 +462,7 @@ class HonchoStorage(Storage):
query: str,
limit: int = 10,
score_threshold: float = 0.5,
filters: Optional[dict[str, Any]] = None,
filters: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""
Search for relevant messages using semantic search.
@ -125,7 +478,7 @@ class HonchoStorage(Storage):
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://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters
See: https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters
Returns:
List of message dictionaries in CrewAI expected format.
@ -136,35 +489,30 @@ class HonchoStorage(Storage):
- metadata: Message metadata including peer_id, created_at, and custom metadata
"""
try:
results = []
# Use semantic search to find relevant messages
# This performs vector similarity search on message content
_ = score_threshold
messages = self.session.search(query=query, filters=filters, limit=limit)
results = []
# Convert to CrewAI expected format
for msg in messages:
# Build base metadata with peer_id and created_at
for message in messages:
metadata = {
"peer_id": msg.peer_id,
"created_at": str(msg.created_at)
if hasattr(msg, "created_at")
"peer_id": message.peer_id,
"created_at": str(message.created_at)
if hasattr(message, "created_at")
else None,
}
# Merge custom metadata if present
if hasattr(msg, "metadata") and msg.metadata:
metadata.update(msg.metadata)
if getattr(message, "metadata", None):
metadata.update(message.metadata)
results.append(
{
"content": msg.content,
"memory": msg.content,
"context": msg.content,
"content": message.content,
"memory": message.content,
"context": message.content,
"metadata": metadata,
}
)
logger.debug("Search for '%s' returned %d results", query, len(results))
logger.debug("Search for %r returned %d results", query, len(results))
return results
except Exception:
@ -172,19 +520,7 @@ class HonchoStorage(Storage):
raise
def reset(self) -> None:
"""
Create a new session, effectively resetting memory.
This creates a new Honcho session with a fresh UUID, allowing the agent
to start a new conversation without the previous context.
"""
try:
new_session_id = str(uuid.uuid4())
self.session = self.honcho.session(new_session_id)
self.session_id = new_session_id
logger.debug("Reset session. New session ID: %s", new_session_id)
except Exception:
logger.exception("Error resetting Honcho session")
raise
"""Start writing to a fresh Honcho session."""
self.session_id = str(uuid.uuid4())
self._session = None
logger.debug("Reset HonchoStorage to session %s", self.session_id)

View File

@ -6,7 +6,7 @@ session context, dialectic API, and semantic search capabilities.
"""
import logging
from typing import Any, Optional
from typing import Any
from crewai.tools import BaseTool
from honcho import Honcho
@ -19,38 +19,49 @@ logger = logging.getLogger(__name__)
class GetContextInput(BaseModel):
"""Input schema for context tool."""
tokens: Optional[int] = Field(
default=None, gt=0, description="Maximum number of tokens to include in the context"
tokens: int | None = Field(
default=None,
gt=0,
description="Maximum number of tokens to include in the context",
)
peer_target: Optional[str] = Field(
default=None, description="A peer ID to get context for (retrieves representation and peer card)"
peer_target: str | None = Field(
default=None,
description="A peer ID to get context for (retrieves representation and peer card)",
)
summary: bool = Field(
default=True, description="Whether to include session summary in the context"
)
peer_perspective: Optional[str] = Field(
default=None, description="Peer ID to use as the perspective for context retrieval"
peer_perspective: str | None = Field(
default=None,
description="Peer ID to use as the perspective for context retrieval",
)
class DialecticInput(BaseModel):
"""Input schema for dialectic (chat) tool."""
query: str = Field(..., min_length=1, description="Natural language question to ask")
target: Optional[str] = Field(
query: str = Field(
..., min_length=1, description="Natural language question to ask"
)
target: str | None = Field(
default=None, description="Optional target peer for local representation query"
)
session_id: Optional[str] = Field(
default=None, description="Optional session ID to scope query to specific session"
session_id: str | None = Field(
default=None,
description="Optional session ID to scope query to specific session",
)
class SearchInput(BaseModel):
"""Input schema for search tool."""
query: str = Field(..., min_length=1, description="Search query for semantic matching")
limit: int = Field(default=10, ge=1, le=100, description="Number of results to return (1-100)")
filters: Optional[dict[str, Any]] = Field(
query: str = Field(
..., min_length=1, description="Search query for semantic matching"
)
limit: int = Field(
default=10, ge=1, le=100, description="Number of results to return (1-100)"
)
filters: dict[str, Any] | None = Field(
default=None,
description=(
"Optional filters to scope the search. Supports Honcho's filter syntax including "
@ -81,6 +92,7 @@ class HonchoGetContextTool(BaseTool):
_honcho: Honcho = PrivateAttr()
_session_id: str = PrivateAttr()
_peer_id: str = PrivateAttr()
_session: Any = PrivateAttr(default=None)
def __init__(self, honcho: Honcho, session_id: str, peer_id: str) -> None:
"""
@ -96,13 +108,19 @@ class HonchoGetContextTool(BaseTool):
self._session_id = session_id
self._peer_id = peer_id
@property
def _honcho_session(self) -> Any:
if self._session is None:
self._session = self._honcho.session(self._session_id)
return self._session
def _run(
self,
tokens: Optional[int] = None,
peer_target: Optional[str] = None,
tokens: int | None = None,
peer_target: str | None = None,
*,
summary: bool = True,
peer_perspective: Optional[str] = None,
peer_perspective: str | None = None,
) -> str:
"""
Execute context retrieval and format results.
@ -117,8 +135,7 @@ class HonchoGetContextTool(BaseTool):
Formatted string containing context information
"""
try:
session = self._honcho.session(self._session_id)
context = session.context(
context = self._honcho_session.context(
summary=summary,
tokens=tokens,
peer_target=peer_target,
@ -179,6 +196,7 @@ class HonchoDialecticTool(BaseTool):
_honcho: Honcho = PrivateAttr()
_session_id: str = PrivateAttr()
_peer_id: str = PrivateAttr()
_peer: Any = PrivateAttr(default=None)
def __init__(self, honcho: Honcho, session_id: str, peer_id: str) -> None:
"""
@ -194,11 +212,17 @@ class HonchoDialecticTool(BaseTool):
self._session_id = session_id
self._peer_id = peer_id
@property
def _honcho_peer(self) -> Any:
if self._peer is None:
self._peer = self._honcho.peer(self._peer_id)
return self._peer
def _run(
self,
query: str,
target: Optional[str] = None,
session_id: Optional[str] = None,
target: str | None = None,
session_id: str | None = None,
) -> str:
"""
Execute dialectic query.
@ -212,13 +236,11 @@ class HonchoDialecticTool(BaseTool):
String response from the dialectic API
"""
try:
peer = self._honcho.peer(self._peer_id)
# Use provided session_id or fall back to default
scope_session_id = session_id or self._session_id
# Query the dialectic API (non-streaming)
response = peer.chat(
response = self._honcho_peer.chat(
query=query,
target=target,
session=scope_session_id,
@ -254,6 +276,7 @@ class HonchoSearchTool(BaseTool):
_honcho: Honcho = PrivateAttr()
_session_id: str = PrivateAttr()
_session: Any = PrivateAttr(default=None)
def __init__(self, honcho: Honcho, session_id: str) -> None:
"""
@ -267,7 +290,15 @@ class HonchoSearchTool(BaseTool):
self._honcho = honcho
self._session_id = session_id
def _run(self, query: str, limit: int = 10, filters: Optional[dict[str, Any]] = None) -> str:
@property
def _honcho_session(self) -> Any:
if self._session is None:
self._session = self._honcho.session(self._session_id)
return self._session
def _run(
self, query: str, limit: int = 10, filters: dict[str, Any] | None = None
) -> str:
"""
Execute semantic search.
@ -280,10 +311,10 @@ class HonchoSearchTool(BaseTool):
Formatted string with search results
"""
try:
session = self._honcho.session(self._session_id)
# Perform semantic search
messages = session.search(query=query, limit=limit, filters=filters)
messages = self._honcho_session.search(
query=query, limit=limit, filters=filters
)
if not messages:
return f"No messages found matching '{query}'"

View File

@ -4,8 +4,6 @@ Basic tests for honcho_crewai package
Validates package structure, imports, and basic functionality.
"""
import pytest
def test_package_import():
"""Test that honcho_crewai imports successfully."""
@ -21,11 +19,18 @@ def test_storage_import():
assert HonchoStorage is not None
def test_memory_storage_import():
"""Test that HonchoMemoryStorage can be imported."""
from honcho_crewai import HonchoMemoryStorage
assert HonchoMemoryStorage is not None
def test_tools_import():
"""Test that tool classes can be imported."""
from honcho_crewai import (
HonchoGetContextTool,
HonchoDialecticTool,
HonchoGetContextTool,
HonchoSearchTool,
)
@ -52,6 +57,7 @@ class TestPackageMetadata:
assert hasattr(honcho_crewai, "__all__")
expected_exports = [
"HonchoStorage",
"HonchoMemoryStorage",
"HonchoGetContextTool",
"HonchoDialecticTool",
"HonchoSearchTool",

View File

@ -1,174 +1,256 @@
"""
Tests for HonchoStorage
Tests the CrewAI-Honcho integration layer, focusing on:
- CrewAI Storage interface compliance
- Metadata mapping (agent/role -> peer_id)
- Format conversion (Honcho -> CrewAI format)
Tests for Honcho CrewAI storage adapters.
"""
from honcho_crewai import HonchoStorage
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from crewai.memory.types import MemoryRecord
from honcho_crewai import HonchoMemoryStorage, HonchoStorage
class FakeMessageCreate:
def __init__(self, peer_id, content, metadata=None, created_at=None):
self.peer_id = peer_id
self.content = content
self.metadata = metadata or {}
self.created_at = created_at
class FakeMessage:
def __init__(self, id, peer_id, content, metadata=None, created_at=None):
self.id = id
self.peer_id = peer_id
self.content = content
self.metadata = metadata or {}
self.created_at = created_at or datetime.now(UTC)
class FakePeer:
def __init__(self, id):
self.id = id
def message(self, content, *, metadata=None, created_at=None):
return FakeMessageCreate(self.id, content, metadata, created_at)
def chat(self, query, **kwargs):
return f"answer: {query} {kwargs}"
class FakeSession:
def __init__(self, id):
self.id = id
self._messages = []
def add_messages(self, messages):
saved = []
for message in messages:
saved_message = FakeMessage(
id=f"msg-{len(self._messages) + 1}",
peer_id=message.peer_id,
content=message.content,
metadata=dict(message.metadata),
created_at=message.created_at,
)
self._messages.append(saved_message)
saved.append(saved_message)
return saved
def search(self, query, filters=None, limit=10):
return self._messages[:limit]
def messages(self, filters=None, size=100, reverse=False):
messages = list(self._messages)
if reverse:
messages.reverse()
return messages
def update_message(self, message, metadata):
message.metadata = metadata
return message
def context(self, **kwargs):
return type(
"FakeContext",
(),
{
"summary": None,
"peer_representation": None,
"peer_card": None,
"messages": self._messages,
},
)()
class FakeHoncho:
def __init__(self):
self.peer_calls = []
self.session_calls = []
self._peers = {}
self._sessions = {}
def peer(self, id):
self.peer_calls.append(id)
self._peers.setdefault(id, FakePeer(id))
return self._peers[id]
def session(self, id):
self.session_calls.append(id)
self._sessions.setdefault(id, FakeSession(id))
return self._sessions[id]
class TestHonchoMemoryStorage:
def test_initialization_is_lazy(self):
honcho = FakeHoncho()
storage = HonchoMemoryStorage(
peer_id="user-1",
session_id="session-1",
honcho_client=honcho,
)
assert storage.session_id == "session-1"
assert storage.peer_id == "user-1"
assert honcho.peer_calls == []
assert honcho.session_calls == []
def test_save_and_search_memory_records(self):
honcho = FakeHoncho()
storage = HonchoMemoryStorage(
peer_id="user-1",
session_id="session-1",
honcho_client=honcho,
)
record = MemoryRecord(
id="record-1",
content="User likes ramen",
scope="/users/user-1",
categories=["preferences"],
metadata={"topic": "food"},
embedding=[1.0, 0.0],
created_at=datetime.now(UTC),
last_accessed=datetime.now(UTC),
)
storage.save([record])
matches = storage.search(
[1.0, 0.0],
scope_prefix="/users",
categories=["preferences"],
metadata_filter={"topic": "food"},
)
assert len(matches) == 1
assert matches[0][0].id == "record-1"
assert matches[0][1] == 1.0
assert honcho.peer_calls == ["user-1"]
assert honcho.session_calls == ["session-1"]
def test_delete_update_and_discovery_methods(self):
honcho = FakeHoncho()
storage = HonchoMemoryStorage(
peer_id="user-1",
session_id="session-1",
honcho_client=honcho,
)
old_record = MemoryRecord(
id="record-1",
content="Old preference",
scope="/users/user-1/preferences",
categories=["preferences"],
metadata={"topic": "food"},
embedding=[1.0, 0.0],
created_at=datetime.now(UTC) - timedelta(days=1),
last_accessed=datetime.now(UTC) - timedelta(days=1),
)
new_record = MemoryRecord(
id="record-1",
content="New preference",
scope="/users/user-1/preferences",
categories=["preferences"],
metadata={"topic": "food"},
embedding=[0.0, 1.0],
created_at=datetime.now(UTC),
last_accessed=datetime.now(UTC),
)
storage.save([old_record])
storage.update(new_record)
assert storage.get_record("record-1").content == "New preference"
assert storage.count("/users") == 1
assert storage.list_categories("/users") == {"preferences": 1}
assert storage.list_scopes("/") == ["/users"]
assert storage.get_scope_info("/users").record_count == 1
assert storage.delete(record_ids=["record-1"]) == 1
assert storage.get_record("record-1") is None
class TestHonchoStorage:
"""Tests for HonchoStorage integration layer."""
def test_legacy_initialization_is_lazy(self):
honcho = FakeHoncho()
def test_initialization(self):
"""Test that HonchoStorage initializes with correct peers and session."""
storage = HonchoStorage(user_id="test_user")
storage = HonchoStorage(
user_id="user-1",
session_id="session-1",
honcho_client=honcho,
)
assert storage is not None
assert storage.session_id is not None
assert storage.user is not None
assert storage.assistant is not None
assert storage.session is not None
assert storage.session_id == "session-1"
assert honcho.peer_calls == []
assert honcho.session_calls == []
def test_initialization_with_custom_session(self):
"""Test that custom session_id is preserved."""
custom_session_id = "my_custom_session"
storage = HonchoStorage(user_id="test_user", session_id=custom_session_id)
def test_legacy_save_maps_roles_to_peers(self):
honcho = FakeHoncho()
storage = HonchoStorage(
user_id="user-1",
session_id="session-1",
honcho_client=honcho,
)
assert storage.session_id == custom_session_id
storage.save("User message", metadata={"role": "user"})
storage.save("Assistant message", metadata={"role": "assistant"})
def test_save_with_different_roles(self):
"""Test that save handles different agent/role metadata."""
storage = HonchoStorage(user_id="test_user_roles")
messages = honcho._sessions["session-1"]._messages
assert [message.peer_id for message in messages] == ["user-1", "assistant"]
# Save with different metadata patterns
storage.save("User via agent", metadata={"agent": "user"})
storage.save("User via role", metadata={"role": "user"})
storage.save("Assistant via agent", metadata={"agent": "assistant"})
storage.save("Default (no metadata)", metadata={})
def test_legacy_search_returns_crewai_external_memory_format(self):
honcho = FakeHoncho()
storage = HonchoStorage(
user_id="user-1",
session_id="session-1",
honcho_client=honcho,
)
# If no exceptions raised, metadata mapping works
storage.save("User likes ramen", metadata={"role": "user", "topic": "food"})
results = storage.search("ramen")
def test_search_returns_crewai_format(self):
"""Test that search returns results in CrewAI format."""
storage = HonchoStorage(user_id="test_user_search")
assert results == [
{
"content": "User likes ramen",
"memory": "User likes ramen",
"context": "User likes ramen",
"metadata": {
"peer_id": "user-1",
"created_at": str(results[0]["metadata"]["created_at"]),
"role": "user",
"topic": "food",
},
}
]
# Add a message
storage.save("Test message", metadata={"agent": "user"})
def test_legacy_reset_is_lazy(self):
honcho = FakeHoncho()
storage = HonchoStorage(
user_id="user-1",
session_id="session-1",
honcho_client=honcho,
)
# Search
results = storage.search("test", limit=10)
# Verify CrewAI format
assert isinstance(results, list)
for result in results:
# Required keys for CrewAI
assert "memory" in result
assert "context" in result
assert "content" in result
assert "metadata" in result
def test_search_includes_all_required_fields(self):
"""Test that all search results have required CrewAI fields."""
storage = HonchoStorage(user_id="test_user_format")
# Add a message
storage.save("Test message", metadata={"agent": "user"})
# Search
results = storage.search("test", limit=5)
# Verify all results have required fields with correct types
for result in results:
assert isinstance(result["content"], str)
assert isinstance(result["memory"], str)
assert isinstance(result["context"], str)
assert isinstance(result["metadata"], dict)
def test_search_formats_summaries_correctly(self):
"""Test that session summaries are formatted with [Session Summary] prefix."""
storage = HonchoStorage(user_id="test_user_summaries")
# Add enough messages to potentially trigger summaries
for i in range(25):
storage.save(
f"Message {i}",
metadata={"agent": "user" if i % 2 == 0 else "assistant"},
)
# Search
results = storage.search("message", limit=10)
# Check summary formatting (if summaries exist)
summary_results = [r for r in results if r["metadata"].get("type") == "summary"]
for summary in summary_results:
# Verify our formatting logic
assert "summary_type" in summary["metadata"]
assert "[Session Summary]" in summary["context"] # Our formatting
def test_reset_creates_new_session_id(self):
"""Test that reset() creates a new session with different ID."""
storage = HonchoStorage(user_id="test_user_reset")
original_session_id = storage.session_id
# Reset
storage.reset()
# Verify new session ID was created
assert storage.session_id != original_session_id
def test_search_with_filters(self):
"""Test that search accepts and uses filters parameter."""
storage = HonchoStorage(user_id="test_user_filters")
# Add messages with different metadata
storage.save("User question about Python", metadata={"agent": "user", "topic": "python"})
storage.save("Assistant answer about Python", metadata={"agent": "assistant", "topic": "python"})
storage.save("User question about JavaScript", metadata={"agent": "user", "topic": "javascript"})
# Search with peer_id filter - filter to only user messages
results = storage.search(
"programming",
limit=10,
filters={"peer_id": storage.user.id}
)
# Verify results are returned and in correct format
assert isinstance(results, list)
for result in results:
assert "memory" in result
assert "content" in result
assert "context" in result
assert "metadata" in result
def test_search_with_metadata_filters(self):
"""Test that search works with metadata filters."""
storage = HonchoStorage(user_id="test_user_metadata_filters")
# Add messages with specific metadata
storage.save("Important message", metadata={"agent": "user", "priority": "high"})
storage.save("Regular message", metadata={"agent": "user", "priority": "low"})
# Search with metadata filter
results = storage.search(
"message",
limit=10,
filters={"metadata": {"priority": "high"}}
)
# Verify results are in correct format
assert isinstance(results, list)
for result in results:
assert "memory" in result
assert "metadata" in result
def test_search_without_filters(self):
"""Test that search works without filters."""
storage = HonchoStorage(user_id="test_user_no_filters")
# Add a message
storage.save("Test message for search", metadata={"agent": "user"})
# Search without filters
results = storage.search("test", limit=5)
# Verify it works and returns correct format
assert isinstance(results, list)
for result in results:
assert "memory" in result
assert "content" in result
assert storage.session_id != "session-1"
assert honcho.session_calls == []

View File

@ -1,194 +1,117 @@
"""
Tests for Honcho CrewAI Tools
Tests the CrewAI-Honcho tool integration layer using real Honcho SDK.
Focuses on tool interface compliance and result formatting.
Tests for Honcho CrewAI tools.
"""
from honcho import Honcho
from honcho_crewai import (
HonchoDialecticTool,
HonchoGetContextTool,
HonchoSearchTool,
)
from test_storage import FakeHoncho
class TestGetContextTool:
"""Tests for HonchoGetContextTool."""
def test_initialization_is_lazy(self):
honcho = FakeHoncho()
def test_initialization(self):
"""Test that tool initializes with correct attributes."""
honcho = Honcho()
tool = HonchoGetContextTool(
honcho=honcho, session_id="test_session", peer_id="test_peer"
honcho=honcho,
session_id="session-1",
peer_id="user-1",
)
assert tool is not None
assert tool.name == "get_session_context"
assert tool.description is not None
assert tool.args_schema is not None
assert honcho.session_calls == []
def test_returns_formatted_context(self):
"""Test that tool returns formatted context string."""
honcho = Honcho()
peer = honcho.peer("context_test_user")
session_id = "context_test_session"
session = honcho.session(session_id)
# Add test message
honcho = FakeHoncho()
peer = honcho.peer("user-1")
session = honcho.session("session-1")
session.add_messages([peer.message("Test message for context")])
# Create and execute tool
honcho.session_calls.clear()
tool = HonchoGetContextTool(
honcho=honcho, session_id=session_id, peer_id="context_test_user"
honcho=honcho,
session_id="session-1",
peer_id="user-1",
)
result = tool._run()
# Verify result is a formatted string
assert isinstance(result, str)
assert len(result) > 0
assert "Messages (1)" in result
assert "user-1: Test message for context" in result
assert honcho.session_calls == ["session-1"]
class TestDialecticTool:
"""Tests for HonchoDialecticTool."""
def test_initialization_is_lazy(self):
honcho = FakeHoncho()
def test_initialization(self):
"""Test that tool initializes with correct attributes."""
honcho = Honcho()
tool = HonchoDialecticTool(
honcho=honcho, session_id="test_session", peer_id="test_peer"
honcho=honcho,
session_id="session-1",
peer_id="user-1",
)
assert tool is not None
assert tool.name == "query_peer_knowledge"
assert tool.description is not None
assert honcho.peer_calls == []
def test_returns_response(self):
"""Test that tool returns a response string."""
honcho = Honcho()
peer = honcho.peer("dialectic_test_user")
session_id = "dialectic_test_session"
session = honcho.session(session_id)
# Add test messages
session.add_messages([peer.message("I love pizza and Italian food")])
# Create and execute tool
honcho = FakeHoncho()
tool = HonchoDialecticTool(
honcho=honcho, session_id=session_id, peer_id="dialectic_test_user"
honcho=honcho,
session_id="session-1",
peer_id="user-1",
)
result = tool._run(query="What does the user like?")
# Verify result is a string
assert isinstance(result, str)
assert len(result) > 0
assert "answer: What does the user like?" in result
assert honcho.peer_calls == ["user-1"]
class TestSearchTool:
"""Tests for HonchoSearchTool."""
def test_initialization_is_lazy(self):
honcho = FakeHoncho()
def test_initialization(self):
"""Test that tool initializes with correct attributes."""
honcho = Honcho()
tool = HonchoSearchTool(honcho=honcho, session_id="test_session")
tool = HonchoSearchTool(honcho=honcho, session_id="session-1")
assert tool is not None
assert tool.name == "search_session_messages"
assert tool.description is not None
assert honcho.session_calls == []
def test_returns_formatted_results(self):
"""Test that tool returns formatted search results."""
honcho = Honcho()
peer = honcho.peer("search_test_user")
session_id = "search_test_session"
session = honcho.session(session_id)
# Add test messages
honcho = FakeHoncho()
peer = honcho.peer("user-1")
session = honcho.session("session-1")
session.add_messages([peer.message("I love pizza and pasta")])
# Create and execute tool
tool = HonchoSearchTool(honcho=honcho, session_id=session_id)
honcho.session_calls.clear()
tool = HonchoSearchTool(honcho=honcho, session_id="session-1")
result = tool._run(query="food", limit=5)
# Verify result is a formatted string
assert isinstance(result, str)
assert len(result) > 0
# Should have either results or "No messages found"
assert "Search Results" in result or "No messages found" in result
def test_search_with_filters(self):
"""Test that search tool accepts and uses filters parameter."""
honcho = Honcho()
peer = honcho.peer("search_filter_test_user")
session_id = "search_filter_test_session"
session = honcho.session(session_id)
# Add test messages
session.add_messages([peer.message("Important message about Python")])
# Create and execute tool with filters
tool = HonchoSearchTool(honcho=honcho, session_id=session_id)
result = tool._run(
query="Python",
limit=5,
filters={"peer_id": peer.id}
)
# Verify result is a formatted string
assert isinstance(result, str)
assert len(result) > 0
def test_search_with_metadata_filters(self):
"""Test that search tool works with metadata filters."""
honcho = Honcho()
peer = honcho.peer("search_metadata_filter_user")
session_id = "search_metadata_filter_session"
session = honcho.session(session_id)
# Add test messages with metadata
session.add_messages([peer.message("High priority task", metadata={"priority": "high"})])
# Create and execute tool with metadata filter
tool = HonchoSearchTool(honcho=honcho, session_id=session_id)
result = tool._run(
query="task",
limit=5,
filters={"metadata": {"priority": "high"}}
)
# Verify result is a formatted string
assert isinstance(result, str)
assert len(result) > 0
assert "Search Results" in result
assert "[user-1] I love pizza and pasta" in result
assert honcho.session_calls == ["session-1"]
class TestToolsWorkTogether:
"""Test that all tools can work together."""
def test_all_tools_in_same_session(self):
"""Test that all three tools can be used in the same session."""
honcho = Honcho()
peer = honcho.peer("combo_test_user")
session_id = "combo_test_session"
session = honcho.session(session_id)
# Add messages
honcho = FakeHoncho()
peer = honcho.peer("user-1")
session = honcho.session("session-1")
session.add_messages([peer.message("I enjoy coding in Python")])
# Create all tools
context_tool = HonchoGetContextTool(
honcho=honcho, session_id=session_id, peer_id="combo_test_user"
honcho=honcho,
session_id="session-1",
peer_id="user-1",
)
dialectic_tool = HonchoDialecticTool(
honcho=honcho, session_id=session_id, peer_id="combo_test_user"
honcho=honcho,
session_id="session-1",
peer_id="user-1",
)
search_tool = HonchoSearchTool(honcho=honcho, session_id=session_id)
search_tool = HonchoSearchTool(honcho=honcho, session_id="session-1")
# Execute all tools
context_result = context_tool._run()
dialectic_result = dialectic_tool._run(query="What does the user like?")
search_result = search_tool._run(query="coding", limit=5)
# Verify all return valid strings
assert isinstance(context_result, str) and len(context_result) > 0
assert isinstance(dialectic_result, str) and len(dialectic_result) > 0
assert isinstance(search_result, str) and len(search_result) > 0
assert context_tool._run()
assert dialectic_tool._run(query="What does the user like?")
assert search_tool._run(query="coding", limit=5)

File diff suppressed because it is too large Load Diff

View File

@ -39,7 +39,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

@ -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
@ -1275,25 +1305,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

@ -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:

View File

@ -694,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

@ -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

@ -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:
@ -101,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]]:
@ -138,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)
@ -287,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(
@ -411,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(
@ -434,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]:

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

@ -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")

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

@ -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
@ -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

View File

@ -28,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,
)

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:

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

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

@ -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

@ -30,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"
@ -64,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"

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