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