Merge branch 'main' into docs/paperclip-integration-refresh
This commit is contained in:
commit
e083d5c7ab
175
.env.template
175
.env.template
|
|
@ -57,160 +57,135 @@ AUTH_USE_AUTH=false
|
|||
# AUTH_JWT_SECRET=your-secret-key-here
|
||||
|
||||
# =============================================================================
|
||||
# LLM API Keys (REQUIRED for full functionality)
|
||||
# LLM Provider (REQUIRED)
|
||||
# =============================================================================
|
||||
# OpenAI API key for embeddings
|
||||
LLM_OPENAI_API_KEY=your-openai-api-key-here
|
||||
|
||||
# Anthropic API key for dialectic and deriver functionality
|
||||
LLM_ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
||||
|
||||
# Google API key for summarization (if using Gemini)
|
||||
# LLM_GEMINI_API_KEY=your-google-api-key-here
|
||||
|
||||
# Groq API key for query generation (if using Groq)
|
||||
# LLM_GROQ_API_KEY=your-groq-api-key-here
|
||||
|
||||
# Base URL for OpenAI Compatible Requests if you want to use a different provider
|
||||
# LLM_OPENAI_COMPATIBLE_BASE_URL=
|
||||
# LLM_OPENAI_COMPATIBLE_API_KEY=
|
||||
|
||||
# Separate vLLM endpoint (for local models)
|
||||
# LLM_VLLM_API_KEY=
|
||||
# LLM_VLLM_BASE_URL=
|
||||
|
||||
# =============================================================================
|
||||
# LLM Configuration
|
||||
# =============================================================================
|
||||
# Global LLM settings
|
||||
# Honcho uses LLMs for memory extraction, summarization, dialectic chat, and
|
||||
# dream consolidation. The server will fail to start without a provider configured.
|
||||
#
|
||||
# Quick start: uncomment the two lines below, set your endpoint and API key,
|
||||
# then uncomment the provider/model lines in each feature section below.
|
||||
# Any OpenAI-compatible endpoint works (OpenRouter, Together, Fireworks, etc.).
|
||||
# Models must support tool calling (function calling).
|
||||
#
|
||||
LLM_OPENAI_COMPATIBLE_BASE_URL=https://openrouter.ai/api/v1
|
||||
LLM_OPENAI_COMPATIBLE_API_KEY=your-api-key-here
|
||||
#
|
||||
# Provider options for each feature: custom, vllm, google, anthropic, openai, groq
|
||||
# "custom" routes through the OpenAI-compatible endpoint above.
|
||||
# Model name format depends on your provider (e.g., OpenRouter: vendor/model-name).
|
||||
#
|
||||
# ---- Alternative: vLLM self-hosted ------------------------------------------
|
||||
# LLM_VLLM_BASE_URL=http://localhost:8000/v1
|
||||
# LLM_VLLM_API_KEY=not-needed
|
||||
#
|
||||
# ---- Alternative: direct vendor keys (no endpoint needed) -------------------
|
||||
# LLM_GEMINI_API_KEY=
|
||||
# LLM_ANTHROPIC_API_KEY=
|
||||
# LLM_OPENAI_API_KEY=
|
||||
# LLM_GROQ_API_KEY=
|
||||
#
|
||||
# ---- General LLM settings ---------------------------------------------------
|
||||
# Embedding provider — defaults to openai (requires LLM_OPENAI_API_KEY).
|
||||
# Set to openrouter to route embeddings through your custom endpoint instead.
|
||||
LLM_EMBEDDING_PROVIDER=openrouter
|
||||
# LLM_DEFAULT_MAX_TOKENS=2500
|
||||
# LLM_EMBEDDING_PROVIDER=openai
|
||||
# LLM_MAX_TOOL_OUTPUT_CHARS=10000 # Max chars for tool output (~2500 tokens)
|
||||
# LLM_MAX_MESSAGE_CONTENT_CHARS=2000 # Max chars per message in tool results
|
||||
# LLM_MAX_TOOL_OUTPUT_CHARS=10000
|
||||
# LLM_MAX_MESSAGE_CONTENT_CHARS=2000
|
||||
|
||||
# =============================================================================
|
||||
# Deriver (Background Worker) Settings
|
||||
# Deriver (Background Worker)
|
||||
# =============================================================================
|
||||
# DERIVER_ENABLED=true
|
||||
DERIVER_PROVIDER=custom
|
||||
DERIVER_MODEL=your-model-here # e.g. google/gemini-2.5-flash
|
||||
# DERIVER_THINKING_BUDGET_TOKENS=1024 # gt=0 required; omit for non-thinking models
|
||||
# DERIVER_WORKERS=1
|
||||
# DERIVER_POLLING_SLEEP_INTERVAL_SECONDS=1.0
|
||||
# DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5
|
||||
# DERIVER_QUEUE_ERROR_RETENTION_SECONDS=2592000 # 30 days
|
||||
# DERIVER_PROVIDER=google
|
||||
# DERIVER_MODEL=gemini-2.5-flash-lite
|
||||
# DERIVER_QUEUE_ERROR_RETENTION_SECONDS=2592000
|
||||
# DERIVER_TEMPERATURE=
|
||||
# DERIVER_DEDUPLICATE=true
|
||||
# DERIVER_MAX_OUTPUT_TOKENS=4096
|
||||
# DERIVER_THINKING_BUDGET_TOKENS=1024
|
||||
# DERIVER_LOG_OBSERVATIONS=false
|
||||
# DERIVER_MAX_INPUT_TOKENS=23000
|
||||
# DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=100
|
||||
# DERIVER_REPRESENTATION_BATCH_MAX_TOKENS=1024
|
||||
# DERIVER_FLUSH_ENABLED=false # Bypass batch token threshold, process work immediately
|
||||
# DERIVER_BACKUP_PROVIDER=
|
||||
# DERIVER_BACKUP_MODEL=
|
||||
# DERIVER_FLUSH_ENABLED=false
|
||||
|
||||
# =============================================================================
|
||||
# Peer Card Configuration
|
||||
# Peer Card
|
||||
# =============================================================================
|
||||
# PEER_CARD_ENABLED=true
|
||||
|
||||
# =============================================================================
|
||||
# Dialectic Settings
|
||||
# Dialectic
|
||||
# =============================================================================
|
||||
# Global dialectic settings
|
||||
# DIALECTIC_MAX_OUTPUT_TOKENS=8192
|
||||
# DIALECTIC_MAX_INPUT_TOKENS=100000
|
||||
# DIALECTIC_HISTORY_TOKEN_LIMIT=8192
|
||||
# DIALECTIC_SESSION_HISTORY_MAX_TOKENS=4096
|
||||
|
||||
# Per-level settings (reasoning_level parameter in API)
|
||||
# Each level can have its own provider, model, thinking budget, tool iterations, and max output tokens
|
||||
# MAX_OUTPUT_TOKENS is optional per level; if not set, uses global DIALECTIC_MAX_OUTPUT_TOKENS
|
||||
|
||||
# Minimal level
|
||||
# DIALECTIC_LEVELS__minimal__PROVIDER=google
|
||||
# DIALECTIC_LEVELS__minimal__MODEL=gemini-2.5-flash-lite
|
||||
#
|
||||
# Per-level provider, model, and tuning:
|
||||
DIALECTIC_LEVELS__minimal__PROVIDER=custom
|
||||
DIALECTIC_LEVELS__minimal__MODEL=your-model-here # e.g. google/gemini-2.5-flash
|
||||
# DIALECTIC_LEVELS__minimal__THINKING_BUDGET_TOKENS=0
|
||||
# DIALECTIC_LEVELS__minimal__MAX_TOOL_ITERATIONS=1
|
||||
# DIALECTIC_LEVELS__minimal__MAX_OUTPUT_TOKENS=250 # Reduced output for cost savings
|
||||
|
||||
# Low level
|
||||
# DIALECTIC_LEVELS__low__PROVIDER=google
|
||||
# DIALECTIC_LEVELS__low__MODEL=gemini-2.5-flash-lite
|
||||
# DIALECTIC_LEVELS__minimal__MAX_OUTPUT_TOKENS=250
|
||||
DIALECTIC_LEVELS__low__PROVIDER=custom
|
||||
DIALECTIC_LEVELS__low__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__low__THINKING_BUDGET_TOKENS=0
|
||||
# DIALECTIC_LEVELS__low__MAX_TOOL_ITERATIONS=5
|
||||
# DIALECTIC_LEVELS__low__MAX_OUTPUT_TOKENS=8192 # Optional: override global default
|
||||
|
||||
# Medium level
|
||||
# DIALECTIC_LEVELS__medium__PROVIDER=anthropic
|
||||
# DIALECTIC_LEVELS__medium__MODEL=claude-haiku-4-5
|
||||
# DIALECTIC_LEVELS__medium__THINKING_BUDGET_TOKENS=1024
|
||||
DIALECTIC_LEVELS__medium__PROVIDER=custom
|
||||
DIALECTIC_LEVELS__medium__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__medium__THINKING_BUDGET_TOKENS=0
|
||||
# DIALECTIC_LEVELS__medium__MAX_TOOL_ITERATIONS=2
|
||||
# DIALECTIC_LEVELS__medium__MAX_OUTPUT_TOKENS=8192 # Optional: override global default
|
||||
# DIALECTIC_LEVELS__medium__TOOL_CHOICE=
|
||||
|
||||
# High level
|
||||
# DIALECTIC_LEVELS__high__PROVIDER=anthropic
|
||||
# DIALECTIC_LEVELS__high__MODEL=claude-haiku-4-5
|
||||
# DIALECTIC_LEVELS__high__THINKING_BUDGET_TOKENS=1024
|
||||
DIALECTIC_LEVELS__high__PROVIDER=custom
|
||||
DIALECTIC_LEVELS__high__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__high__THINKING_BUDGET_TOKENS=0
|
||||
# DIALECTIC_LEVELS__high__MAX_TOOL_ITERATIONS=4
|
||||
# DIALECTIC_LEVELS__high__MAX_OUTPUT_TOKENS=8192 # Optional: override global default
|
||||
|
||||
# Max level
|
||||
# DIALECTIC_LEVELS__max__PROVIDER=anthropic
|
||||
# DIALECTIC_LEVELS__max__MODEL=claude-haiku-4-5
|
||||
# DIALECTIC_LEVELS__max__THINKING_BUDGET_TOKENS=2048
|
||||
DIALECTIC_LEVELS__max__PROVIDER=custom
|
||||
DIALECTIC_LEVELS__max__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__max__THINKING_BUDGET_TOKENS=0
|
||||
# DIALECTIC_LEVELS__max__MAX_TOOL_ITERATIONS=10
|
||||
# DIALECTIC_LEVELS__max__MAX_OUTPUT_TOKENS=8192 # Optional: override global default
|
||||
# Optional backup per level (must set both or neither):
|
||||
# DIALECTIC_LEVELS__max__BACKUP_PROVIDER=google
|
||||
# DIALECTIC_LEVELS__max__BACKUP_MODEL=gemini-2.5-pro
|
||||
|
||||
# =============================================================================
|
||||
# Summary Settings
|
||||
# Summary
|
||||
# =============================================================================
|
||||
# SUMMARY_ENABLED=true
|
||||
SUMMARY_PROVIDER=custom
|
||||
SUMMARY_MODEL=your-model-here # e.g. google/gemini-2.5-flash
|
||||
# SUMMARY_THINKING_BUDGET_TOKENS=512 # gt=0 required; omit for non-thinking models
|
||||
# SUMMARY_MESSAGES_PER_SHORT_SUMMARY=20
|
||||
# SUMMARY_MESSAGES_PER_LONG_SUMMARY=60
|
||||
# SUMMARY_PROVIDER=google
|
||||
# SUMMARY_MODEL=gemini-2.5-flash
|
||||
# SUMMARY_MAX_TOKENS_SHORT=1000
|
||||
# SUMMARY_MAX_TOKENS_LONG=4000
|
||||
# SUMMARY_THINKING_BUDGET_TOKENS=512
|
||||
# SUMMARY_BACKUP_PROVIDER=
|
||||
# SUMMARY_BACKUP_MODEL=
|
||||
|
||||
# =============================================================================
|
||||
# Dream Settings
|
||||
# Dream
|
||||
# =============================================================================
|
||||
# DREAM_ENABLED=true
|
||||
DREAM_PROVIDER=custom
|
||||
DREAM_MODEL=your-model-here # e.g. google/gemini-2.5-flash
|
||||
DREAM_DEDUCTION_MODEL=your-model-here
|
||||
DREAM_INDUCTION_MODEL=your-model-here
|
||||
# DREAM_THINKING_BUDGET_TOKENS=8192 # gt=0 required; omit for non-thinking models
|
||||
# DREAM_DOCUMENT_THRESHOLD=50
|
||||
# DREAM_IDLE_TIMEOUT_MINUTES=60
|
||||
# DREAM_MIN_HOURS_BETWEEN_DREAMS=8
|
||||
# DREAM_ENABLED_TYPES=["omni"]
|
||||
# DREAM_PROVIDER=anthropic
|
||||
# DREAM_MODEL=claude-sonnet-4-20250514
|
||||
# DREAM_MAX_OUTPUT_TOKENS=16384
|
||||
# DREAM_THINKING_BUDGET_TOKENS=8192
|
||||
# DREAM_MAX_TOOL_ITERATIONS=20
|
||||
# DREAM_HISTORY_TOKEN_LIMIT=16384
|
||||
# DREAM_BACKUP_PROVIDER=
|
||||
# DREAM_BACKUP_MODEL=
|
||||
|
||||
# Specialist models (use same provider as main model)
|
||||
# DREAM_DEDUCTION_MODEL=claude-haiku-4-5
|
||||
# DREAM_INDUCTION_MODEL=claude-haiku-4-5
|
||||
|
||||
# Dream Surprisal Settings (Tree-based observation sampling for targeted reasoning)
|
||||
#
|
||||
# Surprisal sampling (advanced):
|
||||
# DREAM_SURPRISAL__ENABLED=false
|
||||
# DREAM_SURPRISAL__TREE_TYPE=kdtree # Options: kdtree, balltree, rptree, covertree, lsh, graph, prototype
|
||||
# DREAM_SURPRISAL__TREE_K=5 # Number of neighbors for kNN-based trees
|
||||
# DREAM_SURPRISAL__SAMPLING_STRATEGY=recent # Options: recent, random, all
|
||||
# DREAM_SURPRISAL__SAMPLE_SIZE=200 # Number of observations to sample for tree building
|
||||
# DREAM_SURPRISAL__TOP_PERCENT_SURPRISAL=0.10 # Top percentage of observations (0.10 = top 10%)
|
||||
# DREAM_SURPRISAL__MIN_HIGH_SURPRISAL_FOR_REPLACE=10 # Hybrid mode: min observations to replace standard questions
|
||||
# DREAM_SURPRISAL__INCLUDE_LEVELS=["explicit","deductive"] # Observation levels to include
|
||||
# DREAM_SURPRISAL__TREE_TYPE=kdtree
|
||||
# DREAM_SURPRISAL__TREE_K=5
|
||||
# DREAM_SURPRISAL__SAMPLING_STRATEGY=recent
|
||||
# DREAM_SURPRISAL__SAMPLE_SIZE=200
|
||||
# DREAM_SURPRISAL__TOP_PERCENT_SURPRISAL=0.10
|
||||
# DREAM_SURPRISAL__MIN_HIGH_SURPRISAL_FOR_REPLACE=10
|
||||
# DREAM_SURPRISAL__INCLUDE_LEVELS=["explicit","deductive"]
|
||||
|
||||
# =============================================================================
|
||||
# Webhook Settings
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
.worktrees/
|
||||
api/**/*.db
|
||||
api/data
|
||||
api/docker-compose.yml
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ git commit -m "docs(readme): update installation instructions"
|
|||
### Python Code Style
|
||||
|
||||
- Follow [PEP 8](https://www.python.org/dev/peps/pep-0008/) style guidelines
|
||||
- Use [Black](https://black.readthedocs.io/) for code formatting (we may add this to CI in the future)
|
||||
- Use [ruff](https://docs.astral.sh/ruff/) for linting and code formatting
|
||||
- Use type hints where possible
|
||||
- Write docstrings for functions and classes using Google style docstrings
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ RUN addgroup --system app && adduser --system --group app && mkdir -p /tmp/uv-ca
|
|||
COPY --chown=app:app src/ /app/src/
|
||||
COPY --chown=app:app migrations/ /app/migrations/
|
||||
COPY --chown=app:app scripts/ /app/scripts/
|
||||
COPY --chown=app:app docker/ /app/docker/
|
||||
COPY --chown=app:app alembic.ini /app/alembic.ini
|
||||
# Copy config files - this will copy config.toml if it exists, and config.toml.example
|
||||
COPY --chown=app:app config.toml* /app/
|
||||
|
|
@ -51,6 +52,6 @@ USER app
|
|||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/openapi.json')" || exit 1
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||
|
||||
CMD ["fastapi", "run", "--host", "0.0.0.0", "src/main.py"]
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -162,8 +162,8 @@ Server.
|
|||
|
||||
Honcho is developed using [python](https://www.python.org/) and [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
The minimum python version is `3.9`
|
||||
The minimum uv version is `0.4.9`
|
||||
The minimum python version is `3.10`
|
||||
The minimum uv version is `0.5.0`
|
||||
|
||||
### Setup
|
||||
|
||||
|
|
@ -221,11 +221,11 @@ Below are the required configurations:
|
|||
```env
|
||||
DB_CONNECTION_URI= # Connection uri for a postgres database (with postgresql+psycopg prefix)
|
||||
|
||||
# LLM Provider API Keys (at least one required depending on your configuration)
|
||||
LLM_ANTHROPIC_API_KEY= # API Key for Anthropic (used for dialectic by default)
|
||||
LLM_OPENAI_API_KEY= # API Key for OpenAI (optional, for embeddings if EMBED_MESSAGES=true)
|
||||
LLM_GEMINI_API_KEY= # API Key for Google Gemini (used for summary/deriver by default)
|
||||
LLM_GROQ_API_KEY= # API Key for Groq (used for query generation by default)
|
||||
# LLM Provider API Keys
|
||||
LLM_GEMINI_API_KEY= # API Key for Google Gemini (used for deriver, summary, and dialectic minimal/low by default)
|
||||
LLM_ANTHROPIC_API_KEY= # API Key for Anthropic (used for dialectic medium/high/max and dream by default)
|
||||
LLM_OPENAI_API_KEY= # API Key for OpenAI (used for embeddings when EMBED_MESSAGES=true)
|
||||
LLM_GROQ_API_KEY= # API Key for Groq (optional)
|
||||
```
|
||||
|
||||
> Note that the `DB_CONNECTION_URI` must have the prefix `postgresql+psycopg` to
|
||||
|
|
@ -455,14 +455,14 @@ If you have this in `config.toml`:
|
|||
|
||||
```toml
|
||||
[db]
|
||||
CONNECTION_URI = "postgresql://localhost/honcho_dev"
|
||||
CONNECTION_URI = "postgresql+psycopg://localhost/honcho_dev"
|
||||
POOL_SIZE = 10
|
||||
```
|
||||
|
||||
You can override just the connection URI in production:
|
||||
|
||||
```bash
|
||||
export DB_CONNECTION_URI="postgresql://prod-server/honcho_prod"
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -55,17 +55,21 @@ EMBEDDING_PROVIDER = "openai"
|
|||
MAX_TOOL_OUTPUT_CHARS = 10000 # Max chars for tool output (~2500 tokens)
|
||||
MAX_MESSAGE_CONTENT_CHARS = 2000 # Max chars per message in tool results
|
||||
|
||||
# API Keys for LLM providers
|
||||
# ANTHROPIC_API_KEY = "your-api-key"
|
||||
# OPENAI_API_KEY = "your-api-key"
|
||||
# OPENAI_COMPATIBLE_API_KEY = "your-api-key"
|
||||
# GEMINI_API_KEY = "your-api-key"
|
||||
# GROQ_API_KEY = "your-api-key"
|
||||
# OPENAI_COMPATIBLE_BASE_URL = "your-base-url"
|
||||
# API Keys for LLM providers (set the ones you need)
|
||||
# GEMINI_API_KEY = "your-api-key" # Default: deriver, summary, dialectic minimal/low
|
||||
# ANTHROPIC_API_KEY = "your-api-key" # Default: dialectic medium/high/max, dream
|
||||
# OPENAI_API_KEY = "your-api-key" # Default: embeddings
|
||||
# GROQ_API_KEY = "your-api-key" # Not used by default
|
||||
|
||||
# Separate vLLM endpoint (for local models)
|
||||
# VLLM_API_KEY = "your-api-key"
|
||||
# VLLM_BASE_URL = "your-base-url"
|
||||
# OpenAI-compatible endpoint (OpenRouter, Together, Fireworks, LiteLLM, etc.)
|
||||
# Set provider to "custom" in feature config to route calls through this endpoint.
|
||||
# OPENAI_COMPATIBLE_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
# OPENAI_COMPATIBLE_API_KEY = "your-api-key"
|
||||
|
||||
# vLLM endpoint (for self-hosted models)
|
||||
# Set provider to "vllm" in feature config to route calls through this endpoint.
|
||||
# VLLM_BASE_URL = "http://localhost:8000/v1"
|
||||
# VLLM_API_KEY = "not-needed"
|
||||
|
||||
# Deriver settings
|
||||
[deriver]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
# Honcho Docker Compose
|
||||
#
|
||||
# Usage:
|
||||
# cp docker-compose.yml.example docker-compose.yml
|
||||
# cp .env.template .env # edit with your provider config
|
||||
# docker compose up -d --build
|
||||
#
|
||||
# By default, ports are bound to 127.0.0.1 (localhost only).
|
||||
# For development, uncomment the source mounts and monitoring services below.
|
||||
|
||||
services:
|
||||
api:
|
||||
image: honcho:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
|
@ -11,16 +20,20 @@ services:
|
|||
redis:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- 8000:8000
|
||||
volumes:
|
||||
- .:/app
|
||||
- venv:/app/.venv
|
||||
- "127.0.0.1:8000:8000"
|
||||
# -- Development: mount source for live reload --
|
||||
# volumes:
|
||||
# - .:/app
|
||||
# - venv:/app/.venv
|
||||
environment:
|
||||
- DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres
|
||||
- CACHE_URL=redis://redis:6379/0?suppress=true
|
||||
- CACHE_ENABLED=true
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
|
||||
deriver:
|
||||
build:
|
||||
context: .
|
||||
|
|
@ -31,27 +44,29 @@ services:
|
|||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
- venv:/app/.venv
|
||||
# -- Development: mount source for live reload --
|
||||
# volumes:
|
||||
# - .:/app
|
||||
# - venv:/app/.venv
|
||||
environment:
|
||||
- DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres
|
||||
- CACHE_URL=redis://redis:6379/0?suppress=true
|
||||
- METRICS_ENABLED=true
|
||||
- CACHE_ENABLED=true
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
|
||||
database:
|
||||
image: pgvector/pgvector:pg15
|
||||
restart: always
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 5432:5432
|
||||
command: ["postgres", "-c", "max_connections=800"]
|
||||
- "127.0.0.1:5432:5432"
|
||||
command: ["postgres", "-c", "max_connections=200"]
|
||||
environment:
|
||||
- POSTGRES_DB=postgres
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
|
|
@ -61,44 +76,49 @@ services:
|
|||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:8.2
|
||||
restart: always
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 6379:6379
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- ./redis-data:/data
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "redis-cli ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
prometheus:
|
||||
image: prom/prometheus:v3.2.1
|
||||
ports:
|
||||
- 9090:9090
|
||||
volumes:
|
||||
- ./docker/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus-data:/prometheus
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
grafana:
|
||||
image: grafana/grafana:11.4.0
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
- GF_AUTH_ANONYMOUS_ENABLED=true
|
||||
- GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
|
||||
volumes:
|
||||
- ./grafana-data:/var/lib/grafana
|
||||
- ./docker/grafana-datasource.yml:/etc/grafana/provisioning/datasources/datasource.yml:ro
|
||||
depends_on:
|
||||
prometheus:
|
||||
condition: service_started
|
||||
|
||||
# -- Development: monitoring stack (uncomment to enable) --
|
||||
# prometheus:
|
||||
# image: prom/prometheus:v3.2.1
|
||||
# ports:
|
||||
# - "127.0.0.1:9090:9090"
|
||||
# volumes:
|
||||
# - ./docker/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
# - prometheus-data:/prometheus
|
||||
# depends_on:
|
||||
# api:
|
||||
# condition: service_started
|
||||
# grafana:
|
||||
# image: grafana/grafana:11.4.0
|
||||
# ports:
|
||||
# - "127.0.0.1:3000:3000"
|
||||
# environment:
|
||||
# - GF_SECURITY_ADMIN_USER=admin
|
||||
# - GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
# - GF_AUTH_ANONYMOUS_ENABLED=true
|
||||
# - GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
|
||||
# volumes:
|
||||
# - ./docker/grafana-datasource.yml:/etc/grafana/provisioning/datasources/datasource.yml:ro
|
||||
# depends_on:
|
||||
# prometheus:
|
||||
# condition: service_started
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
venv:
|
||||
prometheus-data:
|
||||
redis-data:
|
||||
# -- Development: uncomment if using source mounts --
|
||||
# venv:
|
||||
# prometheus-data:
|
||||
|
|
|
|||
|
|
@ -144,7 +144,8 @@
|
|||
"group": "Self-Hosting",
|
||||
"pages": [
|
||||
"v3/contributing/self-hosting",
|
||||
"v3/contributing/configuration"
|
||||
"v3/contributing/configuration",
|
||||
"v3/contributing/troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -96,14 +96,14 @@ If you have this in `config.toml`:
|
|||
|
||||
```toml
|
||||
[db]
|
||||
CONNECTION_URI = "postgresql://localhost/honcho_dev"
|
||||
CONNECTION_URI = "postgresql+psycopg://localhost/honcho_dev"
|
||||
POOL_SIZE = 10
|
||||
```
|
||||
|
||||
You can override just the connection URI in production:
|
||||
|
||||
```bash
|
||||
export DB_CONNECTION_URI="postgresql://prod-server/honcho_prod"
|
||||
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.
|
||||
|
|
@ -149,7 +149,7 @@ LOCAL_METRICS_FILE=metrics.jsonl
|
|||
DB_CONNECTION_URI=postgresql+psycopg://username:password@host:port/database
|
||||
|
||||
# Example for local development
|
||||
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/honcho
|
||||
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres
|
||||
|
||||
# Example for production
|
||||
DB_CONNECTION_URI=postgresql+psycopg://honcho_user:secure_password@db.example.com:5432/honcho_prod
|
||||
|
|
|
|||
|
|
@ -135,24 +135,21 @@ Download from [postgresql.org](https://www.postgresql.org/download/windows/)
|
|||
|
||||
```bash
|
||||
docker run --name honcho-db \
|
||||
-e POSTGRES_DB=honcho \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-p 5432:5432 \
|
||||
-d pgvector/pgvector:pg15
|
||||
```
|
||||
|
||||
### 3. Create Database and Enable Extensions
|
||||
### 3. Enable Extensions
|
||||
|
||||
Connect to PostgreSQL and set up the database:
|
||||
Connect to PostgreSQL and enable pgvector:
|
||||
|
||||
```bash
|
||||
# Connect to PostgreSQL
|
||||
psql -U postgres
|
||||
|
||||
# Create database and enable extensions
|
||||
CREATE DATABASE honcho;
|
||||
\c honcho
|
||||
# Enable extensions on the default database
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
\q
|
||||
|
|
@ -170,7 +167,7 @@ Edit `.env` with your configuration:
|
|||
|
||||
```bash
|
||||
# Database connection
|
||||
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/honcho
|
||||
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres
|
||||
|
||||
# Optional API keys (required for LLM features)
|
||||
OPENAI_API_KEY=your-openai-api-key
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -20,9 +20,9 @@ By the end of this guide, you'll have:
|
|||
Before you begin, ensure you have the following installed:
|
||||
|
||||
### Required Software
|
||||
- **uv** - Python package manager: `pip install uv` (manages Python installations automatically)
|
||||
- **uv** - Python package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh` or `brew install uv`
|
||||
- **Git** - [Download from git-scm.com](https://git-scm.com/downloads)
|
||||
- **Docker** (optional) - [Download from docker.com](https://www.docker.com/products/docker-desktop/)
|
||||
- **Docker** (required for Docker setup, not needed for manual setup) - [Download from docker.com](https://www.docker.com/products/docker-desktop/)
|
||||
|
||||
### Database Options
|
||||
You'll need a PostgreSQL database with the pgvector extension. Choose one:
|
||||
|
|
@ -32,9 +32,48 @@ You'll need a PostgreSQL database with the pgvector extension. Choose one:
|
|||
- **Railway** - Simple cloud PostgreSQL hosting
|
||||
- **Your own PostgreSQL server**
|
||||
|
||||
## LLM Setup
|
||||
|
||||
Honcho uses LLMs for memory extraction, summarization, dialectic chat, and dreaming. The server will **fail to start** without a provider configured.
|
||||
|
||||
You need one API key and one model. Any OpenAI-compatible endpoint works — OpenRouter, Together, Fireworks, Ollama, vLLM, or a direct vendor API. Models must support tool calling (function calling).
|
||||
|
||||
The `.env.template` has provider and model lines ready for each feature. After copying it to `.env`, you need to set three things:
|
||||
|
||||
```bash
|
||||
# 1. Your endpoint and API key (already uncommented in the template)
|
||||
LLM_OPENAI_COMPATIBLE_BASE_URL=https://openrouter.ai/api/v1
|
||||
LLM_OPENAI_COMPATIBLE_API_KEY=sk-or-v1-...
|
||||
|
||||
# 2. Replace "your-model-here" everywhere with your model
|
||||
# (these are spread across the Deriver, Dialectic, Summary, and Dream sections)
|
||||
DERIVER_MODEL=google/gemini-2.5-flash # e.g. google/gemini-2.5-flash
|
||||
SUMMARY_MODEL=google/gemini-2.5-flash
|
||||
DREAM_MODEL=google/gemini-2.5-flash
|
||||
DIALECTIC_LEVELS__minimal__MODEL=google/gemini-2.5-flash
|
||||
# ... same for low, medium, high, max
|
||||
|
||||
# 3. Everything else is already configured:
|
||||
# - PROVIDER=custom for all features (routes through your endpoint)
|
||||
# - THINKING_BUDGET_TOKENS=0 (correct for non-Anthropic models)
|
||||
# - LLM_EMBEDDING_PROVIDER=openrouter (uses same endpoint for embeddings)
|
||||
```
|
||||
|
||||
Use find-and-replace to swap all `your-model-here` with your chosen model in one step.
|
||||
|
||||
<Info>
|
||||
For recommended model tiers per feature, using multiple providers, or direct vendor API keys, see the [Configuration Guide](./configuration#llm-configuration).
|
||||
</Info>
|
||||
|
||||
<Info>
|
||||
**Community quick-start**: [elkimek/honcho-self-hosted](https://github.com/elkimek/honcho-self-hosted) provides a one-command installer with pre-configured model tiers, interactive provider setup, and Hermes Agent integration.
|
||||
</Info>
|
||||
|
||||
## Docker Setup (Recommended)
|
||||
|
||||
The easiest way to get started is using Docker Compose, which handles both the database and Honcho server.
|
||||
Docker Compose handles the database, Redis, and Honcho server. The compose file **builds the image from source** (there is no pre-built image on Docker Hub). This requires Docker with BuildKit enabled — see [Troubleshooting](./troubleshooting#docker-build-fails-with-permission-errors) if the build fails.
|
||||
|
||||
The compose file is production-oriented by default (ports bound to `127.0.0.1`, restart policies, caching enabled). For development, uncomment the source mounts and monitoring services inside the file.
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
|
|
@ -51,45 +90,37 @@ Copy the example environment file and configure it:
|
|||
cp .env.template .env
|
||||
```
|
||||
|
||||
Edit `.env` and set your API keys (if using LLM features):
|
||||
|
||||
```bash
|
||||
# Optional API keys (required for LLM features)
|
||||
OPENAI_API_KEY=your-openai-api-key
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||
|
||||
# Database will be created automatically by Docker
|
||||
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres
|
||||
|
||||
# Disable auth for local development
|
||||
AUTH_USE_AUTH=false
|
||||
```
|
||||
Edit `.env` and configure your LLM provider — see [LLM Setup](#llm-setup) above. The database connection is set in the compose file. Auth is disabled by default (`AUTH_USE_AUTH=false`).
|
||||
|
||||
### 3. Start the Services
|
||||
|
||||
```bash
|
||||
# Copy the example docker-compose file
|
||||
cp docker-compose.yml.example docker-compose.yml
|
||||
|
||||
# Start PostgreSQL and Honcho
|
||||
docker compose up -d
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### 4. Verify It's Working
|
||||
The first build takes a few minutes (compiling from source). Subsequent starts are fast.
|
||||
|
||||
Check that both services are running:
|
||||
This starts four services: **api** (port 8000), **deriver** (background worker), **database** (PostgreSQL with pgvector, port 5432), and **redis** (port 6379). All ports are bound to `127.0.0.1`. Redis caching is enabled by default.
|
||||
|
||||
For development, uncomment the source mount and monitoring sections inside `docker-compose.yml` to enable live reload, Prometheus, and Grafana.
|
||||
|
||||
### 4. Verify
|
||||
|
||||
Migrations run automatically on startup.
|
||||
|
||||
```bash
|
||||
# Check all containers are running
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Test the Honcho API:
|
||||
|
||||
```bash
|
||||
# Health check (confirms the process is up)
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# Check the deriver is processing (look for "polling" or "processing" in logs)
|
||||
docker compose logs deriver --tail 20
|
||||
```
|
||||
|
||||
You should see a response indicating the service is healthy.
|
||||
For a full end-to-end test, see [Verify Your Setup](#verify-your-setup) below.
|
||||
|
||||
## Manual Setup
|
||||
|
||||
|
|
@ -134,26 +165,22 @@ Download from [postgresql.org](https://www.postgresql.org/download/windows/)
|
|||
|
||||
```bash
|
||||
docker run --name honcho-db \
|
||||
-e POSTGRES_DB=honcho \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-p 5432:5432 \
|
||||
-d pgvector/pgvector:pg15
|
||||
```
|
||||
|
||||
### 3. Create Database and Enable Extensions
|
||||
### 3. Enable Extensions
|
||||
|
||||
Connect to PostgreSQL and set up the database:
|
||||
Connect to PostgreSQL and enable pgvector:
|
||||
|
||||
```bash
|
||||
# Connect to PostgreSQL
|
||||
psql -U postgres
|
||||
|
||||
# Create database and enable extensions
|
||||
CREATE DATABASE honcho;
|
||||
\c honcho
|
||||
# Enable the pgvector extension on the default database
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
\q
|
||||
```
|
||||
|
||||
|
|
@ -165,17 +192,10 @@ Create a `.env` file with your settings:
|
|||
cp .env.template .env
|
||||
```
|
||||
|
||||
Edit `.env` with your configuration:
|
||||
Edit `.env` — configure your LLM provider (see [LLM Setup](#llm-setup) above) and set the database connection:
|
||||
|
||||
```bash
|
||||
# Database connection
|
||||
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/honcho
|
||||
|
||||
# Optional API keys (required for LLM features)
|
||||
OPENAI_API_KEY=your-openai-api-key
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||
|
||||
# Development settings
|
||||
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres
|
||||
AUTH_USE_AUTH=false
|
||||
LOG_LEVEL=DEBUG
|
||||
```
|
||||
|
|
@ -191,11 +211,21 @@ uv run alembic upgrade head
|
|||
|
||||
```bash
|
||||
# Start the development server
|
||||
fastapi dev src/main.py
|
||||
uv run fastapi dev src/main.py
|
||||
```
|
||||
|
||||
The server will be available at `http://localhost:8000`.
|
||||
|
||||
### 7. Start the Background Worker (Deriver)
|
||||
|
||||
In a **separate terminal**, start the deriver background worker:
|
||||
|
||||
```bash
|
||||
uv run python -m src.deriver
|
||||
```
|
||||
|
||||
The deriver is essential for Honcho's core functionality. It processes incoming messages to extract observations, build peer representations, generate session summaries, and run dream consolidation. Without it, messages will be stored but no memory or reasoning will occur.
|
||||
|
||||
## Cloud Database Setup
|
||||
|
||||
If you prefer to use a managed PostgreSQL service:
|
||||
|
|
@ -206,7 +236,6 @@ If you prefer to use a managed PostgreSQL service:
|
|||
2. **Enable pgvector extension** in the SQL editor:
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
```
|
||||
3. **Get your connection string** from Settings > Database
|
||||
4. **Update your `.env` file** with the connection string
|
||||
|
|
@ -227,23 +256,38 @@ Once your Honcho server is running, verify everything is working:
|
|||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
# {"status":"ok"}
|
||||
```
|
||||
|
||||
### 2. API Documentation
|
||||
Note: `/health` only confirms the process is running. It does not check database or LLM connectivity.
|
||||
|
||||
### 2. Smoke Test (database + API)
|
||||
|
||||
This confirms the database connection, migrations, and API are all working:
|
||||
|
||||
```bash
|
||||
# Create a workspace
|
||||
curl -s -X POST http://localhost:8000/v3/workspaces \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "test"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
If you get back a workspace object with an `id`, your database is connected and migrations ran correctly.
|
||||
|
||||
### 3. API Documentation
|
||||
|
||||
Visit `http://localhost:8000/docs` to see the interactive API documentation.
|
||||
|
||||
### 3. Test with SDK
|
||||
|
||||
Create a simple test script:
|
||||
### 4. Test with SDK
|
||||
|
||||
```python
|
||||
from honcho import Honcho
|
||||
|
||||
# Connect to your local instance
|
||||
client = Honcho(base_url="http://localhost:8000")
|
||||
client = Honcho(
|
||||
base_url="http://localhost:8000",
|
||||
workspace_id="test"
|
||||
)
|
||||
|
||||
# Create a test peer
|
||||
peer = client.peer("test-user")
|
||||
print(f"Created peer: {peer.id}")
|
||||
```
|
||||
|
|
@ -259,8 +303,7 @@ Now that Honcho is running locally, you can connect your applications:
|
|||
from honcho import Honcho
|
||||
|
||||
client = Honcho(
|
||||
base_url="http://localhost:8000", # Your local instance
|
||||
api_key="your-api-key" # If auth is enabled
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
```
|
||||
|
||||
|
|
@ -269,56 +312,93 @@ client = Honcho(
|
|||
import { Honcho } from '@honcho-ai/sdk';
|
||||
|
||||
const client = new Honcho({
|
||||
baseUrl: 'http://localhost:8000', // Your local instance
|
||||
apiKey: 'your-api-key' // If auth is enabled
|
||||
baseUrl: 'http://localhost:8000',
|
||||
});
|
||||
```
|
||||
|
||||
### Next Steps
|
||||
|
||||
- **Configure Honcho**: Visit the [Configuration Guide](./configuration) for model tiers, provider options, and tuning
|
||||
- **Explore the API**: Check out the [API Reference](../api-reference/introduction)
|
||||
- **Try the SDKs**: See our [guides](../guides) for examples
|
||||
- **Configure Honcho**: Visit the [Configuration Guide](./configuration) for detailed settings
|
||||
- **Join the community**: [Discord](https://discord.gg/honcho)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
Running into issues? See the [Troubleshooting Guide](./troubleshooting) for detailed solutions to common problems including:
|
||||
|
||||
**Database Connection Errors**
|
||||
- Ensure PostgreSQL is running
|
||||
- Verify the connection string format: `postgresql+psycopg://...`
|
||||
- Check that pgvector extension is installed
|
||||
- Startup failures (missing API keys, database issues)
|
||||
- Runtime errors ("An unexpected error occurred" on every request)
|
||||
- Deriver not processing messages
|
||||
- Database connection and migration issues
|
||||
- Docker and Redis problems
|
||||
|
||||
**API Key Issues**
|
||||
- Verify your OpenAI and Anthropic API keys are valid
|
||||
- Check that the keys have sufficient credits/quota
|
||||
|
||||
**Port Already in Use**
|
||||
- Pass a different port to FastAPI or stop other services using port 8000
|
||||
|
||||
**Docker Issues**
|
||||
- Ensure Docker is running
|
||||
- Check container logs: `docker compose logs`
|
||||
- Restart containers: `docker compose down && docker compose up -d`
|
||||
|
||||
**Migration Errors**
|
||||
- Ensure the database exists and pgvector is enabled
|
||||
- Check database permissions
|
||||
- Run migrations manually: `uv run alembic upgrade head`
|
||||
|
||||
### Getting Help
|
||||
|
||||
- **GitHub Issues**: [Report bugs](https://github.com/plastic-labs/honcho/issues)
|
||||
- **Discord**: [Join our community](https://discord.gg/honcho)
|
||||
- **Documentation**: Check the [Configuration Guide](./configuration) for detailed settings
|
||||
**Quick checks:**
|
||||
- Verify the server is running: `curl http://localhost:8000/health`
|
||||
- Check logs: `docker compose logs api` (Docker) or check terminal output (manual setup)
|
||||
- Ensure migrations ran: `uv run alembic upgrade head`
|
||||
|
||||
## Production Considerations
|
||||
|
||||
When self-hosting for production, consider:
|
||||
The default compose file is already production-oriented — ports bound to `127.0.0.1`, restart policies, caching enabled.
|
||||
|
||||
- **Security**: Enable authentication, use HTTPS, secure your database
|
||||
- **Scaling**: Use connection pooling, consider load balancing
|
||||
- **Monitoring**: Set up logging, error tracking, health checks
|
||||
- **Backups**: Regular database backups, disaster recovery plan
|
||||
- **Updates**: Keep Honcho and dependencies updated
|
||||
### Security
|
||||
- Set `AUTH_USE_AUTH=true` and generate a JWT secret with `python scripts/generate_jwt_secret.py`
|
||||
- Use HTTPS via a reverse proxy in front of Honcho. Example with Caddy (automatic TLS):
|
||||
```
|
||||
honcho.example.com {
|
||||
reverse_proxy localhost:8000
|
||||
}
|
||||
```
|
||||
Or with nginx:
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name honcho.example.com;
|
||||
ssl_certificate /etc/letsencrypt/live/honcho.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/honcho.example.com/privkey.pem;
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
- Secure your database with strong credentials and restrict network access
|
||||
- The production compose binds PostgreSQL and Redis to `127.0.0.1` only — they are not accessible from the network
|
||||
|
||||
### Scaling the Deriver
|
||||
- Increase `DERIVER_WORKERS` (default: 1) for higher message throughput
|
||||
- You can also run multiple deriver processes across machines — they coordinate via the database queue
|
||||
- Monitor deriver logs for processing backlog
|
||||
|
||||
### Caching
|
||||
- The production compose enables Redis caching by default (`CACHE_ENABLED=true`)
|
||||
- For the development compose, enable manually: `CACHE_ENABLED=true`
|
||||
- Configure `CACHE_URL` to point to your Redis instance (or use a managed Redis service)
|
||||
|
||||
### Database Migrations
|
||||
- Always run `uv run alembic upgrade head` after updating Honcho before starting the server
|
||||
- Check current migration status with `uv run alembic current`
|
||||
|
||||
### LLM Providers
|
||||
- Ensure your API keys are configured (see [LLM Setup](#llm-setup))
|
||||
- For alternative providers or per-feature model overrides, see the [Configuration Guide](./configuration#llm-configuration)
|
||||
|
||||
### Monitoring
|
||||
- Enable Prometheus metrics with `METRICS_ENABLED=true`. The API exposes `/metrics` on port 8000, the deriver on port 9090 (internal to its container — not published to the host by default).
|
||||
- Enable Sentry error tracking with `SENTRY_ENABLED=true`
|
||||
- The development compose includes Prometheus (host port 9090) and Grafana (host port 3000) for scraping and dashboards. Uncomment those services to enable them.
|
||||
|
||||
### Backups
|
||||
- Set up regular PostgreSQL backups:
|
||||
```bash
|
||||
# One-off backup
|
||||
docker compose exec database pg_dump -U postgres postgres > backup-$(date +%Y%m%d).sql
|
||||
|
||||
# Restore
|
||||
cat backup.sql | docker compose exec -T database psql -U postgres postgres
|
||||
```
|
||||
- Back up your `.env` or `config.toml` configuration files
|
||||
|
|
|
|||
|
|
@ -0,0 +1,299 @@
|
|||
---
|
||||
title: 'Troubleshooting'
|
||||
sidebarTitle: 'Troubleshooting'
|
||||
description: 'Common issues and solutions when self-hosting Honcho'
|
||||
icon: 'wrench'
|
||||
---
|
||||
|
||||
This page covers common issues you may encounter when self-hosting Honcho, what causes them, and how to fix them.
|
||||
|
||||
## Startup Failures
|
||||
|
||||
### Server won't start: "Missing client for ..."
|
||||
|
||||
```
|
||||
ValueError: Missing client for Deriver: google
|
||||
```
|
||||
|
||||
**Cause:** The server validates at startup that all configured LLM providers have API keys. If a provider is referenced in your configuration but the corresponding API key isn't set, the server refuses to start.
|
||||
|
||||
**Fix:** Set the API keys for your configured providers. With default configuration, you need:
|
||||
|
||||
```bash
|
||||
LLM_GEMINI_API_KEY=... # Used by deriver, summary, dialectic minimal/low
|
||||
LLM_ANTHROPIC_API_KEY=... # Used by dialectic medium/high/max, dream
|
||||
LLM_OPENAI_API_KEY=... # Used by embeddings (when EMBED_MESSAGES=true)
|
||||
```
|
||||
|
||||
See the [LLM Setup](/v3/contributing/self-hosting#llm-setup) section for provider configuration. You can change which providers are used in your `.env` or `config.toml` (see [Configuration Guide](./configuration#llm-configuration)).
|
||||
|
||||
### Server won't start: "JWT_SECRET must be set"
|
||||
|
||||
```
|
||||
ValueError: JWT_SECRET must be set if USE_AUTH is true
|
||||
```
|
||||
|
||||
**Cause:** You enabled authentication (`AUTH_USE_AUTH=true`) but didn't provide a JWT secret.
|
||||
|
||||
**Fix:** Generate a secret and set it:
|
||||
|
||||
```bash
|
||||
python scripts/generate_jwt_secret.py
|
||||
# Then set the output as:
|
||||
AUTH_JWT_SECRET=<generated_secret>
|
||||
```
|
||||
|
||||
Or disable authentication for local development: `AUTH_USE_AUTH=false`
|
||||
|
||||
## Runtime Errors
|
||||
|
||||
### API returns "An unexpected error occurred" on every request
|
||||
|
||||
**Cause:** This is almost always a database issue. The health endpoint (`/health`) will return `{"status": "ok"}` even when the database is unreachable because it doesn't check the database connection. The actual error appears in the server logs.
|
||||
|
||||
**Common causes and fixes:**
|
||||
|
||||
1. **Database is unreachable** — Check that PostgreSQL is running and the `DB_CONNECTION_URI` is correct
|
||||
2. **Migrations haven't been run** — The server starts successfully without tables, but every API call will fail. Run:
|
||||
```bash
|
||||
uv run alembic upgrade head
|
||||
```
|
||||
In Docker:
|
||||
```bash
|
||||
docker compose exec api uv run alembic upgrade head
|
||||
```
|
||||
3. **pgvector extension not installed** — The `vector` extension must be enabled in your database:
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
```
|
||||
|
||||
**How to diagnose:** Check the server logs for the actual error. Look for:
|
||||
- `sqlalchemy.exc.OperationalError` — database connection issue
|
||||
- `sqlalchemy.exc.ProgrammingError` with "relation does not exist" — migrations not run
|
||||
- `psycopg.OperationalError` — connection refused or authentication failed
|
||||
|
||||
### Health check passes but API calls fail
|
||||
|
||||
The `/health` endpoint is a lightweight check that confirms the server process is running. It does **not** verify:
|
||||
- Database connectivity
|
||||
- That migrations have been run
|
||||
- That LLM providers are reachable
|
||||
|
||||
To verify full functionality, try creating a workspace:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/v3/workspaces \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "test"}'
|
||||
```
|
||||
|
||||
If this succeeds, your database connection and migrations are working.
|
||||
|
||||
### Deriver not processing messages
|
||||
|
||||
Messages are stored but no observations, summaries, or representations are being generated.
|
||||
|
||||
**Common causes:**
|
||||
|
||||
1. **Deriver isn't running** — In manual setup, the deriver is a separate process:
|
||||
```bash
|
||||
uv run python -m src.deriver
|
||||
```
|
||||
In Docker, it starts automatically via `docker compose up`.
|
||||
|
||||
2. **Deriver can't reach the database** — Check deriver logs for connection errors. The deriver uses the same `DB_CONNECTION_URI` as the API server.
|
||||
|
||||
3. **Missing LLM API key for deriver provider** — By default the deriver uses Google Gemini (`LLM_GEMINI_API_KEY`). Check deriver logs for API errors.
|
||||
|
||||
4. **Processing backlog** — With `DERIVER_WORKERS=1` (default), high message volume can cause a backlog. Increase workers:
|
||||
```bash
|
||||
DERIVER_WORKERS=4
|
||||
```
|
||||
5. **Representation Batch Max** — By default the deriver is set to buffer its operations until there are enough tokens for a given representation in a session. This is set via the `REPRESENTATION_BATCH_MAX_TOKENS` environment variable. If you aren't seeing tasks continue it may be that the batch size is set too high or enough data hasn't flowed into to the session yet. See [token batching](/v3/documentation/core-concepts/reasoning#token-batching) for more details
|
||||
|
||||
## Alternative Provider Issues
|
||||
|
||||
### OpenRouter / custom provider not working
|
||||
|
||||
If you set `PROVIDER=custom` but calls fail:
|
||||
|
||||
1. **Verify the endpoint and key are set:**
|
||||
```bash
|
||||
LLM_OPENAI_COMPATIBLE_BASE_URL=https://openrouter.ai/api/v1
|
||||
LLM_OPENAI_COMPATIBLE_API_KEY=sk-or-v1-...
|
||||
```
|
||||
|
||||
2. **Check model names match the provider's format.** OpenRouter uses `vendor/model` format (e.g., `anthropic/claude-haiku-4-5`), not the raw model ID.
|
||||
|
||||
3. **Ensure your model supports tool calling.** The deriver, dialectic, and dream agents require tool use. Check the provider's model page for tool calling support.
|
||||
|
||||
4. **Check server logs for the actual error.** API errors from the upstream provider will appear in Honcho's logs with the HTTP status code and message body.
|
||||
|
||||
### vLLM / Ollama not responding
|
||||
|
||||
1. **Verify the model server is running** and accessible from the Honcho process (or container):
|
||||
```bash
|
||||
curl http://localhost:8000/v1/models # vLLM
|
||||
curl http://localhost:11434/v1/models # Ollama
|
||||
```
|
||||
|
||||
2. **In Docker**, `localhost` inside a container doesn't reach the host. Use `host.docker.internal` (macOS/Windows) or the host's network IP:
|
||||
```bash
|
||||
LLM_VLLM_BASE_URL=http://host.docker.internal:8000/v1
|
||||
```
|
||||
|
||||
3. **Structured output failures** — vLLM's structured output support is limited to certain response formats. If you see JSON parsing errors, check the deriver/dream logs for the raw response.
|
||||
|
||||
### Thinking budget errors with non-Anthropic providers
|
||||
|
||||
If you see errors like `thinking budget not supported`, `invalid parameter`, or silent failures where agents produce no output, your `THINKING_BUDGET_TOKENS` is likely set to a value > 0 with a provider that doesn't support Anthropic-style extended thinking.
|
||||
|
||||
**Fix:** Set `THINKING_BUDGET_TOKENS=0` for every component when using non-Anthropic providers:
|
||||
|
||||
```bash
|
||||
DERIVER_THINKING_BUDGET_TOKENS=0
|
||||
SUMMARY_THINKING_BUDGET_TOKENS=0
|
||||
DREAM_THINKING_BUDGET_TOKENS=0
|
||||
DIALECTIC_LEVELS__minimal__THINKING_BUDGET_TOKENS=0
|
||||
DIALECTIC_LEVELS__low__THINKING_BUDGET_TOKENS=0
|
||||
DIALECTIC_LEVELS__medium__THINKING_BUDGET_TOKENS=0
|
||||
DIALECTIC_LEVELS__high__THINKING_BUDGET_TOKENS=0
|
||||
DIALECTIC_LEVELS__max__THINKING_BUDGET_TOKENS=0
|
||||
```
|
||||
|
||||
This applies to OpenRouter (with non-Anthropic models), vLLM, Ollama, Groq, Google, and OpenAI providers. Only Anthropic models support the thinking budget parameter.
|
||||
|
||||
## Database Issues
|
||||
|
||||
### Connection string format
|
||||
|
||||
The connection URI **must** use the `postgresql+psycopg` prefix:
|
||||
|
||||
```bash
|
||||
# Correct
|
||||
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres
|
||||
|
||||
# Wrong - will fail
|
||||
DB_CONNECTION_URI=postgresql://postgres:postgres@localhost:5432/postgres
|
||||
DB_CONNECTION_URI=postgres://postgres:postgres@localhost:5432/postgres
|
||||
```
|
||||
|
||||
### Checking migration status
|
||||
|
||||
```bash
|
||||
# See current migration version
|
||||
uv run alembic current
|
||||
|
||||
# See migration history
|
||||
uv run alembic history
|
||||
|
||||
# Upgrade to latest
|
||||
uv run alembic upgrade head
|
||||
```
|
||||
|
||||
## Cache & Redis
|
||||
|
||||
### Redis is optional
|
||||
|
||||
Redis is used for caching when `CACHE_ENABLED=true` (default: `false`). If Redis is unreachable, Honcho **gracefully falls back to in-memory caching** and logs a warning. This means:
|
||||
|
||||
- The server and deriver will still start and function normally
|
||||
- Performance may be reduced under high load without Redis
|
||||
- You do not need Redis for local development or testing
|
||||
|
||||
### Redis connection issues
|
||||
|
||||
If you see Redis connection warnings in logs but `CACHE_ENABLED=false`, they can be safely ignored. If you want caching:
|
||||
|
||||
```bash
|
||||
# Start Redis via Docker
|
||||
docker run -d -p 6379:6379 redis:latest
|
||||
|
||||
# Configure Honcho
|
||||
CACHE_ENABLED=true
|
||||
CACHE_URL=redis://localhost:6379/0
|
||||
```
|
||||
|
||||
## Docker Issues
|
||||
|
||||
### Docker build fails with permission errors
|
||||
|
||||
The Honcho Dockerfile uses BuildKit mount syntax and creates a non-root `app` user. Common build failures:
|
||||
|
||||
**1. BuildKit not enabled**
|
||||
|
||||
The Dockerfile uses `RUN --mount=type=cache` which requires Docker BuildKit. If you see syntax errors during build:
|
||||
|
||||
```bash
|
||||
# Ensure BuildKit is enabled
|
||||
DOCKER_BUILDKIT=1 docker compose build
|
||||
```
|
||||
|
||||
Or add to your Docker daemon config (`/etc/docker/daemon.json`):
|
||||
```json
|
||||
{ "features": { "buildkit": true } }
|
||||
```
|
||||
|
||||
**2. Permission denied during build or at runtime (Linux)**
|
||||
|
||||
On Linux, AppArmor or SELinux can block Docker build operations and volume mounts. Symptoms include permission denied errors during `COPY`, `RUN`, or when the container tries to access mounted volumes.
|
||||
|
||||
```bash
|
||||
# Check if AppArmor is blocking Docker
|
||||
sudo aa-status | grep docker
|
||||
|
||||
# Temporarily test without AppArmor (for diagnosis only)
|
||||
docker compose down
|
||||
sudo aa-remove-unknown
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
For SELinux, add `:z` to volume mounts in `docker-compose.yml`:
|
||||
```yaml
|
||||
volumes:
|
||||
- .:/app:z
|
||||
```
|
||||
|
||||
**3. Volume mount UID mismatch**
|
||||
|
||||
The Dockerfile creates a non-root `app` user, but `docker-compose.yml.example` mounts `.:/app` which overlays the container filesystem with host-owned files. The `app` user inside the container may not have permission to read them.
|
||||
|
||||
If you see permission errors at runtime (not build time), you can either:
|
||||
- Run without the source mount (remove `- .:/app` from volumes — the image already contains the code)
|
||||
- Or fix ownership: `sudo chown -R 100:101 .` (matches the `app` user inside the container)
|
||||
|
||||
### Containers start but API fails
|
||||
|
||||
1. Check container status: `docker compose ps`
|
||||
2. Check API logs: `docker compose logs api`
|
||||
3. Check database logs: `docker compose logs database`
|
||||
4. Ensure migrations ran: `docker compose exec api uv run alembic upgrade head`
|
||||
|
||||
### Port conflicts
|
||||
|
||||
If port 8000 is already in use:
|
||||
|
||||
```bash
|
||||
# Check what's using the port
|
||||
lsof -i :8000
|
||||
|
||||
# Or change the port mapping in docker-compose.yml
|
||||
ports:
|
||||
- "8001:8000" # Map to a different host port
|
||||
```
|
||||
|
||||
### Rebuilding after code changes
|
||||
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
|
||||
If your issue isn't covered here:
|
||||
|
||||
- **Check the logs** — most issues are diagnosed from server or deriver logs
|
||||
- **GitHub Issues** — [Report bugs](https://github.com/plastic-labs/honcho/issues)
|
||||
- **Discord** — [Join our community](https://discord.gg/plasticlabs)
|
||||
- **Configuration** — See the [Configuration Guide](./configuration) for all available settings
|
||||
|
|
@ -41,130 +41,82 @@ Hermes exposes four Honcho tools to the agent:
|
|||
| `honcho_context` | Dialectic Q&A powered by Honcho's LLM. Synthesizes answers from conversation history. |
|
||||
| `honcho_conclude` | Writes durable facts to Honcho when the user states preferences, corrections, or important context. |
|
||||
|
||||
## Two memory layers
|
||||
|
||||
When Honcho is enabled, Hermes operates with two layer memory by default (`hybrid`):
|
||||
|
||||
**Local session history** -- the immediate transcript for the current chat, thread, or CLI session. Use it for recent turns, short-lived task context, and follow-up questions.
|
||||
|
||||
**Honcho memory** -- the semantic, cross-session layer. Use it for user preferences, durable project facts, cross-session continuity, and synthesized peer context.
|
||||
|
||||
## Running Honcho locally with Hermes
|
||||
|
||||
If you want to point Hermes at a local Honcho instance instead of the hosted API:
|
||||
|
||||
### Docker (quickest)
|
||||
Follow the [Self-Hosting Guide](/v3/contributing/self-hosting) to get Honcho running locally. Once it's up, point Hermes at your instance:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/plastic-labs/honcho.git
|
||||
cd honcho
|
||||
cp .env.template .env
|
||||
cp docker-compose.yml.example docker-compose.yml
|
||||
hermes memory setup # select "honcho", enter http://localhost:8000 as the base URL
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
|
||||
```bash
|
||||
OPENAI_API_KEY=your-openai-api-key
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/honcho
|
||||
AUTH_USE_AUTH=false
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
### Manual
|
||||
|
||||
```bash
|
||||
git clone https://github.com/plastic-labs/honcho.git
|
||||
cd honcho
|
||||
uv sync
|
||||
cp .env.template .env
|
||||
```
|
||||
|
||||
Edit `.env` with a local or cloud Postgres connection string and API keys, then:
|
||||
|
||||
```bash
|
||||
uv run alembic upgrade head
|
||||
uv run fastapi dev src/main.py
|
||||
```
|
||||
|
||||
Then update `~/.honcho/config.json` to point at your local instance:
|
||||
Or manually create/edit the config file (checked in order: `$HERMES_HOME/honcho.json` > `~/.hermes/honcho.json` > `~/.honcho/config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "not-needed-with-auth-disabled",
|
||||
"baseUrl": "http://localhost:8000",
|
||||
"hosts": {
|
||||
"hermes": {
|
||||
"workspace": "hermes",
|
||||
"peerName": "your-name",
|
||||
"enabled": true,
|
||||
"aiPeer": "hermes",
|
||||
"memoryMode": "hybrid",
|
||||
"enabled": true
|
||||
"peerName": "your-name",
|
||||
"workspace": "hermes"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `baseUrl` field overrides the default hosted API. With `AUTH_USE_AUTH=false` on the server, the `apiKey` value is ignored but the field must still be present.
|
||||
For the full list of config fields (`recallMode`, `writeFrequency`, `sessionStrategy`, `dialecticReasoningLevel`, etc.), see the [Hermes memory provider docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory-providers#honcho).
|
||||
|
||||
See the full [self-hosting guide](/v3/contributing/self-hosting) for database options, cloud setup, and troubleshooting.
|
||||
<Info>
|
||||
**Community quick-start**: [elkimek/honcho-self-hosted](https://github.com/elkimek/honcho-self-hosted) provides a one-command installer with pre-configured model tiers and Hermes Agent integration.
|
||||
</Info>
|
||||
|
||||
## Verifying the integration
|
||||
|
||||
Steps to test the integration via CLI and agentically by speaking to Hermes agent in natural language.
|
||||
|
||||
### 1. Check configuration
|
||||
### 1. Check status
|
||||
|
||||
```bash
|
||||
hermes honcho status
|
||||
hermes memory status
|
||||
```
|
||||
|
||||
### 2. Test cross-session recall
|
||||
This should show Honcho as the active memory provider with your base URL.
|
||||
|
||||
In one conversation:
|
||||
### 2. Store a fact and recall it across sessions
|
||||
|
||||
In one conversation, tell Hermes something specific:
|
||||
|
||||
```text
|
||||
Remember that my test phrase is velvet circuit.
|
||||
My favorite programming language is Rust and I always use dark mode.
|
||||
```
|
||||
|
||||
In a fresh conversation (different thread, new CLI session):
|
||||
Start a **new session** (different thread, new CLI invocation, or a different platform). Ask:
|
||||
|
||||
```text
|
||||
What is my test phrase?
|
||||
What do you know about my preferences?
|
||||
```
|
||||
|
||||
If Hermes recalls "velvet circuit" after short-term context is gone, Honcho is working.
|
||||
If Hermes mentions Rust and dark mode without being told again, cross-session memory is working. The deriver processed your messages, extracted observations, and the dialectic recalled them.
|
||||
|
||||
### 3. Test writeback
|
||||
### 3. Test tool calling directly
|
||||
|
||||
Tell Hermes a preference:
|
||||
Ask Hermes to use a specific Honcho tool:
|
||||
|
||||
```text
|
||||
Remember that I prefer terse answers.
|
||||
Use your honcho_search tool to find anything you know about me.
|
||||
```
|
||||
|
||||
Wait briefly if writes are asynchronous. Open a fresh conversation:
|
||||
If Hermes calls the tool and returns results, the full tool pipeline (API connection, vector search, embedding) is functional.
|
||||
|
||||
```text
|
||||
How should you respond to me?
|
||||
```
|
||||
|
||||
If Hermes answers with the stored preference, writeback is functioning.
|
||||
|
||||
|
||||
## Session strategy
|
||||
|
||||
| Scope | When to use |
|
||||
|----------------------|---------------------------------------------------------|
|
||||
| Per-Session | A honcho session starts fresh each time a new Hermes session is created. Hermes remembers the user across sessions. |
|
||||
| Per Directory | One honcho session per project directory. Context is scoped to each directory. Coding/project memory scoped to each repository/workspace. |
|
||||
| Global (per user) | Continuity across all chats, threads, and projects. One honcho session globally for the user and Hermes agent. |
|
||||
## Configuration options
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `recallMode` | `hybrid` | `hybrid` (auto-inject + tools), `context` (inject only), `tools` (tools only) |
|
||||
| `writeFrequency` | `async` | `async`, `turn`, `session`, or integer N |
|
||||
| `sessionStrategy` | `per-directory` | `per-directory`, `per-repo`, `per-session`, `global` |
|
||||
| `dialecticReasoningLevel` | `low` | `minimal`, `low`, `medium`, `high`, `max` |
|
||||
| `dialecticDynamic` | `true` | Auto-bump reasoning level by query complexity |
|
||||
| `messageMaxChars` | `25000` | Max chars per message (chunked if exceeded) |
|
||||
|
||||
## Next steps
|
||||
|
||||
|
|
@ -182,6 +134,6 @@ If Hermes answers with the stored preference, writeback is functioning.
|
|||
</Card>
|
||||
|
||||
<Card title="Self-Hosting Guide" icon="server" href="/v3/contributing/self-hosting">
|
||||
Full local environment setup, database options, and troubleshooting.
|
||||
Full local environment setup, provider configuration, and troubleshooting.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
---
|
||||
title: "Zo Computer"
|
||||
icon: 'bolt'
|
||||
description: "Add persistent memory to Zo Computer skills using Honcho"
|
||||
sidebarTitle: 'Zo Computer'
|
||||
---
|
||||
|
||||
[Zo Computer](https://zo.computer) is a cloud AI platform where users build reusable workflows called skills. The Honcho memory skill gives any Zo workflow persistent memory — saving conversations, answering questions about past interactions, and injecting context into LLM prompts.
|
||||
|
||||
<Note>
|
||||
The full source code is available on [GitHub](https://github.com/plastic-labs/honcho/tree/main/examples/zo) with working tests and Zo marketplace submission instructions.
|
||||
</Note>
|
||||
|
||||
## What It Does
|
||||
|
||||
The skill provides three tools that any Zo workflow can call:
|
||||
|
||||
| Tool | Description |
|
||||
| ---- | ----------- |
|
||||
| `save_memory` | Save user or assistant messages to a Honcho session |
|
||||
| `query_memory` | Ask natural language questions about what Honcho remembers |
|
||||
| `get_context` | Retrieve conversation history formatted for LLM use (OpenAI message format) |
|
||||
|
||||
## Setup
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
pip install honcho-ai python-dotenv
|
||||
```
|
||||
|
||||
Set your environment variables:
|
||||
|
||||
```bash
|
||||
HONCHO_API_KEY=your-api-key
|
||||
HONCHO_WORKSPACE_ID=default # optional, defaults to "default"
|
||||
```
|
||||
|
||||
Get your API key at [app.honcho.dev](https://app.honcho.dev).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from tools.save_memory import save_memory
|
||||
from tools.query_memory import query_memory
|
||||
from tools.get_context import get_context
|
||||
|
||||
# Save conversation turns
|
||||
save_memory("alice", "I love hiking in the mountains", "user", "session-1")
|
||||
save_memory("alice", "That sounds wonderful!", "assistant", "session-1")
|
||||
|
||||
# Query what Honcho remembers
|
||||
answer = query_memory("alice", "What are my hobbies?", "session-1")
|
||||
print(answer) # "Alice enjoys hiking in the mountains."
|
||||
|
||||
# Get context ready for an LLM call
|
||||
messages = get_context("alice", "session-1", "assistant", tokens=4000)
|
||||
# Returns [{"role": "user", "content": "..."}, ...]
|
||||
```
|
||||
|
||||
## Saving Messages
|
||||
|
||||
`save_memory` creates peers and sessions automatically on first use and persists the message.
|
||||
|
||||
```python
|
||||
save_memory(
|
||||
user_id="alice", # unique user identifier
|
||||
content="Hello!", # message text
|
||||
role="user", # "user" or "assistant"
|
||||
session_id="session-1", # conversation identifier
|
||||
assistant_id="assistant", # optional, defaults to "assistant"
|
||||
)
|
||||
```
|
||||
|
||||
## Querying Memory
|
||||
|
||||
`query_memory` uses Honcho's Dialectic API to answer natural language questions grounded in stored memory.
|
||||
|
||||
```python
|
||||
answer = query_memory(
|
||||
user_id="alice",
|
||||
query="What are my interests?",
|
||||
session_id="session-1", # optional — omit to query global memory
|
||||
)
|
||||
```
|
||||
|
||||
## Retrieving Context
|
||||
|
||||
`get_context` fetches recent conversation history within a token budget and returns it in OpenAI message format — ready to pass directly to an LLM.
|
||||
|
||||
```python
|
||||
messages = get_context(
|
||||
user_id="alice",
|
||||
session_id="session-1",
|
||||
assistant_id="assistant",
|
||||
tokens=4000, # max tokens to include
|
||||
)
|
||||
# Use directly: llm.chat.completions.create(messages=messages)
|
||||
```
|
||||
|
||||
## Concept Mapping
|
||||
|
||||
| Zo Computer | Honcho |
|
||||
| --- | --- |
|
||||
| Account | Workspace |
|
||||
| User | Peer |
|
||||
| Conversation | Session |
|
||||
| Message | Message |
|
||||
|
||||
## Publishing to the Zo Marketplace
|
||||
|
||||
To submit the skill to the [Zo Skills Registry](https://github.com/zocomputer/skills):
|
||||
|
||||
1. Fork the `zocomputer/skills` repository
|
||||
2. Copy the `examples/zo` directory into `/Community/honcho-memory/` in your fork
|
||||
3. Run `bun validate` to check the skill format
|
||||
4. Submit a pull request
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Source Code" icon="github" href="https://github.com/plastic-labs/honcho/tree/main/examples/zo">
|
||||
Full source, tests, and SKILL.md for the Zo integration
|
||||
</Card>
|
||||
<Card title="Honcho Architecture" icon="sitemap" href="/v3/documentation/core-concepts/architecture">
|
||||
Understand peers, sessions, and how memory works
|
||||
</Card>
|
||||
<Card title="Chat API" icon="brain" href="/v3/documentation/features/chat">
|
||||
Learn more about querying peer memory with the Dialectic API
|
||||
</Card>
|
||||
<Card title="Get Context" icon="messages" href="/v3/documentation/features/get-context">
|
||||
Details on retrieving and formatting conversation context
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
|
@ -62,6 +62,9 @@ Use Honcho as a memory layer in your agent orchestration stack:
|
|||
<Card title="CrewAI" icon="users-gear" href="/v3/guides/integrations/crewai">
|
||||
Give CrewAI agents memory that persists across sessions
|
||||
</Card>
|
||||
<Card title="Zo Computer" icon="bolt" href="/v3/guides/integrations/zo-computer">
|
||||
Persistent memory skill for Zo Computer AI workflows
|
||||
</Card>
|
||||
<Card title="n8n" icon="share-nodes" href="/v3/guides/integrations/n8n">
|
||||
Build intelligent automation workflows with persistent memory
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
# Honcho Memory Skill for Zo Computer
|
||||
|
||||
Give your AI persistent memory across conversations using [Honcho](https://honcho.dev).
|
||||
|
||||
## Features
|
||||
|
||||
- **Auto-Memory**: Save user and assistant messages to Honcho with one call
|
||||
- **Query Memory**: Ask natural language questions about what Honcho remembers ("What are my hobbies?")
|
||||
- **Context Injection**: Retrieve conversation context formatted for direct LLM use
|
||||
- **Multi-Workspace Support**: Manage separate memory spaces via `HONCHO_WORKSPACE_ID`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install honcho-ai python-dotenv
|
||||
```
|
||||
|
||||
Or with uv:
|
||||
|
||||
```bash
|
||||
uv add honcho-ai python-dotenv
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create a `.env` file:
|
||||
|
||||
```env
|
||||
HONCHO_API_KEY=your-api-key-here
|
||||
HONCHO_WORKSPACE_ID=default
|
||||
```
|
||||
|
||||
Get your API key at [honcho.dev](https://honcho.dev).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from tools.save_memory import save_memory
|
||||
from tools.query_memory import query_memory
|
||||
from tools.get_context import get_context
|
||||
|
||||
# Save a conversation turn
|
||||
save_memory("alice", "I love hiking in the mountains", "user", "session-1")
|
||||
save_memory("alice", "That sounds wonderful!", "assistant", "session-1")
|
||||
|
||||
# Query what Honcho remembers
|
||||
answer = query_memory("alice", "What are my hobbies?", "session-1")
|
||||
print(answer) # "Alice enjoys hiking in the mountains."
|
||||
|
||||
# Get context ready for an LLM call
|
||||
messages = get_context("alice", "session-1", "assistant", tokens=4000)
|
||||
# messages is a list of {"role": ..., "content": ...} dicts
|
||||
```
|
||||
|
||||
## Tool Reference
|
||||
|
||||
### `save_memory(user_id, content, role, session_id, assistant_id="assistant")`
|
||||
|
||||
Saves a message to Honcho memory.
|
||||
|
||||
| Param | Type | Description |
|
||||
|---|---|---|
|
||||
| `user_id` | `str` | Unique user identifier |
|
||||
| `content` | `str` | Message text |
|
||||
| `role` | `str` | `"user"` or `"assistant"` |
|
||||
| `session_id` | `str` | Session/conversation identifier |
|
||||
| `assistant_id` | `str` | Peer ID for the assistant. Defaults to `"assistant"` |
|
||||
|
||||
Returns a confirmation string.
|
||||
|
||||
---
|
||||
|
||||
### `query_memory(user_id, query, session_id=None)`
|
||||
|
||||
Queries stored memory using Honcho's Dialectic API.
|
||||
|
||||
| Param | Type | Description |
|
||||
|---|---|---|
|
||||
| `user_id` | `str` | Unique user identifier |
|
||||
| `query` | `str` | Natural language question |
|
||||
| `session_id` | `str \| None` | Optional: scope to a specific session. Defaults to `None` (global memory) |
|
||||
|
||||
Returns a natural language answer.
|
||||
|
||||
> **Note:** In shared workspaces, `query_memory` may return data from other peers if the queried user has no stored memory yet. The Dialectic API draws from workspace-level context as a fallback. Use unique `HONCHO_WORKSPACE_ID` values per user group in production to prevent cross-peer data leakage.
|
||||
|
||||
---
|
||||
|
||||
### `get_context(user_id, session_id, assistant_id, tokens=4000)`
|
||||
|
||||
Retrieves conversation context in OpenAI message format.
|
||||
|
||||
| Param | Type | Description |
|
||||
|---|---|---|
|
||||
| `user_id` | `str` | Unique user identifier |
|
||||
| `session_id` | `str` | Session/conversation identifier |
|
||||
| `assistant_id` | `str` | Peer ID for the assistant |
|
||||
| `tokens` | `int` | Max tokens to include (default: 4000) |
|
||||
|
||||
Returns a list of `{"role": ..., "content": ...}` dicts.
|
||||
|
||||
## Concept Mapping
|
||||
|
||||
| Zo Computer | Honcho |
|
||||
|---|---|
|
||||
| Account | Workspace |
|
||||
| User | Peer |
|
||||
| Conversation | Session |
|
||||
| Message | Message |
|
||||
|
||||
## Running Tests
|
||||
|
||||
Requires a running Honcho server. See the [main repo](../../README.md) for setup instructions.
|
||||
|
||||
```bash
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
## Submitting to the Zo Skill Marketplace
|
||||
|
||||
To publish this skill to the [Zo Skills Registry](https://github.com/zocomputer/skills):
|
||||
|
||||
1. **Fork** the `zocomputer/skills` repository.
|
||||
2. **Copy** this directory into the `/Community` folder of your fork, naming it `honcho-memory`:
|
||||
|
||||
```
|
||||
Community/
|
||||
└── honcho-memory/
|
||||
├── SKILL.md
|
||||
├── README.md
|
||||
├── client.py
|
||||
├── pyproject.toml
|
||||
└── tools/
|
||||
```
|
||||
|
||||
3. **Validate** your skill:
|
||||
|
||||
```bash
|
||||
bun validate
|
||||
```
|
||||
|
||||
4. **Submit a pull request** to the upstream registry repository.
|
||||
|
||||
Once merged, the skill will be automatically added to the Zo marketplace `manifest.json`.
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-or-later
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
---
|
||||
name: honcho-memory
|
||||
description: Gives AI agents persistent memory across conversations using Honcho. Automatically saves and retrieves user context so the AI remembers preferences, history, and facts between sessions. Use when you need the AI to remember past conversations, recall what a user has told it, inject relevant context into prompts, or manage separate memory spaces for different topics.
|
||||
license: AGPL-3.0
|
||||
compatibility: Requires Python 3.9+, honcho-ai>=2.1.0, and a Honcho API key from honcho.dev. Set HONCHO_API_KEY and optionally HONCHO_WORKSPACE_ID in your environment.
|
||||
metadata:
|
||||
author: plastic-labs
|
||||
version: "0.1.0"
|
||||
honcho-sdk: "2.1.0"
|
||||
---
|
||||
|
||||
# Honcho Memory Skill
|
||||
|
||||
This skill provides three tools for storing and retrieving AI memory using [Honcho](https://honcho.dev).
|
||||
|
||||
## Setup
|
||||
|
||||
1. Get a Honcho API key at [honcho.dev](https://honcho.dev).
|
||||
2. Set environment variables:
|
||||
|
||||
```
|
||||
HONCHO_API_KEY=your-api-key
|
||||
HONCHO_WORKSPACE_ID=default # optional, defaults to "default"
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
|
||||
```
|
||||
pip install honcho-ai python-dotenv
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
### `save_memory`
|
||||
|
||||
Saves a conversation turn (user or assistant message) to Honcho.
|
||||
|
||||
**When to use:** After every message exchange to build up the user's memory.
|
||||
|
||||
```python
|
||||
from tools.save_memory import save_memory
|
||||
|
||||
save_memory(
|
||||
user_id="alice", # unique user identifier
|
||||
content="I love hiking", # message text
|
||||
role="user", # "user" or "assistant"
|
||||
session_id="chat-1", # conversation session ID
|
||||
assistant_id="assistant" # optional: assistant peer ID (default: "assistant")
|
||||
)
|
||||
```
|
||||
|
||||
### `query_memory`
|
||||
|
||||
Asks a natural language question against stored memory using Honcho's Dialectic API.
|
||||
|
||||
**When to use:** When the user asks "do you remember...?", or when you need to recall facts about the user before responding.
|
||||
|
||||
```python
|
||||
from tools.query_memory import query_memory
|
||||
|
||||
answer = query_memory(
|
||||
user_id="alice",
|
||||
query="What are Alice's hobbies?",
|
||||
session_id="chat-1" # optional: scope to a session
|
||||
)
|
||||
# Returns: "Alice enjoys hiking."
|
||||
```
|
||||
|
||||
### `get_context`
|
||||
|
||||
Retrieves recent conversation history formatted for direct use in an LLM API call.
|
||||
|
||||
**When to use:** At the start of each LLM call to inject relevant context from past conversations.
|
||||
|
||||
```python
|
||||
from tools.get_context import get_context
|
||||
|
||||
messages = get_context(
|
||||
user_id="alice",
|
||||
session_id="chat-1",
|
||||
assistant_id="assistant",
|
||||
tokens=4000 # max tokens to include
|
||||
)
|
||||
# Returns: [{"role": "user", "content": "..."}, ...]
|
||||
```
|
||||
|
||||
## Concept Mapping
|
||||
|
||||
| Zo Computer | Honcho |
|
||||
|---|---|
|
||||
| Account | Workspace |
|
||||
| User | Peer |
|
||||
| Conversation | Session |
|
||||
| Message | Message |
|
||||
|
||||
## Example: Full Conversation Flow
|
||||
|
||||
```python
|
||||
from tools.save_memory import save_memory
|
||||
from tools.query_memory import query_memory
|
||||
from tools.get_context import get_context
|
||||
|
||||
user_id = "alice"
|
||||
session_id = "session-1"
|
||||
|
||||
# 1. Save user message
|
||||
save_memory(user_id, "I'm learning Rust and love rock climbing", "user", session_id)
|
||||
|
||||
# 2. Save assistant reply
|
||||
save_memory(user_id, "That's great! Both require patience.", "assistant", session_id)
|
||||
|
||||
# 3. In a later session, recall what you know
|
||||
print(query_memory(user_id, "What does Alice do in her free time?"))
|
||||
# → "Alice is learning Rust and enjoys rock climbing."
|
||||
|
||||
# 4. Get context window for next LLM call
|
||||
messages = get_context(user_id, session_id, "assistant", tokens=4000)
|
||||
```
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
[project]
|
||||
name = "honcho-zo-skill"
|
||||
version = "0.1.0"
|
||||
description = "Honcho persistent memory skill for Zo Computer"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"honcho-ai>=2.1.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["tools"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
"""Basic import and structure tests for honcho-zo-skill.
|
||||
|
||||
These tests validate package structure and imports without requiring
|
||||
a running Honcho server.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# Add parent directory to path so tools/ can be imported
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def test_save_memory_import():
|
||||
"""Test that save_memory can be imported."""
|
||||
from tools.save_memory import save_memory
|
||||
|
||||
assert callable(save_memory)
|
||||
|
||||
|
||||
def test_query_memory_import():
|
||||
"""Test that query_memory can be imported."""
|
||||
from tools.query_memory import query_memory
|
||||
|
||||
assert callable(query_memory)
|
||||
|
||||
|
||||
def test_get_context_import():
|
||||
"""Test that get_context can be imported."""
|
||||
from tools.get_context import get_context
|
||||
|
||||
assert callable(get_context)
|
||||
|
||||
|
||||
def test_tools_package_import():
|
||||
"""Test that the tools package exports all three functions."""
|
||||
import tools
|
||||
|
||||
assert hasattr(tools, "save_memory")
|
||||
assert hasattr(tools, "query_memory")
|
||||
assert hasattr(tools, "get_context")
|
||||
|
||||
|
||||
def test_tools_all_exports():
|
||||
"""Test that __all__ contains expected exports."""
|
||||
import tools
|
||||
|
||||
assert hasattr(tools, "__all__")
|
||||
expected = ["get_context", "query_memory", "save_memory"]
|
||||
for name in expected:
|
||||
assert name in tools.__all__, f"{name} not in __all__"
|
||||
|
||||
|
||||
def test_save_memory_raises_on_empty_content():
|
||||
"""Test that save_memory raises ValueError for empty content."""
|
||||
from tools.save_memory import save_memory
|
||||
|
||||
with pytest.raises(ValueError, match="content must not be empty"):
|
||||
save_memory("user1", "", "user", "session1")
|
||||
|
||||
|
||||
def test_query_memory_raises_on_empty_query():
|
||||
"""Test that query_memory raises ValueError for empty query."""
|
||||
from tools.query_memory import query_memory
|
||||
|
||||
with pytest.raises(ValueError, match="query must not be empty"):
|
||||
query_memory("user1", "")
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
"""Functional tests for Honcho Zo skill tools.
|
||||
|
||||
These tests require a Honcho API key set in the HONCHO_API_KEY environment
|
||||
variable. They run against the Honcho cloud API (honcho.dev) by default.
|
||||
Set HONCHO_WORKSPACE_ID to scope tests to a specific workspace.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from tools.get_context import get_context
|
||||
from tools.query_memory import query_memory
|
||||
from tools.save_memory import save_memory
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.getenv("HONCHO_API_KEY"),
|
||||
reason="HONCHO_API_KEY not set — skipping integration tests",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def rate_limit_delay():
|
||||
"""Pause between tests to stay under the Honcho API rate limit (5 req/sec)."""
|
||||
yield
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
def unique_id(prefix: str) -> str:
|
||||
"""Generate a unique ID with a prefix to avoid test state leakage."""
|
||||
return f"{prefix}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
class TestSaveMemory:
|
||||
"""Tests for save_memory tool."""
|
||||
|
||||
def test_returns_confirmation_string(self):
|
||||
"""Test that save_memory returns a non-empty confirmation string."""
|
||||
result = save_memory(unique_id("user"), "Hello, I love hiking!", "user", unique_id("session"))
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_saves_user_message(self):
|
||||
"""Test saving a user-role message."""
|
||||
user_id = unique_id("user")
|
||||
result = save_memory(user_id, "I enjoy Python programming", "user", unique_id("session"))
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "user" in result.lower() or user_id in result
|
||||
|
||||
def test_saves_assistant_message(self):
|
||||
"""Test saving an assistant-role message."""
|
||||
result = save_memory(unique_id("user"), "That sounds great!", "assistant", unique_id("session"))
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_saves_multiple_turns(self):
|
||||
"""Test saving multiple turns in the same session."""
|
||||
user_id = unique_id("user")
|
||||
session_id = unique_id("session")
|
||||
|
||||
result1 = save_memory(user_id, "I love mountains", "user", session_id)
|
||||
result2 = save_memory(user_id, "That's wonderful!", "assistant", session_id)
|
||||
|
||||
assert isinstance(result1, str) and len(result1) > 0
|
||||
assert isinstance(result2, str) and len(result2) > 0
|
||||
|
||||
def test_non_assistant_role_treated_as_user(self):
|
||||
"""Test that any role other than 'assistant' is treated as user."""
|
||||
result = save_memory(unique_id("user"), "Testing role fallback", "human", unique_id("session"))
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_custom_assistant_id(self):
|
||||
"""Test that a custom assistant_id is accepted."""
|
||||
result = save_memory(
|
||||
unique_id("user"), "Hello!", "assistant", unique_id("session"), assistant_id="my-bot"
|
||||
)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
class TestQueryMemory:
|
||||
"""Tests for query_memory tool."""
|
||||
|
||||
def test_returns_string(self):
|
||||
"""Test that query_memory returns a string response."""
|
||||
user_id = unique_id("user")
|
||||
session_id = unique_id("session")
|
||||
save_memory(user_id, "I love pizza and Italian food", "user", session_id)
|
||||
|
||||
result = query_memory(user_id, "What does the user enjoy?")
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_returns_string_with_session_scope(self):
|
||||
"""Test query_memory scoped to a specific session."""
|
||||
user_id = unique_id("user")
|
||||
session_id = unique_id("session")
|
||||
save_memory(user_id, "My favorite color is blue", "user", session_id)
|
||||
|
||||
result = query_memory(user_id, "What is the user's favorite color?", session_id)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_returns_fallback_for_unknown_user(self):
|
||||
"""Test that query_memory returns a non-empty string even for new users."""
|
||||
result = query_memory(unique_id("user"), "What do I like?")
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
class TestGetContext:
|
||||
"""Tests for get_context tool."""
|
||||
|
||||
def test_returns_list(self):
|
||||
"""Test that get_context returns a list."""
|
||||
user_id = unique_id("user")
|
||||
session_id = unique_id("session")
|
||||
save_memory(user_id, "Hello there!", "user", session_id)
|
||||
|
||||
result = get_context(user_id, session_id, "assistant")
|
||||
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_returns_openai_format(self):
|
||||
"""Test that returned messages are in OpenAI format."""
|
||||
user_id = unique_id("user")
|
||||
session_id = unique_id("session")
|
||||
save_memory(user_id, "My name is Alex", "user", session_id)
|
||||
save_memory(user_id, "Nice to meet you, Alex!", "assistant", session_id)
|
||||
|
||||
result = get_context(user_id, session_id, "assistant")
|
||||
|
||||
assert isinstance(result, list)
|
||||
for msg in result:
|
||||
assert "role" in msg
|
||||
assert "content" in msg
|
||||
assert msg["role"] in ("user", "assistant", "system")
|
||||
assert isinstance(msg["content"], str)
|
||||
|
||||
def test_respects_token_limit(self):
|
||||
"""Test that context respects the token limit parameter."""
|
||||
user_id = unique_id("user")
|
||||
session_id = unique_id("session")
|
||||
for i in range(5):
|
||||
save_memory(user_id, f"Message number {i} with some content", "user", session_id)
|
||||
|
||||
result_small = get_context(user_id, session_id, "assistant", tokens=100)
|
||||
result_large = get_context(user_id, session_id, "assistant", tokens=8000)
|
||||
|
||||
assert isinstance(result_small, list)
|
||||
assert isinstance(result_large, list)
|
||||
assert len(result_large) >= len(result_small)
|
||||
|
||||
def test_empty_session_returns_list(self):
|
||||
"""Test that get_context returns an empty list for a session with no messages."""
|
||||
result = get_context(unique_id("user"), unique_id("session"), "assistant")
|
||||
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
class TestToolsWorkTogether:
|
||||
"""Integration tests using all three tools in sequence."""
|
||||
|
||||
def test_save_query_roundtrip(self):
|
||||
"""Test saving a message and then querying it."""
|
||||
user_id = unique_id("user")
|
||||
session_id = unique_id("session")
|
||||
save_memory(user_id, "I am a software engineer who loves Rust", "user", session_id)
|
||||
|
||||
result = query_memory(user_id, "What is the user's profession?", session_id)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_save_then_get_context(self):
|
||||
"""Test that saved messages appear in context."""
|
||||
user_id = unique_id("user")
|
||||
session_id = unique_id("session")
|
||||
save_memory(user_id, "Hello!", "user", session_id)
|
||||
save_memory(user_id, "Hi there!", "assistant", session_id)
|
||||
|
||||
messages = get_context(user_id, session_id, "assistant")
|
||||
|
||||
assert isinstance(messages, list)
|
||||
assert len(messages) >= 1
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
"""Honcho memory tools for Zo Computer."""
|
||||
|
||||
from tools.get_context import get_context
|
||||
from tools.query_memory import query_memory
|
||||
from tools.save_memory import save_memory
|
||||
|
||||
__all__ = ["get_context", "query_memory", "save_memory"]
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"""Honcho client initialization for Zo Computer skill."""
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from honcho import Honcho
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def get_client(workspace_id: str | None = None) -> Honcho:
|
||||
"""Initialize and return a Honcho client.
|
||||
|
||||
Reads HONCHO_API_KEY and HONCHO_WORKSPACE_ID from environment variables.
|
||||
The workspace_id parameter overrides the environment variable if provided.
|
||||
|
||||
Args:
|
||||
workspace_id: Optional workspace ID override. Falls back to the
|
||||
HONCHO_WORKSPACE_ID env var, then to "default".
|
||||
|
||||
Returns:
|
||||
Configured Honcho client instance.
|
||||
"""
|
||||
api_key = os.getenv("HONCHO_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"HONCHO_API_KEY is required. Set it in your environment or .env file."
|
||||
)
|
||||
|
||||
env_workspace = os.getenv("HONCHO_WORKSPACE_ID")
|
||||
resolved_workspace = workspace_id or env_workspace or "default"
|
||||
return Honcho(api_key=api_key, workspace_id=resolved_workspace)
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"""Retrieve conversation context from Honcho formatted for LLM use."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import get_client
|
||||
|
||||
|
||||
def get_context(
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
assistant_id: str,
|
||||
tokens: int = 4000,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Retrieve conversation context ready for injection into an LLM prompt.
|
||||
|
||||
Fetches recent messages from a Honcho session within the given token
|
||||
budget and converts them to OpenAI-compatible message format. Use the
|
||||
returned list directly as the ``messages`` parameter in an LLM API call.
|
||||
|
||||
Args:
|
||||
user_id: Unique identifier for the user peer. Used to ensure the
|
||||
peer is registered in the session before fetching context.
|
||||
session_id: Identifier for the conversation session.
|
||||
assistant_id: Peer ID representing the assistant. This determines
|
||||
which role is mapped to ``"assistant"`` in the output.
|
||||
tokens: Maximum number of tokens to include in the context window.
|
||||
Defaults to 4000.
|
||||
|
||||
Returns:
|
||||
A list of message dicts in OpenAI format:
|
||||
``[{"role": "user" | "assistant", "content": "..."}]``.
|
||||
Returns an empty list if the session has no messages.
|
||||
"""
|
||||
honcho = get_client()
|
||||
user_peer = honcho.peer(user_id)
|
||||
assistant_peer = honcho.peer(assistant_id)
|
||||
session = honcho.session(session_id)
|
||||
|
||||
session.add_peers([user_peer, assistant_peer])
|
||||
|
||||
context = session.context(tokens=tokens)
|
||||
return context.to_openai(assistant=assistant_id)
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
"""Query a user's Honcho memory using the Dialectic API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import get_client
|
||||
|
||||
|
||||
def query_memory(user_id: str, query: str, session_id: str | None = None) -> str:
|
||||
"""Query stored memory for a user using Honcho's Dialectic API.
|
||||
|
||||
Sends a natural language question to Honcho and returns an answer
|
||||
grounded in the peer's long-term representation and stored observations.
|
||||
|
||||
Args:
|
||||
user_id: Unique identifier for the user peer.
|
||||
query: Natural language question, e.g. "What are my hobbies?".
|
||||
session_id: Optional session ID to scope the query to a specific
|
||||
conversation. If omitted, the query draws from global memory.
|
||||
|
||||
Returns:
|
||||
A natural language answer from Honcho's Dialectic API, or a
|
||||
default message if no relevant information was found.
|
||||
|
||||
Raises:
|
||||
ValueError: If query is empty.
|
||||
"""
|
||||
if not query:
|
||||
raise ValueError("query must not be empty")
|
||||
|
||||
honcho = get_client()
|
||||
peer = honcho.peer(user_id)
|
||||
|
||||
response = peer.chat(query=query, session=session_id)
|
||||
|
||||
if response:
|
||||
return str(response)
|
||||
return "No relevant information found in memory."
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"""Save a conversation message to Honcho memory."""
|
||||
|
||||
from .client import get_client
|
||||
|
||||
|
||||
def save_memory(
|
||||
user_id: str,
|
||||
content: str,
|
||||
role: str,
|
||||
session_id: str,
|
||||
assistant_id: str = "assistant",
|
||||
) -> str:
|
||||
"""Save a single conversation turn to Honcho memory.
|
||||
|
||||
Creates the peer and session if they do not already exist. Registers
|
||||
the peer in the session on first use, then persists the message.
|
||||
|
||||
Args:
|
||||
user_id: Unique identifier for the user peer.
|
||||
content: Text content of the message to save.
|
||||
role: Either "user" or "assistant". Determines which peer sends
|
||||
the message. Any value other than "assistant" is treated as "user".
|
||||
session_id: Identifier for the conversation session.
|
||||
assistant_id: Peer ID for the assistant. Defaults to "assistant".
|
||||
|
||||
Returns:
|
||||
A confirmation string describing what was saved.
|
||||
|
||||
Raises:
|
||||
ValueError: If content is empty.
|
||||
"""
|
||||
if not content:
|
||||
raise ValueError("content must not be empty")
|
||||
|
||||
honcho = get_client()
|
||||
user_peer = honcho.peer(user_id)
|
||||
assistant_peer = honcho.peer(assistant_id)
|
||||
session = honcho.session(session_id)
|
||||
|
||||
session.add_peers([user_peer, assistant_peer])
|
||||
|
||||
sender = assistant_peer if role == "assistant" else user_peer
|
||||
session.add_messages([sender.message(content)])
|
||||
|
||||
return f"Saved {role} message to session '{session_id}' for user '{user_id}'."
|
||||
|
|
@ -0,0 +1,503 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.9"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.10'",
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.10'" },
|
||||
{ name = "idna", marker = "python_full_version < '3.10'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.10'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "idna", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "honcho-ai"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/07/fb2a6654a9f44ff1070d88feb269113a865923e0aa91acf7864459179a1b/honcho_ai-2.1.0.tar.gz", hash = "sha256:c1988bbbf61492c2db168c2f0aa4317c489e18ea9867f74cb318a5f1b83289c8", size = 48050, upload-time = "2026-03-30T14:59:56.731Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/34/b814ea3bed1d96807377814461d58294f0d6c5c66e29f06625c0ac6069b6/honcho_ai-2.1.0-py3-none-any.whl", hash = "sha256:c07389036ef839ff31dc66e4757fa451da25ce976830bce108372e0756daf500", size = 58295, upload-time = "2026-03-30T14:59:55.774Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "honcho-zo-skill"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "honcho-ai" },
|
||||
{ name = "python-dotenv", version = "1.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "python-dotenv", version = "1.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "honcho-ai", specifier = ">=2.1.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "anyio", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.10'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/db/160dffb57ed9a3705c4cbcbff0ac03bdae45f1ca7d58ab74645550df3fbd/pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf", size = 2107999, upload-time = "2025-11-04T13:42:03.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/7d/88e7de946f60d9263cc84819f32513520b85c0f8322f9b8f6e4afc938383/pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5", size = 1929745, upload-time = "2025-11-04T13:42:06.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/c2/aef51e5b283780e85e99ff19db0f05842d2d4a8a8cd15e63b0280029b08f/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d", size = 1920220, upload-time = "2025-11-04T13:42:08.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/97/492ab10f9ac8695cd76b2fdb24e9e61f394051df71594e9bcc891c9f586e/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60", size = 2067296, upload-time = "2025-11-04T13:42:10.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/23/984149650e5269c59a2a4c41d234a9570adc68ab29981825cfaf4cfad8f4/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82", size = 2231548, upload-time = "2025-11-04T13:42:13.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/0c/85bcbb885b9732c28bec67a222dbed5ed2d77baee1f8bba2002e8cd00c5c/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5", size = 2362571, upload-time = "2025-11-04T13:42:16.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/4a/412d2048be12c334003e9b823a3fa3d038e46cc2d64dd8aab50b31b65499/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3", size = 2068175, upload-time = "2025-11-04T13:42:18.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/f4/c58b6a776b502d0a5540ad02e232514285513572060f0d78f7832ca3c98b/pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425", size = 2177203, upload-time = "2025-11-04T13:42:22.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/ae/f06ea4c7e7a9eead3d165e7623cd2ea0cb788e277e4f935af63fc98fa4e6/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504", size = 2148191, upload-time = "2025-11-04T13:42:24.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/57/25a11dcdc656bf5f8b05902c3c2934ac3ea296257cc4a3f79a6319e61856/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5", size = 2343907, upload-time = "2025-11-04T13:42:27.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/82/e33d5f4933d7a03327c0c43c65d575e5919d4974ffc026bc917a5f7b9f61/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3", size = 2322174, upload-time = "2025-11-04T13:42:30.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/45/4091be67ce9f469e81656f880f3506f6a5624121ec5eb3eab37d7581897d/pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460", size = 1990353, upload-time = "2025-11-04T13:42:33.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/8a/a98aede18db6e9cd5d66bcacd8a409fcf8134204cdede2e7de35c5a2c5ef/pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b", size = 2015698, upload-time = "2025-11-04T13:42:35.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.10'" },
|
||||
{ name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "packaging", marker = "python_full_version < '3.10'" },
|
||||
{ name = "pluggy", marker = "python_full_version < '3.10'" },
|
||||
{ name = "pygments", marker = "python_full_version < '3.10'" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.10'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
{ name = "packaging", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "pluggy", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "pygments", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "tomli", marker = "python_full_version == '3.10.*'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.10'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
|
@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from src import models, schemas
|
||||
from src.config import settings
|
||||
from src.dependencies import tracked_db
|
||||
from src.embedding_client import embedding_client
|
||||
from src.utils.filter import apply_filter
|
||||
from src.utils.formatting import ILIKE_ESCAPE_CHAR, escape_ilike_pattern
|
||||
|
|
@ -34,6 +35,40 @@ def _deduplicate_messages(
|
|||
return result
|
||||
|
||||
|
||||
def _expunge_snippets(
|
||||
db: AsyncSession, snippets: list[tuple[list[models.Message], list[models.Message]]]
|
||||
) -> None:
|
||||
"""Detach snippet messages from the session, guarding against duplicates."""
|
||||
seen: set[int] = set()
|
||||
for matches, context in snippets:
|
||||
for msg in [*matches, *context]:
|
||||
obj_id = id(msg)
|
||||
if obj_id in seen:
|
||||
continue
|
||||
db.expunge(msg)
|
||||
seen.add(obj_id)
|
||||
|
||||
|
||||
async def get_peer_session_names(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
peer_name: str,
|
||||
) -> list[str]:
|
||||
"""Get all session names where a peer has any membership record.
|
||||
|
||||
Any membership record (regardless of joined_at/left_at) grants visibility
|
||||
to all messages in that session.
|
||||
"""
|
||||
stmt = (
|
||||
select(models.session_peers_table.c.session_name)
|
||||
.where(models.session_peers_table.c.workspace_name == workspace_name)
|
||||
.where(models.session_peers_table.c.peer_name == peer_name)
|
||||
.distinct()
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
|
||||
def _apply_token_limit(
|
||||
base_conditions: list[ColumnElement[Any]], token_limit: int
|
||||
) -> Select[tuple[models.Message]]:
|
||||
|
|
@ -595,22 +630,19 @@ async def update_message(
|
|||
|
||||
|
||||
async def _search_messages_external(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
query_embedding: list[float],
|
||||
limit: int,
|
||||
*,
|
||||
session_name: str | None = None,
|
||||
allowed_session_names: list[str] | None = None,
|
||||
after_date: datetime | None = None,
|
||||
before_date: datetime | None = None,
|
||||
) -> list[models.Message]:
|
||||
"""Query the external vector store for messages and fetch them from the DB.
|
||||
) -> list[str]:
|
||||
"""Query the external vector store and return ordered message IDs.
|
||||
|
||||
Multiple vector records can map to the same message (chunked embeddings),
|
||||
so we oversample from the vector store and deduplicate by message_id.
|
||||
|
||||
Date filters are applied at the DB level since external vector stores
|
||||
don't support temporal filtering.
|
||||
"""
|
||||
external_vector_store = get_external_vector_store()
|
||||
if external_vector_store is None:
|
||||
|
|
@ -621,6 +653,8 @@ async def _search_messages_external(
|
|||
vector_filters: dict[str, Any] = {}
|
||||
if session_name:
|
||||
vector_filters["session_name"] = session_name
|
||||
elif allowed_session_names is not None:
|
||||
vector_filters["session_name"] = {"in": allowed_session_names}
|
||||
|
||||
# Oversample: chunks can map to the same message, and date filters are
|
||||
# applied post-fetch (vector stores don't support temporal filtering),
|
||||
|
|
@ -648,7 +682,18 @@ async def _search_messages_external(
|
|||
if not message_ids:
|
||||
return []
|
||||
|
||||
# Fetch from DB with optional date filtering
|
||||
return message_ids
|
||||
|
||||
|
||||
async def _fetch_messages_by_ids(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
message_ids: list[str],
|
||||
*,
|
||||
after_date: datetime | None = None,
|
||||
before_date: datetime | None = None,
|
||||
) -> list[models.Message]:
|
||||
"""Fetch messages by ID, preserving the supplied ordering."""
|
||||
fetch_stmt = (
|
||||
select(models.Message)
|
||||
.where(models.Message.public_id.in_(message_ids))
|
||||
|
|
@ -662,18 +707,139 @@ async def _search_messages_external(
|
|||
result = await db.execute(fetch_stmt)
|
||||
messages_by_id = {msg.public_id: msg for msg in result.scalars().all()}
|
||||
|
||||
# Preserve vector store similarity order, apply limit
|
||||
return [messages_by_id[mid] for mid in message_ids if mid in messages_by_id][:limit]
|
||||
return [messages_by_id[mid] for mid in message_ids if mid in messages_by_id]
|
||||
|
||||
|
||||
async def _search_messages_pgvector(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
*,
|
||||
query_embedding: list[float],
|
||||
allowed_session_names: list[str] | None = None,
|
||||
after_date: datetime | None = None,
|
||||
before_date: datetime | None = None,
|
||||
limit: int = 10,
|
||||
context_window: int = 2,
|
||||
) -> list[tuple[list[models.Message], list[models.Message]]]:
|
||||
"""Run semantic message search against pgvector-backed embeddings."""
|
||||
# pgvector path: cosine distance in SQL
|
||||
# Oversample because a message with multiple embedding chunks can
|
||||
# produce duplicate rows; we deduplicate in Python to preserve HNSW
|
||||
# index usage (a DISTINCT ON subquery would prevent the index scan).
|
||||
match_stmt = (
|
||||
select(models.Message)
|
||||
.join(
|
||||
models.MessageEmbedding,
|
||||
models.Message.public_id == models.MessageEmbedding.message_id,
|
||||
)
|
||||
.where(models.MessageEmbedding.workspace_name == workspace_name)
|
||||
.order_by(models.MessageEmbedding.embedding.cosine_distance(query_embedding))
|
||||
.limit(limit * 2)
|
||||
)
|
||||
|
||||
if session_name:
|
||||
match_stmt = match_stmt.where(
|
||||
models.MessageEmbedding.session_name == session_name
|
||||
)
|
||||
elif allowed_session_names is not None:
|
||||
match_stmt = match_stmt.where(
|
||||
models.MessageEmbedding.session_name.in_(allowed_session_names)
|
||||
)
|
||||
|
||||
if after_date:
|
||||
match_stmt = match_stmt.where(models.Message.created_at >= after_date)
|
||||
if before_date:
|
||||
match_stmt = match_stmt.where(models.Message.created_at <= before_date)
|
||||
|
||||
result = await db.execute(match_stmt)
|
||||
matched_messages = _deduplicate_messages(result.scalars().all(), limit)
|
||||
|
||||
return await _build_merged_snippets(
|
||||
db, workspace_name, matched_messages, context_window
|
||||
)
|
||||
|
||||
|
||||
async def _semantic_search_messages(
|
||||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
*,
|
||||
query_embedding: list[float],
|
||||
limit: int = 10,
|
||||
context_window: int = 2,
|
||||
operation_name: str,
|
||||
after_date: datetime | None = None,
|
||||
before_date: datetime | None = None,
|
||||
observer: str | None = None,
|
||||
) -> list[tuple[list[models.Message], list[models.Message]]]:
|
||||
"""Run semantic message search with optional temporal filters.
|
||||
|
||||
When observer is provided and session_name is None, results are
|
||||
scoped to sessions the observer has any membership record in.
|
||||
"""
|
||||
# Pre-fetch peer session scope if needed (short-lived DB session)
|
||||
allowed_session_names: list[str] | None = None
|
||||
if observer and not session_name:
|
||||
async with tracked_db(f"{operation_name}.peer_scope") as db:
|
||||
allowed_session_names = await get_peer_session_names(
|
||||
db, workspace_name, observer
|
||||
)
|
||||
if not allowed_session_names:
|
||||
return []
|
||||
|
||||
if settings.VECTOR_STORE.TYPE != "pgvector" and settings.VECTOR_STORE.MIGRATED:
|
||||
message_ids = await _search_messages_external(
|
||||
workspace_name,
|
||||
query_embedding,
|
||||
limit,
|
||||
session_name=session_name,
|
||||
allowed_session_names=allowed_session_names,
|
||||
after_date=after_date,
|
||||
before_date=before_date,
|
||||
)
|
||||
if not message_ids:
|
||||
return []
|
||||
|
||||
async with tracked_db(operation_name) as db:
|
||||
matched_messages = (
|
||||
await _fetch_messages_by_ids(
|
||||
db,
|
||||
workspace_name,
|
||||
message_ids,
|
||||
after_date=after_date,
|
||||
before_date=before_date,
|
||||
)
|
||||
)[:limit]
|
||||
snippets = await _build_merged_snippets(
|
||||
db, workspace_name, matched_messages, context_window
|
||||
)
|
||||
_expunge_snippets(db, snippets)
|
||||
return snippets
|
||||
|
||||
async with tracked_db(operation_name) as db:
|
||||
snippets = await _search_messages_pgvector(
|
||||
db,
|
||||
workspace_name,
|
||||
session_name,
|
||||
query_embedding=query_embedding,
|
||||
allowed_session_names=allowed_session_names,
|
||||
after_date=after_date,
|
||||
before_date=before_date,
|
||||
limit=limit,
|
||||
context_window=context_window,
|
||||
)
|
||||
_expunge_snippets(db, snippets)
|
||||
return snippets
|
||||
|
||||
|
||||
async def search_messages(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
context_window: int = 2,
|
||||
embedding: list[float] | None = None,
|
||||
observer: str | None = None,
|
||||
) -> list[tuple[list[models.Message], list[models.Message]]]:
|
||||
"""
|
||||
Search for messages using semantic similarity and return conversation snippets.
|
||||
|
|
@ -682,86 +848,44 @@ async def search_messages(
|
|||
snippets within the same session are merged to avoid repetition.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session (optional)
|
||||
query: Search query text
|
||||
limit: Maximum number of matching messages to return
|
||||
context_window: Number of messages before/after each match to include
|
||||
embedding: Optional pre-computed embedding
|
||||
observer: When provided and session_name is None, scope results
|
||||
to sessions this peer belongs to
|
||||
|
||||
Returns:
|
||||
List of tuples: (matched_messages, context_messages)
|
||||
Each snippet may contain multiple matches if they were close together.
|
||||
Context messages are ordered chronologically and include the matched messages.
|
||||
"""
|
||||
# Use provided embedding or generate one
|
||||
query_embedding = (
|
||||
embedding if embedding is not None else await embedding_client.embed(query)
|
||||
)
|
||||
|
||||
if settings.VECTOR_STORE.TYPE == "pgvector" or not settings.VECTOR_STORE.MIGRATED:
|
||||
# pgvector path: cosine distance in SQL
|
||||
# Oversample because a message with multiple embedding chunks can
|
||||
# produce duplicate rows; we deduplicate in Python to preserve HNSW
|
||||
# index usage (a DISTINCT ON subquery would prevent the index scan).
|
||||
match_stmt = (
|
||||
select(models.Message)
|
||||
.join(
|
||||
models.MessageEmbedding,
|
||||
models.Message.public_id == models.MessageEmbedding.message_id,
|
||||
)
|
||||
.where(models.MessageEmbedding.workspace_name == workspace_name)
|
||||
.order_by(
|
||||
models.MessageEmbedding.embedding.cosine_distance(query_embedding)
|
||||
)
|
||||
.limit(limit * 2)
|
||||
)
|
||||
|
||||
if session_name:
|
||||
match_stmt = match_stmt.where(
|
||||
models.MessageEmbedding.session_name == session_name
|
||||
)
|
||||
|
||||
result = await db.execute(match_stmt)
|
||||
matched_messages = _deduplicate_messages(result.scalars().all(), limit)
|
||||
else:
|
||||
# External vector store path
|
||||
matched_messages = await _search_messages_external(
|
||||
db, workspace_name, query_embedding, limit, session_name=session_name
|
||||
)
|
||||
|
||||
return await _build_merged_snippets(
|
||||
db, workspace_name, matched_messages, context_window
|
||||
return await _semantic_search_messages(
|
||||
workspace_name,
|
||||
session_name,
|
||||
query_embedding=query_embedding,
|
||||
limit=limit,
|
||||
context_window=context_window,
|
||||
operation_name="message.search_messages",
|
||||
observer=observer,
|
||||
)
|
||||
|
||||
|
||||
async def grep_messages(
|
||||
async def _grep_messages_internal(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
text: str,
|
||||
limit: int = 10,
|
||||
context_window: int = 2,
|
||||
allowed_session_names: list[str] | None = None,
|
||||
) -> list[tuple[list[models.Message], list[models.Message]]]:
|
||||
"""
|
||||
Search for messages containing specific text (case-insensitive substring match).
|
||||
|
||||
Unlike semantic search, this finds EXACT text matches. Useful for finding
|
||||
specific names, dates, phrases, or keywords.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session (optional - searches all sessions if None)
|
||||
text: Text to search for (case-insensitive)
|
||||
limit: Maximum number of matching messages to return
|
||||
context_window: Number of messages before/after each match to include
|
||||
|
||||
Returns:
|
||||
List of tuples: (matched_messages, context_messages)
|
||||
Each snippet may contain multiple matches if they were close together.
|
||||
"""
|
||||
"""Internal implementation of exact-text message search."""
|
||||
# Build the base query with ILIKE for case-insensitive text search
|
||||
escaped_text = escape_ilike_pattern(text)
|
||||
match_stmt = (
|
||||
|
|
@ -776,6 +900,10 @@ async def grep_messages(
|
|||
|
||||
if session_name:
|
||||
match_stmt = match_stmt.where(models.Message.session_name == session_name)
|
||||
elif allowed_session_names is not None:
|
||||
match_stmt = match_stmt.where(
|
||||
models.Message.session_name.in_(allowed_session_names)
|
||||
)
|
||||
|
||||
result = await db.execute(match_stmt)
|
||||
matched_messages = list(result.scalars().all())
|
||||
|
|
@ -785,6 +913,56 @@ async def grep_messages(
|
|||
)
|
||||
|
||||
|
||||
async def grep_messages(
|
||||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
text: str,
|
||||
limit: int = 10,
|
||||
context_window: int = 2,
|
||||
observer: str | None = None,
|
||||
) -> list[tuple[list[models.Message], list[models.Message]]]:
|
||||
"""
|
||||
Search for messages containing specific text (case-insensitive substring match).
|
||||
|
||||
Unlike semantic search, this finds EXACT text matches. Useful for finding
|
||||
specific names, dates, phrases, or keywords.
|
||||
|
||||
Args:
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session (optional - searches all sessions if None)
|
||||
text: Text to search for (case-insensitive)
|
||||
limit: Maximum number of matching messages to return
|
||||
context_window: Number of messages before/after each match to include
|
||||
observer: When provided and session_name is None, scope results
|
||||
to sessions this peer belongs to
|
||||
|
||||
Returns:
|
||||
List of tuples: (matched_messages, context_messages)
|
||||
Each snippet may contain multiple matches if they were close together.
|
||||
"""
|
||||
async with tracked_db("message.grep_messages") as db:
|
||||
# Pre-fetch peer session scope if needed
|
||||
allowed_session_names = None
|
||||
if observer and not session_name:
|
||||
allowed_session_names = await get_peer_session_names(
|
||||
db, workspace_name, observer
|
||||
)
|
||||
if not allowed_session_names:
|
||||
return []
|
||||
|
||||
snippets = await _grep_messages_internal(
|
||||
db,
|
||||
workspace_name,
|
||||
session_name,
|
||||
text,
|
||||
limit,
|
||||
context_window,
|
||||
allowed_session_names=allowed_session_names,
|
||||
)
|
||||
_expunge_snippets(db, snippets)
|
||||
return snippets
|
||||
|
||||
|
||||
async def get_messages_by_date_range(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
|
|
@ -793,6 +971,7 @@ async def get_messages_by_date_range(
|
|||
before_date: datetime | None = None,
|
||||
limit: int = 20,
|
||||
order: str = "desc",
|
||||
observer: str | None = None,
|
||||
) -> list[models.Message]:
|
||||
"""
|
||||
Get messages within a date range.
|
||||
|
|
@ -805,14 +984,27 @@ async def get_messages_by_date_range(
|
|||
before_date: Return messages before this datetime
|
||||
limit: Maximum messages to return
|
||||
order: Sort order - 'asc' for oldest first, 'desc' for newest first
|
||||
observer: When provided and session_name is None, scope results
|
||||
to sessions this peer belongs to
|
||||
|
||||
Returns:
|
||||
List of messages within the date range
|
||||
"""
|
||||
# Pre-fetch peer session scope if needed
|
||||
allowed_session_names = None
|
||||
if observer and not session_name:
|
||||
allowed_session_names = await get_peer_session_names(
|
||||
db, workspace_name, observer
|
||||
)
|
||||
if not allowed_session_names:
|
||||
return []
|
||||
|
||||
stmt = select(models.Message).where(models.Message.workspace_name == workspace_name)
|
||||
|
||||
if session_name:
|
||||
stmt = stmt.where(models.Message.session_name == session_name)
|
||||
elif allowed_session_names is not None:
|
||||
stmt = stmt.where(models.Message.session_name.in_(allowed_session_names))
|
||||
if after_date:
|
||||
stmt = stmt.where(models.Message.created_at >= after_date)
|
||||
if before_date:
|
||||
|
|
@ -830,7 +1022,6 @@ async def get_messages_by_date_range(
|
|||
|
||||
|
||||
async def search_messages_temporal(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
query: str,
|
||||
|
|
@ -839,6 +1030,7 @@ async def search_messages_temporal(
|
|||
limit: int = 10,
|
||||
context_window: int = 2,
|
||||
embedding: list[float] | None = None,
|
||||
observer: str | None = None,
|
||||
) -> list[tuple[list[models.Message], list[models.Message]]]:
|
||||
"""
|
||||
Search for messages using semantic similarity with optional date filtering.
|
||||
|
|
@ -847,7 +1039,6 @@ async def search_messages_temporal(
|
|||
to find recent mentions, or before_date to find what was said before a certain point.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session (optional)
|
||||
query: Search query text
|
||||
|
|
@ -856,58 +1047,24 @@ async def search_messages_temporal(
|
|||
limit: Maximum number of matching messages to return
|
||||
context_window: Number of messages before/after each match to include
|
||||
embedding: Optional pre-computed embedding for the query
|
||||
observer: When provided and session_name is None, scope results
|
||||
to sessions this peer belongs to
|
||||
|
||||
Returns:
|
||||
List of tuples: (matched_messages, context_messages)
|
||||
Each snippet may contain multiple matches if they were close together.
|
||||
"""
|
||||
# Use provided embedding or generate one
|
||||
query_embedding = (
|
||||
embedding if embedding is not None else await embedding_client.embed(query)
|
||||
)
|
||||
|
||||
if settings.VECTOR_STORE.TYPE == "pgvector" or not settings.VECTOR_STORE.MIGRATED:
|
||||
# pgvector path: cosine distance in SQL with date filters
|
||||
# Oversample to handle chunk duplicates (see search_messages comment)
|
||||
match_stmt = (
|
||||
select(models.Message)
|
||||
.join(
|
||||
models.MessageEmbedding,
|
||||
models.Message.public_id == models.MessageEmbedding.message_id,
|
||||
)
|
||||
.where(models.MessageEmbedding.workspace_name == workspace_name)
|
||||
)
|
||||
|
||||
if session_name:
|
||||
match_stmt = match_stmt.where(
|
||||
models.MessageEmbedding.session_name == session_name
|
||||
)
|
||||
|
||||
# Apply date filters on the Message table
|
||||
if after_date:
|
||||
match_stmt = match_stmt.where(models.Message.created_at >= after_date)
|
||||
if before_date:
|
||||
match_stmt = match_stmt.where(models.Message.created_at <= before_date)
|
||||
|
||||
# Order by similarity and limit
|
||||
match_stmt = match_stmt.order_by(
|
||||
models.MessageEmbedding.embedding.cosine_distance(query_embedding)
|
||||
).limit(limit * 2)
|
||||
|
||||
result = await db.execute(match_stmt)
|
||||
matched_messages = _deduplicate_messages(result.scalars().all(), limit)
|
||||
else:
|
||||
# External vector store path with post-fetch date filtering
|
||||
matched_messages = await _search_messages_external(
|
||||
db,
|
||||
workspace_name,
|
||||
query_embedding,
|
||||
limit,
|
||||
session_name=session_name,
|
||||
after_date=after_date,
|
||||
before_date=before_date,
|
||||
)
|
||||
|
||||
return await _build_merged_snippets(
|
||||
db, workspace_name, matched_messages, context_window
|
||||
return await _semantic_search_messages(
|
||||
workspace_name,
|
||||
session_name,
|
||||
query_embedding=query_embedding,
|
||||
after_date=after_date,
|
||||
before_date=before_date,
|
||||
limit=limit,
|
||||
context_window=context_window,
|
||||
operation_name="message.search_messages_temporal",
|
||||
observer=observer,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -223,7 +223,11 @@ async def update_peer(
|
|||
db: AsyncSession, workspace_name: str, peer_name: str, peer: schemas.PeerUpdate
|
||||
) -> models.Peer:
|
||||
"""
|
||||
Update a peer.
|
||||
Get or create a peer, then apply metadata and configuration updates.
|
||||
|
||||
If the peer does not exist, the workspace and peer are created first.
|
||||
Provided metadata and configuration replace the existing values when
|
||||
present.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
|
@ -235,9 +239,8 @@ async def update_peer(
|
|||
The updated peer
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the peer does not exist
|
||||
ValidationException: If the update data is invalid
|
||||
ConflictException: If the update violates a unique constraint
|
||||
ConflictException: If concurrent creation prevents fetching or creating
|
||||
the peer
|
||||
"""
|
||||
peers_result = await get_or_create_peers(
|
||||
db, workspace_name, [schemas.PeerCreate(name=peer_name)]
|
||||
|
|
@ -269,7 +272,6 @@ async def update_peer(
|
|||
return honcho_peer
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(honcho_peer)
|
||||
await peers_result.post_commit()
|
||||
|
||||
cache_key = peer_cache_key(workspace_name, honcho_peer.name)
|
||||
|
|
|
|||
|
|
@ -137,21 +137,30 @@ async def get_or_create_session(
|
|||
_retry: bool = False,
|
||||
) -> GetOrCreateResult[models.Session]:
|
||||
"""
|
||||
Get or create a session in a workspace with specified peers.
|
||||
If the session already exists, the peers are added to the session.
|
||||
Get an active session in a workspace or create it if it does not exist.
|
||||
|
||||
If the session already exists, provided metadata replaces the current
|
||||
metadata, provided configuration keys are merged into the existing
|
||||
configuration, and any provided peers are ensured to be members of the
|
||||
session. If the session does not exist, the workspace and peers are created
|
||||
as needed before the session is created.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session: Session creation schema
|
||||
session: Session creation payload, including optional metadata,
|
||||
configuration, and session-peer configuration
|
||||
workspace_name: Name of the workspace
|
||||
peer_names: List of peer names to add to the session
|
||||
_retry: Whether to retry the operation
|
||||
_retry: Whether to retry after a concurrent create conflict
|
||||
|
||||
Returns:
|
||||
GetOrCreateResult containing the session and whether it was created
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the session does not exist and create is false
|
||||
ConflictException: If we fail to get or create the session
|
||||
ValueError: If session.name is empty
|
||||
ResourceNotFoundException: If the named session exists but is inactive
|
||||
ObserverException: If adding peers would exceed the observer limit
|
||||
ConflictException: If concurrent creation prevents fetching or creating
|
||||
the session
|
||||
"""
|
||||
|
||||
if not session.name:
|
||||
|
|
@ -247,10 +256,10 @@ async def get_or_create_session(
|
|||
workspace_name=workspace_name,
|
||||
session_name=session.name,
|
||||
peer_names=session.peer_names,
|
||||
fetch_after_upsert=False,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(honcho_session)
|
||||
|
||||
# Run deferred cache operations from workspace/peer creation
|
||||
if ws_result is not None:
|
||||
|
|
@ -334,7 +343,11 @@ async def update_session(
|
|||
session_name: str,
|
||||
) -> models.Session:
|
||||
"""
|
||||
Update a session.
|
||||
Get or create a session, then apply metadata and configuration updates.
|
||||
|
||||
Provided metadata replaces the current metadata when present. Provided
|
||||
configuration keys are merged into the existing configuration instead of
|
||||
replacing it wholesale.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
|
@ -346,7 +359,9 @@ async def update_session(
|
|||
The updated session
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the session does not exist or peer is not in session
|
||||
ResourceNotFoundException: If the named session exists but is inactive
|
||||
ConflictException: If concurrent creation prevents fetching or creating
|
||||
the session
|
||||
"""
|
||||
honcho_session: models.Session = (
|
||||
await get_or_create_session(
|
||||
|
|
@ -381,7 +396,6 @@ async def update_session(
|
|||
return honcho_session
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(honcho_session)
|
||||
|
||||
# Only invalidate if we actually updated
|
||||
cache_key = session_cache_key(workspace_name, session_name)
|
||||
|
|
@ -729,7 +743,6 @@ async def clone_session(
|
|||
db.add(new_session_peer)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_session)
|
||||
logger.debug("Session %s cloned successfully", original_session_name)
|
||||
|
||||
# Cache will be populated on next read - read-through pattern
|
||||
|
|
@ -795,7 +808,13 @@ async def get_peers_from_session(
|
|||
# Get all active peers in the session (where left_at is NULL)
|
||||
return (
|
||||
select(models.Peer)
|
||||
.join(models.SessionPeer, models.Peer.name == models.SessionPeer.peer_name)
|
||||
.join(
|
||||
models.SessionPeer,
|
||||
and_(
|
||||
models.Peer.name == models.SessionPeer.peer_name,
|
||||
models.Peer.workspace_name == models.SessionPeer.workspace_name,
|
||||
),
|
||||
)
|
||||
.where(models.SessionPeer.session_name == session_name)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.SessionPeer.left_at.is_(None)) # Only active peers
|
||||
|
|
@ -825,7 +844,13 @@ async def get_session_peer_configuration(
|
|||
models.SessionPeer.configuration.label("session_peer_configuration"),
|
||||
(models.SessionPeer.left_at.is_(None)).label("is_active"),
|
||||
)
|
||||
.join(models.SessionPeer, models.Peer.name == models.SessionPeer.peer_name)
|
||||
.join(
|
||||
models.SessionPeer,
|
||||
and_(
|
||||
models.Peer.name == models.SessionPeer.peer_name,
|
||||
models.Peer.workspace_name == models.SessionPeer.workspace_name,
|
||||
),
|
||||
)
|
||||
.where(models.SessionPeer.session_name == session_name)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.SessionPeer.workspace_name == workspace_name)
|
||||
|
|
@ -912,24 +937,35 @@ async def _get_or_add_peers_to_session(
|
|||
workspace_name: str,
|
||||
session_name: str,
|
||||
peer_names: dict[str, schemas.SessionPeerConfig],
|
||||
*,
|
||||
fetch_after_upsert: bool = True,
|
||||
) -> list[models.SessionPeer]:
|
||||
"""
|
||||
Add multiple peers to an existing session. If a peer already exists in the session,
|
||||
it will be skipped gracefully.
|
||||
Upsert session-peer memberships for a session and optionally fetch the
|
||||
active memberships afterward.
|
||||
|
||||
New peers are inserted, peers that previously left the session are rejoined,
|
||||
and already-active peers keep their existing session-level configuration.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
peer_names: Set of peer names to add to the session
|
||||
peer_names: Mapping of peer names to session-level configuration
|
||||
fetch_after_upsert: If True, query and return the active session peers
|
||||
after the upsert. If False, skip that read and return an empty list.
|
||||
|
||||
Returns:
|
||||
List of all SessionPeer objects (both existing and newly created)
|
||||
Active SessionPeer objects after the upsert, or an empty list when the
|
||||
post-upsert fetch is skipped
|
||||
|
||||
Raises:
|
||||
ValueError: If adding peers would exceed the maximum limit
|
||||
ObserverException: If adding peers would exceed the observer limit
|
||||
"""
|
||||
# If no peers to add, skip the insert and just return existing active session peers
|
||||
if not peer_names:
|
||||
if not fetch_after_upsert:
|
||||
return []
|
||||
select_stmt = select(models.SessionPeer).where(
|
||||
models.SessionPeer.session_name == session_name,
|
||||
models.SessionPeer.workspace_name == workspace_name,
|
||||
|
|
@ -994,6 +1030,9 @@ async def _get_or_add_peers_to_session(
|
|||
)
|
||||
await db.execute(stmt)
|
||||
|
||||
if not fetch_after_upsert:
|
||||
return []
|
||||
|
||||
# Return all active session peers after the upsert
|
||||
select_stmt = select(models.SessionPeer).where(
|
||||
models.SessionPeer.session_name == session_name,
|
||||
|
|
|
|||
|
|
@ -18,17 +18,20 @@ async def get_or_create_webhook_endpoint(
|
|||
webhook: schemas.WebhookEndpointCreate,
|
||||
) -> GetOrCreateResult[schemas.WebhookEndpoint]:
|
||||
"""
|
||||
Get or create a webhook endpoint, optionally for a workspace.
|
||||
Get an existing webhook endpoint for a workspace or create it if missing.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
webhook: Webhook endpoint creation schema
|
||||
|
||||
Returns:
|
||||
GetOrCreateResult containing the webhook endpoint and whether it was created
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the workspace is specified and does not exist
|
||||
ResourceNotFoundException: If the workspace does not exist
|
||||
ValueError: If the workspace already has the maximum number of webhook
|
||||
endpoints
|
||||
"""
|
||||
# Verify workspace exists
|
||||
await get_workspace(db, workspace_name=workspace_name)
|
||||
|
|
@ -39,12 +42,6 @@ async def get_or_create_webhook_endpoint(
|
|||
result = await db.execute(stmt)
|
||||
endpoints = result.scalars().all()
|
||||
|
||||
# No more than WORKSPACE_LIMIT webhooks per workspace
|
||||
if len(endpoints) >= settings.WEBHOOK.MAX_WORKSPACE_LIMIT:
|
||||
raise ValueError(
|
||||
f"Maximum number of webhook endpoints ({settings.WEBHOOK.MAX_WORKSPACE_LIMIT}) reached for this workspace."
|
||||
)
|
||||
|
||||
# Check if webhook already exists for this workspace
|
||||
for endpoint in endpoints:
|
||||
if endpoint.url == webhook.url:
|
||||
|
|
@ -52,6 +49,12 @@ async def get_or_create_webhook_endpoint(
|
|||
schemas.WebhookEndpoint.model_validate(endpoint), created=False
|
||||
)
|
||||
|
||||
# No more than WORKSPACE_LIMIT webhooks per workspace
|
||||
if len(endpoints) >= settings.WEBHOOK.MAX_WORKSPACE_LIMIT:
|
||||
raise ValueError(
|
||||
f"Maximum number of webhook endpoints ({settings.WEBHOOK.MAX_WORKSPACE_LIMIT}) reached for this workspace."
|
||||
)
|
||||
|
||||
# Create new webhook endpoint
|
||||
webhook_endpoint = models.WebhookEndpoint(
|
||||
workspace_name=workspace_name,
|
||||
|
|
@ -59,7 +62,6 @@ async def get_or_create_webhook_endpoint(
|
|||
)
|
||||
db.add(webhook_endpoint)
|
||||
await db.commit()
|
||||
await db.refresh(webhook_endpoint)
|
||||
|
||||
logger.debug("Webhook endpoint created: %s", webhook.url)
|
||||
return GetOrCreateResult(
|
||||
|
|
|
|||
|
|
@ -202,7 +202,11 @@ async def update_workspace(
|
|||
db: AsyncSession, workspace_name: str, workspace: schemas.WorkspaceUpdate
|
||||
) -> models.Workspace:
|
||||
"""
|
||||
Update a workspace.
|
||||
Get or create a workspace, then apply metadata and configuration updates.
|
||||
|
||||
Provided metadata replaces the current metadata when present. Provided
|
||||
configuration keys are merged into the existing configuration instead of
|
||||
replacing it wholesale.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
|
@ -211,6 +215,10 @@ async def update_workspace(
|
|||
|
||||
Returns:
|
||||
The updated workspace
|
||||
|
||||
Raises:
|
||||
ConflictException: If concurrent creation prevents fetching or creating
|
||||
the workspace
|
||||
"""
|
||||
ws_result = await get_or_create_workspace(
|
||||
db,
|
||||
|
|
@ -250,7 +258,6 @@ async def update_workspace(
|
|||
return honcho_workspace
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(honcho_workspace)
|
||||
await ws_result.post_commit()
|
||||
|
||||
# Only invalidate if we actually updated
|
||||
|
|
|
|||
|
|
@ -70,8 +70,7 @@ async def process_item(queue_item: models.QueueItem) -> None:
|
|||
queue_payload,
|
||||
)
|
||||
raise ValueError(f"Invalid payload structure: {str(e)}") from e
|
||||
async with tracked_db() as db:
|
||||
await webhook_delivery.deliver_webhook(db, validated, workspace_name)
|
||||
await webhook_delivery.deliver_webhook(validated, workspace_name)
|
||||
|
||||
elif task_type == "summary":
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -56,6 +56,48 @@ class WorkerOwnership(NamedTuple):
|
|||
aqs_id: str # The ID of the ActiveQueueSession that the worker is processing
|
||||
|
||||
|
||||
def _detach_queue_batch_objects(
|
||||
db: AsyncSession,
|
||||
messages_context: list[models.Message],
|
||||
items_to_process: list[QueueItem],
|
||||
) -> None:
|
||||
"""Detach loaded batch objects so they remain usable after tracked_db exits."""
|
||||
seen: set[int] = set()
|
||||
for obj in [*messages_context, *items_to_process]:
|
||||
obj_id = id(obj)
|
||||
if obj_id in seen:
|
||||
continue
|
||||
db.expunge(obj)
|
||||
seen.add(obj_id)
|
||||
|
||||
|
||||
def _resolve_batch_configuration(
|
||||
items_to_process: list[QueueItem],
|
||||
) -> tuple[list[QueueItem], ResolvedConfiguration | None]:
|
||||
"""Keep only the initial homogeneous configuration prefix for a batch."""
|
||||
if not items_to_process:
|
||||
return [], None
|
||||
|
||||
raw_config = items_to_process[0].payload.get("configuration")
|
||||
resolved_config = (
|
||||
None if raw_config is None else ResolvedConfiguration.model_validate(raw_config)
|
||||
)
|
||||
|
||||
valid_items: list[QueueItem] = []
|
||||
for item in items_to_process:
|
||||
item_raw_config = item.payload.get("configuration")
|
||||
item_config = (
|
||||
None
|
||||
if item_raw_config is None
|
||||
else ResolvedConfiguration.model_validate(item_raw_config)
|
||||
)
|
||||
if item_config != resolved_config:
|
||||
break
|
||||
valid_items.append(item)
|
||||
|
||||
return valid_items, resolved_config
|
||||
|
||||
|
||||
class QueueManager:
|
||||
def __init__(self):
|
||||
self.shutdown_event: asyncio.Event = asyncio.Event()
|
||||
|
|
@ -608,21 +650,19 @@ class QueueManager:
|
|||
)
|
||||
|
||||
batch_max_tokens = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS
|
||||
parsed_key = parse_work_unit_key(work_unit_key)
|
||||
messages_context: list[models.Message] = []
|
||||
items_to_process: list[QueueItem] = []
|
||||
|
||||
async with tracked_db("get_queue_item_batch") as db:
|
||||
# For batch tasks, get messages based on token limit.
|
||||
# Step 1: Parse work_unit_key to get session context and focused sender
|
||||
parsed_key = parse_work_unit_key(work_unit_key)
|
||||
|
||||
# Verify worker still owns the work_unit_key
|
||||
# Step 1: Verify worker still owns the work_unit_key.
|
||||
ownership_check = await db.execute(
|
||||
select(models.ActiveQueueSession.id)
|
||||
.where(models.ActiveQueueSession.work_unit_key == work_unit_key)
|
||||
.where(models.ActiveQueueSession.id == aqs_id)
|
||||
)
|
||||
if not ownership_check.scalar_one_or_none():
|
||||
# Worker lost ownership, return empty
|
||||
await db.commit()
|
||||
return [], [], None
|
||||
|
||||
# Step 2: Build a single SQL query that:
|
||||
|
|
@ -716,11 +756,8 @@ class QueueManager:
|
|||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
if not rows:
|
||||
await db.commit()
|
||||
return [], [], None
|
||||
|
||||
messages_context: list[models.Message] = []
|
||||
items_to_process: list[QueueItem] = []
|
||||
seen_messages: set[int] = set()
|
||||
for m, qi in rows:
|
||||
if m.id not in seen_messages:
|
||||
|
|
@ -729,48 +766,21 @@ class QueueManager:
|
|||
if qi is not None:
|
||||
items_to_process.append(qi)
|
||||
|
||||
if items_to_process:
|
||||
# Enforce homogeneous peer_card_config in the batch
|
||||
# We stop collecting items as soon as we encounter a different configuration
|
||||
payload = items_to_process[0].payload
|
||||
_detach_queue_batch_objects(db, messages_context, items_to_process)
|
||||
|
||||
raw_config = payload.get("configuration")
|
||||
if raw_config is None:
|
||||
resolved_config = None
|
||||
else:
|
||||
resolved_config = ResolvedConfiguration.model_validate(raw_config)
|
||||
items_to_process, resolved_config = _resolve_batch_configuration(
|
||||
items_to_process
|
||||
)
|
||||
|
||||
valid_items: list[QueueItem] = []
|
||||
for item in items_to_process:
|
||||
item_raw_config = item.payload.get("configuration")
|
||||
if item_raw_config is None:
|
||||
item_config = None
|
||||
else:
|
||||
item_config = ResolvedConfiguration.model_validate(
|
||||
item_raw_config
|
||||
)
|
||||
if item_config != resolved_config:
|
||||
break
|
||||
valid_items.append(item)
|
||||
items_to_process = valid_items
|
||||
else:
|
||||
resolved_config = None
|
||||
if items_to_process:
|
||||
max_queue_item_message_id = max(
|
||||
qi.message_id for qi in items_to_process if qi.message_id is not None
|
||||
)
|
||||
messages_context = [
|
||||
m for m in messages_context if m.id <= max_queue_item_message_id
|
||||
]
|
||||
|
||||
if items_to_process:
|
||||
max_queue_item_message_id = max(
|
||||
[
|
||||
qi.message_id
|
||||
for qi in items_to_process
|
||||
if qi.message_id is not None
|
||||
]
|
||||
)
|
||||
messages_context = [ # remove any messages that are after the last message_id from queue items
|
||||
m for m in messages_context if m.id <= max_queue_item_message_id
|
||||
]
|
||||
|
||||
await db.commit()
|
||||
|
||||
return messages_context, items_to_process, resolved_config
|
||||
return messages_context, items_to_process, resolved_config
|
||||
|
||||
async def mark_queue_items_as_processed(
|
||||
self, items: list[QueueItem], work_unit_key: str
|
||||
|
|
|
|||
|
|
@ -196,6 +196,12 @@ app.include_router(webhooks.router, prefix="/v3")
|
|||
app.add_route("/metrics", metrics_endpoint, methods=["GET"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for monitoring and container orchestration."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# Global exception handlers
|
||||
@app.exception_handler(HonchoException)
|
||||
async def honcho_exception_handler(_request: Request, exc: HonchoException):
|
||||
|
|
|
|||
|
|
@ -446,11 +446,10 @@ async def search_peer(
|
|||
...,
|
||||
description="Message search parameters. Use `limit` to control the number of results returned.",
|
||||
),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""Search a Peer's messages, optionally filtered by various criteria."""
|
||||
# take user-provided filter and add workspace_id and peer_id to it
|
||||
filters = body.filters or {}
|
||||
filters["workspace_id"] = workspace_id
|
||||
filters["peer_id"] = peer_id
|
||||
return await search(db, body.query, filters=filters, limit=body.limit)
|
||||
return await search(body.query, filters=filters, limit=body.limit)
|
||||
|
|
|
|||
|
|
@ -794,7 +794,6 @@ async def search_session(
|
|||
body: schemas.MessageSearchOptions = Body(
|
||||
..., description="Message search parameters"
|
||||
),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""
|
||||
Search a Session with optional filters. Use `limit` to control the number of results returned.
|
||||
|
|
@ -804,7 +803,6 @@ async def search_session(
|
|||
filters["workspace_id"] = workspace_id
|
||||
filters["session_id"] = session_id
|
||||
return await search(
|
||||
db,
|
||||
body.query,
|
||||
filters=filters,
|
||||
limit=body.limit,
|
||||
|
|
|
|||
|
|
@ -142,7 +142,6 @@ async def search_workspace(
|
|||
body: schemas.MessageSearchOptions = Body(
|
||||
..., description="Message search parameters"
|
||||
),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""
|
||||
Search messages in a Workspace using optional filters. Use `limit` to control the number of
|
||||
|
|
@ -151,7 +150,7 @@ async def search_workspace(
|
|||
# take user-provided filter and add workspace_id to it
|
||||
filters = body.filters or {}
|
||||
filters["workspace_id"] = workspace_id
|
||||
return await search(db, body.query, filters=filters, limit=body.limit)
|
||||
return await search(body.query, filters=filters, limit=body.limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
|
|||
|
|
@ -854,6 +854,7 @@ async def get_observation_context(
|
|||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
message_ids: list[str],
|
||||
observer: str | None = None,
|
||||
) -> list[models.Message]:
|
||||
"""
|
||||
Retrieve messages for given message IDs along with surrounding context.
|
||||
|
|
@ -867,6 +868,8 @@ async def get_observation_context(
|
|||
workspace_name: Workspace identifier
|
||||
session_name: Session identifier (optional)
|
||||
message_ids: List of message IDs to retrieve
|
||||
observer: When provided and session_name is None, scope results
|
||||
to sessions this peer belongs to
|
||||
|
||||
Returns:
|
||||
List of messages in chronological order, including the requested messages and surrounding context
|
||||
|
|
@ -874,6 +877,17 @@ async def get_observation_context(
|
|||
if not message_ids:
|
||||
return []
|
||||
|
||||
# Pre-fetch peer session scope if needed
|
||||
allowed_session_names: list[str] | None = None
|
||||
if observer and not session_name:
|
||||
from src.crud.message import get_peer_session_names
|
||||
|
||||
allowed_session_names = await get_peer_session_names(
|
||||
db, workspace_name, observer
|
||||
)
|
||||
if not allowed_session_names:
|
||||
return []
|
||||
|
||||
# Use a CTE to get seq_in_session values for target messages
|
||||
stmt = (
|
||||
select(models.Message.seq_in_session)
|
||||
|
|
@ -883,6 +897,8 @@ async def get_observation_context(
|
|||
|
||||
if session_name:
|
||||
stmt = stmt.where(models.Message.session_name == session_name)
|
||||
elif allowed_session_names is not None:
|
||||
stmt = stmt.where(models.Message.session_name.in_(allowed_session_names))
|
||||
|
||||
target_seqs_cte = stmt.cte("target_seqs")
|
||||
|
||||
|
|
@ -905,6 +921,8 @@ async def get_observation_context(
|
|||
|
||||
if session_name:
|
||||
stmt = stmt.where(models.Message.session_name == session_name)
|
||||
elif allowed_session_names is not None:
|
||||
stmt = stmt.where(models.Message.session_name.in_(allowed_session_names))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
messages = list(result.scalars().all())
|
||||
|
|
@ -916,6 +934,7 @@ async def extract_preferences(
|
|||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
observed: str,
|
||||
observer: str | None = None,
|
||||
) -> dict[str, list[str]]:
|
||||
"""
|
||||
Extract user preferences and standing instructions from conversation history.
|
||||
|
|
@ -927,6 +946,8 @@ async def extract_preferences(
|
|||
workspace_name: Workspace identifier
|
||||
session_name: Session identifier (optional)
|
||||
observed: The peer whose preferences to extract
|
||||
observer: When provided and session_name is None, scope results
|
||||
to sessions this peer belongs to
|
||||
|
||||
Returns:
|
||||
Dict with 'messages' list containing potentially relevant messages
|
||||
|
|
@ -959,27 +980,26 @@ async def extract_preferences(
|
|||
|
||||
for query in semantic_queries:
|
||||
try:
|
||||
async with tracked_db("extract_preferences") as db:
|
||||
snippets = await crud.search_messages(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
query=query,
|
||||
limit=10,
|
||||
context_window=0,
|
||||
embedding=(
|
||||
query_embeddings_by_query.get(query)
|
||||
if query_embeddings_by_query is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
for matches, _ in snippets:
|
||||
for msg in matches:
|
||||
if msg.peer_name == observed:
|
||||
content_key = msg.content[:100].lower()
|
||||
if content_key not in seen_content:
|
||||
seen_content.add(content_key)
|
||||
messages.append(f"'{msg.content.strip()}'")
|
||||
snippets = await crud.search_messages(
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
query=query,
|
||||
limit=10,
|
||||
context_window=0,
|
||||
embedding=(
|
||||
query_embeddings_by_query.get(query)
|
||||
if query_embeddings_by_query is not None
|
||||
else None
|
||||
),
|
||||
observer=observer,
|
||||
)
|
||||
for matches, _ in snippets:
|
||||
for msg in matches:
|
||||
if msg.peer_name == observed:
|
||||
content_key = msg.content[:100].lower()
|
||||
if content_key not in seen_content:
|
||||
seen_content.add(content_key)
|
||||
messages.append(f"'{msg.content.strip()}'")
|
||||
except Exception as e:
|
||||
logger.warning("Error in semantic search for '%s': %s", query, e)
|
||||
|
||||
|
|
@ -1265,20 +1285,19 @@ async def _handle_search_memory(ctx: ToolContext, tool_input: dict[str, Any]) ->
|
|||
if ctx.agent_type == "dialectic":
|
||||
limit = min(_safe_int(tool_input.get("top_k"), 20), 20)
|
||||
message_output = None
|
||||
async with tracked_db("tool.search_memory.fallback") as db:
|
||||
snippets = await crud.search_messages(
|
||||
db,
|
||||
workspace_name=ctx.workspace_name,
|
||||
session_name=ctx.session_name,
|
||||
query=query,
|
||||
limit=limit,
|
||||
context_window=0,
|
||||
embedding=query_embedding,
|
||||
snippets = await crud.search_messages(
|
||||
workspace_name=ctx.workspace_name,
|
||||
session_name=ctx.session_name,
|
||||
query=query,
|
||||
limit=limit,
|
||||
context_window=0,
|
||||
embedding=query_embedding,
|
||||
observer=ctx.observer,
|
||||
)
|
||||
if snippets:
|
||||
message_output = _format_message_snippets(
|
||||
snippets, f"for query '{query}'"
|
||||
)
|
||||
if snippets:
|
||||
message_output = _format_message_snippets(
|
||||
snippets, f"for query '{query}'"
|
||||
)
|
||||
if message_output:
|
||||
return (
|
||||
f"No observations yet. Message search results:\n\n{message_output}"
|
||||
|
|
@ -1302,6 +1321,7 @@ async def _handle_get_observation_context(
|
|||
workspace_name=ctx.workspace_name,
|
||||
session_name=ctx.session_name,
|
||||
message_ids=tool_input["message_ids"],
|
||||
observer=ctx.observer,
|
||||
)
|
||||
if not messages:
|
||||
return f"No messages found for IDs {tool_input['message_ids']}"
|
||||
|
|
@ -1326,19 +1346,18 @@ async def _handle_search_messages(ctx: ToolContext, tool_input: dict[str, Any])
|
|||
# Pre-compute embedding outside DB session to avoid holding a connection
|
||||
# during the external API call (same pattern as _handle_search_memory).
|
||||
query_embedding = await embedding_client.embed(query)
|
||||
async with tracked_db("tool.search_messages") as db:
|
||||
snippets = await crud.search_messages(
|
||||
db,
|
||||
workspace_name=ctx.workspace_name,
|
||||
session_name=ctx.session_name,
|
||||
query=query,
|
||||
limit=limit,
|
||||
context_window=2,
|
||||
embedding=query_embedding,
|
||||
)
|
||||
if not snippets:
|
||||
return f"No messages found for query '{query}'"
|
||||
formatted = _format_message_snippets(snippets, f"for query '{query}'")
|
||||
snippets = await crud.search_messages(
|
||||
workspace_name=ctx.workspace_name,
|
||||
session_name=ctx.session_name,
|
||||
query=query,
|
||||
limit=limit,
|
||||
context_window=2,
|
||||
embedding=query_embedding,
|
||||
observer=ctx.observer,
|
||||
)
|
||||
if not snippets:
|
||||
return f"No messages found for query '{query}'"
|
||||
formatted = _format_message_snippets(snippets, f"for query '{query}'")
|
||||
return formatted
|
||||
|
||||
|
||||
|
|
@ -1352,35 +1371,32 @@ async def _handle_grep_messages(ctx: ToolContext, tool_input: dict[str, Any]) ->
|
|||
_safe_int(tool_input.get("context_window"), 2), 2
|
||||
) # Cap context
|
||||
|
||||
async with tracked_db("tool.grep_messages") as db:
|
||||
snippets = await crud.grep_messages(
|
||||
db,
|
||||
workspace_name=ctx.workspace_name,
|
||||
session_name=ctx.session_name,
|
||||
text=text,
|
||||
limit=limit,
|
||||
context_window=context_window,
|
||||
)
|
||||
if not snippets:
|
||||
return f"No messages found containing '{text}'"
|
||||
snippets = await crud.grep_messages(
|
||||
workspace_name=ctx.workspace_name,
|
||||
session_name=ctx.session_name,
|
||||
text=text,
|
||||
limit=limit,
|
||||
context_window=context_window,
|
||||
observer=ctx.observer,
|
||||
)
|
||||
if not snippets:
|
||||
return f"No messages found containing '{text}'"
|
||||
|
||||
# Format with pattern-based snippet extraction
|
||||
snippet_texts: list[str] = []
|
||||
total_matches = sum(len(matches) for matches, _ in snippets)
|
||||
for i, (matches, context) in enumerate(snippets, 1):
|
||||
lines: list[str] = []
|
||||
for msg in context:
|
||||
truncated = _extract_pattern_snippet(msg.content, text)
|
||||
lines.append(
|
||||
format_new_turn_with_timestamp(
|
||||
truncated, msg.created_at, msg.peer_name
|
||||
)
|
||||
)
|
||||
sess = context[0].session_name if context else "unknown"
|
||||
snippet_texts.append(
|
||||
f"--- Snippet {i} (session: {sess}, {len(matches)} match(es)) ---\n"
|
||||
+ "\n".join(lines)
|
||||
# Format with pattern-based snippet extraction
|
||||
snippet_texts: list[str] = []
|
||||
total_matches = sum(len(matches) for matches, _ in snippets)
|
||||
for i, (matches, context) in enumerate(snippets, 1):
|
||||
lines: list[str] = []
|
||||
for msg in context:
|
||||
truncated = _extract_pattern_snippet(msg.content, text)
|
||||
lines.append(
|
||||
format_new_turn_with_timestamp(truncated, msg.created_at, msg.peer_name)
|
||||
)
|
||||
sess = context[0].session_name if context else "unknown"
|
||||
snippet_texts.append(
|
||||
f"--- Snippet {i} (session: {sess}, {len(matches)} match(es)) ---\n"
|
||||
+ "\n".join(lines)
|
||||
)
|
||||
|
||||
output = (
|
||||
f"Found {total_matches} messages containing '{text}' in {len(snippets)} conversation snippets:\n\n"
|
||||
|
|
@ -1425,6 +1441,7 @@ async def _handle_get_messages_by_date_range(
|
|||
before_date=before_date,
|
||||
limit=limit,
|
||||
order=order,
|
||||
observer=ctx.observer,
|
||||
)
|
||||
msg_count = len(messages)
|
||||
messages_text = (
|
||||
|
|
@ -1483,31 +1500,28 @@ async def _handle_search_messages_temporal(
|
|||
# Pre-compute embedding outside DB session to avoid holding a connection
|
||||
# during the external API call.
|
||||
query_embedding = await embedding_client.embed(query)
|
||||
async with tracked_db("tool.search_messages_temporal") as db:
|
||||
snippets = await crud.search_messages_temporal(
|
||||
db,
|
||||
workspace_name=ctx.workspace_name,
|
||||
session_name=ctx.session_name,
|
||||
query=query,
|
||||
after_date=after_date,
|
||||
before_date=before_date,
|
||||
limit=limit,
|
||||
context_window=context_window,
|
||||
embedding=query_embedding,
|
||||
)
|
||||
date_filter: list[str] = []
|
||||
if after_date_str:
|
||||
date_filter.append(f"after {after_date_str}")
|
||||
if before_date_str:
|
||||
date_filter.append(f"before {before_date_str}")
|
||||
filter_desc = f" ({' and '.join(date_filter)})" if date_filter else ""
|
||||
snippets = await crud.search_messages_temporal(
|
||||
workspace_name=ctx.workspace_name,
|
||||
session_name=ctx.session_name,
|
||||
query=query,
|
||||
after_date=after_date,
|
||||
before_date=before_date,
|
||||
limit=limit,
|
||||
context_window=context_window,
|
||||
embedding=query_embedding,
|
||||
observer=ctx.observer,
|
||||
)
|
||||
date_filter: list[str] = []
|
||||
if after_date_str:
|
||||
date_filter.append(f"after {after_date_str}")
|
||||
if before_date_str:
|
||||
date_filter.append(f"before {before_date_str}")
|
||||
filter_desc = f" ({' and '.join(date_filter)})" if date_filter else ""
|
||||
|
||||
if not snippets:
|
||||
return f"No messages found for query '{query}'{filter_desc}"
|
||||
if not snippets:
|
||||
return f"No messages found for query '{query}'{filter_desc}"
|
||||
|
||||
formatted = _format_message_snippets(
|
||||
snippets, f"for query '{query}'{filter_desc}"
|
||||
)
|
||||
formatted = _format_message_snippets(snippets, f"for query '{query}'{filter_desc}")
|
||||
return formatted
|
||||
|
||||
|
||||
|
|
@ -1659,6 +1673,7 @@ async def _handle_extract_preferences(
|
|||
workspace_name=ctx.workspace_name,
|
||||
session_name=ctx.session_name,
|
||||
observed=ctx.observed,
|
||||
observer=ctx.observer,
|
||||
)
|
||||
|
||||
messages = results.get("messages", [])
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from src import models
|
||||
from src.config import settings
|
||||
from src.dependencies import tracked_db
|
||||
from src.embedding_client import embedding_client
|
||||
from src.exceptions import ValidationException
|
||||
from src.models import session_peers_table
|
||||
|
|
@ -23,6 +24,13 @@ from src.vector_store import get_external_vector_store
|
|||
T = TypeVar("T")
|
||||
|
||||
|
||||
def _uses_pgvector_message_search() -> bool:
|
||||
"""Return True when semantic message search can stay entirely in Postgres."""
|
||||
return (
|
||||
settings.VECTOR_STORE.TYPE == "pgvector" or not settings.VECTOR_STORE.MIGRATED
|
||||
)
|
||||
|
||||
|
||||
def reciprocal_rank_fusion(*ranked_lists: list[T], k: int = 60, limit: int) -> list[T]:
|
||||
"""
|
||||
Combine multiple ranked lists using Reciprocal Rank Fusion (RRF).
|
||||
|
|
@ -65,122 +73,115 @@ def reciprocal_rank_fusion(*ranked_lists: list[T], k: int = 60, limit: int) -> l
|
|||
return result[:limit]
|
||||
|
||||
|
||||
async def _semantic_search(
|
||||
db: AsyncSession,
|
||||
query: str,
|
||||
async def query_external_vector_message_ids(
|
||||
workspace_name: str,
|
||||
embedding_query: list[float],
|
||||
limit: int,
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> list[models.Message]:
|
||||
"""
|
||||
Perform semantic search using external vector store for message embeddings.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
query: Search query
|
||||
workspace_name: Name of the workspace to search in
|
||||
limit: Maximum number of results to return
|
||||
filters: Optional filters to apply at vector store level (supports: session_id, peer_id)
|
||||
|
||||
Returns:
|
||||
list of messages ordered by semantic similarity
|
||||
"""
|
||||
try:
|
||||
embedding_query = await embedding_client.embed(query)
|
||||
except ValueError as e:
|
||||
raise ValidationException(
|
||||
f"Query exceeds maximum token limit of {settings.MAX_EMBEDDING_TOKENS}."
|
||||
) from e
|
||||
|
||||
# Query Postgres / pgvector directly
|
||||
if settings.EMBED_MESSAGES and (
|
||||
settings.VECTOR_STORE.TYPE == "pgvector" or not settings.VECTOR_STORE.MIGRATED
|
||||
):
|
||||
# Join message_embeddings with messages to get full message objects
|
||||
distance_expr = models.MessageEmbedding.embedding.cosine_distance(
|
||||
embedding_query
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(models.Message)
|
||||
.join(
|
||||
models.MessageEmbedding,
|
||||
models.Message.public_id == models.MessageEmbedding.message_id,
|
||||
)
|
||||
.where(models.MessageEmbedding.embedding.isnot(None))
|
||||
.where(models.MessageEmbedding.workspace_name == workspace_name)
|
||||
)
|
||||
|
||||
# Apply all additional filters using the standard filter utility
|
||||
# filters dict uses external names (session_id, peer_id) which apply_filter will map
|
||||
# to internal column names (session_name, peer_name)
|
||||
if filters:
|
||||
# Create a copy with workspace added
|
||||
internal_filters = filters.copy()
|
||||
internal_filters["workspace_id"] = workspace_name
|
||||
stmt = apply_filter(stmt, models.Message, internal_filters)
|
||||
|
||||
# Order by cosine distance and limit
|
||||
stmt = stmt.order_by(distance_expr).limit(limit)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
# FALLBACK: Use external vector store (Turbopuffer, LanceDB)
|
||||
) -> list[str]:
|
||||
"""Query the external vector store and return ordered message IDs."""
|
||||
external_vector_store = get_external_vector_store()
|
||||
if external_vector_store is None:
|
||||
return []
|
||||
|
||||
namespace = external_vector_store.get_vector_namespace("message", workspace_name)
|
||||
|
||||
# Build vector store filters from the provided filters
|
||||
vector_filters: dict[str, Any] = {}
|
||||
if filters:
|
||||
# Map external filter keys to vector store metadata keys
|
||||
if "session_id" in filters:
|
||||
vector_filters["session_name"] = filters["session_id"]
|
||||
if "peer_id" in filters:
|
||||
vector_filters["peer_name"] = filters["peer_id"]
|
||||
|
||||
# Query external vector store for similar message embeddings
|
||||
# Since all filters are applied at the vector store level, we don't need to oversample
|
||||
# Oversample: multiple chunk-level hits can map to the same message,
|
||||
# so fetch extra to ensure enough unique messages after deduplication.
|
||||
vector_results = await external_vector_store.query(
|
||||
namespace,
|
||||
embedding_query,
|
||||
top_k=limit,
|
||||
top_k=limit * 3,
|
||||
filters=vector_filters if vector_filters else None,
|
||||
)
|
||||
|
||||
if not vector_results:
|
||||
return []
|
||||
|
||||
# Extract message IDs from vector metadata
|
||||
# Use dict to deduplicate while preserving order (dict keys maintain insertion order in Python 3.7+)
|
||||
seen_message_ids: dict[str, None] = {}
|
||||
|
||||
for result in vector_results:
|
||||
message_id = result.metadata.get("message_id")
|
||||
if message_id and message_id not in seen_message_ids:
|
||||
seen_message_ids[message_id] = None
|
||||
|
||||
message_ids = list(seen_message_ids.keys())
|
||||
return list(seen_message_ids.keys())
|
||||
|
||||
# Fetch messages from database by the IDs from vector search and reapply filters
|
||||
semantic_query = select(models.Message).where(
|
||||
models.Message.public_id.in_(message_ids)
|
||||
)
|
||||
semantic_query = apply_filter(semantic_query, models.Message, filters)
|
||||
|
||||
result = await db.execute(semantic_query)
|
||||
async def fetch_messages_by_ids(
|
||||
db: AsyncSession,
|
||||
message_ids: list[str],
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> list[models.Message]:
|
||||
"""Fetch messages by ID and preserve the input ordering."""
|
||||
if not message_ids:
|
||||
return []
|
||||
|
||||
stmt = select(models.Message).where(models.Message.public_id.in_(message_ids))
|
||||
stmt = apply_filter(stmt, models.Message, filters)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
messages = {msg.public_id: msg for msg in result.scalars().all()}
|
||||
|
||||
# Return messages in order of similarity (preserving vector store order)
|
||||
ordered_messages: list[models.Message] = []
|
||||
for msg_id in message_ids:
|
||||
if msg_id in messages:
|
||||
ordered_messages.append(messages[msg_id])
|
||||
return [messages[msg_id] for msg_id in message_ids if msg_id in messages]
|
||||
|
||||
return ordered_messages
|
||||
|
||||
async def _semantic_search_pgvector(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
embedding_query: list[float],
|
||||
limit: int,
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> list[models.Message]:
|
||||
"""
|
||||
Perform semantic message search using pgvector in Postgres.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace to search in
|
||||
embedding_query: Pre-computed embedding for the search query
|
||||
limit: Maximum number of results to return
|
||||
filters: Optional filters to apply to the message query
|
||||
|
||||
Returns:
|
||||
list of messages ordered by semantic similarity
|
||||
"""
|
||||
distance_expr = models.MessageEmbedding.embedding.cosine_distance(embedding_query)
|
||||
|
||||
stmt = (
|
||||
select(models.Message)
|
||||
.join(
|
||||
models.MessageEmbedding,
|
||||
models.Message.public_id == models.MessageEmbedding.message_id,
|
||||
)
|
||||
.where(models.MessageEmbedding.embedding.isnot(None))
|
||||
.where(models.MessageEmbedding.workspace_name == workspace_name)
|
||||
)
|
||||
|
||||
if filters:
|
||||
internal_filters = filters.copy()
|
||||
internal_filters["workspace_id"] = workspace_name
|
||||
stmt = apply_filter(stmt, models.Message, internal_filters)
|
||||
|
||||
# Oversample because a message with multiple embedding chunks can
|
||||
# produce duplicate rows; we deduplicate in Python to preserve HNSW
|
||||
# index usage (a DISTINCT ON subquery would prevent the index scan).
|
||||
stmt = stmt.order_by(distance_expr).limit(limit * 2)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
seen: set[str] = set()
|
||||
deduped: list[models.Message] = []
|
||||
for msg in result.scalars().all():
|
||||
if msg.public_id not in seen:
|
||||
seen.add(msg.public_id)
|
||||
deduped.append(msg)
|
||||
return deduped[:limit]
|
||||
|
||||
|
||||
async def _filter_by_peer_perspective(
|
||||
|
|
@ -308,7 +309,6 @@ async def _fulltext_search(
|
|||
|
||||
|
||||
async def search(
|
||||
db: AsyncSession,
|
||||
query: str,
|
||||
*,
|
||||
filters: dict[str, Any] | None = None,
|
||||
|
|
@ -321,7 +321,6 @@ async def search(
|
|||
are available, providing better search results than either method alone.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
query: Search query to match against message content
|
||||
filters: Optional filters to scope search (must include workspace_id for semantic search).
|
||||
Special filter 'peer_perspective' will search across all messages from sessions that the peer is/was a member of,
|
||||
|
|
@ -368,50 +367,81 @@ async def search(
|
|||
|
||||
stmt = apply_filter(stmt, models.Message, filters)
|
||||
|
||||
search_results: list[list[models.Message]] = []
|
||||
workspace_name: str | None = None
|
||||
if filters:
|
||||
workspace_value = filters.get("workspace_id") or filters.get("workspace_name")
|
||||
if isinstance(workspace_value, str):
|
||||
workspace_name = workspace_value
|
||||
|
||||
semantic_limit = limit * 4 if peer_perspective_name else limit * 2
|
||||
query_embedding: list[float] | None = None
|
||||
semantic_message_ids: list[str] | None = None
|
||||
|
||||
# Perform semantic search if enabled and we have workspace context
|
||||
# workspace_id is required for semantic search to determine the vector namespace
|
||||
workspace_name: str | None = filters.get("workspace_id") if filters else None
|
||||
if settings.EMBED_MESSAGES and isinstance(workspace_name, str):
|
||||
# Type narrowing: workspace_name is guaranteed to be str in this block
|
||||
# Get more results for fusion (increase if peer_perspective filtering is applied post-search)
|
||||
semantic_limit = limit * 4 if peer_perspective_name else limit * 2
|
||||
semantic_results = await _semantic_search(
|
||||
db=db,
|
||||
query=query,
|
||||
workspace_name=workspace_name,
|
||||
limit=semantic_limit,
|
||||
filters=filters,
|
||||
)
|
||||
try:
|
||||
query_embedding = await embedding_client.embed(query)
|
||||
except ValueError as e:
|
||||
raise ValidationException(
|
||||
f"Query exceeds maximum token limit of {settings.MAX_EMBEDDING_TOKENS}."
|
||||
) from e
|
||||
|
||||
# Apply peer_perspective filtering to semantic results if needed
|
||||
# Vector store can't handle temporal filtering (joined_at/left_at), so filter post-search
|
||||
if peer_perspective_name:
|
||||
semantic_results = await _filter_by_peer_perspective(
|
||||
db, semantic_results, workspace_name, peer_perspective_name
|
||||
if not _uses_pgvector_message_search():
|
||||
semantic_message_ids = await query_external_vector_message_ids(
|
||||
workspace_name=workspace_name,
|
||||
embedding_query=query_embedding,
|
||||
limit=semantic_limit,
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
search_results.append(semantic_results)
|
||||
async def _run_search(active_db: AsyncSession) -> list[models.Message]:
|
||||
search_results: list[list[models.Message]] = []
|
||||
|
||||
# Perform full-text search
|
||||
# Get more results for fusion
|
||||
fulltext_limit = limit * 2
|
||||
fulltext_results = await _fulltext_search(
|
||||
db=db, query=query, stmt=stmt, limit=fulltext_limit
|
||||
)
|
||||
search_results.append(fulltext_results)
|
||||
if (
|
||||
settings.EMBED_MESSAGES
|
||||
and isinstance(workspace_name, str)
|
||||
and query_embedding is not None
|
||||
):
|
||||
if _uses_pgvector_message_search():
|
||||
semantic_results = await _semantic_search_pgvector(
|
||||
db=active_db,
|
||||
workspace_name=workspace_name,
|
||||
embedding_query=query_embedding,
|
||||
limit=semantic_limit,
|
||||
filters=filters,
|
||||
)
|
||||
else:
|
||||
semantic_results = await fetch_messages_by_ids(
|
||||
db=active_db,
|
||||
message_ids=semantic_message_ids or [],
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
# Combine results using RRF if we have multiple search methods
|
||||
if len(search_results) > 1:
|
||||
# Use RRF to combine semantic and full-text results
|
||||
combined_results = reciprocal_rank_fusion(*search_results, limit=limit)
|
||||
elif len(search_results) == 1:
|
||||
# Single search method - apply limit directly
|
||||
combined_results = search_results[0]
|
||||
combined_results = combined_results[:limit]
|
||||
else:
|
||||
# No search results
|
||||
combined_results = []
|
||||
if peer_perspective_name:
|
||||
semantic_results = await _filter_by_peer_perspective(
|
||||
active_db,
|
||||
semantic_results,
|
||||
workspace_name,
|
||||
peer_perspective_name,
|
||||
)
|
||||
|
||||
return combined_results
|
||||
search_results.append(semantic_results)
|
||||
|
||||
fulltext_results = await _fulltext_search(
|
||||
db=active_db,
|
||||
query=query,
|
||||
stmt=stmt,
|
||||
limit=limit * 2,
|
||||
)
|
||||
search_results.append(fulltext_results)
|
||||
|
||||
if len(search_results) > 1:
|
||||
return reciprocal_rank_fusion(*search_results, limit=limit)
|
||||
if len(search_results) == 1:
|
||||
return search_results[0][:limit]
|
||||
return []
|
||||
|
||||
async with tracked_db("search.messages") as managed_db:
|
||||
combined_results = await _run_search(managed_db)
|
||||
for message in combined_results:
|
||||
managed_db.expunge(message)
|
||||
return combined_results
|
||||
|
|
|
|||
|
|
@ -9,42 +9,41 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from src.config import settings
|
||||
from src.crud.webhook import list_webhook_endpoints
|
||||
from src.dependencies import tracked_db
|
||||
from src.utils.formatting import utc_now_iso
|
||||
from src.utils.queue_payload import WebhookPayload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def deliver_webhook(
|
||||
db: AsyncSession, payload: WebhookPayload, workspace_name: str
|
||||
) -> None:
|
||||
async def deliver_webhook(payload: WebhookPayload, workspace_name: str) -> None:
|
||||
"""
|
||||
Deliver a single webhook event to its configured endpoints.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
try:
|
||||
async with tracked_db("webhook.deliver") as db:
|
||||
webhook_urls = await _get_webhook_urls(db, workspace_name)
|
||||
if not webhook_urls:
|
||||
logger.debug(
|
||||
f"No webhook endpoints for workspace {workspace_name}, skipping."
|
||||
)
|
||||
return
|
||||
|
||||
event_payload = {
|
||||
"type": payload.event_type,
|
||||
"data": payload.data,
|
||||
"timestamp": utc_now_iso(),
|
||||
}
|
||||
event_json = json.dumps(
|
||||
event_payload, separators=(",", ":"), sort_keys=True
|
||||
if not webhook_urls:
|
||||
logger.debug(
|
||||
f"No webhook endpoints for workspace {workspace_name}, skipping."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
signature = _generate_webhook_signature(event_json)
|
||||
except ValueError:
|
||||
logger.exception("Failed to generate webhook signature")
|
||||
return
|
||||
event_payload = {
|
||||
"type": payload.event_type,
|
||||
"data": payload.data,
|
||||
"timestamp": utc_now_iso(),
|
||||
}
|
||||
event_json = json.dumps(event_payload, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
try:
|
||||
signature = _generate_webhook_signature(event_json)
|
||||
except ValueError:
|
||||
logger.exception("Failed to generate webhook signature")
|
||||
return
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
tasks = [
|
||||
client.post(
|
||||
url=url,
|
||||
|
|
@ -73,10 +72,10 @@ async def deliver_webhook(
|
|||
f"Failed delivery for {payload.event_type} to {url}. Exception: {result}"
|
||||
)
|
||||
|
||||
except httpx.RequestError:
|
||||
logger.exception(f"Error sending webhook for {workspace_name}.")
|
||||
except Exception:
|
||||
logger.exception("Unexpected error delivering webhook.")
|
||||
except httpx.RequestError:
|
||||
logger.exception(f"Error sending webhook for {workspace_name}.")
|
||||
except Exception:
|
||||
logger.exception("Unexpected error delivering webhook.")
|
||||
|
||||
|
||||
async def _get_webhook_urls(db: AsyncSession, workspace_name: str) -> list[str]:
|
||||
|
|
|
|||
|
|
@ -441,10 +441,6 @@ class BaseRunner(ABC, Generic[ResultT]):
|
|||
f"{self.get_metrics_prefix()}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
)
|
||||
self.logger: Logger = configure_logging()
|
||||
# Semaphore for rate limiting concurrent item execution
|
||||
self._concurrency_semaphore: asyncio.Semaphore | None = (
|
||||
asyncio.Semaphore(config.max_concurrent) if config.max_concurrent else None
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Abstract methods - must be implemented by subclasses
|
||||
|
|
@ -559,59 +555,64 @@ class BaseRunner(ABC, Generic[ResultT]):
|
|||
print(f"Limiting to {self.config.max_concurrent} concurrent item(s)")
|
||||
|
||||
overall_start = time.time()
|
||||
all_results: list[ResultT] = []
|
||||
all_results: list[ResultT | None] = [None] * len(items)
|
||||
|
||||
# Process in batches
|
||||
batch_size = self.config.batch_size
|
||||
for i in range(0, len(items), batch_size):
|
||||
batch = items[i : i + batch_size]
|
||||
batch_num = (i // batch_size) + 1
|
||||
total_batches = (len(items) + batch_size - 1) // batch_size
|
||||
# Two-level concurrency:
|
||||
# - inflight_sem limits how many items may be in the pipeline at once
|
||||
# - active_sem limits how many items may actively hit Honcho at once
|
||||
# Items release active_sem while waiting on queue polling so other work
|
||||
# can progress, but inflight_sem prevents an unlimited thundering herd.
|
||||
concurrency = self.config.max_concurrent or self.config.batch_size
|
||||
inflight_sem = asyncio.Semaphore(concurrency)
|
||||
active_sem = asyncio.Semaphore(concurrency)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Processing batch {batch_num}/{total_batches} ({len(batch)} items)")
|
||||
print(f"{'=' * 60}")
|
||||
async def _run_item(index: int, item: Any) -> None:
|
||||
async with inflight_sem:
|
||||
result = await self.execute_item(
|
||||
item,
|
||||
self._get_honcho_url(index),
|
||||
active_sem=active_sem,
|
||||
)
|
||||
all_results[index] = result
|
||||
|
||||
# Run items in batch concurrently (with optional rate limiting)
|
||||
batch_results = await asyncio.gather(
|
||||
*[
|
||||
self._execute_item_with_limit(item, self._get_honcho_url(i + idx))
|
||||
for idx, item in enumerate(batch)
|
||||
]
|
||||
)
|
||||
|
||||
all_results.extend(batch_results)
|
||||
tasks = [
|
||||
asyncio.create_task(_run_item(index, item))
|
||||
for index, item in enumerate(items)
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
overall_duration = time.time() - overall_start
|
||||
|
||||
# Finalize metrics
|
||||
self.metrics_collector.finalize_collection()
|
||||
|
||||
return all_results, overall_duration
|
||||
missing_indexes = [
|
||||
index for index, result in enumerate(all_results) if result is None
|
||||
]
|
||||
if missing_indexes:
|
||||
raise RuntimeError(
|
||||
f"Missing benchmark results for item indexes: {missing_indexes}"
|
||||
)
|
||||
|
||||
async def _execute_item_with_limit(self, item: Any, honcho_url: str) -> ResultT:
|
||||
"""Wrapper that applies concurrency limiting if configured."""
|
||||
if self._concurrency_semaphore:
|
||||
async with self._concurrency_semaphore:
|
||||
return await self.execute_item(item, honcho_url)
|
||||
return await self.execute_item(item, honcho_url)
|
||||
return [cast(ResultT, result) for result in all_results], overall_duration
|
||||
|
||||
async def execute_item(self, item: Any, honcho_url: str) -> ResultT:
|
||||
async def execute_item(
|
||||
self,
|
||||
item: Any,
|
||||
honcho_url: str,
|
||||
active_sem: asyncio.Semaphore | None = None,
|
||||
) -> ResultT:
|
||||
"""
|
||||
Execute a single benchmark item.
|
||||
|
||||
This method orchestrates the standard flow:
|
||||
1. Create workspace and client
|
||||
2. Setup peers and session
|
||||
3. Ingest messages
|
||||
4. Wait for queue to empty
|
||||
5. Trigger dreams
|
||||
6. Execute questions
|
||||
7. Cleanup (if configured)
|
||||
Active work (setup, ingest, dream scheduling, query execution) acquires
|
||||
``active_sem`` when provided. Idle queue polling releases that slot so
|
||||
other items can continue making forward progress.
|
||||
|
||||
Args:
|
||||
item: The item to process
|
||||
honcho_url: URL of the Honcho instance to use
|
||||
active_sem: Optional semaphore limiting active I/O phases
|
||||
|
||||
Returns:
|
||||
Result for this item
|
||||
|
|
@ -635,21 +636,22 @@ class BaseRunner(ABC, Generic[ResultT]):
|
|||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Setup peers
|
||||
await self.setup_peers(ctx, item)
|
||||
# Setup peers/session and ingest under the active semaphore.
|
||||
if active_sem:
|
||||
await active_sem.acquire()
|
||||
try:
|
||||
await self.setup_peers(ctx, item)
|
||||
await self.setup_session(ctx, item)
|
||||
|
||||
# Setup session
|
||||
await self.setup_session(ctx, item)
|
||||
|
||||
# Ingest messages
|
||||
print(f"[{workspace_id}] Ingesting messages...")
|
||||
message_count = await self.ingest_messages(ctx, item)
|
||||
print(f"[{workspace_id}] Ingested {message_count} messages")
|
||||
print(f"[{workspace_id}] Ingesting messages...")
|
||||
message_count = await self.ingest_messages(ctx, item)
|
||||
print(f"[{workspace_id}] Ingested {message_count} messages")
|
||||
finally:
|
||||
if active_sem:
|
||||
active_sem.release()
|
||||
|
||||
# Wait for deriver queue
|
||||
print(f"[{workspace_id}] Waiting for deriver queue to empty...")
|
||||
await asyncio.sleep(1) # Give time for tasks to be queued
|
||||
|
||||
queue_empty = await self._wait_for_queue_empty(ctx.honcho_client)
|
||||
if not queue_empty:
|
||||
raise TimeoutError(
|
||||
|
|
@ -670,20 +672,72 @@ class BaseRunner(ABC, Generic[ResultT]):
|
|||
+ f"{len(dream_observers)} observer(s) across "
|
||||
+ f"{len(dream_session_ids)} session(s)..."
|
||||
)
|
||||
for observer in dream_observers:
|
||||
for dream_session_id in dream_session_ids:
|
||||
success = await self._trigger_dream(
|
||||
ctx.honcho_client, workspace_id, observer, dream_session_id
|
||||
)
|
||||
if not success:
|
||||
|
||||
if self.config.skip_dream:
|
||||
print(f"[{workspace_id}] Skipping dreams (--skip-dream)")
|
||||
else:
|
||||
|
||||
async def _schedule_dream(
|
||||
observer: str,
|
||||
session_id: str,
|
||||
) -> bool:
|
||||
try:
|
||||
if active_sem:
|
||||
await active_sem.acquire()
|
||||
try:
|
||||
await ctx.honcho_client.aio.schedule_dream(
|
||||
observer=observer,
|
||||
session=session_id,
|
||||
observed=observer,
|
||||
)
|
||||
finally:
|
||||
if active_sem:
|
||||
active_sem.release()
|
||||
print(
|
||||
f"[{workspace_id}] Warning: Dream for {observer} in "
|
||||
+ f"session {dream_session_id} did not complete"
|
||||
f"[{workspace_id}] Dream triggered for "
|
||||
+ f"{observer}/{observer} in {session_id}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[{workspace_id}] ERROR: Dream trigger exception "
|
||||
+ f"for {observer} in {session_id}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
dream_results = await asyncio.gather(
|
||||
*[
|
||||
_schedule_dream(observer, dream_session_id)
|
||||
for observer in dream_observers
|
||||
for dream_session_id in dream_session_ids
|
||||
]
|
||||
)
|
||||
|
||||
if all(dream_results):
|
||||
success = await self._wait_for_queue_empty(ctx.honcho_client)
|
||||
if success:
|
||||
print(f"[{workspace_id}] All dreams completed")
|
||||
else:
|
||||
print(f"[{workspace_id}] Dreams timed out")
|
||||
elif any(dream_results):
|
||||
failed = [i for i, ok in enumerate(dream_results) if not ok]
|
||||
print(
|
||||
f"[{workspace_id}] Warning: {len(failed)} of "
|
||||
+ f"{len(dream_results)} dream schedules failed"
|
||||
)
|
||||
await self._wait_for_queue_empty(ctx.honcho_client)
|
||||
else:
|
||||
print(f"[{workspace_id}] Warning: No dreams were scheduled")
|
||||
|
||||
# Execute questions
|
||||
print(f"[{workspace_id}] Executing questions...")
|
||||
result = await self.execute_questions(ctx, item)
|
||||
if active_sem:
|
||||
await active_sem.acquire()
|
||||
try:
|
||||
result = await self.execute_questions(ctx, item)
|
||||
finally:
|
||||
if active_sem:
|
||||
active_sem.release()
|
||||
|
||||
# Cleanup
|
||||
if self.config.cleanup_workspace:
|
||||
|
|
@ -765,13 +819,15 @@ class BaseRunner(ABC, Generic[ResultT]):
|
|||
async def _wait_for_queue_empty(
|
||||
self, honcho_client: Honcho, session_id: str | None = None
|
||||
) -> bool:
|
||||
"""Wait for the deriver queue to be empty."""
|
||||
"""Wait for the deriver queue to be empty with exponential backoff."""
|
||||
start_time = time.time()
|
||||
delay = 0.2
|
||||
while True:
|
||||
try:
|
||||
status = await honcho_client.aio.queue_status(session=session_id)
|
||||
except Exception:
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 1.5, 2.0)
|
||||
if time.time() - start_time >= self.config.timeout_seconds:
|
||||
return False
|
||||
continue
|
||||
|
|
@ -781,7 +837,8 @@ class BaseRunner(ABC, Generic[ResultT]):
|
|||
|
||||
if time.time() - start_time >= self.config.timeout_seconds:
|
||||
return False
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 1.5, 2.0)
|
||||
|
||||
async def _trigger_dream(
|
||||
self,
|
||||
|
|
@ -815,8 +872,6 @@ class BaseRunner(ABC, Generic[ResultT]):
|
|||
|
||||
print(f"[{workspace_id}] Dream triggered for {observer}/{observed}")
|
||||
|
||||
# Wait for dream to complete
|
||||
await asyncio.sleep(2)
|
||||
success = await self._wait_for_queue_empty(honcho_client)
|
||||
if success:
|
||||
print(f"[{workspace_id}] Dream for {observer} completed")
|
||||
|
|
|
|||
|
|
@ -752,8 +752,11 @@ def mock_tracked_db(db_engine: AsyncEngine, request: pytest.FixtureRequest):
|
|||
patch("src.dialectic.chat.tracked_db", mock_tracked_db_context),
|
||||
patch("src.utils.summarizer.tracked_db", mock_tracked_db_context),
|
||||
patch("src.webhooks.events.tracked_db", mock_tracked_db_context),
|
||||
patch("src.webhooks.webhook_delivery.tracked_db", mock_tracked_db_context),
|
||||
patch("src.utils.agent_tools.tracked_db", mock_tracked_db_context),
|
||||
patch("src.utils.search.tracked_db", mock_tracked_db_context),
|
||||
patch("src.crud.document.tracked_db", mock_tracked_db_context),
|
||||
patch("src.crud.message.tracked_db", mock_tracked_db_context),
|
||||
patch("src.dialectic.core.tracked_db", mock_tracked_db_context),
|
||||
patch("src.dreamer.specialists.tracked_db", mock_tracked_db_context),
|
||||
patch("src.dreamer.surprisal.tracked_db", mock_tracked_db_context),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ Tests for message embedding functionality.
|
|||
These tests verify that message embeddings are created, stored, and can be searched.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
|
@ -12,7 +14,9 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models
|
||||
from src.config import settings
|
||||
from src.crud import create_messages
|
||||
from src.crud import message as message_crud
|
||||
from src.models import Peer, Workspace
|
||||
from src.schemas import MessageCreate
|
||||
from src.utils.search import search
|
||||
|
|
@ -240,8 +244,7 @@ async def test_semantic_search_when_embeddings_enabled(
|
|||
initial_call_count: int = mock_openai_embeddings["embed"].call_count
|
||||
|
||||
search_results = await search(
|
||||
db=db_session,
|
||||
query=search_query,
|
||||
search_query,
|
||||
filters={
|
||||
"workspace_id": test_workspace.name,
|
||||
"session_id": test_session.name,
|
||||
|
|
@ -257,6 +260,212 @@ async def test_semantic_search_when_embeddings_enabled(
|
|||
assert created_message.public_id in found_message_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_messages_external_lookup_happens_before_tracked_db(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""External semantic lookup should finish before opening tracked_db."""
|
||||
monkeypatch.setattr(settings.VECTOR_STORE, "MIGRATED", True)
|
||||
monkeypatch.setattr(settings.VECTOR_STORE, "TYPE", "external")
|
||||
|
||||
call_order: list[str] = []
|
||||
message = models.Message(
|
||||
workspace_name="workspace",
|
||||
session_name="session",
|
||||
peer_name="peer",
|
||||
content="Relevant external search result",
|
||||
seq_in_session=1,
|
||||
token_count=5,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
class FakeDb:
|
||||
def expunge(self, _obj: object) -> None:
|
||||
call_order.append("expunge")
|
||||
|
||||
fake_db = FakeDb()
|
||||
|
||||
async def fake_search_messages_external(
|
||||
workspace_name: str,
|
||||
query_embedding: list[float],
|
||||
limit: int,
|
||||
*,
|
||||
session_name: str | None = None,
|
||||
allowed_session_names: list[str] | None = None,
|
||||
after_date: datetime | None = None,
|
||||
before_date: datetime | None = None,
|
||||
) -> list[str]:
|
||||
_ = (
|
||||
workspace_name,
|
||||
query_embedding,
|
||||
limit,
|
||||
session_name,
|
||||
allowed_session_names,
|
||||
after_date,
|
||||
before_date,
|
||||
)
|
||||
call_order.append("external")
|
||||
return ["message-1"]
|
||||
|
||||
async def fake_fetch_messages_by_ids(
|
||||
db: FakeDb,
|
||||
workspace_name: str,
|
||||
message_ids: list[str],
|
||||
*,
|
||||
after_date: datetime | None = None,
|
||||
before_date: datetime | None = None,
|
||||
) -> list[models.Message]:
|
||||
_ = (workspace_name, message_ids, after_date, before_date)
|
||||
assert db is fake_db
|
||||
call_order.append("fetch")
|
||||
return [message]
|
||||
|
||||
async def fake_build_merged_snippets(
|
||||
db: FakeDb,
|
||||
workspace_name: str,
|
||||
matched_messages: list[models.Message],
|
||||
context_window: int,
|
||||
) -> list[tuple[list[models.Message], list[models.Message]]]:
|
||||
_ = (workspace_name, context_window)
|
||||
assert db is fake_db
|
||||
assert matched_messages == [message]
|
||||
call_order.append("build")
|
||||
return [([message], [message])]
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_tracked_db(_operation_name: str | None = None):
|
||||
call_order.append("enter")
|
||||
yield fake_db
|
||||
call_order.append("exit")
|
||||
|
||||
monkeypatch.setattr(
|
||||
message_crud, "_search_messages_external", fake_search_messages_external
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
message_crud, "_fetch_messages_by_ids", fake_fetch_messages_by_ids
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
message_crud, "_build_merged_snippets", fake_build_merged_snippets
|
||||
)
|
||||
monkeypatch.setattr(message_crud, "tracked_db", fake_tracked_db)
|
||||
|
||||
snippets = await message_crud.search_messages(
|
||||
workspace_name="workspace",
|
||||
session_name="session",
|
||||
query="relevant query",
|
||||
embedding=[0.1, 0.2, 0.3],
|
||||
)
|
||||
|
||||
assert snippets == [([message], [message])]
|
||||
assert call_order.index("external") < call_order.index("enter")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_messages_temporal_external_lookup_happens_before_tracked_db(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""Temporal external semantic lookup should finish before opening tracked_db."""
|
||||
monkeypatch.setattr(settings.VECTOR_STORE, "MIGRATED", True)
|
||||
monkeypatch.setattr(settings.VECTOR_STORE, "TYPE", "external")
|
||||
|
||||
call_order: list[str] = []
|
||||
after_date = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||
before_date = datetime(2024, 12, 31, tzinfo=timezone.utc)
|
||||
message = models.Message(
|
||||
workspace_name="workspace",
|
||||
session_name="session",
|
||||
peer_name="peer",
|
||||
content="Relevant temporal external search result",
|
||||
seq_in_session=1,
|
||||
token_count=5,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
class FakeDb:
|
||||
def expunge(self, _obj: object) -> None:
|
||||
call_order.append("expunge")
|
||||
|
||||
fake_db = FakeDb()
|
||||
|
||||
async def fake_search_messages_external(
|
||||
workspace_name: str,
|
||||
query_embedding: list[float],
|
||||
limit: int,
|
||||
*,
|
||||
session_name: str | None = None,
|
||||
allowed_session_names: list[str] | None = None,
|
||||
after_date: datetime | None = None,
|
||||
before_date: datetime | None = None,
|
||||
) -> list[str]:
|
||||
_ = (
|
||||
workspace_name,
|
||||
query_embedding,
|
||||
limit,
|
||||
session_name,
|
||||
allowed_session_names,
|
||||
)
|
||||
assert after_date is not None
|
||||
assert before_date is not None
|
||||
call_order.append("external")
|
||||
return ["message-1"]
|
||||
|
||||
async def fake_fetch_messages_by_ids(
|
||||
db: FakeDb,
|
||||
workspace_name: str,
|
||||
message_ids: list[str],
|
||||
*,
|
||||
after_date: datetime | None = None,
|
||||
before_date: datetime | None = None,
|
||||
) -> list[models.Message]:
|
||||
_ = (workspace_name, message_ids)
|
||||
assert db is fake_db
|
||||
assert after_date is not None
|
||||
assert before_date is not None
|
||||
call_order.append("fetch")
|
||||
return [message]
|
||||
|
||||
async def fake_build_merged_snippets(
|
||||
db: FakeDb,
|
||||
workspace_name: str,
|
||||
matched_messages: list[models.Message],
|
||||
context_window: int,
|
||||
) -> list[tuple[list[models.Message], list[models.Message]]]:
|
||||
_ = (workspace_name, context_window)
|
||||
assert db is fake_db
|
||||
assert matched_messages == [message]
|
||||
call_order.append("build")
|
||||
return [([message], [message])]
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_tracked_db(_operation_name: str | None = None):
|
||||
call_order.append("enter")
|
||||
yield fake_db
|
||||
call_order.append("exit")
|
||||
|
||||
monkeypatch.setattr(
|
||||
message_crud, "_search_messages_external", fake_search_messages_external
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
message_crud, "_fetch_messages_by_ids", fake_fetch_messages_by_ids
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
message_crud, "_build_merged_snippets", fake_build_merged_snippets
|
||||
)
|
||||
monkeypatch.setattr(message_crud, "tracked_db", fake_tracked_db)
|
||||
|
||||
snippets = await message_crud.search_messages_temporal(
|
||||
workspace_name="workspace",
|
||||
session_name="session",
|
||||
query="relevant query",
|
||||
after_date=after_date,
|
||||
before_date=before_date,
|
||||
embedding=[0.1, 0.2, 0.3],
|
||||
)
|
||||
|
||||
assert snippets == [([message], [message])]
|
||||
assert call_order.index("external") < call_order.index("enter")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_chunking_creates_multiple_embeddings(
|
||||
db_session: AsyncSession,
|
||||
|
|
|
|||
|
|
@ -136,5 +136,8 @@ def mock_tracked_db(ts_db_session: async_sessionmaker[AsyncSession]):
|
|||
patch("src.dialectic.chat.tracked_db", ts_tracked_db),
|
||||
patch("src.utils.summarizer.tracked_db", ts_tracked_db),
|
||||
patch("src.webhooks.events.tracked_db", ts_tracked_db),
|
||||
patch("src.webhooks.webhook_delivery.tracked_db", ts_tracked_db),
|
||||
patch("src.utils.search.tracked_db", ts_tracked_db),
|
||||
patch("src.crud.message.tracked_db", ts_tracked_db),
|
||||
):
|
||||
yield
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import pytest
|
|||
from nanoid import generate as generate_nanoid
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models
|
||||
from src import crud, models
|
||||
from src.utils.search import search
|
||||
|
||||
|
||||
|
|
@ -62,11 +62,10 @@ async def test_peer_perspective_search_single_session(
|
|||
created_at=join_time + datetime.timedelta(seconds=2),
|
||||
)
|
||||
db_session.add_all([msg1, msg2])
|
||||
await db_session.flush()
|
||||
await db_session.commit()
|
||||
|
||||
# Search with peer_perspective filter
|
||||
results = await search(
|
||||
db_session,
|
||||
"Message",
|
||||
filters={"peer_perspective": peer1.name, "workspace_id": workspace.name},
|
||||
limit=10,
|
||||
|
|
@ -132,11 +131,10 @@ async def test_peer_perspective_search_multiple_sessions(
|
|||
created_at=join_time + datetime.timedelta(seconds=2),
|
||||
)
|
||||
db_session.add_all([msg1, msg2])
|
||||
await db_session.flush()
|
||||
await db_session.commit()
|
||||
|
||||
# Search with peer_perspective filter
|
||||
results = await search(
|
||||
db_session,
|
||||
"Message",
|
||||
filters={"peer_perspective": peer1.name, "workspace_id": workspace.name},
|
||||
limit=10,
|
||||
|
|
@ -212,11 +210,10 @@ async def test_peer_perspective_search_temporal_constraints(
|
|||
created_at=leave_time + datetime.timedelta(seconds=1),
|
||||
)
|
||||
db_session.add_all([msg_before, msg_during, msg_after])
|
||||
await db_session.flush()
|
||||
await db_session.commit()
|
||||
|
||||
# Search with peer_perspective filter
|
||||
results = await search(
|
||||
db_session,
|
||||
"Message",
|
||||
filters={"peer_perspective": peer1.name, "workspace_id": workspace.name},
|
||||
limit=10,
|
||||
|
|
@ -279,11 +276,10 @@ async def test_peer_perspective_search_active_member(
|
|||
created_at=join_time + datetime.timedelta(seconds=100),
|
||||
)
|
||||
db_session.add_all([msg1, msg2])
|
||||
await db_session.flush()
|
||||
await db_session.commit()
|
||||
|
||||
# Search with peer_perspective filter
|
||||
results = await search(
|
||||
db_session,
|
||||
"Message",
|
||||
filters={"peer_perspective": peer1.name, "workspace_id": workspace.name},
|
||||
limit=10,
|
||||
|
|
@ -339,11 +335,10 @@ async def test_peer_perspective_search_no_sessions(
|
|||
created_at=join_time + datetime.timedelta(seconds=1),
|
||||
)
|
||||
db_session.add(msg)
|
||||
await db_session.flush()
|
||||
await db_session.commit()
|
||||
|
||||
# Search with peer_perspective filter for peer1 (not in any sessions)
|
||||
results = await search(
|
||||
db_session,
|
||||
"Message",
|
||||
filters={"peer_perspective": peer1.name, "workspace_id": workspace.name},
|
||||
limit=10,
|
||||
|
|
@ -408,11 +403,10 @@ async def test_peer_perspective_search_boundary_timestamps(
|
|||
created_at=leave_time, # Exact leave time
|
||||
)
|
||||
db_session.add_all([msg_at_join, msg_at_leave])
|
||||
await db_session.flush()
|
||||
await db_session.commit()
|
||||
|
||||
# Search with peer_perspective filter
|
||||
results = await search(
|
||||
db_session,
|
||||
"Message",
|
||||
filters={"peer_perspective": peer1.name, "workspace_id": workspace.name},
|
||||
limit=10,
|
||||
|
|
@ -422,3 +416,291 @@ async def test_peer_perspective_search_boundary_timestamps(
|
|||
assert len(results) == 2
|
||||
assert msg_at_join.public_id in [m.public_id for m in results]
|
||||
assert msg_at_leave.public_id in [m.public_id for m in results]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for observer scoping in CRUD message functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def _setup_multi_session_workspace(db_session: AsyncSession):
|
||||
"""Helper: create workspace with 2 sessions, 2 peers. peer1 only in session1."""
|
||||
workspace = models.Workspace(name=generate_nanoid())
|
||||
db_session.add(workspace)
|
||||
await db_session.flush()
|
||||
|
||||
peer1 = models.Peer(name="observer", workspace_name=workspace.name)
|
||||
peer2 = models.Peer(name="other", workspace_name=workspace.name)
|
||||
db_session.add_all([peer1, peer2])
|
||||
await db_session.flush()
|
||||
|
||||
session1 = models.Session(name="session_visible", workspace_name=workspace.name)
|
||||
session2 = models.Session(name="session_hidden", workspace_name=workspace.name)
|
||||
db_session.add_all([session1, session2])
|
||||
await db_session.flush()
|
||||
|
||||
join_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
|
||||
minutes=10
|
||||
)
|
||||
|
||||
# peer1 is only in session1
|
||||
await db_session.execute(
|
||||
models.session_peers_table.insert().values(
|
||||
workspace_name=workspace.name,
|
||||
session_name=session1.name,
|
||||
peer_name=peer1.name,
|
||||
joined_at=join_time,
|
||||
left_at=None,
|
||||
)
|
||||
)
|
||||
# peer2 is in both sessions
|
||||
for s in [session1, session2]:
|
||||
await db_session.execute(
|
||||
models.session_peers_table.insert().values(
|
||||
workspace_name=workspace.name,
|
||||
session_name=s.name,
|
||||
peer_name=peer2.name,
|
||||
joined_at=join_time,
|
||||
left_at=None,
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
msg_visible = models.Message(
|
||||
content="visible message with keyword",
|
||||
session_name=session1.name,
|
||||
peer_name=peer2.name,
|
||||
workspace_name=workspace.name,
|
||||
seq_in_session=1,
|
||||
created_at=join_time + datetime.timedelta(seconds=1),
|
||||
)
|
||||
msg_hidden = models.Message(
|
||||
content="hidden message with keyword",
|
||||
session_name=session2.name,
|
||||
peer_name=peer2.name,
|
||||
workspace_name=workspace.name,
|
||||
seq_in_session=1,
|
||||
created_at=join_time + datetime.timedelta(seconds=2),
|
||||
)
|
||||
db_session.add_all([msg_visible, msg_hidden])
|
||||
await db_session.commit()
|
||||
|
||||
return workspace, peer1, peer2, session1, session2, msg_visible, msg_hidden
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_messages_observer_scoping_excludes_non_member_sessions(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""grep_messages with observer excludes messages from sessions the observer isn't in."""
|
||||
(
|
||||
workspace,
|
||||
peer1,
|
||||
_,
|
||||
_,
|
||||
_,
|
||||
msg_visible,
|
||||
msg_hidden,
|
||||
) = await _setup_multi_session_workspace(db_session)
|
||||
|
||||
# Without scoping: both messages found
|
||||
results_unscoped = await crud.grep_messages(
|
||||
workspace_name=workspace.name,
|
||||
session_name=None,
|
||||
text="keyword",
|
||||
)
|
||||
all_matched_ids = [m.public_id for matches, _ in results_unscoped for m in matches]
|
||||
assert msg_visible.public_id in all_matched_ids
|
||||
assert msg_hidden.public_id in all_matched_ids
|
||||
|
||||
# With observer scoping: only visible message found
|
||||
results_scoped = await crud.grep_messages(
|
||||
workspace_name=workspace.name,
|
||||
session_name=None,
|
||||
text="keyword",
|
||||
observer=peer1.name,
|
||||
)
|
||||
scoped_ids = [m.public_id for matches, _ in results_scoped for m in matches]
|
||||
assert msg_visible.public_id in scoped_ids
|
||||
assert msg_hidden.public_id not in scoped_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_messages_by_date_range_observer_scoping(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""get_messages_by_date_range with observer excludes non-member sessions."""
|
||||
(
|
||||
workspace,
|
||||
peer1,
|
||||
_,
|
||||
_,
|
||||
_,
|
||||
msg_visible,
|
||||
msg_hidden,
|
||||
) = await _setup_multi_session_workspace(db_session)
|
||||
|
||||
# Without scoping
|
||||
results_unscoped = await crud.get_messages_by_date_range(
|
||||
db_session,
|
||||
workspace_name=workspace.name,
|
||||
session_name=None,
|
||||
)
|
||||
unscoped_ids = [m.public_id for m in results_unscoped]
|
||||
assert msg_visible.public_id in unscoped_ids
|
||||
assert msg_hidden.public_id in unscoped_ids
|
||||
|
||||
# With observer scoping
|
||||
results_scoped = await crud.get_messages_by_date_range(
|
||||
db_session,
|
||||
workspace_name=workspace.name,
|
||||
session_name=None,
|
||||
observer=peer1.name,
|
||||
)
|
||||
scoped_ids = [m.public_id for m in results_scoped]
|
||||
assert msg_visible.public_id in scoped_ids
|
||||
assert msg_hidden.public_id not in scoped_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_messages_observer_scoping_noop_when_session_provided(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""When session_name is provided, observer is ignored."""
|
||||
(
|
||||
workspace,
|
||||
peer1,
|
||||
_,
|
||||
_,
|
||||
session_hidden,
|
||||
_,
|
||||
msg_hidden,
|
||||
) = await _setup_multi_session_workspace(db_session)
|
||||
|
||||
results = await crud.grep_messages(
|
||||
workspace_name=workspace.name,
|
||||
session_name=session_hidden.name,
|
||||
text="keyword",
|
||||
observer=peer1.name,
|
||||
)
|
||||
matched_ids = [m.public_id for matches, _ in results for m in matches]
|
||||
assert msg_hidden.public_id in matched_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_messages_observer_scoping_empty_when_no_sessions(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Observer not in any sessions returns empty results."""
|
||||
workspace = models.Workspace(name=generate_nanoid())
|
||||
db_session.add(workspace)
|
||||
await db_session.flush()
|
||||
|
||||
loner = models.Peer(name="loner", workspace_name=workspace.name)
|
||||
other = models.Peer(name="other", workspace_name=workspace.name)
|
||||
db_session.add_all([loner, other])
|
||||
await db_session.flush()
|
||||
|
||||
session = models.Session(name="s1", workspace_name=workspace.name)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
await db_session.execute(
|
||||
models.session_peers_table.insert().values(
|
||||
workspace_name=workspace.name,
|
||||
session_name=session.name,
|
||||
peer_name=other.name,
|
||||
joined_at=datetime.datetime.now(datetime.timezone.utc),
|
||||
left_at=None,
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
msg = models.Message(
|
||||
content="some keyword content",
|
||||
session_name=session.name,
|
||||
peer_name=other.name,
|
||||
workspace_name=workspace.name,
|
||||
seq_in_session=1,
|
||||
created_at=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
db_session.add(msg)
|
||||
await db_session.commit()
|
||||
|
||||
results = await crud.grep_messages(
|
||||
workspace_name=workspace.name,
|
||||
session_name=None,
|
||||
text="keyword",
|
||||
observer=loner.name,
|
||||
)
|
||||
assert results == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_messages_observer_scoping_left_session_still_visible(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Observer who left a session still sees all messages in that session.
|
||||
|
||||
Any membership record (regardless of left_at) grants full session visibility.
|
||||
"""
|
||||
workspace = models.Workspace(name=generate_nanoid())
|
||||
db_session.add(workspace)
|
||||
await db_session.flush()
|
||||
|
||||
observer = models.Peer(name="obs", workspace_name=workspace.name)
|
||||
other = models.Peer(name="other", workspace_name=workspace.name)
|
||||
db_session.add_all([observer, other])
|
||||
await db_session.flush()
|
||||
|
||||
session = models.Session(name="s1", workspace_name=workspace.name)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
base_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
|
||||
minutes=10
|
||||
)
|
||||
join_time = base_time
|
||||
leave_time = base_time + datetime.timedelta(minutes=5)
|
||||
|
||||
await db_session.execute(
|
||||
models.session_peers_table.insert().values(
|
||||
workspace_name=workspace.name,
|
||||
session_name=session.name,
|
||||
peer_name=observer.name,
|
||||
joined_at=join_time,
|
||||
left_at=leave_time,
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
# Message during membership
|
||||
msg_during = models.Message(
|
||||
content="keyword during",
|
||||
session_name=session.name,
|
||||
peer_name=other.name,
|
||||
workspace_name=workspace.name,
|
||||
seq_in_session=1,
|
||||
created_at=join_time + datetime.timedelta(minutes=2),
|
||||
)
|
||||
# Message after observer left — still visible because any membership grants full access
|
||||
msg_after = models.Message(
|
||||
content="keyword after",
|
||||
session_name=session.name,
|
||||
peer_name=other.name,
|
||||
workspace_name=workspace.name,
|
||||
seq_in_session=2,
|
||||
created_at=leave_time + datetime.timedelta(minutes=1),
|
||||
)
|
||||
db_session.add_all([msg_during, msg_after])
|
||||
await db_session.commit()
|
||||
|
||||
results = await crud.grep_messages(
|
||||
workspace_name=workspace.name,
|
||||
session_name=None,
|
||||
text="keyword",
|
||||
observer=observer.name,
|
||||
)
|
||||
matched_ids = [m.public_id for matches, _ in results for m in matches]
|
||||
assert msg_during.public_id in matched_ids
|
||||
assert msg_after.public_id in matched_ids
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from src.utils.agent_tools import (
|
|||
_handle_grep_messages, # pyright: ignore[reportPrivateUsage]
|
||||
_handle_search_memory, # pyright: ignore[reportPrivateUsage]
|
||||
_handle_search_messages, # pyright: ignore[reportPrivateUsage]
|
||||
_handle_search_messages_temporal, # pyright: ignore[reportPrivateUsage]
|
||||
_handle_update_peer_card, # pyright: ignore[reportPrivateUsage]
|
||||
create_observations,
|
||||
create_tool_executor,
|
||||
|
|
@ -528,15 +529,15 @@ class TestSearchMemory:
|
|||
return []
|
||||
|
||||
async def fake_search_messages(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
context_window: int = 2,
|
||||
embedding: list[float] | None = None,
|
||||
observer: str | None = None,
|
||||
) -> list[tuple[list[models.Message], list[models.Message]]]:
|
||||
_ = (db, workspace_name, session_name, query, limit, context_window)
|
||||
_ = (workspace_name, session_name, query, limit, context_window, observer)
|
||||
fallback_embeddings.append(embedding)
|
||||
msg = models.Message(
|
||||
workspace_name=ctx.workspace_name,
|
||||
|
|
@ -610,6 +611,78 @@ class TestGrepMessages:
|
|||
assert "ERROR" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSearchMessagesTemporal:
|
||||
"""Tests for _handle_search_messages_temporal."""
|
||||
|
||||
async def test_reuses_precomputed_embedding(
|
||||
self,
|
||||
make_tool_context: Callable[..., ToolContext],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""Embeds once and forwards the precomputed embedding to CRUD search."""
|
||||
ctx = make_tool_context()
|
||||
|
||||
embed_calls: list[str] = []
|
||||
forwarded_embeddings: list[list[float] | None] = []
|
||||
|
||||
async def fake_embed(query: str) -> list[float]:
|
||||
embed_calls.append(query)
|
||||
return [0.9, 0.1, 0.3]
|
||||
|
||||
async def fake_search_messages_temporal(
|
||||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
query: str,
|
||||
after_date: datetime | None = None,
|
||||
before_date: datetime | None = None,
|
||||
limit: int = 10,
|
||||
context_window: int = 2,
|
||||
embedding: list[float] | None = None,
|
||||
observer: str | None = None,
|
||||
) -> list[tuple[list[models.Message], list[models.Message]]]:
|
||||
_ = (
|
||||
workspace_name,
|
||||
session_name,
|
||||
query,
|
||||
after_date,
|
||||
before_date,
|
||||
limit,
|
||||
context_window,
|
||||
observer,
|
||||
)
|
||||
forwarded_embeddings.append(embedding)
|
||||
msg = models.Message(
|
||||
workspace_name=ctx.workspace_name,
|
||||
session_name=ctx.session_name,
|
||||
peer_name=ctx.observed,
|
||||
content="Relevant temporal fallback message",
|
||||
seq_in_session=1,
|
||||
token_count=5,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
return [([msg], [msg])]
|
||||
|
||||
monkeypatch.setattr("src.utils.agent_tools.embedding_client.embed", fake_embed)
|
||||
monkeypatch.setattr(
|
||||
"src.utils.agent_tools.crud.search_messages_temporal",
|
||||
fake_search_messages_temporal,
|
||||
)
|
||||
|
||||
result = await _handle_search_messages_temporal(
|
||||
ctx,
|
||||
{
|
||||
"query": "when did this happen",
|
||||
"after_date": "2024-01-01",
|
||||
"before_date": "2024-12-31",
|
||||
},
|
||||
)
|
||||
|
||||
assert "Found" in result
|
||||
assert embed_calls == ["when did this happen"]
|
||||
assert forwarded_embeddings == [[0.9, 0.1, 0.3]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetMessagesByDateRange:
|
||||
"""Tests for _handle_get_messages_by_date_range."""
|
||||
|
|
@ -991,15 +1064,15 @@ class TestExtractPreferences:
|
|||
embedding_args: list[list[float] | None] = []
|
||||
|
||||
async def fake_search_messages(
|
||||
_db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
query: str,
|
||||
limit: int,
|
||||
context_window: int,
|
||||
embedding: list[float] | None,
|
||||
observer: str | None = None,
|
||||
) -> list[tuple[list[models.Message], list[models.Message]]]:
|
||||
_ = (limit, context_window)
|
||||
_ = (limit, context_window, observer)
|
||||
embedding_args.append(embedding)
|
||||
msg = models.Message(
|
||||
workspace_name=workspace_name,
|
||||
|
|
@ -1292,3 +1365,55 @@ class TestObservationLockRegistry:
|
|||
# All 100 entries should be cleaned up
|
||||
remaining = sum(1 for k in _observation_locks if k[0].startswith("ws_growth_"))
|
||||
assert remaining == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestObserverPeerNameWiring:
|
||||
"""Tests that tool handlers pass observer to CRUD functions."""
|
||||
|
||||
async def test_grep_messages_passes_observer(
|
||||
self,
|
||||
make_tool_context: Callable[..., ToolContext],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""_handle_grep_messages passes ctx.observer as observer."""
|
||||
ctx = make_tool_context()
|
||||
captured_kwargs: dict[str, Any] = {}
|
||||
|
||||
async def fake_grep_messages(
|
||||
**kwargs: Any,
|
||||
) -> list[tuple[list[models.Message], list[models.Message]]]:
|
||||
captured_kwargs.update(kwargs)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.utils.agent_tools.crud.grep_messages", fake_grep_messages
|
||||
)
|
||||
|
||||
await _handle_grep_messages(ctx, {"text": "hello"})
|
||||
|
||||
assert captured_kwargs["observer"] == ctx.observer
|
||||
|
||||
async def test_get_messages_by_date_range_passes_observer(
|
||||
self,
|
||||
make_tool_context: Callable[..., ToolContext],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""_handle_get_messages_by_date_range passes ctx.observer as observer."""
|
||||
ctx = make_tool_context()
|
||||
captured_kwargs: dict[str, Any] = {}
|
||||
|
||||
async def fake_get_messages_by_date_range(
|
||||
_db: Any, **kwargs: Any
|
||||
) -> list[models.Message]:
|
||||
captured_kwargs.update(kwargs)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.utils.agent_tools.crud.get_messages_by_date_range",
|
||||
fake_get_messages_by_date_range,
|
||||
)
|
||||
|
||||
await _handle_get_messages_by_date_range(ctx, {"after_date": "2024-01-01"})
|
||||
|
||||
assert captured_kwargs["observer"] == ctx.observer
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ async def test_deliver_webhook_skips_when_no_urls(
|
|||
)
|
||||
|
||||
payload = WebhookPayload(event_type="peer.created", data={"id": "p_123"})
|
||||
await webhook_delivery.deliver_webhook(AsyncMock(), payload, "workspace-a")
|
||||
await webhook_delivery.deliver_webhook(payload, "workspace-a")
|
||||
|
||||
assert fake_client.calls == []
|
||||
|
||||
|
|
@ -162,7 +162,7 @@ async def test_deliver_webhook_posts_signed_payload_to_each_endpoint(
|
|||
event_type="message.created",
|
||||
data={"id": "m_1", "workspace": "workspace-a"},
|
||||
)
|
||||
await webhook_delivery.deliver_webhook(AsyncMock(), payload, "workspace-a")
|
||||
await webhook_delivery.deliver_webhook(payload, "workspace-a")
|
||||
|
||||
expected_event_json = json.dumps(
|
||||
{
|
||||
|
|
@ -210,7 +210,7 @@ async def test_deliver_webhook_handles_signature_generation_failure(
|
|||
monkeypatch.setattr(httpx, "AsyncClient", async_client_factory)
|
||||
|
||||
payload = WebhookPayload(event_type="workspace.updated", data={"id": "ws_1"})
|
||||
await webhook_delivery.deliver_webhook(AsyncMock(), payload, "workspace-a")
|
||||
await webhook_delivery.deliver_webhook(payload, "workspace-a")
|
||||
|
||||
assert fake_client.calls == []
|
||||
|
||||
|
|
@ -233,4 +233,4 @@ async def test_deliver_webhook_catches_request_errors(
|
|||
monkeypatch.setattr(httpx, "AsyncClient", async_client_factory)
|
||||
|
||||
payload = WebhookPayload(event_type="workspace.updated", data={"id": "ws_1"})
|
||||
await webhook_delivery.deliver_webhook(AsyncMock(), payload, "workspace-a")
|
||||
await webhook_delivery.deliver_webhook(payload, "workspace-a")
|
||||
|
|
|
|||
Loading…
Reference in New Issue