# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. # Honcho Overview ## What is Honcho? Honcho is an infrastructure layer for building AI agents with memory and social cognition. Its primary purposes include: - Imbuing agents with a sense of identity - Personalizing user experiences through understanding user psychology - 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 Honcho leverages the inherent reasoning capabilities of LLMs to build coherent models of user psychology over time, enabling more personalized and effective AI interactions. ## Core Concepts ### Peer Paradigm Honcho uses a peer-based model where both users and agents are represented as "peers". This unified approach enables: - Multi-participant sessions with mixed human and AI agents - Configurable observation settings (which peers observe which others) - Flexible identity management for all participants ### Key Primitives - **Workspace** (formerly App): The root organizational unit containing all resources - **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. 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: `/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), 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 #### Chat Endpoint (Dialectic agent) (`/peers/{peer_id}/chat`) - Provides bespoke responses informed by the representation - Integrates long-term facts from vector storage - Supports streaming responses - Configurable LLM providers #### Message Processing Pipeline 1. Messages created via API (batch or single) 2. Enqueued for background processing: - `representation`: Update peer's context - `summary`: Create session summaries 3. Session-based queue processing ensures order 4. Results stored internally in vector DB ### Configuration - Hierarchical config: config.toml + environment variables - Database settings with connection pooling - Multiple LLM provider support - Background worker (deriver) settings - Authentication can be toggled on/off ## Development Guide ### Commands - Setup: `uv sync` - Run server: `uv run fastapi dev src/main.py` - Run tests: `uv run pytest tests/` - Run single test: `uv run pytest tests/path/to/test_file.py::test_function` - Linting: `uv run ruff check src/` - Typechecking: `uv run basedpyright` - Format code: `uv run ruff format src/` ### SDK Testing #### TypeScript SDK **🚨 DO NOT RUN `bun test` DIRECTLY. IT WILL NOT WORK. 🚨** The TypeScript SDK tests require a running Honcho server with database and Redis. Running `bun test` alone will fail immediately because there's no server. The tests are orchestrated via pytest which handles all the infrastructure setup. **The ONLY way to run TypeScript SDK tests:** ```bash # From the monorepo root (not from sdks/typescript/) uv run pytest tests/ -k typescript ``` **To type-check the TypeScript SDK (this is fine to run directly):** ```bash cd sdks/typescript && bun run tsc --noEmit ``` ### Code Style - Follow isort conventions with absolute imports preferred - Use explicit type hints with SQLAlchemy mapped_column annotations - snake_case for variables/functions; PascalCase for classes - Line length: 88 chars (Black compatible) - Explicit error handling with appropriate exception types - 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. - **Never write through a read-only session** (`tracked_db(..., read_only=True)`, `get_read_db`, `ReadSessionLocal`). These run in AUTOCOMMIT mode with no transaction: writes are NOT blocked by the database — they silently commit immediately, and `begin_nested()` savepoints break. There is no runtime guard; this is enforced by convention only. Use `read_only=True` strictly for SELECT-only windows; anything that mutates (including get-or-create paths) must use a regular write session. #### Multi-row locking and deadlocks Tables written concurrently by more than one worker — `documents` (deriver, dreamer, scope backfill/removal, reconciler) and `queue` (every deriver replica) — deadlock when two writers touch an overlapping row set in different orders. Rules: - **A multi-row `SELECT ... FOR UPDATE` MUST carry an explicit `ORDER BY `.** Without it Postgres locks in scan order, which differs per plan, so two writers with overlapping sets can cycle. `_apply_document_row_updates` in `src/crud/document.py` is the reference implementation. - **`WHERE id IN (...)` does NOT impose an order**, so sorting the Python list is a no-op — the list order is discarded and the planner picks `Bitmap Heap Scan` (ctid order), `Index Scan` (id order), or `Seq Scan` per invocation. Deterministic ordering requires either a preceding `SELECT ... ORDER BY id FOR UPDATE` or `WHERE id IN (SELECT id ... ORDER BY id FOR UPDATE)`. - **`Document.id` is a random nanoid** (`models.py`), so id order is uncorrelated with physical order — an unordered predicate `UPDATE`/`DELETE` is roughly a coin flip against an id-ordered locker per row pair, not a rare edge case. (`QueueItem.id` is an integer identity, so there id order is also chronological.) - **Prefer no lock at all.** A single `UPDATE ... WHERE ` acquires row locks as it writes and has no separate lock phase to get wrong. Reach for `FOR UPDATE` only when a value must be read, computed in Python, and written back — that read-modify-write is the only reason `_apply_document_row_updates` locks (it replaced a server-side `func.greatest()`), and `populate_existing=True` is required with it so the identity map doesn't serve a stale pre-lock value. Server-side expressions (`func.greatest`, the JSONB `-` operator) avoid the lock entirely; see `_clear_work_unit_retry_attempts` in `src/deriver/queue_manager.py`. - `FOR UPDATE SKIP LOCKED` (the reconciler's claim pattern) never waits, so it cannot be a deadlock partner — but holding those locks across an external call still stalls other writers. See the "never hold a DB session during external calls" rule above. #### Auth scoping - **`allow_member_read=True` (in `require_auth(...)`) is read-only — NEVER set it on a route that mutates state.** It lets a peer-scoped key reach a session route when its peer is an active member of the session, so on a mutating route it would hand any session member write access (message injection, config mutation, deletion). HTTP method is not a reliable read/write signal here (some read routes use POST for a richer body), so this is enforced by an explicit allowlist in `tests/routes/test_auth_route_policy.py` — adding the flag to a new route fails that test until you consciously add the route to `EXPECTED_MEMBER_READ_ROUTES`, and you must never add a mutating method there. - **When a member-read route is keyed by another sub-resource** (e.g. `peers/{peer_id}/config`), the handler must additionally confirm a peer-scoped caller only reads its OWN resource (`jwt_params.p == peer_id`, else raise `AuthenticationException`). Membership grants session access, not access to a co-member's data. See `get_peer_config` in `src/routers/sessions.py`. ### 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 several specialized LLM agents. They share tool definitions and the LLM client abstraction in `src/utils/agent_tools.py` + `src/llm/`. > **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." #### 1. Deriver (`src/deriver/`) **Role**: Memory formation through content ingestion. 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. - **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`). - **Custom instructions**: per-workspace/peer guidance can be threaded into the prompt via reasoning configuration; `DERIVER__MAX_CUSTOM_INSTRUCTIONS_TOKENS` caps the addition (default 2000) and `DERIVER__MAX_INPUT_TOKENS` defaults to 25000 to make room. #### 2. Dialectic (`src/dialectic/`) **Role**: Analysis and recall for answering queries. 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.) - **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`). #### 3. Dreamer (`src/dreamer/`) **Role**: Consolidation and self-improvement of memory. 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 - **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-retry provider selection is pinned via an `AttemptPlan` so stream-final retries don't bounce back to primary after the tool loop has settled on fallback. - **Per-agent model config**: each agent has its own `MODEL_CONFIG` in `src/config.py` with fallback chains (see `ConfiguredModelSettings`, `FallbackModelSettings`). - **Telemetry**: cloudevents in `src/telemetry/events/` cover API routes, dialectic, dream, deletion, reconciliation, representation, and per-call LLM accounting (`llm.py` — `LLMCallCompletedEvent` fires once per provider hit with full cost-attribution context). High-volume events are sampled deterministically per `run_id` via `TelemetrySettings.HIGH_VOLUME_SAMPLE_RATE`. - **Prometheus metrics** (`src/telemetry/prometheus/`): every metric carries a `namespace` label and every recorder is fail-soft (a metrics error never propagates into a request or a worker loop). Counter children with a *bounded* label domain are zero-initialized per process at startup — `initialize_bounded_metrics(instance_type=...)`, called from the `src/main.py` lifespan (`api`) and `src/deriver/__main__.py` (`deriver`) — so an absent series means a broken scrape rather than "nothing happened". Two consequences worth knowing before touching telemetry: - **Adding a `BaseEvent` subclass requires adding its `_event_type` to `ALL_EVENT_TYPES`** in `src/telemetry/events/__init__.py` (and to `HIGH_VOLUME_EVENT_TYPES` if `_volume_class == "high_volume"`). Enforced by the drift guards in `tests/telemetry/test_metric_zero_init.py`, which assert set-equality against the discovered subclasses. - **A service-wide, non-additive gauge must be refreshed by every replica on its own timer**, and aggregated with `max()`/`avg()`, never `sum()`. `message_embeddings_pending` is the example: it reports a DB-global count, so it is driven from `ReconcilerScheduler._scheduler_loop` (runs on all replicas) rather than from the work-unit-deduped reconciliation cycle — otherwise, combined with the zero-init, every replica that never won the work unit would export a confident permanent `0`. ### Project Structure ``` src/ ├── 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 provider client (configurable dimensions │ # via EMBEDDING_MODEL_CONFIG__DIMENSIONS_MODE) ├── 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; 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 - All tables use text IDs (nanoid format) as primary keys - Composite foreign keys for multi-tenant relationships - Feature flags on workspace, peer, and session levels - Token counting on messages for usage tracking - JSONB metadata fields for extensibility - HNSW indexes for vector similarity search ### Key Architectural Decisions 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 - Custom exceptions defined in src/exceptions.py - Use specific exception types (ResourceNotFoundException, ValidationException, etc.) - Proper logging with context instead of print statements - Global exception handlers defined in main.py - See docs/contributing/error-handling.mdx for details ### Notes - Always use `uv run` or `uv` to prefix any commands related to python to ensure you use the virtual environment