diff --git a/README.md b/README.md index aee30c05..0d3b6c1d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,11 @@ Read the user documentation [here](https://docs.honcho.dev) - [Project Structure](#project-structure) - [Usage](#usage) +- [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) @@ -55,6 +60,76 @@ Additionally, Honcho can be self-hosted for testing and evaluation purposes. See [Contributing](./CONTRIBUTING.md) for more details on how to setup a local version of Honcho. +## 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 diff --git a/src/config.py b/src/config.py index 98a6fe8f..5d11b6de 100644 --- a/src/config.py +++ b/src/config.py @@ -1,5 +1,6 @@ +import logging from pathlib import Path -from typing import Any, Dict, Optional, Tuple +from typing import Any, Optional import tomllib from dotenv import load_dotenv @@ -14,15 +15,15 @@ from pydantic_settings import ( load_dotenv() -def load_toml_config(config_path: str = "config.toml") -> Dict[str, Any]: +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 Exception as e: - print(f"Warning: Failed to load {config_path}: {e}") + except (tomllib.TOMLDecodeError, OSError) as exc: + logging.warning("Failed to load %s: %s", config_path, exc) return {} return {} @@ -37,28 +38,26 @@ class TomlConfigSettingsSource(PydanticBaseSettingsSource): def __init__(self, settings_cls: type[BaseSettings]): super().__init__(settings_cls) - def get_field_value( - self, field_name: str, field_info: Any - ) -> Tuple[Any, str, bool]: + SECTION_MAP: dict[str, str] = { + "DB": "db", + "AUTH": "auth", + "SENTRY": "sentry", + "OPENTELEMETRY": "opentelemetry", + "LLM": "llm", + "AGENT": "agent", + "DERIVER": "deriver", + "HISTORY": "history", + "": "app", # For AppSettings with no prefix + } + + def get_field_value(self, 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_map = { - "DB": "db", - "AUTH": "auth", - "SENTRY": "sentry", - "OPENTELEMETRY": "opentelemetry", - "LLM": "llm", - "AGENT": "agent", - "DERIVER": "deriver", - "HISTORY": "history", - "": "app", # For AppSettings with no prefix - } - - section = section_map.get(prefix, prefix.lower()) + section = self.SECTION_MAP.get(prefix, prefix.lower()) toml_data = TOML_CONFIG.get(section, {}) # Try different case variations @@ -70,34 +69,17 @@ class TomlConfigSettingsSource(PydanticBaseSettingsSource): return field_value, field_name, False - def __call__(self) -> Dict[str, Any]: + 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] - # Map prefixes to TOML sections - section_map = { - "DB": "db", - "AUTH": "auth", - "SENTRY": "sentry", - "OPENTELEMETRY": "opentelemetry", - "LLM": "llm", - "AGENT": "agent", - "DERIVER": "deriver", - "HISTORY": "history", - "": "app", # For AppSettings with no prefix - } - - section = section_map.get(prefix, prefix.lower()) + section = self.SECTION_MAP.get(prefix, prefix.lower()) toml_data = TOML_CONFIG.get(section, {}) # Convert keys to uppercase to match field names - result = {} - for key, value in toml_data.items(): - result[key.upper()] = value - - return result + return {key.upper(): value for key, value in toml_data.items()} class TomlSettings(BaseSettings): diff --git a/src/db.py b/src/db.py index b559029a..93872b3c 100644 --- a/src/db.py +++ b/src/db.py @@ -12,7 +12,8 @@ connect_args = {"prepare_threshold": None} # Context variable to store request context request_context: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( - "request_context", default=None + "request_context", + default=None, ) engine = create_async_engine( diff --git a/src/deriver/queue.py b/src/deriver/queue.py index 827f1dda..82489ce0 100644 --- a/src/deriver/queue.py +++ b/src/deriver/queue.py @@ -1,7 +1,7 @@ import asyncio import os import signal -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from logging import getLogger import sentry_sdk @@ -12,10 +12,11 @@ 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 -from src.config import settings logger = getLogger(__name__) @@ -116,7 +117,7 @@ class QueueManager: """Get available sessions that aren't being processed""" # Clean up stale sessions stale_delta = timedelta(minutes=settings.DERIVER.STALE_SESSION_TIMEOUT_MINUTES) - five_minutes_ago = datetime.utcnow() - stale_delta + five_minutes_ago = datetime.now(timezone.utc) - stale_delta await db.execute( delete(models.ActiveQueueSession).where( models.ActiveQueueSession.last_updated < five_minutes_ago @@ -191,13 +192,17 @@ class QueueManager: ) else: self.queue_empty_flag.set() - await asyncio.sleep(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS) + 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) + logger.exception("Error in polling loop: %s", str(e)) if settings.SENTRY.ENABLED: sentry_sdk.capture_exception(e) # Note: rollback is handled by tracked_db dependency - await asyncio.sleep(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS) + await asyncio.sleep( + settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS + ) finally: logger.info("Polling loop stopped")