Centralize Configurations (#115)

* fix (schemas): Backwards compatability for metamessage_type and idiomatic schemas

* fix: Pagination Test

* feat: Change codebase to rely on src/config.py

* feat (config): Using pydantic settings for managing settings values across the project

* chore (docs): Add README and coderabbit nitpicks

* chore (lint): Ruff fixes

* fix: Add DB Tracing Configuration

* fix (config): Configurable Pool Class

* fix (config): Add validations

* chore: Code Rabbit Nitpicks

* fix (config): Attempt to fix github actions

* fix (db): Use variable engine settings

* fix (actions): Mock Chat function entirely

* fix (config): Linter errors, docs, and Dockerfile

* chore: Code Rabbit nitpicks

* chore (docs): Increment Version Numbers

* fix (alembic): Fix alembic migrations to use config file

* fix (test): force test_db schema on public

* chore: Code Rabbit nitpick

* fix (config): Add LLM provider settings and consolidate LLM usage to use model client

* fix deps and get tests working

* pass tests

* Update GitHub Actions workflow for unit tests: streamline branch references and enhance environment variable names for clarity.

* Update OpenAI client initialization to use API key from configuration settings

* chore (docs): Update config templates and fix linter errors

* chore (docs): Update Changelog

* chore (docs): Update Changelog, README, and CONTRIBUTING

* chore: Code Rabbit Comments

* chore: Code Rabbit

---------

Co-authored-by: hyusap <paulayush@gmail.com>
This commit is contained in:
Vineeth Voruganti 2025-06-24 11:00:52 -04:00 committed by GitHub
parent d332321138
commit 8ff3cd7a1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 2947 additions and 1835 deletions

View File

@ -1,44 +1,111 @@
CONNECTION_URI=postgresql+psycopg://testuser:testpwd@localhost:5432/honcho # sample for local database
# CONNECTION_URI=postgresql+psycopg://testuser:testpwd@database:5432/honcho # sample for docker-compose database
# Honcho Environment Variables Template
# Copy this file to .env and fill in the appropriate values
#
# Required variables are marked with (REQUIRED)
# Optional variables have default values and can be left commented out
# Use something unique here if you want to share a database with other projects.
# Leave blank for public. Make sure to avoid `-` in name.
DATABASE_SCHEMA=
# Auth
# Set to true to enable API authorization. Blank is equivalent to false.
USE_AUTH=false
# Required if USE_AUTH is true. Generate with scripts/generate_jwt_secret.py
AUTH_JWT_SECRET=
# These are included for convenience in local testing if you want to quickly swap out providers
# but are not actually used by Honcho
OPENROUTER_API_KEY=
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
CEREBRAS_API_KEY=
CEREBRAS_BASE_URL=https://api.cerebras.ai/v1
GROQ_API_KEY=
GROQ_BASE_URL=https://api.groq.com/openai/v1
# These are the ones that are actually used by the model client
OPENAI_COMPATIBLE_BASE_URL=
OPENAI_COMPATIBLE_API_KEY=
# Sentry
SENTRY_ENABLED=false
SENTRY_DSN=
# Deriver
DERIVER_WORKERS=1
# Anthropic
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
# Langfuse
LANGFUSE_SECRET_KEY=
LANGFUSE_PUBLIC_KEY=
LANGFUSE_HOST=https://us.cloud.langfuse.com
# set logger level
# =============================================================================
# Application Settings
# =============================================================================
LOG_LEVEL=INFO
FASTAPI_HOST=0.0.0.0
FASTAPI_PORT=8000
# =============================================================================
# Database Settings (REQUIRED)
# =============================================================================
# Connection URI for PostgreSQL database with pgvector support
# Must use postgresql+psycopg prefix for SQLAlchemy compatibility
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres
# Optional database settings
# DB_SCHEMA=public
# DB_POOL_SIZE=10
# DB_MAX_OVERFLOW=20
# DB_POOL_TIMEOUT=30
# DB_POOL_RECYCLE=300
# DB_POOL_PRE_PING=true
# DB_POOL_USE_LIFO=true
# DB_SQL_DEBUG=false
# DB_TRACING=false
# =============================================================================
# Authentication Settings
# =============================================================================
# Whether to enable authentication (set to true for production)
AUTH_USE_AUTH=false
# JWT secret key (REQUIRED if AUTH_USE_AUTH=true)
# Generate with: python scripts/generate_jwt_secret.py
# AUTH_JWT_SECRET=your-secret-key-here
# =============================================================================
# LLM API Keys (REQUIRED for full functionality)
# =============================================================================
# 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=
# =============================================================================
# LLM Configuration
# =============================================================================
# Global LLM settings
# LLM_DEFAULT_MAX_TOKENS=1000
# LLM_DEFAULT_TEMPERATURE=0.0
# Dialectic LLM settings
# LLM_DIALECTIC_PROVIDER=anthropic
# LLM_DIALECTIC_MODEL=claude-3-7-sonnet-20250219
# Query generation LLM settings
# LLM_QUERY_GENERATION_PROVIDER=groq
# LLM_QUERY_GENERATION_MODEL=llama-3.1-8b-instant
# Summarization LLM settings
# LLM_SUMMARY_PROVIDER=gemini
# LLM_SUMMARY_MODEL=gemini-2.0-flash-lite
# LLM_SUMMARY_MAX_TOKENS_SHORT=1000
# LLM_SUMMARY_MAX_TOKENS_LONG=2000
# =============================================================================
# Agent Settings
# =============================================================================
# AGENT_SEMANTIC_SEARCH_TOP_K=10
# AGENT_SEMANTIC_SEARCH_MAX_DISTANCE=0.85
# AGENT_TOM_INFERENCE_METHOD=single_prompt
# =============================================================================
# Deriver (Background Worker) Settings
# =============================================================================
# DERIVER_WORKERS=1
# DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5
# DERIVER_POLLING_SLEEP_INTERVAL_SECONDS=1.0
# DERIVER_TOM_METHOD=single_prompt
# DERIVER_USER_REPRESENTATION_METHOD=long_term
# =============================================================================
# History Settings
# =============================================================================
# HISTORY_MESSAGES_PER_SHORT_SUMMARY=20
# HISTORY_MESSAGES_PER_LONG_SUMMARY=60
# =============================================================================
# Monitoring and Observability (Optional)
# =============================================================================
# Sentry error tracking
# SENTRY_ENABLED=false
# SENTRY_DSN=your-sentry-dsn-here
# SENTRY_TRACES_SAMPLE_RATE=0.1
# SENTRY_PROFILES_SAMPLE_RATE=0.1

View File

@ -2,9 +2,9 @@ name: FastAPI Tests with PostgreSQL and uv
on:
push:
branches: [ main ]
branches: [main]
pull_request:
branches: [ main ]
branches: [main]
jobs:
test:
@ -27,31 +27,27 @@ jobs:
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v2
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
- name : Install the project
run: uv sync --all-extras --dev
- name: Run Tests
run: uv run pytest -x
env:
CONNECTION_URI: postgresql+psycopg://postgres:postgres@localhost:5432/test_db
USE_AUTH: false
SENTRY_ENABLED: false
OPENTELEMETRY_ENABLED: false
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Install uv
uses: astral-sh/setup-uv@v2
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
- name: Install the project
run: uv sync --all-extras --dev
- name: Run Tests
run: uv run pytest -x
env:
DB_CONNECTION_URI: postgresql+psycopg://postgres:postgres@localhost:5432/test_db
AUTH_USE_AUTH: false
SENTRY_ENABLED: false
LLM_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
LLM_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

4
.gitignore vendored
View File

@ -176,4 +176,6 @@ supabase/
docs/node_modules
timing_logs.csv
timing_logs.csv
config.toml

View File

@ -1,4 +1,9 @@
{
"python.analysis.typeCheckingMode": "basic",
"files.exclude": {}
"files.exclude": {},
"python.testing.pytestArgs": [
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}

View File

@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Batch message operations and enhanced message querying with token and message count limits
- Search and summary functionalities scoped by workspace, peer, and session
- Session context retrieval with summaries and token allocation
- HNSW Index for Documents Table
- Centralized Configuration via Environment Variables or `config.toml` file
### Changed
@ -38,6 +40,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
- Improved error handling and validation for batch message operations and metadata
- Database Sessions to be more atomic to reduce idle in transaction time
### Removed

View File

@ -1,6 +1,7 @@
# Honcho Overview
## What is Honcho?
Honcho is an infrastructure layer for building AI agents with social cognition and theory of mind capabilities. Its primary purposes include:
- Imbuing agents with a sense of identity
@ -14,12 +15,15 @@ Honcho leverages the inherent theory-of-mind capabilities of LLMs to build coher
## Core Concepts
### Peer Paradigm
Honcho uses a peer-based model where both users and agents are represented as "peers". This unified approach enables:
- Multi-participant sessions with mixed human and AI agents
- Configurable observation settings (which peers observe which others)
- Flexible identity management for all participants
### Key Primitives
- **Workspace** (formerly App): The root organizational unit containing all resources
- **Peer** (formerly User): Any participant in the system (human or AI)
- **Session**: A conversation context that can involve multiple peers
@ -29,7 +33,9 @@ Honcho uses a peer-based model where both users and agents are represented as "p
## Architecture Overview
### API Structure
All API routes follow the pattern: `/v2/{resource}/{id}/{action}`
All API routes follow the pattern: `/v1/{resource}/{id}/{action}`
- **Workspaces**: Create, list, update, search
- **Peers**: Create, list, update, chat (dialectic), messages, representation
- **Sessions**: Create, list, update, delete, clone, manage peers, get context
@ -39,12 +45,14 @@ All API routes follow the pattern: `/v2/{resource}/{id}/{action}`
### Key Features
#### Dialectic API (`/peers/{peer_id}/chat`)
- Provides theory-of-mind informed responses
- Integrates long-term facts from vector storage
- Supports streaming responses
- Configurable LLM providers
#### Message Processing Pipeline
1. Messages created via API (batch or single)
2. Enqueued for background processing:
- `representation`: Update peer's theory of mind
@ -53,12 +61,14 @@ All API routes follow the pattern: `/v2/{resource}/{id}/{action}`
4. Results stored internally in vector DB
#### Theory of Mind System
- Multiple implementation methods (conversational, single_prompt, long_term)
- Facts extracted from messages and stored in collections
- Representations combine short-term inference with long-term facts
- Configurable via peer and session feature flags
### Configuration
- Hierarchical config: config.toml + environment variables
- Database settings with connection pooling
- Multiple LLM provider support
@ -68,6 +78,7 @@ All API routes follow the pattern: `/v2/{resource}/{id}/{action}`
## Development Guide
### Commands
- Setup: `uv sync`
- Run server: `fastapi dev src/main.py`
- Run tests: `pytest tests/`
@ -76,6 +87,7 @@ All API routes follow the pattern: `/v2/{resource}/{id}/{action}`
- Format code: `ruff format src/`
### Code Style
- Follow isort conventions with absolute imports preferred
- Use explicit type hints with SQLAlchemy mapped_column annotations
- snake_case for variables/functions; PascalCase for classes
@ -84,6 +96,7 @@ All API routes follow the pattern: `/v2/{resource}/{id}/{action}`
- Docstrings: Use Google style docstrings
### Project Structure
```
src/
├── main.py # FastAPI app setup with middleware and exception handlers
@ -109,10 +122,12 @@ src/
├── cache.py # Caching utilities
└── model_client.py # LLM client abstraction
```
- Tests in pytest with fixtures in tests/conftest.py
- Use environment variables via python-dotenv (.env)
### Database Design
- All tables use text IDs (nanoid format) as primary keys
- Composite foreign keys for multi-tenant relationships
- Feature flags on workspace, peer, and session levels
@ -121,6 +136,7 @@ src/
- HNSW indexes for vector similarity search
### Key Architectural Decisions
1. **Multi-Peer Sessions**: Sessions can have multiple participants with different observation settings
2. **Flexible Theory of Mind**: Pluggable ToM implementations (conversational, single_prompt, long_term)
3. **Background Processing**: Async queue system for expensive operations
@ -130,8 +146,13 @@ src/
7. **Session History**: Two-tier summarization (short every 20 messages, long every 60)
### Error Handling
- Custom exceptions defined in src/exceptions.py
- Use specific exception types (ResourceNotFoundException, ValidationException, etc.)
- Proper logging with context instead of print statements
- Global exception handlers defined in main.py
- See docs/contributing/error-handling.mdx for details
- See docs/contributing/error-handling.mdx for details
### Notes
- Always use `uv run` or `uv` to prefix any commands related to python to ensure you use the virtual environment

View File

@ -1,164 +1,173 @@
# Contributing
# Contributing to Honcho
This project is completely open source and welcomes any and all open source
contributions. The workflow for contributing is to make a fork of the
repository. You can claim an issue in the issues tab or start a new thread to
indicate a feature or bug fix you are working on.
Thank you for your interest in contributing to Honcho! This guide outlines the process for contributing to the project and our development conventions.
Once you have finished your contribution make a PR , and it will be reviewed by
a project manager. Feel free to join us in our
[discord](http://discord.gg/plasticlabs) to discuss your changes or get help.
## Getting Started
Your changes will undergo a period of testing and discussion before finally
being entered into the `main` branch and being staged for release
Before you start contributing, please:
## Local Development
1. **Set up your development environment** - Follow the [Local Development guide](./README.md#local-development) in the README to get Honcho running locally.
Below is a guide on setting up a local environment for running the Honcho
Server.
2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/plasticlabs) to discuss your changes, get help, or ask questions.
> This guide was made using a M1 Macbook Pro. For any compatibility issues
> on different platforms please raise an Issue.
3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to.
### Prerequisites and Dependencies
## Contribution Workflow
Honcho is developed using [python](https://www.python.org/) and [uv](https://docs.astral.sh/uv/).
### 1. Fork and Clone
The minimum python version is `3.9`
The minimum poetry version is `0.4.9`
1. Fork the repository on GitHub
2. Clone your fork locally:
```bash
git clone https://github.com/YOUR_USERNAME/honcho.git
cd honcho
```
3. Add the upstream repository as a remote:
```bash
git remote add upstream https://github.com/plastic-labs/honcho.git
```
### Setup
### 2. Create a Branch
Once the dependencies are installed on the system run the following steps to get
the local project setup.
1. Clone the repository
Create a new branch for your feature or bug fix:
```bash
git clone https://github.com/plastic-labs/honcho.git
git checkout -b feature/your-feature-name
# or
git checkout -b fix/your-bug-fix-name
```
2. Enter the repository and install the python dependencies
**Branch naming conventions:**
We recommend using a virtual environment to isolate the dependencies for Honcho
from other projects on the same system. `uv` will create a virtual environment
when you sync your dependencies in the project.
- `feature/description` - for new features
- `fix/description` - for bug fixes
- `docs/description` - for documentation updates
- `refactor/description` - for code refactoring
- `test/description` - for adding or updating tests
Putting this together:
### 3. Make Your Changes
- Write clean, readable code that follows our coding standards (see below)
- Add tests for new functionality
- Update documentation as needed
- Make sure your changes don't break existing functionality
### 4. Commit Your Changes
We follow conventional commit standards. Format your commit messages as:
```
type(scope): description
[optional body]
[optional footer]
```
**Types:**
- `feat`: A new feature
- `fix`: A bug fix
- `docs`: Documentation only changes
- `style`: Changes that do not affect the meaning of the code
- `refactor`: A code change that neither fixes a bug nor adds a feature
- `test`: Adding missing tests or correcting existing tests
- `chore`: Changes to the build process or auxiliary tools
**Examples:**
```bash
cd honcho
uv sync
git commit -m "feat(api): add new dialectic endpoint for user insights"
git commit -m "fix(db): resolve connection pool timeout issue"
git commit -m "docs(readme): update installation instructions"
```
This will create a virtual environment and install the dependencies for Honcho.
The default virtual environment will be located at `honcho/.venv`. Activate the
virtual environment via:
### 5. Submit a Pull Request
```bash
source honcho/.venv/bin/activate
```
1. Push your branch to your fork:
3. Set up a database
```bash
git push origin your-branch-name
```
Honcho utilized [Postgres](https://www.postgresql.org/) for its database with
pgvector. An easy way to get started with a postgresdb is to create a project
with [Supabase](https://supabase.com/)
2. Create a pull request on GitHub from your branch to the `main` branch
A `docker-compose` template is also available with a database configuration
available.
3. Fill out the pull request template with:
- A clear description of what changes you've made
- The motivation for the changes
- Any relevant issue numbers (use "Closes #123" to auto-close issues)
- Screenshots or examples if applicable
4. Edit the environment variables.
## Coding Standards
Honcho uses a `.env` file for managing runtime environment variables. A
`.env.template` file is included for convenience. Several of the configurations
are not required and are only necessary for additional logging, monitoring, and
security.
### Python Code Style
Below are the required configurations
- 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 type hints where possible
- Write docstrings for functions and classes using Google style docstrings
```env
CONNECTION_URI= # Connection uri for a postgres database
OPENAI_API_KEY= # API Key for OpenAI used for embedding documents
ANTHROPIC_API_KEY= # API Key for Anthropic used for the deriver and dialectic API
```
### Code Organization
> Note that the `CONNECTION_URI` must have the prefix `postgresql+psycopg` to
> function properly. This is a requirement brought by `sqlalchemy`
- Keep functions focused and single-purpose
- Use meaningful variable and function names
- Add comments for complex logic
- Follow existing patterns in the codebase
The template has the additional functionality disabled by default. To ensure
that they are disabled you can verify the following environment variables are
set to false.
### Testing
```env
USE_AUTH=false
SENTRY_ENABLED=false
```
- Write unit tests for new functionality
- Ensure existing tests pass before submitting
- Use descriptive test names that explain what is being tested
- Mock external dependencies appropriately
If you set `USE_AUTH` to true you will need to generate a JWT secret. You can
do this with the following command:
### Documentation
```bash
python scripts/generate_jwt_secret.py
```
- Update relevant documentation for new features
- Include examples in docstrings where helpful
- Keep README and other docs up to date with changes
This will generate a JWT secret and print it to the console. You can then set
the `AUTH_JWT_SECRET` environment variable. This is required for `USE_AUTH`.
## Review Process
```env
AUTH_JWT_SECRET=<generated_secret>
```
1. **Automated checks** - Your PR will run through automated checks including tests and linting
2. **Project maintainer review** - A project maintainer will review your code for:
- Code quality and adherence to standards
- Functionality and correctness
- Test coverage
- Documentation completeness
3. **Discussion and iteration** - You may be asked to make changes or clarifications
4. **Approval and merge** - Once approved, your PR will be merged into `main`
5. Launch the API
## Types of Contributions
With the dependencies installed, a database setup and enabled with `pgvector`,
and the environment variables setup you can now launch a local instance of
Honcho. The following command will launch the storage API for Honcho
We welcome various types of contributions:
```bash
fastapi dev src/main.py
```
- **Bug fixes** - Help us squash bugs and improve stability
- **New features** - Add functionality that benefits the community
- **Documentation** - Improve or expand our documentation
- **Tests** - Increase test coverage and reliability
- **Performance improvements** - Help make Honcho faster and more efficient
- **Examples and tutorials** - Help other developers use Honcho
This is a development server that will reload whenever code is changed. When
first launching the API with a connection the database it will provision the
necessary tables for Honcho to operate.
## Issue Reporting
### Docker
When reporting bugs or requesting features:
As mentioned earlier a `docker-compose` template is included for running Honcho.
As an alternative to running Honcho locally it can also be run with the compose
template.
1. Check if the issue already exists
2. Use the appropriate issue template
3. Provide clear reproduction steps for bugs
4. Include relevant environment information
5. Be specific about expected vs actual behavior
The docker-compose template is set to use an environment file called `.env`.
You can also copy the `.env.template` and fill with the appropriate values.
## Questions and Support
Copy the template and update the appropriate environment variables before
launching the service.
- **General questions** - Join our [Discord](http://discord.gg/plasticlabs)
- **Bug reports** - Use GitHub issues
- **Feature requests** - Use GitHub issues with the feature request template
- **Security issues** - Please email us privately rather than opening a public issue
```bash
cd honcho/api
cp .env.template .env
# update the file with openai key and other wanted environment variables
cp docker-compose.yml.example docker-compose.yml
docker compose up
```
## License
### Deploy on Fly
By contributing to Honcho, you agree that your contributions will be licensed under the same [AGPL-3.0 License](./LICENSE) that covers the project.
The API can also be deployed on fly.io. Follow the [Fly.io
Docs](https://fly.io/docs/getting-started/) to setup your environment and the
`flyctl`.
A sample `fly.toml` is included for convenience.
> Note. The fly.toml does not include launching a Postgres database. This must
> be configured separately
Once `flyctl` is set up use the following commands to launch the application:
```bash
cd honcho/api
flyctl launch --no-deploy # Follow the prompts and edit as you see fit
cat .env | flyctl secrets import # Load in your secrets
flyctl deploy # Deploy with appropriate environment variables
```
Thank you for helping make Honcho better! 🫡

View File

@ -37,6 +37,8 @@ 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 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/
EXPOSE 8000

273
README.md
View File

@ -1,6 +1,6 @@
# 🫡 Honcho
![Static Badge](https://img.shields.io/badge/Version-2.0.0-blue)
![Static Badge](https://img.shields.io/badge/Version-2.0.1-blue)
[![Discord](https://img.shields.io/discord/1016845111637839922?style=flat&logo=discord&logoColor=23ffffff&label=Plastic%20Labs&labelColor=235865F2)](https://discord.gg/plasticlabs)
[![arXiv](https://img.shields.io/badge/arXiv-2310.06983-b31b1b.svg)](https://arxiv.org/abs/2310.06983)
![GitHub License](https://img.shields.io/github/license/plastic-labs/honcho)
@ -19,9 +19,20 @@ Read the user documentation [here](https://docs.honcho.dev)
- [Project Structure](#project-structure)
- [Usage](#usage)
- [Local Development](#local-development)
- [Prerequisites and Dependencies](#prerequisites-and-dependencies)
- [Setup](#setup)
- [Docker](#docker)
- [Deploy on Fly](#deploy-on-fly)
- [Configuration](#configuration)
- [Using config.toml](#using-configtoml)
- [Using Environment Variables](#using-environment-variables)
- [Configuration Priority](#configuration-priority)
- [Example](#example)
- [Architecture](#architecture)
- [Storage](#storage)
- [Insights](#insights)
- [Contributing](#contributing)
- [License](#license)
## Project Structure
@ -40,25 +51,249 @@ along with various guides.
## Usage
Currently, there is a demo server of Honcho running at https://demo.honcho.dev.
This server is not production ready and does not have an reliability guarantees.
It is purely there for evaluation purposes.
When you first install the SDKs they will be ready to go, pointing at
[https://demo.honcho.dev](https://demo.honcho.dev) which is a demo server of Honcho. This server has no
authentication, no SLA, and should only be used for testing and getting familiar
with Honcho.
A private beta for a tenant isolated production ready version of Honcho is
currently underway. If interested fill out this
[typeform](https://plasticlabs.typeform.com/honchobeta) and the Plastic Labs
team will reach out to onboard users.
For a production ready version of Honcho sign up for an account at
[https://app.honcho.dev](https://app.honcho.dev) and get started. When you sign up you'll be prompted to
join an organization which will have a dedicated instance of Honcho.
Provision API keys and change your base url to point to
[https://api.honcho.dev](https://api.honcho.dev)
Additionally, Honcho can be self-hosted for testing and evaluation purposes. See
[Contributing](./CONTRIBUTING.md) for more details on how to setup a local
the [Local Development](#local-development) section below for details on how to set up a local
version of Honcho.
## Local Development
Below is a guide on setting up a local environment for running the Honcho
Server.
> This guide was made using a M3 Macbook Pro. For any compatibility issues
> on different platforms, please raise an Issue.
### Prerequisites and Dependencies
Honcho is developed using [python](https://www.python.org/) and [uv](https://docs.astral.sh/uv/).
The minimum python version is `3.9`
The minimum uv version is `0.4.9`
### Setup
Once the dependencies are installed on the system run the following steps to get
the local project setup.
1. **Clone the repository**
```bash
git clone https://github.com/plastic-labs/honcho.git
```
2. **Enter the repository and install the python dependencies**
We recommend using a virtual environment to isolate the dependencies for Honcho
from other projects on the same system. `uv` will create a virtual environment
when you sync your dependencies in the project.
```bash
cd honcho
uv sync
```
This will create a virtual environment and install the dependencies for Honcho.
The default virtual environment will be located at `honcho/.venv`. Activate the
virtual environment via:
```bash
source honcho/.venv/bin/activate
```
3. **Set up a database**
Honcho utilizes [Postgres](https://www.postgresql.org/) for its database with
pgvector. An easy way to get started with a postgres database is to create a project
with [Supabase](https://supabase.com/)
A `docker-compose` template is also available with a database configuration.
4. **Edit the environment variables**
Honcho uses a `.env` file for managing runtime environment variables. A
`.env.template` file is included for convenience. Several of the configurations
are not required and are only necessary for additional logging, monitoring, and
security.
Below are the required configurations:
```env
DB_CONNECTION_URI= # Connection uri for a postgres database
OPENAI_API_KEY= # API Key for OpenAI used for embedding documents
ANTHROPIC_API_KEY= # API Key for Anthropic used for the deriver and dialectic API
```
> Note that the `DB_CONNECTION_URI` must have the prefix `postgresql+psycopg` to
> function properly. This is a requirement brought by `sqlalchemy`
The template has the additional functionality disabled by default. To ensure
that they are disabled you can verify the following environment variables are
set to false:
```env
AUTH_USE_AUTH=false
SENTRY_ENABLED=false
```
If you set `AUTH_USE_AUTH` to true you will need to generate a JWT secret. You can
do this with the following command:
```bash
python scripts/generate_jwt_secret.py
```
This will generate a JWT secret and print it to the console. You can then set
the `AUTH_JWT_SECRET` environment variable. This is required for `AUTH_USE_AUTH`:
```env
AUTH_JWT_SECRET=<generated_secret>
```
5. **Launch the API**
With the dependencies installed, a database setup and enabled with `pgvector`,
and the environment variables setup you can now launch a local instance of
Honcho. The following command will launch the storage API for Honcho:
```bash
fastapi dev src/main.py
```
This is a development server that will reload whenever code is changed. When
first launching the API with a connection to the database it will provision the
necessary tables for Honcho to operate.
### Docker
As mentioned earlier a `docker-compose` template is included for running Honcho.
As an alternative to running Honcho locally it can also be run with the compose
template.
The docker-compose template is set to use an environment file called `.env`.
You can also copy the `.env.template` and fill with the appropriate values.
Copy the template and update the appropriate environment variables before
launching the service:
```bash
cd honcho
cp .env.template .env
# update the file with openai key and other wanted environment variables
cp docker-compose.yml.example docker-compose.yml
docker compose up
```
### Deploy on Fly
The API can also be deployed on fly.io. Follow the [Fly.io
Docs](https://fly.io/docs/getting-started/) to setup your environment and the
`flyctl`.
A sample `fly.toml` is included for convenience.
> Note: The fly.toml does not include launching a Postgres database. This must
> be configured separately
Once `flyctl` is set up use the following commands to launch the application:
```bash
cd honcho
flyctl launch --no-deploy # Follow the prompts and edit as you see fit
cat .env | flyctl secrets import # Load in your secrets
flyctl deploy # Deploy with appropriate environment variables
```
## Configuration
Honcho uses a flexible configuration system that supports both TOML files and environment variables. Configuration values are loaded in the following priority order (highest to lowest):
1. Environment variables
2. `.env` file (for local development)
3. `config.toml` file
4. Default values
### Using config.toml
Copy the example configuration file to get started:
```bash
cp config.toml.example config.toml
```
Then modify the values as needed. The TOML file is organized into sections:
- `[app]` - Application-level settings (log level, host, port)
- `[db]` - Database connection and pool settings
- `[auth]` - Authentication configuration
- `[llm]` - LLM provider and model settings
- `[agent]` - Agent behavior settings
- `[deriver]` - Background worker settings
- `[history]` - Message history settings
### Using Environment Variables
All configuration values can be overridden using environment variables. The environment variable names follow this pattern:
- `{SECTION}_{KEY}` for nested settings
- Just `{KEY}` for app-level settings
Examples:
- `DB_CONNECTION_URI` - Database connection string
- `AUTH_JWT_SECRET` - JWT secret key
- `LLM_DIALECTIC_MODEL` - Dialectic LLM model
- `LOG_LEVEL` - Application log level
### Configuration Priority
When a configuration value is set in multiple places, Honcho uses this priority:
1. **Environment variables** - Always take precedence
2. **.env file** - Loaded for local development
3. **config.toml** - Base configuration
4. **Default values** - Built-in defaults
This allows you to:
- Use `config.toml` for base configuration
- Override specific values with environment variables in production
- Use `.env` files for local development without modifying config.toml
### Example
If you have this in `config.toml`:
```toml
[db]
CONNECTION_URI = "postgresql://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"
```
The application will use the production connection URI while keeping the pool size from config.toml.
## Architecture
The functionality of Honcho can be split into two different services: Storage
and Insights.
### Peer Paradigm
Honcho uses a peer-based model where both users and agents are represented as "peers". This unified approach enables:
@ -100,6 +335,7 @@ Workspaces
```
**Relationship Details:**
- A **Workspace** contains multiple **Peers**
- **Peers** and **Sessions** have a many-to-many relationship (peers can participate in multiple sessions, sessions can have multiple peers)
- **Messages** can exist at two levels:
@ -136,7 +372,10 @@ The `Message` represents an atomic data unit that can exist at two levels:
- **Session-level Messages**: Communication between peers within a session context
- **Peer-level Messages**: Arbitrary data ingested by a peer to enhance its global representation (independent of any session)
All messages are labeled by their source peer and can be processed asynchronously to update theory-of-mind models. This flexible design allows for both conversational interactions and broader data ingestion for personality modeling.
All messages are labeled by their source peer and can be processed
asynchronously to update theory-of-mind models. This flexible design allows for
both conversational interactions and broader data ingestion for personality
modeling.
#### Collections
@ -145,10 +384,8 @@ familiar with RAG based applications will be familiar with these. `Collections`
store vector embedded data that developers and agents can retrieve against using
functions like cosine similarity.
Developers can create multiple `Collections` for a peer for different purposes
such as modeling different personas, adding third-party data such as emails and
PDF files, and more. Collections are also used internally by Honcho to store
theory-of-mind representations.
Collections are also used internally by Honcho while creating theory-of-mind
representations of peers.
#### Documents
@ -163,7 +400,7 @@ in reserved `Collections`.
The system uses a sophisticated message processing pipeline:
1. Messages are created via API
1. Messages are created via API
2. Enqueued for background processing including:
- `representation`: Update peer's theory of mind
- `summary`: Create session summaries
@ -189,6 +426,10 @@ API include:
- Asking Honcho for a 2nd opinion or approach about how to respond to the Peer
- Getting personalized responses that incorporate long-term facts and context
## Contributing
We welcome contributions to Honcho! Please read our [Contributing Guide](./CONTRIBUTING.md) for details on our development process, coding conventions, and how to submit pull requests.
## License
Honcho is licensed under the AGPL-3.0 License. Learn more at the [License file](./LICENSE)

82
config.toml.example Normal file
View File

@ -0,0 +1,82 @@
# Honcho Configuration File
# This file demonstrates all available configuration options.
# Copy this to config.toml and modify as needed.
# Environment variables will override these values.
# Application-level settings
[app]
LOG_LEVEL = "INFO"
FASTAPI_HOST = "0.0.0.0"
FASTAPI_PORT = 8000
# Database settings
[db]
CONNECTION_URI = "postgresql+psycopg://postgres:postgres@localhost:5432/postgres"
SCHEMA = "public"
POOL_CLASS = "default"
POOL_PRE_PING = true
POOL_SIZE = 10
MAX_OVERFLOW = 20
POOL_TIMEOUT = 30 # seconds
POOL_RECYCLE = 300 # seconds
POOL_USE_LIFO = true
SQL_DEBUG = false
TRACING = false
# Authentication settings
[auth]
USE_AUTH = false
JWT_SECRET = "your-secret-key-here" # Must be set if USE_AUTH is true
# Sentry settings
[sentry]
ENABLED = false
DSN = ""
TRACES_SAMPLE_RATE = 0.1
PROFILES_SAMPLE_RATE = 0.1
# LLM settings
[llm]
DEFAULT_MAX_TOKENS = 1000
DEFAULT_TEMPERATURE = 0.0
# Dialectic specific
DIALECTIC_PROVIDER = "anthropic"
DIALECTIC_MODEL = "claude-3-7-sonnet-20250219"
# Query Generation specific
QUERY_GENERATION_PROVIDER = "groq"
QUERY_GENERATION_MODEL = "llama-3.1-8b-instant"
# Summarization specific
SUMMARY_PROVIDER = "gemini"
SUMMARY_MODEL = "gemini-2.0-flash-lite"
SUMMARY_MAX_TOKENS_SHORT = 1000
SUMMARY_MAX_TOKENS_LONG = 2000
# 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-api-key"
# Agent settings
[agent]
SEMANTIC_SEARCH_TOP_K = 10
SEMANTIC_SEARCH_MAX_DISTANCE = 0.85
TOM_INFERENCE_METHOD = "single_prompt"
# Deriver settings
[deriver]
WORKERS = 1
STALE_SESSION_TIMEOUT_MINUTES = 5
POLLING_SLEEP_INTERVAL_SECONDS = 1.0
TOM_METHOD = "single_prompt"
USER_REPRESENTATION_METHOD = "long_term"
# History settings
[history]
MESSAGES_PER_SHORT_SUMMARY = 20
MESSAGES_PER_LONG_SUMMARY = 60

View File

@ -1,5 +1,4 @@
import logging
import os
import sys
from logging.config import fileConfig
from pathlib import Path
@ -8,6 +7,8 @@ from alembic import context
from dotenv import load_dotenv
from sqlalchemy import engine_from_config, pool, text
from src.config import settings
# Import your models
from src.db import Base
@ -20,7 +21,7 @@ logging.getLogger("alembic").setLevel(logging.DEBUG)
sys.path.append(str(Path(__file__).parents[1]))
# Load environment variables
load_dotenv()
load_dotenv(override=True)
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@ -44,9 +45,9 @@ target_metadata = Base.metadata
def get_url() -> str:
url = os.getenv("CONNECTION_URI")
url = settings.DB.CONNECTION_URI
if url is None:
raise ValueError("CONNECTION_URI environment variable is not set")
raise ValueError("DB_CONNECTION_URI not set")
return url

View File

@ -4,9 +4,11 @@ from typing import Optional
import sqlalchemy as sa
from alembic import op
from src.config import settings
def get_schema() -> str:
return getenv("DATABASE_SCHEMA", "public")
return settings.DB.SCHEMA
def table_exists(table_name: str, inspector: Optional[sa.Inspector] = None) -> bool:

View File

@ -5,28 +5,31 @@ Revises: 20f89a421aff
Create Date: 2025-05-19 17:00:18.151735
"""
from typing import Sequence, Union
from os import getenv
from collections.abc import Sequence
from typing import Union
from alembic import op
from sqlalchemy import text
from src.config import settings
# revision identifiers, used by Alembic.
revision: str = '66e63cf2cf77'
down_revision: Union[str, None] = '20f89a421aff'
revision: str = "66e63cf2cf77"
down_revision: Union[str, None] = "20f89a421aff"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
schema = getenv("DATABASE_SCHEMA", "public")
schema = settings.DB.SCHEMA
def upgrade() -> None:
# Create HNSW index on the embedding column for the documents table for cosine distance
# Parameters:
# - m: max number of connections (edges) per node (default=16)
# - ef_construction: size of the candidate list during index construction (default=64)
print(f"Creating HNSW index idx_documents_embedding_hnsw on {schema}.documents table")
print(
f"Creating HNSW index idx_documents_embedding_hnsw on {schema}.documents table"
)
try:
op.execute(
text(
@ -37,12 +40,20 @@ def upgrade() -> None:
"""
)
)
print(f"HNSW index idx_documents_embedding_hnsw created on {schema}.documents table")
print(
f"HNSW index idx_documents_embedding_hnsw created on {schema}.documents table"
)
except Exception as e:
print(f"Error creating HNSW index idx_documents_embedding_hnsw on {schema}.documents table: {e}")
print(
f"Error creating HNSW index idx_documents_embedding_hnsw on {schema}.documents table: {e}"
)
def downgrade() -> None:
print(f"Dropping HNSW index idx_documents_embedding_hnsw from {schema}.documents table")
def downgrade() -> None:
print(
f"Dropping HNSW index idx_documents_embedding_hnsw from {schema}.documents table"
)
op.execute(text(f"DROP INDEX IF EXISTS {schema}.idx_documents_embedding_hnsw;"))
print(f"HNSW index idx_documents_embedding_hnsw dropped from {schema}.documents table")
print(
f"HNSW index idx_documents_embedding_hnsw dropped from {schema}.documents table"
)

View File

@ -1,12 +1,12 @@
"""Initial schema creation
Revision ID: a1b2c3d4e5f6
Revises:
Revises:
Create Date: 2024-01-01 00:00:00.000000
"""
from collections.abc import Sequence
from os import getenv
from typing import Union
import sqlalchemy as sa
@ -14,201 +14,454 @@ from alembic import op
from pgvector.sqlalchemy import Vector
from sqlalchemy.dialects import postgresql
from src.config import settings
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
schema = getenv("DATABASE_SCHEMA", "public")
schema = settings.DB.SCHEMA
# Create apps table
op.create_table('apps',
sa.Column('id', sa.BigInteger(), sa.Identity(), nullable=False),
sa.Column('public_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
sa.CheckConstraint("length(public_id) = 21", name='public_id_length'),
sa.CheckConstraint("length(name) <= 512", name='name_length'),
sa.CheckConstraint("public_id ~ '^[A-Za-z0-9_-]+$'", name='public_id_format'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_apps')),
sa.UniqueConstraint('name', name=op.f('uq_apps_name')),
sa.UniqueConstraint('public_id', name=op.f('uq_apps_public_id')),
op.create_table(
"apps",
sa.Column("id", sa.BigInteger(), sa.Identity(), nullable=False),
sa.Column("public_id", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"metadata",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
),
sa.CheckConstraint("length(public_id) = 21", name="public_id_length"),
sa.CheckConstraint("length(name) <= 512", name="name_length"),
sa.CheckConstraint("public_id ~ '^[A-Za-z0-9_-]+$'", name="public_id_format"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_apps")),
sa.UniqueConstraint("name", name=op.f("uq_apps_name")),
sa.UniqueConstraint("public_id", name=op.f("uq_apps_public_id")),
schema=schema,
)
op.create_index(op.f('ix_apps_created_at'), 'apps', ['created_at'], unique=False, schema=schema)
op.create_index(op.f('ix_apps_id'), 'apps', ['id'], unique=False, schema=schema)
op.create_index(op.f('ix_apps_name'), 'apps', ['name'], unique=False, schema=schema)
op.create_index(op.f('ix_apps_public_id'), 'apps', ['public_id'], unique=False, schema=schema)
op.create_index(
op.f("ix_apps_created_at"), "apps", ["created_at"], unique=False, schema=schema
)
op.create_index(op.f("ix_apps_id"), "apps", ["id"], unique=False, schema=schema)
op.create_index(op.f("ix_apps_name"), "apps", ["name"], unique=False, schema=schema)
op.create_index(
op.f("ix_apps_public_id"), "apps", ["public_id"], unique=False, schema=schema
)
# Create users table
op.create_table('users',
sa.Column('id', sa.BigInteger(), sa.Identity(), nullable=False),
sa.Column('public_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('app_id', sa.Text(), nullable=False),
sa.CheckConstraint("length(public_id) = 21", name='public_id_length'),
sa.CheckConstraint("length(name) <= 512", name='name_length'),
sa.CheckConstraint("public_id ~ '^[A-Za-z0-9_-]+$'", name='public_id_format'),
sa.ForeignKeyConstraint(['app_id'], ['apps.public_id'], name=op.f('fk_users_app_id_apps')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_users')),
sa.UniqueConstraint('name', 'app_id', name='unique_name_app_user'),
sa.UniqueConstraint('public_id', name=op.f('uq_users_public_id')),
op.create_table(
"users",
sa.Column("id", sa.BigInteger(), sa.Identity(), nullable=False),
sa.Column("public_id", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column(
"metadata",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("app_id", sa.Text(), nullable=False),
sa.CheckConstraint("length(public_id) = 21", name="public_id_length"),
sa.CheckConstraint("length(name) <= 512", name="name_length"),
sa.CheckConstraint("public_id ~ '^[A-Za-z0-9_-]+$'", name="public_id_format"),
sa.ForeignKeyConstraint(
["app_id"], ["apps.public_id"], name=op.f("fk_users_app_id_apps")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_users")),
sa.UniqueConstraint("name", "app_id", name="unique_name_app_user"),
sa.UniqueConstraint("public_id", name=op.f("uq_users_public_id")),
schema=schema,
)
op.create_index(op.f('ix_users_app_id'), 'users', ['app_id'], unique=False, schema=schema)
op.create_index(op.f('ix_users_created_at'), 'users', ['created_at'], unique=False, schema=schema)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False, schema=schema)
op.create_index(op.f('ix_users_name'), 'users', ['name'], unique=False, schema=schema)
op.create_index(op.f('ix_users_public_id'), 'users', ['public_id'], unique=False, schema=schema)
op.create_index(
op.f("ix_users_app_id"), "users", ["app_id"], unique=False, schema=schema
)
op.create_index(
op.f("ix_users_created_at"),
"users",
["created_at"],
unique=False,
schema=schema,
)
op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False, schema=schema)
op.create_index(
op.f("ix_users_name"), "users", ["name"], unique=False, schema=schema
)
op.create_index(
op.f("ix_users_public_id"), "users", ["public_id"], unique=False, schema=schema
)
# Create sessions table
op.create_table('sessions',
sa.Column('id', sa.BigInteger(), sa.Identity(), nullable=False),
sa.Column('public_id', sa.Text(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.CheckConstraint("length(public_id) = 21", name='public_id_length'),
sa.CheckConstraint("public_id ~ '^[A-Za-z0-9_-]+$'", name='public_id_format'),
sa.ForeignKeyConstraint(['user_id'], ['users.public_id'], name=op.f('fk_sessions_user_id_users')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sessions')),
sa.UniqueConstraint('public_id', name=op.f('uq_sessions_public_id')),
op.create_table(
"sessions",
sa.Column("id", sa.BigInteger(), sa.Identity(), nullable=False),
sa.Column("public_id", sa.Text(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column(
"metadata",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("user_id", sa.Text(), nullable=False),
sa.CheckConstraint("length(public_id) = 21", name="public_id_length"),
sa.CheckConstraint("public_id ~ '^[A-Za-z0-9_-]+$'", name="public_id_format"),
sa.ForeignKeyConstraint(
["user_id"], ["users.public_id"], name=op.f("fk_sessions_user_id_users")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_sessions")),
sa.UniqueConstraint("public_id", name=op.f("uq_sessions_public_id")),
schema=schema,
)
op.create_index(
op.f("ix_sessions_created_at"),
"sessions",
["created_at"],
unique=False,
schema=schema,
)
op.create_index(
op.f("ix_sessions_id"), "sessions", ["id"], unique=False, schema=schema
)
op.create_index(
op.f("ix_sessions_public_id"),
"sessions",
["public_id"],
unique=False,
schema=schema,
)
op.create_index(
op.f("ix_sessions_user_id"),
"sessions",
["user_id"],
unique=False,
schema=schema,
)
op.create_index(op.f('ix_sessions_created_at'), 'sessions', ['created_at'], unique=False, schema=schema)
op.create_index(op.f('ix_sessions_id'), 'sessions', ['id'], unique=False, schema=schema)
op.create_index(op.f('ix_sessions_public_id'), 'sessions', ['public_id'], unique=False, schema=schema)
op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False, schema=schema)
# Create messages table
op.create_table('messages',
sa.Column('id', sa.BigInteger(), sa.Identity(), nullable=False),
sa.Column('public_id', sa.Text(), nullable=False),
sa.Column('session_id', sa.Text(), nullable=False),
sa.Column('is_user', sa.Boolean(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("length(public_id) = 21", name='public_id_length'),
sa.CheckConstraint("length(content) <= 65535", name='content_length'),
sa.CheckConstraint("public_id ~ '^[A-Za-z0-9_-]+$'", name='public_id_format'),
sa.ForeignKeyConstraint(['session_id'], ['sessions.public_id'], name=op.f('fk_messages_session_id_sessions')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_messages')),
sa.UniqueConstraint('public_id', name=op.f('uq_messages_public_id')),
op.create_table(
"messages",
sa.Column("id", sa.BigInteger(), sa.Identity(), nullable=False),
sa.Column("public_id", sa.Text(), nullable=False),
sa.Column("session_id", sa.Text(), nullable=False),
sa.Column("is_user", sa.Boolean(), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column(
"metadata",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint("length(public_id) = 21", name="public_id_length"),
sa.CheckConstraint("length(content) <= 65535", name="content_length"),
sa.CheckConstraint("public_id ~ '^[A-Za-z0-9_-]+$'", name="public_id_format"),
sa.ForeignKeyConstraint(
["session_id"],
["sessions.public_id"],
name=op.f("fk_messages_session_id_sessions"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_messages")),
sa.UniqueConstraint("public_id", name=op.f("uq_messages_public_id")),
schema=schema,
)
op.create_index(
op.f("ix_messages_created_at"),
"messages",
["created_at"],
unique=False,
schema=schema,
)
op.create_index(
op.f("ix_messages_id"), "messages", ["id"], unique=False, schema=schema
)
op.create_index(
op.f("ix_messages_public_id"),
"messages",
["public_id"],
unique=False,
schema=schema,
)
op.create_index(
op.f("ix_messages_session_id"),
"messages",
["session_id"],
unique=False,
schema=schema,
)
op.create_index(op.f('ix_messages_created_at'), 'messages', ['created_at'], unique=False, schema=schema)
op.create_index(op.f('ix_messages_id'), 'messages', ['id'], unique=False, schema=schema)
op.create_index(op.f('ix_messages_public_id'), 'messages', ['public_id'], unique=False, schema=schema)
op.create_index(op.f('ix_messages_session_id'), 'messages', ['session_id'], unique=False, schema=schema)
# Create metamessages table
op.create_table('metamessages',
sa.Column('id', sa.BigInteger(), sa.Identity(), nullable=False),
sa.Column('public_id', sa.Text(), nullable=False),
sa.Column('metamessage_type', sa.Text(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('message_id', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
sa.CheckConstraint("length(public_id) = 21", name='public_id_length'),
sa.CheckConstraint("length(content) <= 65535", name='content_length'),
sa.CheckConstraint("length(metamessage_type) <= 512", name='metamessage_type_length'),
sa.CheckConstraint("public_id ~ '^[A-Za-z0-9_-]+$'", name='public_id_format'),
sa.ForeignKeyConstraint(['message_id'], ['messages.public_id'], name=op.f('fk_metamessages_message_id_messages')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_metamessages')),
sa.UniqueConstraint('public_id', name=op.f('uq_metamessages_public_id')),
op.create_table(
"metamessages",
sa.Column("id", sa.BigInteger(), sa.Identity(), nullable=False),
sa.Column("public_id", sa.Text(), nullable=False),
sa.Column("metamessage_type", sa.Text(), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("message_id", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"metadata",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
),
sa.CheckConstraint("length(public_id) = 21", name="public_id_length"),
sa.CheckConstraint("length(content) <= 65535", name="content_length"),
sa.CheckConstraint(
"length(metamessage_type) <= 512", name="metamessage_type_length"
),
sa.CheckConstraint("public_id ~ '^[A-Za-z0-9_-]+$'", name="public_id_format"),
sa.ForeignKeyConstraint(
["message_id"],
["messages.public_id"],
name=op.f("fk_metamessages_message_id_messages"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_metamessages")),
sa.UniqueConstraint("public_id", name=op.f("uq_metamessages_public_id")),
schema=schema,
)
op.create_index(
op.f("ix_metamessages_created_at"),
"metamessages",
["created_at"],
unique=False,
schema=schema,
)
op.create_index(
op.f("ix_metamessages_id"), "metamessages", ["id"], unique=False, schema=schema
)
op.create_index(
op.f("ix_metamessages_message_id"),
"metamessages",
["message_id"],
unique=False,
schema=schema,
)
op.create_index(
op.f("ix_metamessages_metamessage_type"),
"metamessages",
["metamessage_type"],
unique=False,
schema=schema,
)
op.create_index(
op.f("ix_metamessages_public_id"),
"metamessages",
["public_id"],
unique=False,
schema=schema,
)
op.create_index(op.f('ix_metamessages_created_at'), 'metamessages', ['created_at'], unique=False, schema=schema)
op.create_index(op.f('ix_metamessages_id'), 'metamessages', ['id'], unique=False, schema=schema)
op.create_index(op.f('ix_metamessages_message_id'), 'metamessages', ['message_id'], unique=False, schema=schema)
op.create_index(op.f('ix_metamessages_metamessage_type'), 'metamessages', ['metamessage_type'], unique=False, schema=schema)
op.create_index(op.f('ix_metamessages_public_id'), 'metamessages', ['public_id'], unique=False, schema=schema)
# Create collections table
op.create_table('collections',
sa.Column('id', sa.BigInteger(), sa.Identity(), nullable=False),
sa.Column('public_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
sa.Column('user_id', sa.Text(), nullable=False),
sa.CheckConstraint("length(public_id) = 21", name='public_id_length'),
sa.CheckConstraint("length(name) <= 512", name='name_length'),
sa.CheckConstraint("public_id ~ '^[A-Za-z0-9_-]+$'", name='public_id_format'),
sa.ForeignKeyConstraint(['user_id'], ['users.public_id'], name=op.f('fk_collections_user_id_users')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_collections')),
sa.UniqueConstraint('name', 'user_id', name='unique_name_collection_user'),
sa.UniqueConstraint('public_id', name=op.f('uq_collections_public_id')),
op.create_table(
"collections",
sa.Column("id", sa.BigInteger(), sa.Identity(), nullable=False),
sa.Column("public_id", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"metadata",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
),
sa.Column("user_id", sa.Text(), nullable=False),
sa.CheckConstraint("length(public_id) = 21", name="public_id_length"),
sa.CheckConstraint("length(name) <= 512", name="name_length"),
sa.CheckConstraint("public_id ~ '^[A-Za-z0-9_-]+$'", name="public_id_format"),
sa.ForeignKeyConstraint(
["user_id"], ["users.public_id"], name=op.f("fk_collections_user_id_users")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_collections")),
sa.UniqueConstraint("name", "user_id", name="unique_name_collection_user"),
sa.UniqueConstraint("public_id", name=op.f("uq_collections_public_id")),
schema=schema,
)
op.create_index(
op.f("ix_collections_created_at"),
"collections",
["created_at"],
unique=False,
schema=schema,
)
op.create_index(
op.f("ix_collections_id"), "collections", ["id"], unique=False, schema=schema
)
op.create_index(
op.f("ix_collections_name"),
"collections",
["name"],
unique=False,
schema=schema,
)
op.create_index(
op.f("ix_collections_public_id"),
"collections",
["public_id"],
unique=False,
schema=schema,
)
op.create_index(
op.f("ix_collections_user_id"),
"collections",
["user_id"],
unique=False,
schema=schema,
)
op.create_index(op.f('ix_collections_created_at'), 'collections', ['created_at'], unique=False, schema=schema)
op.create_index(op.f('ix_collections_id'), 'collections', ['id'], unique=False, schema=schema)
op.create_index(op.f('ix_collections_name'), 'collections', ['name'], unique=False, schema=schema)
op.create_index(op.f('ix_collections_public_id'), 'collections', ['public_id'], unique=False, schema=schema)
op.create_index(op.f('ix_collections_user_id'), 'collections', ['user_id'], unique=False, schema=schema)
# Create documents table
op.create_table('documents',
sa.Column('id', sa.BigInteger(), sa.Identity(), nullable=False),
sa.Column('public_id', sa.Text(), nullable=False),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('embedding', Vector(1536), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('collection_id', sa.Text(), nullable=False),
sa.CheckConstraint("length(public_id) = 21", name='public_id_length'),
sa.CheckConstraint("length(content) <= 65535", name='content_length'),
sa.CheckConstraint("public_id ~ '^[A-Za-z0-9_-]+$'", name='public_id_format'),
sa.ForeignKeyConstraint(['collection_id'], ['collections.public_id'], name=op.f('fk_documents_collection_id_collections')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_documents')),
sa.UniqueConstraint('public_id', name=op.f('uq_documents_public_id')),
op.create_table(
"documents",
sa.Column("id", sa.BigInteger(), sa.Identity(), nullable=False),
sa.Column("public_id", sa.Text(), nullable=False),
sa.Column(
"metadata",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("embedding", Vector(1536), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("collection_id", sa.Text(), nullable=False),
sa.CheckConstraint("length(public_id) = 21", name="public_id_length"),
sa.CheckConstraint("length(content) <= 65535", name="content_length"),
sa.CheckConstraint("public_id ~ '^[A-Za-z0-9_-]+$'", name="public_id_format"),
sa.ForeignKeyConstraint(
["collection_id"],
["collections.public_id"],
name=op.f("fk_documents_collection_id_collections"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_documents")),
sa.UniqueConstraint("public_id", name=op.f("uq_documents_public_id")),
schema=schema,
)
op.create_index(
op.f("ix_documents_collection_id"),
"documents",
["collection_id"],
unique=False,
schema=schema,
)
op.create_index(
op.f("ix_documents_created_at"),
"documents",
["created_at"],
unique=False,
schema=schema,
)
op.create_index(
op.f("ix_documents_id"), "documents", ["id"], unique=False, schema=schema
)
op.create_index(
op.f("ix_documents_public_id"),
"documents",
["public_id"],
unique=False,
schema=schema,
)
op.create_index(op.f('ix_documents_collection_id'), 'documents', ['collection_id'], unique=False, schema=schema)
op.create_index(op.f('ix_documents_created_at'), 'documents', ['created_at'], unique=False, schema=schema)
op.create_index(op.f('ix_documents_id'), 'documents', ['id'], unique=False, schema=schema)
op.create_index(op.f('ix_documents_public_id'), 'documents', ['public_id'], unique=False, schema=schema)
# Create queue table
op.create_table('queue',
sa.Column('id', sa.BigInteger(), sa.Identity(), nullable=False),
sa.Column('session_id', sa.BigInteger(), nullable=False),
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('processed', sa.Boolean(), nullable=False, server_default='false'),
sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], name=op.f('fk_queue_session_id_sessions')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_queue')),
op.create_table(
"queue",
sa.Column("id", sa.BigInteger(), sa.Identity(), nullable=False),
sa.Column("session_id", sa.BigInteger(), nullable=False),
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("processed", sa.Boolean(), nullable=False, server_default="false"),
sa.ForeignKeyConstraint(
["session_id"], ["sessions.id"], name=op.f("fk_queue_session_id_sessions")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_queue")),
schema=schema,
)
op.create_index(
op.f("ix_queue_session_id"),
"queue",
["session_id"],
unique=False,
schema=schema,
)
op.create_index(op.f('ix_queue_session_id'), 'queue', ['session_id'], unique=False, schema=schema)
# Create active_queue_sessions table
op.create_table('active_queue_sessions',
sa.Column('session_id', sa.BigInteger(), nullable=False),
sa.Column('last_updated', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], name=op.f('fk_active_queue_sessions_session_id_sessions')),
sa.PrimaryKeyConstraint('session_id', name=op.f('pk_active_queue_sessions')),
op.create_table(
"active_queue_sessions",
sa.Column("session_id", sa.BigInteger(), nullable=False),
sa.Column(
"last_updated",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["session_id"],
["sessions.id"],
name=op.f("fk_active_queue_sessions_session_id_sessions"),
),
sa.PrimaryKeyConstraint("session_id", name=op.f("pk_active_queue_sessions")),
schema=schema,
)
op.create_index(
op.f("ix_active_queue_sessions_session_id"),
"active_queue_sessions",
["session_id"],
unique=False,
schema=schema,
)
op.create_index(op.f('ix_active_queue_sessions_session_id'), 'active_queue_sessions', ['session_id'], unique=False, schema=schema)
def downgrade() -> None:
schema = getenv("DATABASE_SCHEMA", "public")
op.drop_table('active_queue_sessions', schema=schema)
op.drop_table('queue', schema=schema)
op.drop_table('documents', schema=schema)
op.drop_table('collections', schema=schema)
op.drop_table('metamessages', schema=schema)
op.drop_table('messages', schema=schema)
op.drop_table('sessions', schema=schema)
op.drop_table('users', schema=schema)
op.drop_table('apps', schema=schema)
schema = settings.DB.SCHEMA
op.drop_table("active_queue_sessions", schema=schema)
op.drop_table("queue", schema=schema)
op.drop_table("documents", schema=schema)
op.drop_table("collections", schema=schema)
op.drop_table("metamessages", schema=schema)
op.drop_table("messages", schema=schema)
op.drop_table("sessions", schema=schema)
op.drop_table("users", schema=schema)
op.drop_table("apps", schema=schema)

View File

@ -7,7 +7,6 @@ Create Date: 2025-04-03 15:32:16.733312
"""
from collections.abc import Sequence
from os import getenv
from typing import Union
import sqlalchemy as sa
@ -15,6 +14,8 @@ from alembic import op
from sqlalchemy.exc import IntegrityError, ProgrammingError
from sqlalchemy.sql import text
from src.config import settings
# revision identifiers, used by Alembic.
revision: str = "b765d82110bd"
down_revision: Union[str, None] = "c3828084f472"
@ -23,7 +24,7 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
schema = getenv("DATABASE_SCHEMA", "public")
schema = settings.DB.SCHEMA
conn = op.get_bind()
@ -33,13 +34,21 @@ def upgrade() -> None:
# 1. Add new columns to metamessages table if they don't exist
if "user_id" not in existing_columns:
op.add_column("metamessages", sa.Column("user_id", sa.TEXT(), nullable=True), schema=schema)
op.add_column(
"metamessages",
sa.Column("user_id", sa.TEXT(), nullable=True),
schema=schema,
)
print("Added user_id column")
else:
print("user_id column already exists")
if "session_id" not in existing_columns:
op.add_column("metamessages", sa.Column("session_id", sa.TEXT(), nullable=True), schema=schema)
op.add_column(
"metamessages",
sa.Column("session_id", sa.TEXT(), nullable=True),
schema=schema,
)
print("Added session_id column")
else:
print("session_id column already exists")
@ -56,7 +65,7 @@ def upgrade() -> None:
"metamessages",
"users",
["user_id"],
["public_id"]
["public_id"],
)
print("Created user_id foreign key")
except IntegrityError:
@ -78,7 +87,11 @@ def upgrade() -> None:
# 3. Make message_id nullable if it's not already
try:
op.alter_column(
"metamessages", "message_id", existing_type=sa.TEXT(), nullable=True, schema=schema
"metamessages",
"message_id",
existing_type=sa.TEXT(),
nullable=True,
schema=schema,
)
print("Made message_id nullable")
except (ProgrammingError, IntegrityError) as e:
@ -235,7 +248,11 @@ def upgrade() -> None:
if null_user_count == 0:
try:
op.alter_column(
"metamessages", "user_id", existing_type=sa.TEXT(), nullable=False, schema=schema
"metamessages",
"user_id",
existing_type=sa.TEXT(),
nullable=False,
schema=schema,
)
print("Made user_id not nullable")
except Exception as e:
@ -247,7 +264,11 @@ def upgrade() -> None:
else:
try:
op.alter_column(
"metamessages", "user_id", existing_type=sa.TEXT(), nullable=False, schema=schema
"metamessages",
"user_id",
existing_type=sa.TEXT(),
nullable=False,
schema=schema,
)
print("Made user_id not nullable")
except Exception as e:
@ -255,12 +276,14 @@ def upgrade() -> None:
def downgrade() -> None:
schema = getenv("DATABASE_SCHEMA", "public")
schema = settings.DB.SCHEMA
# Add try-except blocks to handle case where elements don't exist
# 1. Remove the check constraint
try:
op.drop_constraint("message_requires_session", "metamessages", type_="check", schema=schema)
op.drop_constraint(
"message_requires_session", "metamessages", type_="check", schema=schema
)
except Exception as e:
print(f"Error dropping message_requires_session constraint: {e}")
@ -278,14 +301,20 @@ def downgrade() -> None:
# 3. Remove foreign key constraints
try:
op.drop_constraint(
"fk_metamessages_session_id_sessions", "metamessages", type_="foreignkey", schema=schema
"fk_metamessages_session_id_sessions",
"metamessages",
type_="foreignkey",
schema=schema,
)
except Exception as e:
print(f"Error dropping session_id foreign key: {e}")
try:
op.drop_constraint(
"fk_metamessages_user_id_users", "metamessages", type_="foreignkey", schema=schema
"fk_metamessages_user_id_users",
"metamessages",
type_="foreignkey",
schema=schema,
)
except Exception as e:
print(f"Error dropping user_id foreign key: {e}")
@ -296,7 +325,11 @@ def downgrade() -> None:
DELETE FROM metamessages WHERE message_id IS NULL
""")
op.alter_column(
"metamessages", "message_id", existing_type=sa.TEXT(), nullable=False, schema=schema
"metamessages",
"message_id",
existing_type=sa.TEXT(),
nullable=False,
schema=schema,
)
except Exception as e:
print(f"Error making message_id not nullable: {e}")

View File

@ -13,6 +13,8 @@ from typing import Union
import sqlalchemy as sa
from alembic import op
from src.config import settings
# revision identifiers, used by Alembic.
revision: str = "c3828084f472"
down_revision: Union[str, None] = "a1b2c3d4e5f6"
@ -21,7 +23,7 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
schema = getenv("DATABASE_SCHEMA", "public")
schema = settings.DB.SCHEMA
# Add new indexes
op.create_index(
@ -57,7 +59,7 @@ def upgrade() -> None:
def downgrade() -> None:
schema = getenv("DATABASE_SCHEMA", "public")
schema = settings.DB.SCHEMA
# Remove new indexes
op.drop_index("idx_users_app_lookup", table_name="users", schema=schema)

View File

@ -1,6 +1,6 @@
[project]
name = "honcho"
version = "2.0.0"
version = "2.0.1"
description = "Honcho Server"
authors = [
{name = "Plastic Labs", email = "hello@plasticlabs.ai"},
@ -22,9 +22,11 @@ dependencies = [
"anthropic>=0.36.0",
"nanoid>=2.0.0",
"alembic>=1.14.0",
"langfuse>=2.57.1",
"langfuse>=2.57.1,<3.0.0",
"pyjwt>=2.10.0",
"google-genai>=1.10.0",
"pydantic-settings>=2.9.1",
"tomli>=2.0.0; python_version < '3.11'",
"tiktoken>=0.9.0",
]
[tool.uv]

View File

@ -6,13 +6,13 @@ from typing import Any, Optional
import sentry_sdk
from anthropic import MessageStreamManager
from dotenv import load_dotenv
from langfuse.decorators import langfuse_context, observe
from sentry_sdk.ai.monitoring import ai_track
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models, schemas
from src.config import settings
from src.dependencies import tracked_db
from src.deriver.tom import get_tom_inference
from src.deriver.tom.embeddings import CollectionEmbeddingStore
@ -23,11 +23,6 @@ from src.utils.model_client import ModelClient, ModelProvider
# Configure logging
logger = logging.getLogger(__name__)
DEF_DIALECTIC_PROVIDER = ModelProvider.ANTHROPIC
DEF_DIALECTIC_MODEL = "claude-3-7-sonnet-20250219"
DEF_QUERY_GENERATION_PROVIDER = ModelProvider.GROQ
DEF_QUERY_GENERATION_MODEL = "llama-3.1-8b-instant"
QUERY_GENERATION_SYSTEM = """Given this query about a user, generate 3 focused search queries that would help retrieve relevant facts about the user.
Each query should focus on a specific aspect related to the original query, rephrased to maximize semantic search effectiveness.
For example, if the original query asks "what does the user like to eat?", generated queries might include "user's food preferences", "user's favorite cuisine", etc.
@ -37,8 +32,6 @@ QUERY_GENERATION_SYSTEM = """Given this query about a user, generate 3 focused s
Example:
["query about interests", "query about personality", "query about experiences"]"""
load_dotenv()
class Dialectic:
def __init__(self, agent_input: str, user_representation: str, chat_history: str):
@ -46,7 +39,8 @@ class Dialectic:
self.user_representation = user_representation
self.chat_history = chat_history
self.client = ModelClient(
provider=DEF_DIALECTIC_PROVIDER, model=DEF_DIALECTIC_MODEL
provider=ModelProvider(settings.LLM.DIALECTIC_PROVIDER),
model=settings.LLM.DIALECTIC_MODEL,
)
self.system_prompt = """You are operating as a context service that helps maintain psychological understanding of users across applications. Alongside a query, you'll receive: 1) previously collected psychological context about the user that I've maintained, 2) a series of long-term facts about the user, and 3) their current conversation/interaction from the requesting application. Your goal is to analyze this information and provide theory-of-mind insights that help applications personalize their responses. Please respond in a brief, matter-of-fact, and appropriate manner to convey as much relevant information to the application based on its query and the user's most recent message. You are encouraged to provide any context from the provided resources that helps provide a more complete or nuanced understanding of the user, as long as it is somewhat relevant to the query. If the context provided doesn't help address the query, write absolutely NOTHING but "None"."""
@ -77,7 +71,9 @@ class Dialectic:
logger.debug("Calling model for generation")
model_start = asyncio.get_event_loop().time()
response = await self.client.generate(
messages=[message], system=self.system_prompt, max_tokens=1000
messages=[message],
system=self.system_prompt,
max_tokens=settings.LLM.DEFAULT_MAX_TOKENS,
)
model_time = asyncio.get_event_loop().time() - model_start
logger.debug(
@ -115,7 +111,9 @@ class Dialectic:
logger.debug("Calling model for streaming")
model_start = asyncio.get_event_loop().time()
stream = await self.client.stream(
messages=[message], system=self.system_prompt, max_tokens=1000
messages=[message],
system=self.system_prompt,
max_tokens=settings.LLM.DEFAULT_MAX_TOKENS,
)
stream_setup_time = asyncio.get_event_loop().time() - model_start
@ -311,7 +309,9 @@ async def get_long_term_facts(
collection_name=collection_name,
)
facts = await query_embedding_store.get_relevant_facts(
search_query, top_k=10, max_distance=0.85
search_query,
top_k=settings.AGENT.SEMANTIC_SEARCH_TOP_K,
max_distance=settings.AGENT.SEMANTIC_SEARCH_MAX_DISTANCE,
)
query_time = asyncio.get_event_loop().time() - query_start
logger.debug(f"Query {i + 1} retrieved {len(facts)} facts in {query_time:.2f}s")
@ -351,7 +351,9 @@ async def run_tom_inference(chat_history: str) -> str:
# Get chat history length to determine if this is a new conversation
tom_inference_response = await get_tom_inference(
chat_history, user_representation="", method="single_prompt"
chat_history,
user_representation="",
method=settings.AGENT.TOM_INFERENCE_METHOD,
)
# Extract the prediction from the response
@ -381,9 +383,18 @@ async def generate_semantic_queries(query: str) -> list[str]:
logger.debug("Calling LLM for query generation")
llm_start = asyncio.get_event_loop().time()
try:
provider = ModelProvider(settings.LLM.QUERY_GENERATION_PROVIDER)
except ValueError as e:
logger.error(
f"Invalid query-generation provider '{settings.LLM.QUERY_GENERATION_PROVIDER}': {e}"
)
raise
# Create a new model client
client = ModelClient(
provider=DEF_QUERY_GENERATION_PROVIDER, model=DEF_QUERY_GENERATION_MODEL
provider=provider,
model=settings.LLM.QUERY_GENERATION_MODEL,
)
# Prepare the messages for Anthropic
@ -394,7 +405,7 @@ async def generate_semantic_queries(query: str) -> list[str]:
result = await client.generate(
messages=messages,
system=QUERY_GENERATION_SYSTEM,
max_tokens=1000,
max_tokens=settings.LLM.DEFAULT_MAX_TOKENS,
use_caching=True, # Likely not caching because the system prompt is under 1000 tokens
)
llm_time = asyncio.get_event_loop().time() - llm_start

256
src/config.py Normal file
View File

@ -0,0 +1,256 @@
import logging
from pathlib import Path
from typing import Annotated, Any, ClassVar, Optional
import tomllib
from dotenv import load_dotenv
from pydantic import Field, field_validator, model_validator
from pydantic.fields import FieldInfo
from pydantic_settings import (
BaseSettings,
DotEnvSettingsSource,
EnvSettingsSource,
PydanticBaseSettingsSource,
SettingsConfigDict,
)
# Load .env file for local development.
# Make sure this is called before AppSettings is instantiated if you rely on .env for AppSettings construction.
load_dotenv(override=True)
logger = logging.getLogger(__name__)
def load_toml_config(config_path: str = "config.toml") -> dict[str, Any]:
"""Load configuration from TOML file if it exists."""
config_file = Path(config_path)
if config_file.exists():
try:
with open(config_file, "rb") as f:
return tomllib.load(f)
except (tomllib.TOMLDecodeError, OSError) as exc:
logger.warning("Failed to load %s: %s", config_path, exc)
return {}
return {}
# Load TOML config once
TOML_CONFIG = load_toml_config()
class TomlConfigSettingsSource(PydanticBaseSettingsSource):
"""Custom settings source for loading from TOML file."""
def __init__(self, settings_cls: type[BaseSettings]) -> None:
super().__init__(settings_cls)
SECTION_MAP: ClassVar[dict[str, str]] = {
"DB": "db",
"AUTH": "auth",
"SENTRY": "sentry",
"LLM": "llm",
"AGENT": "agent",
"DERIVER": "deriver",
"HISTORY": "history",
"": "app", # For AppSettings with no prefix
}
def get_field_value(
self, field: FieldInfo, field_name: str
) -> tuple[Any, str, bool]:
# Get the env_prefix from the model config
prefix = self.settings_cls.model_config.get("env_prefix", "")
if prefix.endswith("_"):
prefix = prefix[:-1]
# Map prefixes to TOML sections
section = self.SECTION_MAP.get(prefix, prefix.lower())
toml_data = TOML_CONFIG.get(section, {})
# Try different case variations
field_value = toml_data.get(field_name.lower())
if field_value is None:
field_value = toml_data.get(field_name.upper())
if field_value is None:
field_value = toml_data.get(field_name)
return field_value, field_name, False
def __call__(self) -> dict[str, Any]:
# Get the env_prefix from the model config
prefix = self.settings_cls.model_config.get("env_prefix", "")
if prefix.endswith("_"):
prefix = prefix[:-1]
section = self.SECTION_MAP.get(prefix, prefix.lower())
toml_data = TOML_CONFIG.get(section, {})
# Convert keys to uppercase to match field names
return {key.upper(): value for key, value in toml_data.items()}
class HonchoSettings(BaseSettings):
"""Base class for all settings models in Honcho.
Defines the source precedence for loading settings.
"""
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: EnvSettingsSource,
dotenv_settings: DotEnvSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
# Correct precedence: init > env > .env > toml > secrets > defaults
return (
init_settings,
env_settings,
dotenv_settings,
TomlConfigSettingsSource(settings_cls),
file_secret_settings,
)
class DBSettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="DB_")
CONNECTION_URI: str = (
"postgresql+psycopg://postgres:postgres@localhost:5432/postgres"
)
SCHEMA: str = "public"
POOL_CLASS: str = "default"
POOL_PRE_PING: bool = True
POOL_SIZE: Annotated[int, Field(default=10, gt=0, le=1000)] = 10
MAX_OVERFLOW: Annotated[int, Field(default=20, ge=0, le=1000)] = 20
POOL_TIMEOUT: Annotated[int, Field(default=30, gt=0, le=300)] = (
30 # seconds (max 5 minutes)
)
POOL_RECYCLE: Annotated[int, Field(default=300, gt=0, le=7200)] = (
300 # seconds (max 2 hours)
)
POOL_USE_LIFO: bool = True
SQL_DEBUG: bool = False
TRACING: bool = False
class AuthSettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="AUTH_")
USE_AUTH: bool = False
JWT_SECRET: Optional[str] = None # Must be set if USE_AUTH is true
@model_validator(mode="after")
def _require_jwt_secret(self) -> "AuthSettings":
if self.USE_AUTH and not self.JWT_SECRET:
raise ValueError("JWT_SECRET must be set if USE_AUTH is true")
return self
class SentrySettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="SENTRY_")
ENABLED: bool = False
DSN: Optional[str] = None
TRACES_SAMPLE_RATE: Annotated[float, Field(default=0.1, ge=0.0, le=1.0)] = 0.1
PROFILES_SAMPLE_RATE: Annotated[float, Field(default=0.1, ge=0.0, le=1.0)] = 0.1
class LLMSettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="LLM_")
# API Keys for LLM providers
ANTHROPIC_API_KEY: Optional[str] = None
OPENAI_API_KEY: Optional[str] = None
OPENAI_COMPATIBLE_API_KEY: Optional[str] = None
GEMINI_API_KEY: Optional[str] = None
GROQ_API_KEY: Optional[str] = None # Added missing GROQ API key
OPENAI_COMPATIBLE_BASE_URL: Optional[str] = None
# General LLM settings
DEFAULT_MAX_TOKENS: Annotated[int, Field(default=1000, gt=0, le=100000)] = 1000
DEFAULT_TEMPERATURE: Annotated[float, Field(default=0.0, ge=0.0, le=2.0)] = 0.0
# Dialectic specific
DIALECTIC_PROVIDER: str = "anthropic"
DIALECTIC_MODEL: str = "claude-3-haiku-20240307"
# DIALECTIC_SYSTEM_PROMPT_FILE: Optional[str] = "prompts/dialectic_system.txt" # Example for file-based
# Query Generation specific
QUERY_GENERATION_PROVIDER: str = "groq"
QUERY_GENERATION_MODEL: str = "llama3-8b-8192"
# QUERY_GENERATION_SYSTEM_PROMPT_FILE: Optional[str] = "prompts/query_generation_system.txt"
# Summarization specific
SUMMARY_PROVIDER: str = "gemini"
SUMMARY_MODEL: str = (
"gemini-1.5-flash-latest" # Consider specific model version if needed
)
SUMMARY_MAX_TOKENS_SHORT: Annotated[int, Field(default=1000, gt=0, le=10000)] = 1000
SUMMARY_MAX_TOKENS_LONG: Annotated[int, Field(default=2000, gt=0, le=20000)] = 2000
# SUMMARY_SYSTEM_PROMPT_SHORT_FILE: Optional[str] = "prompts/summary_short_system.txt"
# SUMMARY_SYSTEM_PROMPT_LONG_FILE: Optional[str] = "prompts/summary_long_system.txt"
class AgentSettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="AGENT_")
SEMANTIC_SEARCH_TOP_K: Annotated[int, Field(default=10, gt=0, le=100)] = 10
SEMANTIC_SEARCH_MAX_DISTANCE: Annotated[
float, Field(default=0.85, ge=0.0, le=1.0)
] = 0.85 # Max distance for semantic search relevance
TOM_INFERENCE_METHOD: str = "single_prompt"
class DeriverSettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="DERIVER_")
WORKERS: Annotated[int, Field(default=1, gt=0, le=100)] = 1
STALE_SESSION_TIMEOUT_MINUTES: Annotated[int, Field(default=5, gt=0, le=1440)] = (
5 # Max 24 hours
)
POLLING_SLEEP_INTERVAL_SECONDS: Annotated[
float, Field(default=1.0, gt=0.0, le=60.0)
] = 1.0
TOM_METHOD: str = "single_prompt"
USER_REPRESENTATION_METHOD: str = "long_term"
class HistorySettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="HISTORY_")
MESSAGES_PER_SHORT_SUMMARY: Annotated[int, Field(default=20, gt=0, le=100)] = 20
MESSAGES_PER_LONG_SUMMARY: Annotated[int, Field(default=60, gt=0, le=500)] = 60
class AppSettings(HonchoSettings):
# No env_prefix for app-level settings
model_config = SettingsConfigDict(env_prefix="", env_nested_delimiter="__")
# Application-wide settings
LOG_LEVEL: str = "INFO"
FASTAPI_HOST: str = "0.0.0.0"
FASTAPI_PORT: Annotated[int, Field(default=8000, gt=0, le=65535)] = 8000
# Nested settings models
DB: DBSettings = Field(default_factory=DBSettings)
AUTH: AuthSettings = Field(default_factory=AuthSettings)
SENTRY: SentrySettings = Field(default_factory=SentrySettings)
LLM: LLMSettings = Field(default_factory=LLMSettings)
AGENT: AgentSettings = Field(default_factory=AgentSettings)
DERIVER: DeriverSettings = Field(default_factory=DeriverSettings)
HISTORY: HistorySettings = Field(default_factory=HistorySettings)
@field_validator("LOG_LEVEL")
@classmethod
def validate_log_level(cls, v: str) -> str:
log_level = v.upper()
if log_level not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
raise ValueError(f"Invalid log level: {v}")
return log_level
# Create a single global instance of the settings
settings: AppSettings = AppSettings()

View File

@ -11,12 +11,17 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.types import BigInteger
from src.config import settings
from . import models, schemas
from .exceptions import ResourceNotFoundException
from .exceptions import (
ResourceNotFoundException,
)
from .utils.model_client import ModelClient, ModelProvider
load_dotenv(override=True)
openai_client = AsyncOpenAI()
openai_client = AsyncOpenAI(api_key=settings.LLM.OPENAI_API_KEY)
logger = getLogger(__name__)
@ -24,6 +29,10 @@ USER_REPRESENTATION_METADATA_KEY = "user_representation"
SESSION_PEERS_LIMIT = int(os.getenv("SESSION_PEERS_LIMIT", 10))
# Create a ModelClient instance for embeddings
# Using OpenAI provider for embeddings as it's the most common
embedding_client = ModelClient(provider=ModelProvider.OPENAI)
########################################################
# workspace methods
########################################################
@ -1513,11 +1522,8 @@ async def query_documents(
max_distance: Optional[float] = None,
top_k: int = 5,
) -> Sequence[models.Document]:
# Using async client with await
response = await openai_client.embeddings.create(
model="text-embedding-3-small", input=query
)
embedding_query = response.data[0].embedding
# Using ModelClient for embeddings
embedding_query = await embedding_client.embed(query)
stmt = (
select(models.Document)
.where(models.Document.workspace_name == workspace_name)
@ -1572,12 +1578,8 @@ async def create_document(
collection_name=collection_name,
)
# Using async client with await
response = await openai_client.embeddings.create(
input=document.content, model="text-embedding-3-small"
)
embedding = response.data[0].embedding
# Using ModelClient for embeddings
embedding = await embedding_client.embed(document.content)
if duplicate_threshold is not None:
# Check if there are duplicates within the threshold
@ -1633,11 +1635,8 @@ async def get_duplicate_documents(
List of documents that are similar to the provided content
"""
# Get embedding for the content
# Using async client with await
response = await openai_client.embeddings.create(
input=content, model="text-embedding-3-small"
)
embedding = response.data[0].embedding
# Using ModelClient for embeddings
embedding = await embedding_client.embed(content)
# Find documents with similar embeddings
stmt = (

View File

@ -1,13 +1,12 @@
import contextvars
import os
from typing import Optional
from dotenv import load_dotenv
from sqlalchemy import MetaData, create_engine, text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import declarative_base
from sqlalchemy.pool import NullPool
load_dotenv()
from src.config import settings
connect_args = {"prepare_threshold": None}
@ -16,16 +15,28 @@ request_context: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
"request_context", default=None
)
engine_kwargs = {}
if settings.DB.POOL_CLASS == "null":
engine_kwargs["poolclass"] = NullPool
else:
# Only add pool-related kwargs for pooled connections
engine_kwargs.update(
{
"pool_pre_ping": settings.DB.POOL_PRE_PING,
"pool_size": settings.DB.POOL_SIZE,
"max_overflow": settings.DB.MAX_OVERFLOW,
"pool_timeout": settings.DB.POOL_TIMEOUT,
"pool_recycle": settings.DB.POOL_RECYCLE,
"pool_use_lifo": settings.DB.POOL_USE_LIFO,
}
)
engine = create_async_engine(
os.environ["CONNECTION_URI"],
settings.DB.CONNECTION_URI,
connect_args=connect_args,
echo=os.getenv("SQL_DEBUG", "false").lower() == "true", # Only enable in debug mode
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
pool_timeout=30,
pool_recycle=300, # Recycle connections after 5 minutes
pool_use_lifo=True, # Use last-in-first-out (LIFO) to prevent connection spread
echo=settings.DB.SQL_DEBUG,
**engine_kwargs,
)
SessionLocal = async_sessionmaker(
@ -35,7 +46,7 @@ SessionLocal = async_sessionmaker(
bind=engine,
)
table_schema = os.getenv("DATABASE_SCHEMA", "public")
table_schema = settings.DB.SCHEMA
meta = MetaData()
meta.schema = table_schema
Base = declarative_base(metadata=meta)
@ -48,9 +59,9 @@ def init_db():
# Create a sync engine for schema operations
sync_engine = create_engine(
os.environ["CONNECTION_URI"],
pool_pre_ping=True,
echo=os.getenv("SQL_DEBUG", "false").lower() == "true",
settings.DB.CONNECTION_URI,
pool_pre_ping=settings.DB.POOL_PRE_PING,
echo=settings.DB.SQL_DEBUG,
)
with sync_engine.connect() as connection:

View File

@ -5,7 +5,8 @@ from fastapi import Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from .db import SessionLocal, request_context
from src.config import settings
from src.db import SessionLocal, request_context
async def get_db():
@ -15,7 +16,8 @@ async def get_db():
db: AsyncSession = SessionLocal()
try:
await db.execute(text(f"SET application_name = '{context}'"))
if settings.DB.TRACING:
await db.execute(text(f"SET application_name = '{context}'"))
yield db
except Exception:
await db.rollback()
@ -41,9 +43,10 @@ async def tracked_db(operation_name=None):
db = SessionLocal()
try:
await db.execute(
text(f"SET application_name = '{context or f'task:{operation_name}'}'")
)
if settings.DB.TRACING:
await db.execute(
text(f"SET application_name = '{context or f'task:{operation_name}'}'")
)
yield db
# Explicitly end transaction if still open

View File

@ -8,6 +8,8 @@ from pydantic import BaseModel, ValidationError
from rich.console import Console
from sqlalchemy.ext.asyncio import AsyncSession
from src.config import settings
from .. import crud
from ..utils import history
from .tom.embeddings import CollectionEmbeddingStore
@ -18,8 +20,8 @@ logging.getLogger("sqlalchemy.engine.Engine").disabled = True
console = Console(markup=False)
TOM_METHOD = os.getenv("TOM_METHOD", "single_prompt")
USER_REPRESENTATION_METHOD = os.getenv("USER_REPRESENTATION_METHOD", "long_term")
TOM_METHOD = settings.DERIVER.TOM_METHOD
USER_REPRESENTATION_METHOD = settings.DERIVER.USER_REPRESENTATION_METHOD
class PayloadSchema(BaseModel):
@ -75,9 +77,11 @@ async def process_item(db: AsyncSession, payload: dict):
"Finished processing message %s in %s %s",
validated_payload.message_id,
"session" if validated_payload.session_name else "peer",
validated_payload.session_name
if validated_payload.session_name
else validated_payload.sender_name,
(
validated_payload.session_name
if validated_payload.session_name
else validated_payload.sender_name
),
)
await summarize_if_needed(
db,

View File

@ -1,5 +1,4 @@
import asyncio
import os
import signal
from collections.abc import Sequence
from dataclasses import dataclass
@ -14,13 +13,15 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import func
from src.config import settings
from .. import models
from ..dependencies import tracked_db
from .consumer import process_item
logger = getLogger(__name__)
load_dotenv()
load_dotenv(override=True)
@dataclass(frozen=True)
@ -49,17 +50,17 @@ class QueueManager:
self.owned_work_units: set[WorkUnit] = set()
self.queue_empty_flag = asyncio.Event()
# Initialize from environment
self.workers = int(os.getenv("DERIVER_WORKERS", 1))
# Initialize from settings
self.workers = settings.DERIVER.WORKERS
self.semaphore = asyncio.Semaphore(self.workers)
# Initialize Sentry if enabled
if os.getenv("SENTRY_ENABLED", "False").lower() == "true":
# Initialize Sentry if enabled, using settings
if settings.SENTRY.ENABLED:
sentry_sdk.init(
dsn=os.getenv("SENTRY_DSN"),
dsn=settings.SENTRY.DSN,
enable_tracing=True,
traces_sample_rate=0.1,
profiles_sample_rate=0.1,
traces_sample_rate=settings.SENTRY.TRACES_SAMPLE_RATE,
profiles_sample_rate=settings.SENTRY.PROFILES_SAMPLE_RATE,
integrations=[AsyncioIntegration()],
)
@ -131,7 +132,7 @@ class QueueManager:
logger.info("Cleanup completed successfully")
except Exception as e:
logger.error(f"Error during cleanup: {str(e)}")
if os.getenv("SENTRY_ENABLED", "False").lower() == "true":
if settings.SENTRY.ENABLED:
sentry_sdk.capture_exception(e)
##########################
@ -203,14 +204,14 @@ class QueueManager:
while not self.shutdown_event.is_set():
if self.queue_empty_flag.is_set():
# logger.debug("Queue empty flag set, waiting")
await asyncio.sleep(1)
await asyncio.sleep(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS)
self.queue_empty_flag.clear()
continue
# Check if we have capacity before querying
if self.semaphore.locked():
# logger.debug("All workers busy, waiting")
await asyncio.sleep(1) # Wait before trying again
await asyncio.sleep(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS)
continue
# Use the dependency for transaction safety
@ -251,13 +252,17 @@ class QueueManager:
)
else:
self.queue_empty_flag.set()
await asyncio.sleep(1)
await asyncio.sleep(
settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS
)
except Exception as e:
logger.error(f"Error in polling loop: {str(e)}", exc_info=True)
if os.getenv("SENTRY_ENABLED", "False").lower() == "true":
logger.exception("Error in polling loop")
if settings.SENTRY.ENABLED:
sentry_sdk.capture_exception(e)
# Note: rollback is handled by tracked_db dependency
await asyncio.sleep(1)
await asyncio.sleep(
settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS
)
finally:
logger.info("Polling loop stopped")
@ -297,7 +302,7 @@ class QueueManager:
f"Error processing message {message.id}: {str(e)}",
exc_info=True,
)
if os.getenv("SENTRY_ENABLED", "False").lower() == "true":
if settings.SENTRY.ENABLED:
sentry_sdk.capture_exception(e)
finally:
# Prevent malformed messages from stalling queue indefinitely

View File

@ -2,10 +2,14 @@ import os
import sentry_sdk
from anthropic import Anthropic
from anthropic.types import MessageParam, TextBlock
from anthropic.types import MessageParam
from langfuse.decorators import langfuse_context, observe
from sentry_sdk.ai.monitoring import ai_track
from src.utils.model_client import ModelClient
model_client = ModelClient()
# Initialize the Anthropic client
anthropic = Anthropic(
api_key=os.getenv("ANTHROPIC_API_KEY"),
@ -70,17 +74,15 @@ async def get_tom_inference_conversational(
langfuse_context.update_current_observation(
input=messages, model="claude-3-5-sonnet-20240620"
)
message = anthropic.messages.create(
model="claude-3-5-sonnet-20240620",
# Use ModelClient instead of direct Anthropic client
response = await model_client.generate(
messages=[dict(msg) for msg in messages],
max_tokens=1000,
temperature=0,
messages=messages,
temperature=0.0,
)
# skip blocks that are not text and return the first text block
for block in message.content:
if isinstance(block, TextBlock):
return block.text
raise RuntimeError("No text block returned by LLM")
return response
@ai_track("User Representation")
@ -144,14 +146,12 @@ async def get_user_representation_conversational(
langfuse_context.update_current_observation(
input=messages, model="claude-3-5-sonnet-20240620"
)
message = anthropic.messages.create(
model="claude-3-5-sonnet-20240620",
# Use ModelClient instead of direct Anthropic client
response = await model_client.generate(
messages=[dict(msg) for msg in messages],
max_tokens=1000,
temperature=0,
messages=messages,
temperature=0.0,
)
# skip blocks that are not text and return the first text block
for block in message.content:
if isinstance(block, TextBlock):
return block.text
raise RuntimeError("No text block returned by LLM")
return response

View File

@ -1,5 +1,4 @@
import logging
import os
import re
import uuid
from contextlib import asynccontextmanager
@ -12,6 +11,7 @@ from fastapi_pagination import add_pagination
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
from src.config import settings
from src.db import engine, request_context
from src.exceptions import HonchoException
from src.routers import (
@ -24,18 +24,14 @@ from src.routers import (
from src.security import create_admin_jwt
def get_log_level(env_var="LOG_LEVEL", default="INFO"):
def get_log_level() -> int:
"""
Convert log level string from environment variable to logging module constant.
Args:
env_var: Name of the environment variable to check
default: Default log level if environment variable is not set
Convert log level string from settings to logging module constant.
Returns:
int: The logging level constant (e.g., logging.INFO)
"""
log_level_str = os.getenv(env_var, default).upper()
log_level_str = settings.LOG_LEVEL.upper()
log_levels = {
"CRITICAL": logging.CRITICAL, # 50
@ -64,7 +60,7 @@ async def setup_admin_jwt():
# Sentry Setup
SENTRY_ENABLED = os.getenv("SENTRY_ENABLED", "False").lower() == "true"
SENTRY_ENABLED = settings.SENTRY.ENABLED
if SENTRY_ENABLED:
def before_send(event, hint):
@ -83,9 +79,9 @@ if SENTRY_ENABLED:
# For custom log levels, use the LoggingIntegration class:
# sentry_sdk.init(..., integrations=[LoggingIntegration(level=logging.INFO, event_level=logging.ERROR)])
sentry_sdk.init(
dsn=os.getenv("SENTRY_DSN"),
traces_sample_rate=0.4,
profiles_sample_rate=0.4,
dsn=settings.SENTRY.DSN,
traces_sample_rate=settings.SENTRY.TRACES_SAMPLE_RATE,
profiles_sample_rate=settings.SENTRY.PROFILES_SAMPLE_RATE,
before_send=before_send,
integrations=[
StarletteIntegration(
@ -114,7 +110,7 @@ app = FastAPI(
title="Honcho API",
summary="The Identity Layer for the Agentic World",
description="""Honcho is a platform for giving agents user-centric memory and social cognition""",
version="2.0.0",
version="2.0.1",
contact={
"name": "Plastic Labs",
"url": "https://honcho.dev",
@ -127,7 +123,12 @@ app = FastAPI(
},
)
origins = ["http://localhost", "http://127.0.0.1:8000", "https://demo.honcho.dev"]
origins = [
"http://localhost",
"http://127.0.0.1:8000",
"https://demo.honcho.dev",
"https://api.honcho.dev",
]
app.add_middleware(
CORSMiddleware,
@ -171,6 +172,8 @@ async def global_exception_handler(request: Request, exc: Exception):
@app.middleware("http")
async def track_request(request: Request, call_next):
if not settings.DB.TRACING:
return await call_next(request)
# Create a request ID that includes endpoint information
endpoint = re.sub(r"/[A-Za-z0-9_-]{21}", "", request.url.path).replace("/", "_")
request_id = f"{request.method}:{endpoint}:{str(uuid.uuid4())[:8]}"

View File

@ -27,7 +27,7 @@ from sqlalchemy.sql import func
from .db import Base
load_dotenv()
load_dotenv(override=True)
logger = getLogger(__name__)

View File

@ -1,9 +1,9 @@
import datetime
import logging
import os
from fastapi import APIRouter, Depends, Query
from src.config import settings
from src.exceptions import DisabledException, ValidationException
from src.security import (
JWTParams,
@ -13,8 +13,6 @@ from src.security import (
logger = logging.getLogger(__name__)
USE_AUTH = os.getenv("USE_AUTH", "False").lower() == "true"
router = APIRouter(
prefix="/keys",
tags=["keys"],
@ -34,7 +32,7 @@ async def create_key(
expires_at: datetime.datetime | None = None,
):
"""Create a new Key"""
if not USE_AUTH:
if not settings.AUTH.USE_AUTH:
raise DisabledException()
# Validate that at least one parameter is provided for proper scoping

View File

@ -192,6 +192,11 @@ class DocumentCreate(DocumentBase):
metadata: dict = {}
class DocumentUpdate(DocumentBase):
content: Annotated[str, Field(min_length=1, max_length=100000)]
metadata: dict | None = None
class DialecticOptions(BaseModel):
session_id: Optional[str] = Field(
None, description="ID of the session to scope the representation to"

View File

@ -1,6 +1,5 @@
import datetime
import logging
import os
from typing import Annotated, Optional
import jwt
@ -9,21 +8,13 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from src.config import settings
from src.dependencies import get_db
from .exceptions import AuthenticationException
logger = logging.getLogger(__name__)
USE_AUTH = os.getenv("USE_AUTH", "False").lower() == "true"
AUTH_JWT_SECRET = os.getenv("AUTH_JWT_SECRET", "") if USE_AUTH else ""
if USE_AUTH and AUTH_JWT_SECRET == "":
print(
"\n ERROR: No JWT secret provided. Set the AUTH_JWT_SECRET environment variable.\n"
)
exit(1)
security = HTTPBearer(
auto_error=False,
)
@ -83,7 +74,9 @@ def create_admin_jwt() -> str:
def create_jwt(params: JWTParams) -> str:
"""Create a JWT token from the given parameters."""
payload = {k: v for k, v in params.__dict__.items() if v is not None}
return jwt.encode(payload, AUTH_JWT_SECRET.encode("utf-8"), algorithm="HS256")
if not settings.AUTH.JWT_SECRET:
raise ValueError("AUTH_JWT_SECRET is not set, cannot create JWT.")
return jwt.encode(payload, settings.AUTH.JWT_SECRET.encode("utf-8"), algorithm="HS256")
async def verify_jwt(token: str) -> JWTParams:
@ -91,8 +84,10 @@ async def verify_jwt(token: str) -> JWTParams:
params = JWTParams()
try:
if not settings.AUTH.JWT_SECRET:
raise ValueError("AUTH_JWT_SECRET is not set, cannot verify JWT.")
decoded = jwt.decode(
token, AUTH_JWT_SECRET.encode("utf-8"), algorithms=["HS256"]
token, settings.AUTH.JWT_SECRET.encode("utf-8"), algorithms=["HS256"]
)
if "t" in decoded:
params.t = decoded["t"]
@ -169,7 +164,7 @@ async def auth(
session_name: Optional[str] = None,
) -> JWTParams:
"""Authenticate the given JWT and return the decoded parameters."""
if not USE_AUTH:
if not settings.AUTH.USE_AUTH:
return JWTParams(t="", ad=True)
if not credentials or not credentials.credentials:
logger.warning("No access token provided")

View File

@ -5,6 +5,7 @@ from typing import Optional, TypedDict
from sqlalchemy.ext.asyncio import AsyncSession
from src.config import settings
from src.utils.model_client import ModelClient, ModelProvider
from .. import crud, models
@ -47,8 +48,8 @@ __all__ = [
# Configuration constants for summaries
MESSAGES_PER_SHORT_SUMMARY = 20 # How often to create short summaries
MESSAGES_PER_LONG_SUMMARY = 60 # How often to create long summaries
MESSAGES_PER_SHORT_SUMMARY = settings.HISTORY.MESSAGES_PER_SHORT_SUMMARY
MESSAGES_PER_LONG_SUMMARY = settings.HISTORY.MESSAGES_PER_LONG_SUMMARY
# The types of summary to store in the session metadata
@ -58,8 +59,8 @@ class SummaryType(Enum):
# Default model settings for summary generation
DEFAULT_PROVIDER = ModelProvider.GEMINI
DEFAULT_MODEL = "gemini-2.0-flash-lite"
# DEFAULT_PROVIDER = ModelProvider.GEMINI
# DEFAULT_MODEL = "gemini-2.0-flash-lite"
async def get_summary(
@ -170,12 +171,19 @@ Provide a {"comprehensive" if summary_type == SummaryType.LONG else "concise"} s
Provide a {"comprehensive" if summary_type == SummaryType.LONG else "concise"} summary that captures the key points and context."""
# Create a model client
client = ModelClient(provider=DEFAULT_PROVIDER, model=DEFAULT_MODEL)
client = ModelClient(
provider=ModelProvider(settings.LLM.SUMMARY_PROVIDER),
model=settings.LLM.SUMMARY_MODEL,
)
# Generate the summary
llm_messages = [{"role": "user", "content": user_prompt}]
max_tokens = max_tokens or (1000 if summary_type == SummaryType.SHORT else 2000)
max_tokens = max_tokens or (
settings.LLM.SUMMARY_MAX_TOKENS_SHORT
if summary_type == SummaryType.SHORT
else settings.LLM.SUMMARY_MAX_TOKENS_LONG
)
try:
summary_text = await client.generate(
@ -202,9 +210,11 @@ Provide a {"comprehensive" if summary_type == SummaryType.LONG else "concise"} s
# Fallback to a basic summary in case of error
# Do not save this failed summary to the session metadata.
return Summary(
content=f"Conversation with {len(messages)} messages about {messages[-1].content[:30]}..."
if messages
else "No messages to summarize!",
content=(
f"Conversation with {len(messages)} messages about {messages[-1].content[:30]}..."
if messages
else "No messages to summarize!"
),
message_count=0,
summary_type=summary_type.value,
created_at=datetime.datetime.now(datetime.timezone.utc).isoformat(),

View File

@ -2,13 +2,11 @@
Utility functions for interacting with various language model APIs.
"""
import os
from enum import Enum
from typing import Any, Optional, Protocol
import sentry_sdk
from anthropic import AsyncAnthropic
from dotenv import load_dotenv
from google import genai
from google.genai import types as genai_types
from langfuse.decorators import langfuse_context, observe
@ -16,8 +14,7 @@ from langfuse.decorators import langfuse_context, observe
# from openai import AsyncOpenAI
from langfuse.openai import AsyncOpenAI
# Load environment variables
load_dotenv()
from src.config import settings
# Supported model providers
@ -48,8 +45,11 @@ OPENAI_COMPATIBLE_PROVIDERS = [
ModelProvider.GROQ,
]
DEFAULT_TEMPERATURE = 0.0
DEFAULT_MAX_TOKENS = 1000
DEFAULT_TEMPERATURE: float = settings.LLM.DEFAULT_TEMPERATURE
DEFAULT_MAX_TOKENS: int = settings.LLM.DEFAULT_MAX_TOKENS
# Default embedding model
DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
class Message(Protocol):
@ -86,20 +86,29 @@ class ModelClient:
# Setup provider-specific clients
if provider == ModelProvider.ANTHROPIC:
self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
self.api_key = api_key or settings.LLM.ANTHROPIC_API_KEY
if not self.api_key:
raise ValueError("Anthropic API key is required")
self.client = AsyncAnthropic(api_key=self.api_key)
elif provider in OPENAI_COMPATIBLE_PROVIDERS:
self.api_key = api_key or os.getenv("OPENAI_COMPATIBLE_API_KEY")
self.base_url = base_url or os.getenv("OPENAI_COMPATIBLE_BASE_URL")
# Use specific API key based on provider
if provider == ModelProvider.OPENAI:
self.api_key = api_key or settings.LLM.OPENAI_API_KEY
elif provider == ModelProvider.GROQ:
self.api_key = api_key or settings.LLM.GROQ_API_KEY
else:
self.api_key = api_key or settings.LLM.OPENAI_COMPATIBLE_API_KEY
self.base_url = base_url or settings.LLM.OPENAI_COMPATIBLE_BASE_URL
if not self.api_key:
raise ValueError("OpenAI-compatible API key is required")
raise ValueError(f"{provider.value} API key is required")
self.openai_client = AsyncOpenAI(
api_key=self.api_key, base_url=self.base_url
)
elif provider == ModelProvider.GEMINI:
self.api_key = api_key or os.getenv("GEMINI_API_KEY")
self.api_key = api_key or settings.LLM.GEMINI_API_KEY
if not self.api_key:
raise ValueError("Gemini API key is required")
self.gemini_client = genai.Client(api_key=self.api_key)
@ -539,3 +548,44 @@ class ModelClient:
)
return stream
@observe()
async def embed(
self,
text: str,
model: Optional[str] = None,
) -> list[float]:
"""
Generate embeddings for the given text.
Args:
text: The text to embed
model: The embedding model to use (optional, defaults to text-embedding-3-small)
Returns:
The embedding vector as a list of floats
"""
# Currently only supports OpenAI-compatible providers for embeddings
if self.provider not in [ModelProvider.OPENAI, ModelProvider.OPENROUTER]:
raise ValueError(
f"Embeddings not supported for provider: {self.provider}. "
"Please use OpenAI or OpenRouter for embeddings."
)
if not self.openai_client:
raise ValueError("OpenAI client not initialized")
embedding_model = model or DEFAULT_EMBEDDING_MODEL
with sentry_sdk.start_transaction(
op="embedding-api", name=f"{self.provider} Embedding Call"
):
langfuse_context.update_current_observation(
input=text, model=embedding_model
)
response = await self.openai_client.embeddings.create(
model=embedding_model, input=text
)
return response.data[0].embedding

View File

@ -1,5 +1,4 @@
import logging # noqa: I001
import os
import jwt
from nanoid import generate as generate_nanoid
from unittest.mock import patch, MagicMock, AsyncMock
@ -21,6 +20,7 @@ from src.dependencies import get_db
from src.exceptions import HonchoException
from src.security import create_admin_jwt, create_jwt, JWTParams
from src.main import app
from src.config import settings
# Create a custom handler that doesn't get closed prematurely
@ -45,18 +45,16 @@ logging.getLogger("sqlalchemy.engine.Engine").disabled = True
# Test database URL
# TODO use environment variable
CONNECTION_URI = make_url(
os.getenv(
"CONNECTION_URI",
"postgresql+psycopg://postgres:postgres@localhost:5432/postgres",
)
DB_URI = (
settings.DB.CONNECTION_URI
or "postgresql+psycopg://postgres:postgres@localhost:5432/postgres"
)
CONNECTION_URI = make_url(DB_URI)
TEST_DB_URL = CONNECTION_URI.set(database="test_db")
DEFAULT_DB_URL = str(CONNECTION_URI.set(database="postgres"))
# Test API authorization
USE_AUTH = os.getenv("USE_AUTH", "False").lower() == "true"
AUTH_JWT_SECRET = os.getenv("AUTH_JWT_SECRET", "test-secret")
# Test API authorization - no longer needed as module-level constants
# We'll use settings.AUTH directly where needed
def create_test_database(db_url):
@ -118,6 +116,15 @@ async def db_engine():
create_test_database(TEST_DB_URL)
engine = await setup_test_database(TEST_DB_URL)
# Force the schema to 'public' for tests
# Save the original schema to restore later
original_schema = Base.metadata.schema
Base.metadata.schema = "public"
# Update all table schemas to public
for table in Base.metadata.tables.values():
table.schema = "public"
# Drop all tables first to ensure clean state
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@ -128,6 +135,11 @@ async def db_engine():
await engine.dispose()
# Restore original schema
Base.metadata.schema = original_schema
for table in Base.metadata.tables.values():
table.schema = original_schema
drop_database(TEST_DB_URL)
@ -157,7 +169,7 @@ async def client(db_session):
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as c:
if USE_AUTH:
if settings.AUTH.USE_AUTH:
# give the test client the admin JWT
c.headers["Authorization"] = f"Bearer {create_admin_jwt()}"
yield c
@ -181,11 +193,8 @@ def auth_client(client, request, monkeypatch):
Always ensures USE_AUTH is set to True.
"""
# Ensure USE_AUTH is always True for this fixture
import src.routers.keys as keys_module
import src.security as security
monkeypatch.setattr(keys_module, "USE_AUTH", "true")
monkeypatch.setattr(security, "USE_AUTH", "true")
monkeypatch.setattr(settings.AUTH, "USE_AUTH", True)
monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret")
# Clear any existing Authorization header
client.headers.pop("Authorization", None)

View File

@ -1,4 +1,3 @@
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -102,9 +101,15 @@ def test_unsupported_provider_initialization():
def test_missing_api_key_initialization():
"""Test initialization without required API key."""
with (
patch.dict(os.environ, {}, clear=True),
patch("src.utils.model_client.settings") as mock_settings,
pytest.raises(ValueError, match="API key is required"),
):
# Mock the settings to return None for all API keys
mock_settings.LLM.ANTHROPIC_API_KEY = None
mock_settings.LLM.OPENAI_API_KEY = None
mock_settings.LLM.OPENAI_COMPATIBLE_API_KEY = None
mock_settings.LLM.GROQ_API_KEY = None
mock_settings.LLM.GEMINI_API_KEY = None
ModelClient()

2574
uv.lock

File diff suppressed because it is too large Load Diff