diff --git a/.claude/skills/honcho-cli/SKILL.md b/.claude/skills/honcho-cli/SKILL.md new file mode 100644 index 00000000..e2276669 --- /dev/null +++ b/.claude/skills/honcho-cli/SKILL.md @@ -0,0 +1,117 @@ +--- +name: honcho-cli +description: Inspect and debug Honcho workspaces via the `honcho` CLI. Use when investigating peer representations, memory state, session context, queue status, or dialectic quality — any task that requires introspection of a Honcho deployment. +allowed-tools: Bash(honcho:*), Bash(jq:*), Read, Grep +--- + +# Honcho CLI + +`honcho` wraps the Honcho Python SDK with agent-friendly defaults: JSON output, structured errors, input validation. Use it to inspect workspace state, debug peer memory, and diagnose the dialectic. + +## Output & config + +- **TTY**: human-readable tables (default when interactive) +- **Piped / `--json`**: JSON — collection commands emit arrays, single-resource commands emit objects +- **Exit codes**: `0` success · `1` client error (bad input, not found) · `2` server error · `3` auth error +- **Config**: `~/.honcho/config.json` (shared with other Honcho tools). The CLI owns `apiKey` and `environmentUrl` at the top level; run `honcho init` to confirm or set them. Per-command scope (workspace / peer / session) is via `-w` / `-p` / `-s` flags or `HONCHO_*` env vars. + +## Command groups + +- `honcho config` — CLI configuration +- `honcho workspace` — inspect, delete, search +- `honcho peer` — inspect, card, chat, search +- `honcho session` — inspect, messages, context, summaries +- `honcho message` — list and get +- `honcho conclusion` — list, search, create, delete + +## Rules + +- Always pass `--json` when processing output programmatically. +- Run `honcho peer inspect` before `honcho peer chat` to understand context. +- Use `honcho session context` to see exactly what an agent receives. +- Never run `honcho workspace delete` without `honcho workspace inspect` first. +- Check queue status when derivation seems stalled. +- Compare peer card with conclusions to understand memory state. + +## Inspection tour + +When orienting to a Honcho deployment, walk outside-in: + +### 1. Understand the workspace + +```bash +honcho workspace inspect --json +``` + +### 2. Find the peer + +```bash +honcho peer list --json +honcho peer inspect --json +``` + +### 3. Check peer's memory + +```bash +honcho peer card --json +honcho conclusion list --observer --json +honcho conclusion search "topic" --observer --json +``` + +### 4. Debug a session + +```bash +honcho session inspect --json +honcho message list --last 20 --json +honcho session context --json +honcho session summaries --json +``` + +### 5. Search across workspace + +```bash +honcho workspace search "query" --json +honcho peer search "query" --json +``` + +## Debugging playbook + +### Peer not learning? + +```bash +# Is observation enabled? +honcho peer inspect --json | jq '.configuration' + +# Is the deriver queue processing messages? +honcho workspace queue-status --json + +# What conclusions exist? +honcho conclusion list --observer --json +honcho conclusion search "expected topic" --observer --json +``` + +### Session context looks wrong? + +```bash +# Raw context an agent would receive +honcho session context --json + +# Summaries feeding the context +honcho session summaries --json + +# Recent message history +honcho message list --last 50 --json +``` + +### Dialectic giving bad answers? + +```bash +# What the peer card says +honcho peer card --json + +# Conclusions on the specific topic +honcho conclusion search "topic" --observer --json + +# Exercise the dialectic directly +honcho peer chat "what do you know about X?" --json +``` diff --git a/.claude/skills/honcho-integration/SKILL.md b/.claude/skills/honcho-integration/SKILL.md index 9cdd978f..ecfdb009 100644 --- a/.claude/skills/honcho-integration/SKILL.md +++ b/.claude/skills/honcho-integration/SKILL.md @@ -91,6 +91,8 @@ Based on interview responses, implement the integration: ### Phase 4: Verification +- If the Honcho CLI is available, run `honcho doctor` to confirm connectivity before testing the integration code +- Use `honcho peer list` and `honcho peer chat` to verify peers exist and the dialectic endpoint works independently of the integration - Ensure all message exchanges are stored to Honcho - Verify AI peers have `observe_me=False` (unless user specifically wants AI observation) - Check that the workspace ID is consistent across the codebase @@ -106,6 +108,16 @@ Based on interview responses, implement the integration: 2. **Get an API key** ask the user to get a Honcho API key from and add it to the environment. +3. **Verify with the CLI** (optional but recommended). If the user has the Honcho CLI installed (`pip install honcho-cli`), they can validate their setup before writing any integration code: + + ```bash + honcho init # persist API key + URL to ~/.honcho/config.json + honcho doctor # verify connectivity, config, workspace health + honcho peer chat # test the dialectic endpoint interactively + ``` + + This is the fastest way to confirm the API key and URL are correct before debugging SDK code. + ## Installation ### Python (use uv) @@ -139,8 +151,8 @@ response = peer.chat("What does this user prefer?") # Async usage (FastAPI, Starlette) from honcho import Honcho honcho = Honcho(workspace_id="my-app", api_key=os.environ["HONCHO_API_KEY"]) -peer = honcho.aio.peer("user-123") -response = await peer.chat("What does this user prefer?") +peer = await honcho.aio.peer("user-123") +response = await peer.aio.chat("What does this user prefer?") ``` Match the client to the framework — check whether the codebase uses `async def` handlers or sync `def` handlers and choose accordingly. The rest of this skill shows sync Python examples; swap to `.aio` equivalents for async codebases. @@ -188,7 +200,7 @@ Create peers for **every entity** in your business logic - users AND AI assistan **Python:** ```python -from honcho import PeerConfig +from honcho.api_types import PeerConfig # Human users user = honcho.peer("user-123") @@ -524,6 +536,8 @@ When integrating Honcho into an existing codebase: - [ ] Pre-fetch pattern for simpler integrations - [ ] context() for conversation history - [ ] Store messages after each exchange to build user models +- [ ] (Optional) Run `honcho doctor` to verify connectivity before testing integration code +- [ ] (Optional) Use `honcho peer chat` to test dialectic queries independently ## Common Mistakes to Avoid diff --git a/.claude/skills/honcho-integration/references/bot-frameworks/nanobot/session.py b/.claude/skills/honcho-integration/references/bot-frameworks/nanobot/session.py index 2e4b3e43..b5ebc081 100644 --- a/.claude/skills/honcho-integration/references/bot-frameworks/nanobot/session.py +++ b/.claude/skills/honcho-integration/references/bot-frameworks/nanobot/session.py @@ -13,7 +13,7 @@ from nanobot.honcho.client import get_honcho_client if TYPE_CHECKING: from honcho import Honcho - from honcho.session import SessionPeerConfig + from honcho.api_types import SessionPeerConfig @dataclass @@ -101,7 +101,7 @@ class HonchoSessionManager: """ Get or create a Honcho peer. - Peers are lazy -- no API call until first use. + As of v2.1.0, peer() always makes a get-or-create API call. Observation settings are controlled per-session via SessionPeerConfig. Args: @@ -138,7 +138,7 @@ class HonchoSessionManager: session = self.honcho.session(session_id) # Configure peer observation settings - from honcho.session import SessionPeerConfig + from honcho.api_types import SessionPeerConfig user_config = SessionPeerConfig(observe_me=True, observe_others=True) ai_config = SessionPeerConfig(observe_me=False, observe_others=True) diff --git a/.claude/skills/migrate-honcho-py/DETAILED-CHANGES.md b/.claude/skills/migrate-honcho-py/DETAILED-CHANGES.md index 624cdd7a..74e93a4b 100644 --- a/.claude/skills/migrate-honcho-py/DETAILED-CHANGES.md +++ b/.claude/skills/migrate-honcho-py/DETAILED-CHANGES.md @@ -476,3 +476,132 @@ from honcho.api_types import SessionPeerConfig ``` **Note:** `MessageCreateParam` (singular) is now `MessageCreateParams` (plural). + +--- + +## 14. Card Method Deprecation and set_card (v2.0.1) + +### Before (v2.0.0) + +```python +card: list[str] | None = peer.card() +``` + +### After (v2.0.1+) + +```python +# get_card() is the preferred method +card: list[str] | None = peer.get_card() + +# card() still works but emits a deprecation warning +card = peer.card() # Deprecated + +# New: set_card() +updated = peer.set_card(["Fact 1", "Fact 2"]) +updated = peer.set_card(["Fact 1"], target="other-peer") + +# Async variants +card = await peer.aio.get_card() +await peer.aio.set_card(["Fact 1"]) +``` + +--- + +## 15. Strict Input Validation (v2.0.2) + +All Pydantic input models now use `extra="forbid"`, raising `ValidationError` for unknown fields. + +```python +from honcho.api_types import PeerConfig + +# This now raises ValidationError instead of silently ignoring the typo +PeerConfig(observe_mee=True) # ValidationError: extra fields not permitted +``` + +--- + +## 16. peer() and session() Always Make API Calls (v2.1.0) + +### Before (v2.0.x) + +```python +# Without options: lazy object, no API call +peer = client.peer("user-123") +# peer.created_at was None + +# With options: made API call +peer = client.peer("user-123", metadata={"key": "value"}) +``` + +### After (v2.1.0+) + +```python +# Always makes a get-or-create API call +peer = client.peer("user-123") +# peer.created_at is now always populated + +# Async +peer = await client.aio.peer("user-123") +``` + +All Peer/Session objects now have `created_at` populated immediately after construction. + +--- + +## 17. New Properties: created_at, is_active (v2.1.0) + +```python +# Peer +peer = client.peer("user-123") +print(peer.created_at) # datetime | None + +# Session +session = client.session("sess-1") +print(session.created_at) # datetime | None +print(session.is_active) # bool | None + +# These are refreshed by get_metadata(), get_configuration(), and refresh() +peer.refresh() +session.refresh() +``` + +--- + +## 18. get_message() on Session (v2.1.0) + +```python +# Fetch a single message by ID +msg = session.get_message("msg-abc123") +print(msg.content, msg.created_at) + +# Async +msg = await session.aio.get_message("msg-abc123") +``` + +--- + +## 19. Pagination Parameters (v2.1.0) + +All list methods now accept `page`, `size`, and `reverse`: + +```python +# Defaults: page=1, size=50, reverse=False +peers_page = client.peers(page=2, size=25, reverse=True) + +# Returns SyncPage / AsyncPage with: +print(peers_page.total) # Total items +print(peers_page.pages) # Total pages +print(peers_page.has_next_page()) + +# Works on: +# client.peers(), client.sessions() +# peer.sessions() +# session.messages() +# scope.list() +``` + +--- + +## 20. Broader HTTP Retry Logic (v2.1.1) + +The SDK now catches `httpx.NetworkError` and `httpx.RemoteProtocolError` for retry in addition to `httpx.TimeoutException` and `httpx.ConnectError`. This is transparent — no code changes needed. diff --git a/.claude/skills/migrate-honcho-py/MIGRATION-CHECKLIST.md b/.claude/skills/migrate-honcho-py/MIGRATION-CHECKLIST.md index 5a2d01ff..ef3254da 100644 --- a/.claude/skills/migrate-honcho-py/MIGRATION-CHECKLIST.md +++ b/.claude/skills/migrate-honcho-py/MIGRATION-CHECKLIST.md @@ -4,7 +4,7 @@ Use this checklist to track migration progress. Copy into your working notes and ## Dependencies -- [ ] Update `honcho` package to v2.0.0 +- [ ] Update `honcho` package to v2.1.1 - [ ] Remove any `honcho-core` imports ## Async Architecture Changes @@ -113,6 +113,39 @@ Use this checklist to track migration progress. Copy into your working notes and - `UnprocessableEntityError`, `RateLimitError`, `ServerError` - `TimeoutError`, `ConnectionError` +## Card Method Updates (v2.0.1) + +- [ ] Replace `peer.card()` with `peer.get_card()` (card() is deprecated) +- [ ] Use `peer.set_card(list[str])` if setting peer cards + +## Strict Validation (v2.0.2) + +- [ ] Verify no input models pass unknown/misspelled fields (now raises `ValidationError`) +- [ ] Check for typos in `PeerConfig`, `SessionConfiguration`, `WorkspaceConfiguration` fields + +## peer() / session() API Call Change (v2.1.0) + +- [ ] Update code that relied on lazy `peer()` / `session()` — they now always make API calls +- [ ] Add `await` if using async and previously didn't need it for lazy construction + +## New Properties (v2.1.0) + +- [ ] Use `peer.created_at` / `session.created_at` where creation time is needed +- [ ] Use `session.is_active` where session active status is needed + +## New Methods (v2.1.0) + +- [ ] Use `session.get_message(message_id)` to fetch single messages by ID + +## Pagination Parameters (v2.1.0) + +- [ ] Add `page`, `size`, `reverse` parameters to list calls where needed: + - [ ] `client.peers()` + - [ ] `client.sessions()` + - [ ] `peer.sessions()` + - [ ] `session.messages()` + - [ ] `scope.list()` + ## Final Verification - [ ] Run type checker (mypy/pyright) with no errors diff --git a/.claude/skills/migrate-honcho-py/SKILL.md b/.claude/skills/migrate-honcho-py/SKILL.md index de9c87f3..c9f8320e 100644 --- a/.claude/skills/migrate-honcho-py/SKILL.md +++ b/.claude/skills/migrate-honcho-py/SKILL.md @@ -1,13 +1,13 @@ --- name: migrate-honcho -description: Migrates Honcho Python SDK code from v1.6.0 to v2.0.0. Use when upgrading honcho package, fixing breaking changes after upgrade, or when errors mention AsyncHoncho, observations, Representation class, .core property, or get_config methods. +description: Migrates Honcho Python SDK code from v1.6.0 to v2.1.1. Use when upgrading honcho package, fixing breaking changes after upgrade, or when errors mention AsyncHoncho, observations, Representation class, .core property, or get_config methods. --- -# Honcho Python SDK Migration (v1.6.0 → v2.0.0) +# Honcho Python SDK Migration (v1.6.0 → v2.1.1) ## Overview -This skill migrates code from `honcho` Python SDK v1.6.0 to v2.0.0 (required for Honcho 3.0.0+). +This skill migrates code from `honcho` Python SDK v1.6.0 to v2.1.1 (required for Honcho 3.0.0+). **Key breaking changes:** @@ -184,18 +184,92 @@ updated = client.update_message(message=msg, metadata={"key": "value"}, session= updated = session.update_message(message=msg, metadata={"key": "value"}) ``` -### 10. Update card() return type +### 10. Update card() return type and method name ```python # Before card: str = peer.card() # Returns str -# After -card: list[str] | None = peer.card() # Returns list[str] | None +# After (v2.0.0+) +card: list[str] | None = peer.get_card() # Returns list[str] | None if card: print("\n".join(card)) + +# peer.card() still works but is deprecated — use get_card() + +# New in v2.0.1: set_card() +peer.set_card(["Prefers dark mode", "Located in US"]) ``` +### 11. Strict input validation (v2.0.2+) + +All input models now reject unknown fields via `extra="forbid"` Pydantic validation. Previously, misspelled or extraneous fields were silently ignored. + +```python +# Before (v2.0.1 and earlier) — silently ignored +peer = client.peer("user-1", configuration=PeerConfig(observe_mee=True)) # typo silently ignored + +# After (v2.0.2+) — raises ValidationError +peer = client.peer("user-1", configuration=PeerConfig(observe_mee=True)) # ValidationError! +``` + +### 12. peer() and session() always make API calls (v2.1.0+) + +**Breaking**: `peer()` and `session()` now always make a get-or-create API call. Previously, calling without metadata/configuration returned a lazy object with no API call. + +```python +# Before (v2.0.x) — no API call without options +peer = client.peer("user-123") # Lazy, no network request + +# After (v2.1.0+) — always hits the API +peer = client.peer("user-123") # Makes POST to /peers (get-or-create) + +# Async +peer = await client.aio.peer("user-123") # Also always hits API +``` + +### 13. New properties and methods (v2.1.0+) + +```python +# created_at on Peer and Session +peer = client.peer("user-123") +print(peer.created_at) # datetime | None + +session = client.session("sess-1") +print(session.created_at) # datetime | None + +# is_active on Session +print(session.is_active) # bool | None + +# get_message() on Session +msg = session.get_message("msg-id") +# Async: msg = await session.aio.get_message("msg-id") +``` + +### 14. Pagination parameters on list methods (v2.1.0+) + +All list methods now accept `page`, `size`, and `reverse` parameters: + +```python +# Before (v2.0.x) — only filters +peers_page = client.peers(filters={"metadata": {"role": "admin"}}) + +# After (v2.1.0+) — pagination controls +peers_page = client.peers( + filters={"metadata": {"role": "admin"}}, + page=2, + size=25, + reverse=True +) + +# Works on: client.peers(), client.sessions(), peer.sessions(), +# session.messages(), scope.list() +``` + +### 15. Broader HTTP retry logic (v2.1.1+) + +The SDK now retries on `httpx.TimeoutException`, `httpx.NetworkError`, and `httpx.RemoteProtocolError` (previously only `httpx.TimeoutException` and `httpx.ConnectError`). These are mapped to the SDK's `TimeoutError` and `ConnectionError` respectively. No code changes needed — this is transparent. + ## Quick Reference Table | v1.6.0 | v2.0.0 | @@ -222,6 +296,8 @@ if card: | `.get_peer_config()` | `.get_peer_configuration()` | | `.set_peer_config()` | `.set_peer_configuration()` | | `client.update_message()` | `session.update_message()` | +| `peer.card()` | `peer.get_card()` *(card() deprecated)* | +| *(new)* | `peer.set_card(list[str])` | | `chat(stream=True)` | `chat_stream()` | | `include_most_derived=` | `include_most_frequent=` | | `max_observations=` | `max_conclusions=` | @@ -230,6 +306,10 @@ if card: | `PeerContext` | `PeerContextResponse` | | `DeriverStatus` | `QueueStatusResponse` | | `client.core` | *(removed)* | +| *(new v2.1.0)* | `peer.created_at` / `session.created_at` | +| *(new v2.1.0)* | `session.is_active` | +| *(new v2.1.0)* | `session.get_message(id)` | +| *(new v2.1.0)* | `page=`, `size=`, `reverse=` on list methods | ## Detailed Reference diff --git a/.claude/skills/migrate-honcho-ts/DETAILED-CHANGES.md b/.claude/skills/migrate-honcho-ts/DETAILED-CHANGES.md index 76273d34..9133756e 100644 --- a/.claude/skills/migrate-honcho-ts/DETAILED-CHANGES.md +++ b/.claude/skills/migrate-honcho-ts/DETAILED-CHANGES.md @@ -432,3 +432,152 @@ interface SummaryData { tokenCount: number } ``` + +--- + +## Post-v2.0.0 Changes + +--- + +## Card Method Deprecation and setCard (v2.0.1) + +### Before (v2.0.0) + +```typescript +const card = await peer.card(target) // string[] | null +``` + +### After (v2.0.1+) + +```typescript +// getCard() is the preferred method +const card = await peer.getCard(target) // string[] | null + +// card() still works but is deprecated +const card = await peer.card(target) // Deprecated + +// New: setCard() +const updated = await peer.setCard(['Fact 1', 'Fact 2']) +const updated = await peer.setCard(['Fact 1'], targetPeer) +``` + +--- + +## Strict Input Validation (v2.0.2) + +Client constructor and all input schemas now use `.strict()` Zod validation. + +```typescript +// Before (v2.0.1) — silently ignored +const honcho = new Honcho({ baseUrl: 'http://...' }) // typo fell back to default + +// After (v2.0.2+) — ZodError thrown +const honcho = new Honcho({ baseUrl: 'http://...' }) // ZodError: Unrecognized key "baseUrl" +``` + +--- + +## peer() and session() Always Make API Calls (v2.1.0) + +### Before (v2.0.x) + +```typescript +// Without options: lazy object, no API call +const peer = honcho.peer('user-123') + +// With options: made API call +const peer = await honcho.peer('user-123', { metadata: { key: 'value' } }) +``` + +### After (v2.1.0+) + +```typescript +// Always makes a get-or-create API call +const peer = await honcho.peer('user-123') +// peer.createdAt is now always populated +``` + +--- + +## New Properties: createdAt, isActive (v2.1.0) + +```typescript +// Peer +const peer = await honcho.peer('user-123') +console.log(peer.createdAt) // string | undefined + +// Session +const session = await honcho.session('sess-1') +console.log(session.createdAt) // string | undefined +console.log(session.isActive) // boolean | undefined + +// Refreshed by getMetadata(), getConfiguration(), and refresh() +await session.refresh() +``` + +--- + +## getMessage() on Session (v2.1.0) + +```typescript +// Fetch a single message by ID +const msg = await session.getMessage('msg-abc123') +console.log(msg.content, msg.createdAt) +``` + +--- + +## Pagination Parameters (v2.1.0) + +All list methods now accept `page`, `size`, and `reverse`: + +```typescript +// Defaults: page=1, size=50, reverse=false +const peersPage = await honcho.peers({ + filters: { metadata: { role: 'admin' } }, + page: 2, + size: 25, + reverse: true +}) + +// Page properties: +console.log(peersPage.total) // Total items +console.log(peersPage.pages) // Total pages +console.log(peersPage.hasNextPage) // boolean + +// Works on: +// honcho.peers(), honcho.sessions(), honcho.workspaces() +// peer.sessions() +// session.messages() +// scope.list() +``` + +--- + +## searchQuery Moved in context() (v2.1.0) + +### Before (v2.0.x) + +```typescript +const ctx = await session.context({ + searchQuery: 'What are my preferences?', + representationOptions: { maxConclusions: 50 } +}) +``` + +### After (v2.1.0+) + +```typescript +const ctx = await session.context({ + representationOptions: { + searchQuery: 'What are my preferences?', + maxConclusions: 50 + } +}) +``` + +--- + +## Broader Fetch Retry Logic (v2.1.1) + +The SDK now retries on all `TypeError` network failures (connection resets, DNS errors, etc.) instead of only those containing `'fetch'` in the error message. This is transparent — no code changes needed. diff --git a/.claude/skills/migrate-honcho-ts/MIGRATION-CHECKLIST.md b/.claude/skills/migrate-honcho-ts/MIGRATION-CHECKLIST.md index 6adcf94b..7abdd277 100644 --- a/.claude/skills/migrate-honcho-ts/MIGRATION-CHECKLIST.md +++ b/.claude/skills/migrate-honcho-ts/MIGRATION-CHECKLIST.md @@ -5,7 +5,7 @@ Use this checklist to track migration progress. Copy into your working notes and ## Dependencies - [ ] Remove `@honcho-ai/core` from dependencies -- [ ] Update `@honcho-ai/sdk` to v2.0.0 +- [ ] Update `@honcho-ai/sdk` to v2.1.1 ## Client-Level Changes @@ -101,6 +101,44 @@ Use this checklist to track migration progress. Copy into your working notes and - [ ] Remove usage of `Representation` class methods (`.explicit`, `.deductive`, `.isEmpty()`, `.diff()`) - [ ] Handle representation as plain string +## Card Method Updates (v2.0.1) + +- [ ] Replace `peer.card()` with `peer.getCard()` (card() is deprecated) +- [ ] Use `peer.setCard(string[])` if setting peer cards + +## Strict Validation (v2.0.2) + +- [ ] Verify no constructor options or input schemas pass unknown/misspelled fields (now throws `ZodError`) +- [ ] Check for `baseUrl` vs `baseURL` typo in Honcho constructor + +## peer() / session() API Call Change (v2.1.0) + +- [ ] Update code that relied on lazy `peer()` / `session()` — they now always make API calls +- [ ] Ensure all `peer()` and `session()` calls are `await`ed + +## New Properties (v2.1.0) + +- [ ] Use `peer.createdAt` / `session.createdAt` where creation time is needed +- [ ] Use `session.isActive` where session active status is needed + +## New Methods (v2.1.0) + +- [ ] Use `session.getMessage(messageId)` to fetch single messages by ID + +## Pagination Parameters (v2.1.0) + +- [ ] Add `page`, `size`, `reverse` parameters to list calls where needed: + - [ ] `honcho.peers()` + - [ ] `honcho.sessions()` + - [ ] `honcho.workspaces()` + - [ ] `peer.sessions()` + - [ ] `session.messages()` + - [ ] `scope.list()` + +## searchQuery Location Change (v2.1.0) + +- [ ] Move `searchQuery` from top-level `context()` options to `representationOptions.searchQuery` + ## Final Verification - [ ] Run TypeScript compiler with no errors diff --git a/.claude/skills/migrate-honcho-ts/SKILL.md b/.claude/skills/migrate-honcho-ts/SKILL.md index 26cbff70..5de5e973 100644 --- a/.claude/skills/migrate-honcho-ts/SKILL.md +++ b/.claude/skills/migrate-honcho-ts/SKILL.md @@ -1,13 +1,13 @@ --- name: migrate-honcho-ts -description: Migrates Honcho TypeScript SDK code from v1.6.0 to v2.0.0. Use when upgrading @honcho-ai/sdk, fixing breaking changes after upgrade, or when errors mention removed APIs like .core, getConfig, observations, or snake_case properties. +description: Migrates Honcho TypeScript SDK code from v1.6.0 to v2.1.1. Use when upgrading @honcho-ai/sdk, fixing breaking changes after upgrade, or when errors mention removed APIs like .core, getConfig, observations, or snake_case properties. --- -# Honcho TypeScript SDK Migration (v1.6.0 → v2.0.0) +# Honcho TypeScript SDK Migration (v1.6.0 → v2.1.1) ## Overview -This skill migrates code from `@honcho-ai/sdk` v1.6.0 to v2.0.0 (required for Honcho 3.0.0+). +This skill migrates code from `@honcho-ai/sdk` v1.6.0 to v2.1.1 (required for Honcho 3.0.0+). **Key breaking changes:** @@ -148,6 +148,101 @@ await honcho.updateMessage(message, metadata, session) await session.updateMessage(message, metadata) ``` +### 11. Update card() to getCard() (v2.0.1+) + +```typescript +// Before +const card = await peer.card(target) + +// After (v2.0.1+) +const card = await peer.getCard(target) // Returns string[] | null + +// peer.card() still works but is deprecated — use getCard() + +// New: setPeerCard / setCard +await peer.setCard(['Prefers dark mode', 'Located in US']) +``` + +### 12. Strict input validation (v2.0.2+) + +Client constructor and all input schemas now reject unknown options via `.strict()` Zod validation. + +```typescript +// Before (v2.0.1 and earlier) — silently ignored +const honcho = new Honcho({ baseUrl: 'http://...' }) // typo: baseUrl vs baseURL — silently fell back to default + +// After (v2.0.2+) — throws ZodError +const honcho = new Honcho({ baseUrl: 'http://...' }) // ZodError! Use baseURL +``` + +### 13. peer() and session() always make API calls (v2.1.0+) + +**Breaking**: `peer()` and `session()` now always make a get-or-create API call. Previously, calling without metadata/configuration returned a lazy object with no API call. + +```typescript +// Before (v2.0.x) — no API call without options +const session = honcho.session('my-session') // Lazy, no network request + +// After (v2.1.0+) — always hits the API +const session = await honcho.session('my-session') // Makes POST to /sessions (get-or-create) +``` + +### 14. New properties and methods (v2.1.0+) + +```typescript +// createdAt on Peer and Session +const peer = await honcho.peer('user-123') +console.log(peer.createdAt) // string | undefined + +const session = await honcho.session('sess-1') +console.log(session.createdAt) // string | undefined + +// isActive on Session +console.log(session.isActive) // boolean | undefined + +// getMessage() on Session +const msg = await session.getMessage('msg-id') +``` + +### 15. Pagination parameters on list methods (v2.1.0+) + +All list methods now accept `page`, `size`, and `reverse` parameters: + +```typescript +// Before (v2.0.x) — only filters +const peers = await honcho.peers({ metadata: { role: 'admin' } }) + +// After (v2.1.0+) — pagination controls via options object +const peers = await honcho.peers({ + filters: { metadata: { role: 'admin' } }, + page: 2, + size: 25, + reverse: true +}) + +// Legacy raw-filter form still works: +const peers = await honcho.peers({ metadata: { role: 'admin' } }) + +// Works on: honcho.peers(), honcho.sessions(), honcho.workspaces(), +// peer.sessions(), session.messages(), scope.list() +``` + +### 16. searchQuery moved in context() (v2.1.0+) + +**Breaking**: `searchQuery` removed from top-level `context()` options. Use `representationOptions.searchQuery` instead. + +```typescript +// Before (v2.0.x) +await session.context({ searchQuery: '...' }) + +// After (v2.1.0+) +await session.context({ representationOptions: { searchQuery: '...' } }) +``` + +### 17. Broader fetch retry logic (v2.1.1+) + +The SDK now retries on all `TypeError` network failures (connection resets, DNS errors, etc.) instead of only those with `'fetch'` in the message. No code changes needed — this is transparent. + ## Quick Reference Table | v1.6.0 | v2.0.0 | @@ -172,15 +267,22 @@ await session.updateMessage(message, metadata) | `session.workingRep()` | `session.representation()` | | `session.peerConfig()` | `session.getPeerConfiguration()` | | `session.setPeerConfig()` | `session.setPeerConfiguration()` | -| `{ timeoutMs: 60000 }` | `{ timeout: 60 }` | +| `{ timeoutMs: 60000 }` | `{ timeout: 60000 }` | | `{ maxObservations: 50 }` | `{ maxConclusions: 50 }` | | `{ includeMostDerived }` | `{ includeMostFrequent }` | | `{ lastUserMessage }` | `{ searchQuery }` | | `{ config: ... }` | `{ configuration: ... }` | | `message.peer_id` | `message.peerId` | | `message.created_at` | `message.createdAt` | +| `peer.card()` | `peer.getCard()` *(card() deprecated)* | +| *(new)* | `peer.setCard(string[])` | | `Observation` | `Conclusion` | | `ObservationScope` | `ConclusionScope` | +| *(new v2.1.0)* | `peer.createdAt` / `session.createdAt` | +| *(new v2.1.0)* | `session.isActive` | +| *(new v2.1.0)* | `session.getMessage(id)` | +| *(new v2.1.0)* | `page`, `size`, `reverse` on list methods | +| `context({ searchQuery })` | `context({ representationOptions: { searchQuery } })` | ## Detailed Reference diff --git a/.env.template b/.env.template index c922fe54..123af642 100644 --- a/.env.template +++ b/.env.template @@ -15,8 +15,13 @@ LOG_LEVEL=INFO # Embedding settings # EMBED_MESSAGES=true -# MAX_EMBEDDING_TOKENS=8192 -# MAX_EMBEDDING_TOKENS_PER_REQUEST=300000 +# EMBEDDING_VECTOR_DIMENSIONS=1536 +# EMBEDDING_MAX_INPUT_TOKENS=8192 +# EMBEDDING_MAX_TOKENS_PER_REQUEST=300000 +# EMBEDDING_MODEL_CONFIG__TRANSPORT=openai +# EMBEDDING_MODEL_CONFIG__MODEL=text-embedding-3-small +# EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL= +# EMBEDDING_MODEL_CONFIG__OVERRIDES__API_KEY_ENV= # LANGFUSE_HOST= # LANGFUSE_PUBLIC_KEY= @@ -32,7 +37,7 @@ LOG_LEVEL=INFO # ============================================================================= # Connection URI for PostgreSQL database with pgvector support # Must use postgresql+psycopg prefix for SQLAlchemy compatibility -DB_CONNECTION_URI=postgresql+psycopg://testuser:testpwd@localhost:5432/honcho +DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres # Optional database settings # DB_SCHEMA=public @@ -57,160 +62,160 @@ 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= +# Honcho uses LLMs for memory extraction, summarization, dialectic chat, and +# dream consolidation. The server will fail to start without a provider configured. +# +# Quick start: set LLM_OPENAI_API_KEY below to use the built-in defaults. +# Text-generation features default to transport = "openai" and +# model = "gpt-5.4-mini". Embeddings default to transport = "openai" and +# model = "text-embedding-3-small". For OpenAI-compatible proxies +# (OpenRouter, Together, Fireworks, vLLM, Ollama, LiteLLM), override +# MODEL_CONFIG__MODEL and MODEL_CONFIG__OVERRIDES__BASE_URL on each feature +# section you want to route through that endpoint. +# Models must support tool calling (function calling). +# +# Supported transports: openai, anthropic, gemini +# Each transport picks up its API key from the corresponding LLM_*_API_KEY. +# Base URLs are set per-module via MODEL_CONFIG__OVERRIDES__BASE_URL. +# +LLM_OPENAI_API_KEY=your-api-key-here +# LLM_ANTHROPIC_API_KEY= +# LLM_GEMINI_API_KEY= # ============================================================================= # LLM Configuration # ============================================================================= # Global LLM settings # 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 # ============================================================================= -# Deriver (Background Worker) Settings +# Deriver (Background Worker) # ============================================================================= # DERIVER_ENABLED=true +# Defaults: +# DERIVER_MODEL_CONFIG__TRANSPORT=openai +# DERIVER_MODEL_CONFIG__MODEL=gpt-5.4-mini +# Optional overrides: +# DERIVER_MODEL_CONFIG__MODEL=your-model-here +# DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1 # 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_TEMPERATURE= +# DERIVER_MODEL_CONFIG__TEMPERATURE= +# DERIVER_MODEL_CONFIG__THINKING_EFFORT=minimal +# DERIVER_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024 # Gemini/Anthropic only # DERIVER_DEDUPLICATE=true -# DERIVER_MAX_OUTPUT_TOKENS=4096 -# DERIVER_THINKING_BUDGET_TOKENS=1024 +# DERIVER_MODEL_CONFIG__MAX_OUTPUT_TOKENS=4096 # 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_MODEL_CONFIG__FALLBACK__MODEL= +# DERIVER_MODEL_CONFIG__FALLBACK__TRANSPORT= +# DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL= +# DERIVER_MODEL_CONFIG__OVERRIDES__API_KEY_ENV= # ============================================================================= -# 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 -# DIALECTIC_LEVELS__minimal__THINKING_BUDGET_TOKENS=0 +# Each level has its own nested MODEL_CONFIG, tool iterations, and max output tokens. +# MAX_OUTPUT_TOKENS is optional per level; if not set, uses global DIALECTIC_MAX_OUTPUT_TOKENS. +# Defaults: +# DIALECTIC_LEVELS__minimal__MODEL_CONFIG__TRANSPORT=openai +# DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL=gpt-5.4-mini # 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__low__THINKING_BUDGET_TOKENS=0 +# DIALECTIC_LEVELS__minimal__MAX_OUTPUT_TOKENS=250 +# DIALECTIC_LEVELS__minimal__TOOL_CHOICE=any +# DIALECTIC_LEVELS__low__MODEL_CONFIG__TRANSPORT=openai +# DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL=gpt-5.4-mini # 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__low__TOOL_CHOICE=any +# DIALECTIC_LEVELS__medium__MODEL_CONFIG__TRANSPORT=openai +# DIALECTIC_LEVELS__medium__MODEL_CONFIG__MODEL=gpt-5.4-mini # 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__MODEL_CONFIG__TRANSPORT=openai +# DIALECTIC_LEVELS__high__MODEL_CONFIG__MODEL=gpt-5.4-mini # 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__MODEL_CONFIG__TRANSPORT=openai +# DIALECTIC_LEVELS__max__MODEL_CONFIG__MODEL=gpt-5.4-mini # DIALECTIC_LEVELS__max__MAX_TOOL_ITERATIONS=10 -# DIALECTIC_LEVELS__max__MAX_OUTPUT_TOKENS=8192 # Optional: override global default +# Optional overrides: +# DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL=your-model-here +# DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL=your-model-here +# DIALECTIC_LEVELS__medium__MODEL_CONFIG__MODEL=your-model-here +# DIALECTIC_LEVELS__high__MODEL_CONFIG__MODEL=your-model-here +# DIALECTIC_LEVELS__max__MODEL_CONFIG__MODEL=your-model-here +# DIALECTIC_LEVELS__max__MODEL_CONFIG__THINKING_EFFORT=medium +# DIALECTIC_LEVELS__max__MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024 # Optional backup per level (must set both or neither): -# DIALECTIC_LEVELS__max__BACKUP_PROVIDER=google -# DIALECTIC_LEVELS__max__BACKUP_MODEL=gemini-2.5-pro +# DIALECTIC_LEVELS__max__MODEL_CONFIG__FALLBACK__MODEL=gemini-2.5-pro +# DIALECTIC_LEVELS__max__MODEL_CONFIG__FALLBACK__TRANSPORT=gemini # ============================================================================= -# Summary Settings +# Summary # ============================================================================= # SUMMARY_ENABLED=true +# Defaults: +# SUMMARY_MODEL_CONFIG__TRANSPORT=openai +# SUMMARY_MODEL_CONFIG__MODEL=gpt-5.4-mini +# Optional overrides: +# SUMMARY_MODEL_CONFIG__MODEL=your-model-here +# SUMMARY_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1 +# SUMMARY_MODEL_CONFIG__THINKING_EFFORT=minimal +# SUMMARY_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024 # Gemini/Anthropic only # 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= +# SUMMARY_MODEL_CONFIG__FALLBACK__MODEL= # ============================================================================= -# Dream Settings +# Dream # ============================================================================= # DREAM_ENABLED=true +# Defaults: +# DREAM_DEDUCTION_MODEL_CONFIG__TRANSPORT=openai +# DREAM_DEDUCTION_MODEL_CONFIG__MODEL=gpt-5.4-mini +# DREAM_INDUCTION_MODEL_CONFIG__TRANSPORT=openai +# DREAM_INDUCTION_MODEL_CONFIG__MODEL=gpt-5.4-mini +# Optional overrides: +# DREAM_DEDUCTION_MODEL_CONFIG__MODEL=your-model-here +# DREAM_DEDUCTION_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1 +# DREAM_INDUCTION_MODEL_CONFIG__MODEL=your-model-here +# DREAM_INDUCTION_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1 # 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/.github/PULL_REQUEST_TEMPLATE/release.md b/.github/PULL_REQUEST_TEMPLATE/release.md index 4072d41b..bcfda404 100644 --- a/.github/PULL_REQUEST_TEMPLATE/release.md +++ b/.github/PULL_REQUEST_TEMPLATE/release.md @@ -31,7 +31,7 @@ For more information on closing issues using keywords, please check https://docs ## **Changelog** ### **Added** diff --git a/.gitignore b/.gitignore index 9a31fc38..e6cbf1d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.worktrees/ api/**/*.db api/data api/docker-compose.yml @@ -181,6 +182,7 @@ docs/node_modules timing_logs.csv +config.json config.toml .aider* diff --git a/CHANGELOG.md b/CHANGELOG.md index bda2c32b..ab9302c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,87 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] + +### Added + +- New `src/llm/` package as the single owner of provider runtime: clients, backends, history adapters, tool loop, request builder, credentials, and caching policy +- `AttemptPlan` dataclass captures per-retry provider selection (client, model, reasoning_effort, thinking_budget_tokens, selected_config) and pins it across stream-final retries so streaming doesn't bounce back to primary after the tool loop has settled on fallback +- Gemini JSON-schema sanitizer for `function_declarations` — strips keywords Gemini's validator rejects (`additionalProperties`, `allOf`, etc.) while preserving semantics for all other backends +- Dreamer specialists derive `effective_max_tokens` from `model_config.max_output_tokens` with a per-specialist default fallback +- Regression tests covering fallback-config thinking-param reach, provider_params → extra_params boundary, OpenAI reasoning-model parameter routing, Gemini blocked finish_reason handling, and fail-fast `max_tool_iterations` validation + +### Changed + +- All LLM orchestration moved out of `src/utils/clients.py` into `src/llm/` with modules split by responsibility (api, executor, tool_loop, runtime, registry, conversation, request_builder, credentials, caching, backends, history_adapters) +- Default `ModelConfig` factories (deriver, summary, dreamer specialists, dialectic levels) normalized to `openai/gpt-5.4-mini` with no extra parameters set by default; operators add transport/thinking overrides explicitly +- OpenAI reasoning-model routing widened via `_uses_max_completion_tokens` heuristic covering `gpt-5.x` and `o1/o3/o4` — these models receive `max_completion_tokens` instead of `max_tokens` +- Override client factories switched from unbounded `@cache` to `@lru_cache(maxsize=128)` for predictable memory growth on long-running processes +- `get_backend` now delegates to `client_for_model_config`, so the live-test path and production path share one missing-API-key validation +- Blocked Gemini responses (`SAFETY`, `RECITATION`, `PROHIBITED_CONTENT`, `BLOCKLIST`) raise `LLMError` in the streaming path too (previously only the non-streaming path), ensuring retry/fallback logic fires uniformly +- Transport-change env overrides now strip transport-specific thinking params (thinking_budget_tokens vs. reasoning_effort) during config merge, including at the dialectic-level merge, so switching from Anthropic → OpenAI doesn't leave orphaned Anthropic-only params that the OpenAI backend would reject +- `max_tool_iterations` out-of-range inputs now raise `ValidationException` instead of being silently clamped +- Troubleshooting docs updated to reflect nested-env-var form for per-component thinking-budget overrides + +### Fixed + +- Fallback `ModelConfig` temperature and `thinking_budget_tokens` reach the backend on the final retry — previously the primary's values were pre-populated into caller kwargs early and clobbered fallback values via `effective_config_for_call(update=...)` +- Stream-final retries pin to the `AttemptPlan` that succeeded rather than re-running provider selection through the outer `current_attempt` ContextVar (which could roll streaming back to primary after the tool loop had already switched to fallback) +- OpenAI structured-output calls continue to use `chat.completions.parse()` with strict schema enforcement, while tool-calling paths use `chat.completions.create()` without `strict:True` for broader proxy compatibility (OpenRouter, vLLM, Ollama) +- Gemini `cached_content` reuse keys now include `system_instruction` and `tool_config` so cache hits don't cross configurations that differ only in those fields + +### Removed + +- `src/utils/clients.py` deleted; its responsibilities are split across `src/llm/registry.py`, `src/llm/credentials.py`, and the backend-specific modules + +## [3.0.6] - 2026-04-10 + +### Changed + +- Tightened transaction scopes across search, agent tools, queue manager, and webhook delivery to minimize DB connection hold time during external operations (#525) +- Search operations refactored to two-phase pattern — external work (embeddings, LLM calls) completes before opening a transaction (#525) +- Agent tool executor performs external operations before acquiring DB sessions (#525) +- Queue manager transaction scope reduced to only the critical section (#525) +- Webhook delivery no longer holds a DB session parameter (#525) + +### Fixed + +- Session leakage in non-session-scoped dialectic chat calls (#526) + +### Added + +- Health check endpoint (`/health`) for container orchestration and load balancer probes (#510) + +## [3.0.5] - 2026-04-03 + +### Fixed + +- explicit rollback on all transactions to force connection closed + +## [3.0.4] - 2026-04-02 + +### Added + +- JSONB metadata validation enforces 100 key limit and max depth of 5 (#419) + +### Changed + +- Schemas refactored from single `schemas.py` into `schemas/api.py`, `schemas/configuration.py`, and `schemas/internal.py` with backwards-compatible re-exports (#419) + +### Fixed + +- Missing `deleted_at` filter on `RepresentationManager._query_documents_recent()` and `._query_documents_most_derived()` allowed soft-deleted documents to leak into the deriver's working representation (#456) +- `CleanupStaleItemsCompletedEvent` emitted spuriously when no queue item was actually deleted (#454) +- Empty JSON file uploads caused unhandled errors; now returns normalized error responses (#434) +- Memory leak: `_observation_locks` switched to `WeakValueDictionary` to prevent unbounded growth (#419) +- SQL injection in `dependencies.py`: parameterized `set_config` calls to prevent injection via request context (#419) +- NUL byte crashes: string inputs (message content, queries, peer cards) now stripped at schema level (#419) +- Filter recursion depth capped at 5 to prevent stack overflow (#419) +- Dedup-skipped observations now correctly reflected in created counts (#477) +- External vector store support for message search — routes queries through configured external vector store with oversampling and + deduplication to handle chunked embeddings (#479) +- Dialectic agent no longer holds a DB connection during LLM calls — embeddings are pre-computed before tool execution, DB sessions isolated in `extract_preferences`, `query_documents` no longer accepts a DB session parameter (#477) + ## [3.0.3] - 2026-02-25 ### Added @@ -454,7 +535,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - `/list` endpoints to not require a request body -- `metamessage_type` to `label` with backwards compatability +- `metamessage_type` to `label` with backwards compatibility - Database Provisioning to rely on alembic - Database Session Manager to explicitly rollback transactions before closing the connection @@ -628,7 +709,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Authentication Middleware now implemented using built-in FastAPI Security module - Get by name routes for users and collections now include "name" in slug -- Python SDK moved to separate [respository](https://github.com/plastic-labs/honcho-python) +- Python SDK moved to separate [repository](https://github.com/plastic-labs/honcho-python) ### Fixed @@ -699,7 +780,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - session_data is now metadata -- session_data is a JSON field used python `dict` for compatability +- session_data is a JSON field used python `dict` for compatibility ## [0.0.2] — 2024-02-01 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e405212..9817dd1e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ Before you start contributing, please: 1. **Set up your development environment** - Follow the [Local Development guide](./README.md#local-development) in the README to get Honcho running locally. -2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/plasticlabs) to discuss your changes, get help, or ask questions. +2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions. 3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to. @@ -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 @@ -164,7 +164,7 @@ When reporting bugs or requesting features: ## Questions and Support -- **General questions** - Join our [Discord](http://discord.gg/plasticlabs) +- **General questions** - Join our [Discord](http://discord.gg/honcho) - **Bug reports** - Use GitHub issues - **Feature requests** - Use GitHub issues with the feature request template - **Security issues** - Please email us privately rather than opening a public issue diff --git a/Dockerfile b/Dockerfile index 30a8d09b..18f23a85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,13 +32,16 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # Place executables in the environment at the front of the path ENV PATH="/app/.venv/bin:$PATH" +ENV HOME=/app +ENV UV_CACHE_DIR=/tmp/uv-cache # Create non-root user and set ownership -RUN addgroup --system app && adduser --system --group app && chown -R app:app /app +RUN addgroup --system app && adduser --system --group app && mkdir -p /tmp/uv-cache && chown -R app:app /app /tmp/uv-cache 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/ @@ -48,7 +51,4 @@ 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 ["fastapi", "run", "--host", "0.0.0.0", "src/main.py"] diff --git a/README.md b/README.md index 1be34c29..9a7cf541 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ --- -![Static Badge](https://img.shields.io/badge/Version-3.0.3-blue) +![Static Badge](https://img.shields.io/badge/Version-3.0.6-blue) [![PyPI version](https://img.shields.io/pypi/v/honcho-ai.svg)](https://pypi.org/project/honcho-ai/) [![NPM version](https://img.shields.io/npm/v/@honcho-ai/sdk.svg)](https://npmjs.org/package/@honcho-ai/sdk) -[![Discord](https://img.shields.io/discord/1016845111637839922?style=flat&logo=discord&logoColor=23ffffff&label=Plastic%20Labs&labelColor=235865F2)](https://discord.gg/plasticlabs) +[![Discord](https://img.shields.io/discord/1016845111637839922?style=flat&logo=discord&logoColor=23ffffff&label=Plastic%20Labs&labelColor=235865F2)](https://discord.gg/honcho) Honcho is an open source memory library with a managed service for building stateful agents. Use it with any model, framework, or architecture. It enables agents to build @@ -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,10 @@ 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) ``` > Note that the `DB_CONNECTION_URI` must have the prefix `postgresql+psycopg` to @@ -420,16 +419,17 @@ Then modify the values as needed. The TOML file is organized into sections: All configuration values can be overridden using environment variables. The environment variable names follow this pattern: -- `{SECTION}_{KEY}` for nested settings +- `{SECTION}_{KEY}` for top-level section settings +- Use `__` inside `{KEY}` for nested settings - Just `{KEY}` for app-level settings Examples: - `DB_CONNECTION_URI` - Database connection string - `AUTH_JWT_SECRET` - JWT secret key -- `DIALECTIC_LEVELS__low__MODEL` - Model for low reasoning level -- `DERIVER_PROVIDER` - Provider for background deriver -- `SUMMARY_PROVIDER` - Summary generation provider +- `DERIVER_MODEL_CONFIG__TRANSPORT` - Transport for the background deriver +- `SUMMARY_MODEL_CONFIG__MODEL` - Summary model override +- `DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL` - Model for low reasoning level - `LOG_LEVEL` - Application log level - `METRICS_ENABLED` - Enable Prometheus metrics - `TELEMETRY_ENABLED` - Enable CloudEvents telemetry @@ -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..236f3402 100644 --- a/config.toml.example +++ b/config.toml.example @@ -11,8 +11,6 @@ GET_CONTEXT_MAX_TOKENS = 100000 MAX_FILE_SIZE = 5242880 # 5MB MAX_MESSAGE_SIZE = 25000 # Characters EMBED_MESSAGES = true -MAX_EMBEDDING_TOKENS = 8192 -MAX_EMBEDDING_TOKENS_PER_REQUEST = 300000 # LANGFUSE_HOST = "https://api.langfuse.com" # LANGFUSE_PUBLIC_KEY = "your-public-key-here" # COLLECT_METRICS_LOCAL = false @@ -51,21 +49,32 @@ PROFILES_SAMPLE_RATE = 0.1 # LLM settings [llm] DEFAULT_MAX_TOKENS = 2500 -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 +# API Keys for LLM providers (set the ones you need) +# Supported transports: openai, anthropic, gemini +# Base URLs are set per-module via model_config.overrides.base_url +# Built-in text-generation defaults use openai / gpt-5.4-mini. +# Embeddings default to openai / text-embedding-3-small. +OPENAI_API_KEY = "your-api-key-here" # 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" -# Separate vLLM endpoint (for local models) -# VLLM_API_KEY = "your-api-key" -# VLLM_BASE_URL = "your-base-url" +# Embedding settings +[embedding] +VECTOR_DIMENSIONS = 1536 +MAX_INPUT_TOKENS = 8192 +MAX_TOKENS_PER_REQUEST = 300000 + +[embedding.model_config] +transport = "openai" +model = "text-embedding-3-small" + +# Optional module-level endpoint overrides +# [embedding.model_config.overrides] +# base_url = "https://embedding-proxy.internal.example/v1" +# api_key_env = "EMBEDDING_CUSTOM_API_KEY" # Deriver settings [deriver] @@ -74,20 +83,38 @@ WORKERS = 1 POLLING_SLEEP_INTERVAL_SECONDS = 1.0 STALE_SESSION_TIMEOUT_MINUTES = 5 # QUEUE_ERROR_RETENTION_SECONDS = 2592000 # 30 days -PROVIDER = "google" -MODEL = "gemini-2.5-flash-lite" -# TEMPERATURE = 0.0 -# BACKUP_PROVIDER = "anthropic" -# BACKUP_MODEL = "claude-haiku-4-5" DEDUPLICATE = true -MAX_OUTPUT_TOKENS = 4096 -THINKING_BUDGET_TOKENS = 1024 LOG_OBSERVATIONS = false MAX_INPUT_TOKENS = 23000 WORKING_REPRESENTATION_MAX_OBSERVATIONS = 100 REPRESENTATION_BATCH_MAX_TOKENS = 1024 FLUSH_ENABLED = false # Bypass batch token threshold, process work immediately +[deriver.model_config] +transport = "openai" +model = "gpt-5.4-mini" +# temperature = 0.0 +# thinking_effort = "minimal" +# thinking_budget_tokens = 1024 +# max_output_tokens = 4096 + +# Optional module-level endpoint overrides +# transport = "openai" +# model = "my-local-model" +# [deriver.model_config.overrides] +# base_url = "https://llm.internal.example/v1" +# api_key_env = "DERIVER_CUSTOM_API_KEY" + +# Optional fallback model +# [deriver.model_config.fallback] +# transport = "anthropic" +# model = "claude-haiku-4-5" +# [deriver.model_config.fallback.overrides] +# base_url = "https://llm-backup.internal.example/v1" +# api_key_env = "DERIVER_CUSTOM_BACKUP_API_KEY" +# [deriver.model_config.overrides.provider_params] +# verbosity = "low" + # Peer card settings [peer_card] ENABLED = true @@ -102,55 +129,64 @@ SESSION_HISTORY_MAX_TOKENS = 4096 # Per-level settings for reasoning levels # MAX_OUTPUT_TOKENS is optional per level; if not set, uses global MAX_OUTPUT_TOKENS [dialectic.levels.minimal] -PROVIDER = "google" -MODEL = "gemini-2.5-flash-lite" -THINKING_BUDGET_TOKENS = 0 MAX_TOOL_ITERATIONS = 1 MAX_OUTPUT_TOKENS = 250 +TOOL_CHOICE = "any" + +[dialectic.levels.minimal.model_config] +transport = "openai" +model = "gpt-5.4-mini" [dialectic.levels.low] -PROVIDER = "google" -MODEL = "gemini-2.5-flash-lite" -THINKING_BUDGET_TOKENS = 0 MAX_TOOL_ITERATIONS = 5 -# MAX_OUTPUT_TOKENS = 8192 # Optional: override global default +TOOL_CHOICE = "any" + +[dialectic.levels.low.model_config] +transport = "openai" +model = "gpt-5.4-mini" [dialectic.levels.medium] -PROVIDER = "anthropic" -MODEL = "claude-haiku-4-5" -THINKING_BUDGET_TOKENS = 1024 MAX_TOOL_ITERATIONS = 2 -# MAX_OUTPUT_TOKENS = 8192 # Optional: override global default + +[dialectic.levels.medium.model_config] +transport = "openai" +model = "gpt-5.4-mini" [dialectic.levels.high] -PROVIDER = "anthropic" -MODEL = "claude-haiku-4-5" -THINKING_BUDGET_TOKENS = 1024 MAX_TOOL_ITERATIONS = 4 -# MAX_OUTPUT_TOKENS = 8192 # Optional: override global default + +[dialectic.levels.high.model_config] +transport = "openai" +model = "gpt-5.4-mini" [dialectic.levels.max] -PROVIDER = "anthropic" -MODEL = "claude-haiku-4-5" -THINKING_BUDGET_TOKENS = 2048 MAX_TOOL_ITERATIONS = 10 -# MAX_OUTPUT_TOKENS = 8192 # Optional: override global default -# Backup provider example (optional, must set both or neither): -# BACKUP_PROVIDER = "google" -# BACKUP_MODEL = "gemini-2.5-pro" + +[dialectic.levels.max.model_config] +transport = "openai" +model = "gpt-5.4-mini" + +# [dialectic.levels.max.model_config.fallback] +# transport = "gemini" +# model = "gemini-2.5-pro" # Summary settings [summary] ENABLED = true MESSAGES_PER_SHORT_SUMMARY = 20 MESSAGES_PER_LONG_SUMMARY = 60 -PROVIDER = "google" -MODEL = "gemini-2.5-flash" MAX_TOKENS_SHORT = 1000 MAX_TOKENS_LONG = 4000 -THINKING_BUDGET_TOKENS = 512 -# BACKUP_PROVIDER = "google" -# BACKUP_MODEL = "gemini-2.5-flash" + +[summary.model_config] +transport = "openai" +model = "gpt-5.4-mini" +# thinking_effort = "minimal" +# thinking_budget_tokens = 1024 + +# [summary.model_config.fallback] +# transport = "anthropic" +# model = "claude-haiku-4-5" # Dream settings [dream] @@ -159,18 +195,16 @@ DOCUMENT_THRESHOLD = 50 IDLE_TIMEOUT_MINUTES = 60 MIN_HOURS_BETWEEN_DREAMS = 8 ENABLED_TYPES = ["omni"] -PROVIDER = "anthropic" -MODEL = "claude-sonnet-4-20250514" -MAX_OUTPUT_TOKENS = 16384 -THINKING_BUDGET_TOKENS = 8192 MAX_TOOL_ITERATIONS = 20 HISTORY_TOKEN_LIMIT = 16384 -# BACKUP_PROVIDER = "google" -# BACKUP_MODEL = "gemini-2.5-flash" -# Specialist models (use same provider as main model) -DEDUCTION_MODEL = "claude-haiku-4-5" -INDUCTION_MODEL = "claude-haiku-4-5" +[dream.deduction_model_config] +transport = "openai" +model = "gpt-5.4-mini" + +[dream.induction_model_config] +transport = "openai" +model = "gpt-5.4-mini" # Surprisal-based sampling subsystem [dream.surprisal] @@ -220,6 +254,8 @@ TYPE = "pgvector" # Migration flag: set to true when migration from pgvector is complete MIGRATED = false NAMESPACE = "honcho" +# This should match embedding.vector_dimensions. pgvector and dual-write mode +# currently still require 1536 until a schema migration lands. DIMENSIONS = 1536 # TURBOPUFFER_API_KEY = "your-turbopuffer-api-key" # TURBOPUFFER_REGION = "us-east-1" diff --git a/docker-compose.yml.example b/docker-compose.yml.example index 39b5d8c3..d59f1cee 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -1,75 +1,124 @@ +# 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 + entrypoint: ["sh", "docker/entrypoint.sh"] depends_on: database: condition: service_healthy + 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: - - .env + - path: .env + required: false + restart: unless-stopped + deriver: build: context: . dockerfile: Dockerfile - entrypoint: ["uv", "run", "python", "-m", "src.deriver"] + entrypoint: ["/app/.venv/bin/python", "-m", "src.deriver"] depends_on: database: condition: service_healthy - volumes: - - .:/app - - venv:/app/.venv + redis: + condition: service_healthy + # -- 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: - - .env + - 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=honcho - - POSTGRES_USER=testuser - - POSTGRES_PASSWORD=testpwd - - POSTGRES_HOST_AUTH_METHOD=trust + - POSTGRES_DB=postgres + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres - PGDATA=/var/lib/postgresql/data/pgdata volumes: - ./database/init.sql:/docker-entrypoint-initdb.d/init.sql - pgdata:/var/lib/postgresql/data/ healthcheck: - test: ["CMD-SHELL", "pg_isready -U testuser -d honcho"] + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] 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 - 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 + + # -- 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: + redis-data: + # -- Development: uncomment if using source mounts -- + # venv: + # prometheus-data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 00000000..bc8e3f37 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "Running database migrations..." +/app/.venv/bin/python scripts/provision_db.py + +echo "Starting API server..." +exec /app/.venv/bin/fastapi run --host 0.0.0.0 src/main.py diff --git a/docker/grafana-datasource.yml b/docker/grafana-datasource.yml new file mode 100644 index 00000000..bb009bb2 --- /dev/null +++ b/docker/grafana-datasource.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false diff --git a/docker/prometheus.yml b/docker/prometheus.yml new file mode 100644 index 00000000..18041391 --- /dev/null +++ b/docker/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: honcho-api + static_configs: + - targets: ["api:8000"] + - job_name: honcho-deriver + static_configs: + - targets: ["deriver:9090"] diff --git a/docs/bun.lock b/docs/bun.lock index 182c58c0..b30aeb65 100644 --- a/docs/bun.lock +++ b/docs/bun.lock @@ -6,7 +6,6 @@ "name": "honcho-docs", "dependencies": { "@mintlify/scraping": "^4.0.467", - "honcho-ai": "^0.0.11", }, "devDependencies": { "mint": "^4.2.204", @@ -298,8 +297,6 @@ "@types/node": ["@types/node@18.19.120", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-WtCGHFXnVI8WHLxDAt5TbnCM4eSE+nI0QN2NJtwzcgMhht2eNz6V9evJrk+lwC8bCY8OWV5Ym8Jz7ZEyGnKnMA=="], - "@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="], - "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -326,8 +323,6 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], - "aggregate-error": ["aggregate-error@4.0.1", "", { "dependencies": { "clean-stack": "^4.0.0", "indent-string": "^5.0.0" } }, "sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w=="], "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], @@ -682,12 +677,10 @@ "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], - "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], + "form-data-encoder": ["form-data-encoder@2.1.4", "", {}, "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw=="], "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], - "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], @@ -788,8 +781,6 @@ "hex-rgb": ["hex-rgb@5.0.0", "", {}, "sha512-NQO+lgVUCtHxZ792FodgW0zflK+ozS9X9dwGp9XvvmPlH7pyxd588cn24TD3rmPm/N0AIRXF10Otah8yKqGw4w=="], - "honcho-ai": ["honcho-ai@0.0.11", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-SUl/PnMldTCz8G4S8faP00M2iFd9qWDkI5U8w0FQ7OC6SgKzTf1nJ/j3gyzctzR2IZ6LrOz/2d5OwO4f/PCMww=="], - "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], @@ -802,8 +793,6 @@ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], - "ico-endec": ["ico-endec@0.1.6", "", {}, "sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ=="], "iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], @@ -1136,9 +1125,7 @@ "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], - "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], - - "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -1590,8 +1577,6 @@ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], - "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], - "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -1646,8 +1631,6 @@ "@asyncapi/parser/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], - "@asyncapi/parser/node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="], - "@inquirer/checkbox/@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="], "@inquirer/checkbox/@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], @@ -1714,6 +1697,8 @@ "@stoplight/better-ajv-errors/leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], + "@stoplight/json-ref-readers/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "@stoplight/json-ref-readers/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "@stoplight/spectral-core/@stoplight/types": ["@stoplight/types@13.6.0", "", { "dependencies": { "@types/json-schema": "^7.0.4", "utility-types": "^3.10.0" } }, "sha512-dzyuzvUjv3m1wmhPfq82lCVYGcXG0xUYgqnWfCq3PCVR4BKFhjdkHrnJ+jIDoMKvXb05AZP/ObQF6+NpDo29IQ=="], @@ -1724,6 +1709,8 @@ "@stoplight/spectral-parsers/@stoplight/types": ["@stoplight/types@14.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.4", "utility-types": "^3.10.0" } }, "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g=="], + "@stoplight/spectral-runtime/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "@stoplight/yaml/@stoplight/types": ["@stoplight/types@14.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.4", "utility-types": "^3.10.0" } }, "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g=="], "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -1772,8 +1759,6 @@ "glob/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], - "got/form-data-encoder": ["form-data-encoder@2.1.4", "", {}, "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw=="], - "gray-matter/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], "ink/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -1940,10 +1925,6 @@ "inquirer/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], - "is-online/got/form-data-encoder": ["form-data-encoder@2.1.4", "", {}, "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw=="], - - "public-ip/got/form-data-encoder": ["form-data-encoder@2.1.4", "", {}, "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw=="], - "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "widest-line/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="], diff --git a/docs/changelog/compatibility-guide.mdx b/docs/changelog/compatibility-guide.mdx index 61c45202..17fd55c9 100644 --- a/docs/changelog/compatibility-guide.mdx +++ b/docs/changelog/compatibility-guide.mdx @@ -10,14 +10,14 @@ This guide helps you match the right SDK version to your Honcho API version. New - **Latest:** v2.0.1 + **Latest:** v2.1.1 ```bash npm install @honcho-ai/sdk ``` - **Latest:** v2.0.1 + **Latest:** v2.1.1 ```bash pip install honcho-ai @@ -30,7 +30,10 @@ This guide helps you match the right SDK version to your Honcho API version. New | Honcho API Version | TypeScript SDK | Python SDK | |-------------------|---------------|------------| -| v3.0.3 (Current) | v2.0.1 | v2.0.1 | +| v3.0.6 (Current) | v2.1.1 | v2.1.1 | +| v3.0.5 | v2.1.0 | v2.1.0 | +| v3.0.4 | v2.1.0 | v2.1.0 | +| v3.0.3 | v2.1.0 | v2.1.0 | | v3.0.2 | v2.0.0+ | v2.0.0+ | | v3.0.1 | v2.0.0+ | v2.0.0+ | | v3.0.0 | v2.0.0+ | v2.0.0+ | diff --git a/docs/changelog/introduction.mdx b/docs/changelog/introduction.mdx index 38ba79e3..d2d31331 100644 --- a/docs/changelog/introduction.mdx +++ b/docs/changelog/introduction.mdx @@ -27,7 +27,54 @@ Welcome to the Honcho changelog! This section documents all notable changes to t ### Honcho API and SDK Changelogs - + + ### Changed + + - Tightened transaction scopes across search, agent tools, queue manager, and webhook delivery to minimize DB connection hold time during external operations (#525) + - Search operations refactored to two-phase pattern — external work (embeddings, LLM calls) completes before opening a transaction (#525) + - Agent tool executor performs external operations before acquiring DB sessions (#525) + - Queue manager transaction scope reduced to only the critical section (#525) + - Webhook delivery no longer holds a DB session parameter (#525) + + ### Fixed + + - Session leakage in non-session-scoped dialectic chat calls (#526) + + ### Added + + - Health check endpoint (`/health`) for container orchestration and load balancer probes (#510) + + + + ### Fixed + + - explicit rollback on all transactions to force connection closed + + + + ### Added + + - JSONB metadata validation enforces 100 key limit and max depth of 5 (#419) + + ### Changed + + - Schemas refactored from single `schemas.py` into `schemas/api.py`, `schemas/configuration.py`, and `schemas/internal.py` with backwards-compatible re-exports (#419) + + ### Fixed + + - Missing `deleted_at` filter on `RepresentationManager._query_documents_recent()` and `._query_documents_most_derived()` allowed soft-deleted documents to leak into the deriver's working representation (#456) + - `CleanupStaleItemsCompletedEvent` emitted spuriously when no queue item was actually deleted (#454) + - Empty JSON file uploads caused unhandled errors; now returns normalized error responses (#434) + - Memory leak: `_observation_locks` switched to `WeakValueDictionary` to prevent unbounded growth (#419) + - SQL injection in `dependencies.py`: parameterized `set_config` calls to prevent injection via request context (#419) + - NUL byte crashes: string inputs (message content, queries, peer cards) now stripped at schema level (#419) + - Filter recursion depth capped at 5 to prevent stack overflow (#419) + - Dedup-skipped observations now correctly reflected in created counts (#477) + - External vector store support for message search — routes queries through configured external vector store with oversampling and + deduplication to handle chunked embeddings (#479) + - Dialectic agent no longer holds a DB connection during LLM calls — embeddings are pre-computed before tool execution, DB sessions isolated in `extract_preferences`, `query_documents` no longer accepts a DB session parameter (#477) + + ### Added - Consolidated session context into a single DB session with 40/60 token budget allocation between summary and messages @@ -477,7 +524,7 @@ Welcome to the Honcho changelog! This section documents all notable changes to t ### Changed - `/list` endpoints to not require a request body - - `metamessage_type` to `label` with backwards compatability + - `metamessage_type` to `label` with backwards compatibility - Database Provisioning to rely on alembic - Database Session Manager to explicitly rollback transactions before closing the connection @@ -511,7 +558,35 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [Python SDK](https://pypi.org/project/honcho-ai/) - + + ### Fixed + + - Broadened HTTP retry logic to cover `httpx.NetworkError` and `httpx.RemoteProtocolError` in addition to `httpx.TimeoutException` and `httpx.ConnectError`, improving resilience against transient network failures + + + ### Added + + - `created_at` property on `Peer` and `Session` objects + - `is_active` property on `Session` objects + - `get_message(message_id)` method on `Session` (sync and async) to fetch a single message by ID + - `page`, `size`, and `reverse` pagination parameters on all list methods + + ### Changed + + - **Breaking**: `peer()` and `session()` now always make a get-or-create API call — no more lazy initialization + - Response configuration models now tolerate unknown fields from newer servers for forward compatibility + + ### Fixed + + - Sync and async `Session.get_metadata()`, `get_configuration()`, and `refresh()` now refresh cached `created_at` and `is_active` values along with metadata and configuration + - `honcho.__version__` now derives from package metadata, with a source-checkout fallback, so it stays aligned with released package versions + + + ### Changed + + - All input models now reject unknown fields via strict Pydantic validation (`extra="forbid"`). Previously, misspelled or extraneous fields were silently ignored. Now a `ValidationError` is raised with the unrecognized field name. + + ### Added - `set_peer_card` method @@ -625,7 +700,59 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [TypeScript SDK](https://www.npmjs.com/package/@honcho-ai/sdk) - + + ### Fixed + + - Broadened fetch error retry logic to catch all `TypeError` network failures (connection resets, DNS errors, etc.) instead of only those with `'fetch'` in the message, improving resilience across runtimes (Node, Bun, browsers) + + + ### Added + + - `createdAt` property on `Peer` and `Session` wrapper objects + - `isActive` property on `Session` wrapper objects + - `getMessage(messageId)` method on `Session` to fetch a single message by ID + - `Peer.representation()`, `Session.representation()`, and `Session.context()` now accept `Message` objects for `searchQuery` + - `page`, `size`, and `reverse` pagination controls on all list methods + + ### Changed + + - **Breaking**: `searchQuery` removed from top-level `context()` options — use `representationOptions.searchQuery` instead: + ```typescript + // Before (v2.0.x) + await session.context({ searchQuery: "..." }); + // After (v2.1.0) + await session.context({ representationOptions: { searchQuery: "..." } }); + ``` + - List methods (`peers()`, `sessions()`, `messages()`, `workspaces()`) support both the new options object and the legacy raw-filter form + - Representation search options now accept strings and content-like objects, including `Message` instances, while rejecting whitespace-only or invalid runtime inputs + - **Breaking**: `peer()` and `session()` now always make a get-or-create API call — no more lazy initialization. If you relied on constructing SDK objects without triggering a network request, note that every `peer()` and `session()` call now hits the API: + ```typescript + // Before (v2.0.x) — no API call + const session = honcho.session("my-session"); + // After (v2.1.0) — makes a get-or-create API call + const session = await honcho.session("my-session"); + ``` + - Response configuration models now tolerate unknown fields from newer servers for forward compatibility + - Moved `@types/node` from `dependencies` to `devDependencies` + + ### Fixed + + - `uploadFile()` now rejects unsupported top-level binary/object inputs and only validates inputs the serializer can actually upload + - `uploadFile()` now serializes message configuration using API field names, matching `addMessages()` + - Session fetch methods now refresh cached `createdAt` and `isActive` values alongside metadata and configuration + + + ### Changed + + - Client constructor now rejects unknown options via `.strict()` Zod validation. Previously, misspelled options (e.g., `baseUrl` instead of `baseURL`) were silently ignored, causing the SDK to fall back to defaults. Now a `ZodError` is thrown with the unrecognized key name. + - All input schemas now use `.strict()` validation to reject unknown fields. + - `FileUploadSchema.configuration` now uses `MessageConfigurationSchema` instead of open record type. + + ### Fixed + + - README example used `baseUrl` instead of `baseURL`. + + ### Added - `setPeerCard` method @@ -745,4 +872,4 @@ Welcome to the Honcho changelog! This section documents all notable changes to t If you encounter issues using the Honcho API or its SDKs: 1. Open an issue on [GitHub](https://github.com/plastic-labs/honcho/issues) -2. Join our [Discord community](http://discord.gg/plasticlabs) for support +2. Join our [Discord community](http://discord.gg/honcho) for support diff --git a/docs/docs.json b/docs/docs.json index 39b675be..85a6bb1f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -19,14 +19,21 @@ }, "favicon": "/favicon.svg", "contextual": { - "options": ["copy", "view", "chatgpt", "claude"] + "options": [ + "copy", + "view", + "chatgpt", + "claude" + ] }, "navigation": { "versions": [ { - "version": "v3.0.3", + "version": "v3.0.5", "api": { - "openapi": ["v3/openapi.json"] + "openapi": [ + "v3/openapi.json" + ] }, "tabs": [ { @@ -77,7 +84,8 @@ "group": "Reference", "pages": [ "v3/documentation/reference/platform", - "v3/documentation/reference/sdk" + "v3/documentation/reference/sdk", + "v3/documentation/reference/cli" ] } ] @@ -87,17 +95,23 @@ "groups": [ { "group": "Overview", - "pages": ["v3/guides/overview"] + "pages": [ + "v3/guides/overview" + ] }, { "group": "Integrations", "pages": [ "v3/guides/integrations/claude-code", + "v3/guides/integrations/opencode", "v3/guides/integrations/crewai", "v3/guides/integrations/langgraph", "v3/guides/integrations/mcp", "v3/guides/integrations/n8n", "v3/guides/integrations/openclaw", + "v3/guides/integrations/hermes", + "v3/guides/integrations/zo-computer", + "v3/guides/integrations/paperclip", "v3/guides/integrations/sillytavern" ] }, @@ -105,20 +119,24 @@ "group": "Tutorials", "pages": [ "v3/guides/discord", + "v3/guides/granola", "v3/guides/telegram", - "v3/guides/integrations/reachy-mini" + "v3/guides/integrations/reachy-mini", + "v3/guides/gmail" ] }, { "group": "Community Integrations", "pages": [ "v3/guides/community/agent0", - "v3/guides/community/hermes" + "v3/guides/community/pi-honcho-memory" ] }, { "group": "Migrations", - "pages": ["v3/guides/migrations/mem0"] + "pages": [ + "v3/guides/migrations/mem0" + ] } ] }, @@ -129,7 +147,8 @@ "group": "Self-Hosting", "pages": [ "v3/contributing/self-hosting", - "v3/contributing/configuration" + "v3/contributing/configuration", + "v3/contributing/troubleshooting" ] }, { @@ -146,7 +165,9 @@ "groups": [ { "group": "API Documentation", - "pages": ["v3/api-reference/introduction"] + "pages": [ + "v3/api-reference/introduction" + ] }, { "group": "workspaces", @@ -224,7 +245,9 @@ }, { "group": "miscellaneous", - "pages": ["v3/api-reference/endpoint/keys/create-key"] + "pages": [ + "v3/api-reference/endpoint/keys/create-key" + ] } ] }, @@ -245,7 +268,9 @@ { "version": "v2.5.1", "api": { - "openapi": ["v2/openapi.json"] + "openapi": [ + "v2/openapi.json" + ] }, "tabs": [ { @@ -292,24 +317,31 @@ "groups": [ { "group": "Getting Started", - "pages": ["v2/guides/overview"] + "pages": [ + "v2/guides/overview" + ] }, { "group": "Migrations", - "pages": ["v2/migrations/from-mem0"] + "pages": [ + "v2/migrations/from-mem0" + ] }, { "group": "Integrations", "pages": [ "v2/integrations/crewai", "v2/integrations/langgraph", - "v2/integrations/mcp", - "v2/integrations/n8n" + "v2/integrations/mcp" ] }, { "group": "Application Interfaces", - "pages": ["v2/guides/discord", "v2/guides/telegram"] + "pages": [ + "v2/guides/discord", + "v2/guides/n8n", + "v2/guides/telegram" + ] } ] }, @@ -318,7 +350,9 @@ "groups": [ { "group": "API Documentation", - "pages": ["v2/api-reference/introduction"] + "pages": [ + "v2/api-reference/introduction" + ] }, { "group": "workspaces", @@ -422,7 +456,9 @@ { "version": "v1.1.0", "api": { - "openapi": ["openapi.json"] + "openapi": [ + "openapi.json" + ] }, "tabs": [ { @@ -452,15 +488,23 @@ "groups": [ { "group": "Getting Started", - "pages": ["v1/guides/overview", "v1/guides/streaming-response"] + "pages": [ + "v1/guides/overview", + "v1/guides/streaming-response" + ] }, { "group": "Application Interfaces", - "pages": ["v1/guides/discord", "v1/guides/honcho-mcp"] + "pages": [ + "v1/guides/discord", + "v1/guides/honcho-mcp" + ] }, { "group": "Personal Memory", - "pages": ["v1/guides/dialectic-endpoint"] + "pages": [ + "v1/guides/dialectic-endpoint" + ] } ] }, @@ -469,7 +513,9 @@ "groups": [ { "group": "API Documentation", - "pages": ["v1/api-reference/introduction"] + "pages": [ + "v1/api-reference/introduction" + ] }, { "group": "apps", @@ -517,7 +563,9 @@ }, { "group": "keys", - "pages": ["v1/api-reference/endpoint/keys/create-key"] + "pages": [ + "v1/api-reference/endpoint/keys/create-key" + ] }, { "group": "metamessages", diff --git a/docs/images/overview/honcho-overview-dark.svg b/docs/images/overview/honcho-overview-dark.svg index 753eb87b..f7e85b46 100644 --- a/docs/images/overview/honcho-overview-dark.svg +++ b/docs/images/overview/honcho-overview-dark.svg @@ -1,4 +1,4 @@ Store Messages inHonchoIngestionHoncho Store Messages in LongTerm MemoryLong Term Memory StoreSends Message toReasoning EngineHoncho APIHonchoReasoningEngineMessagesPeer CardsSummariesRepresentationsSends Derived Insights to LongTerm MemoryMessagesHoncho APIGet ContextChatSearchWorking RepresentationsRetreival + @font-face { font-family: Excalifont; src: url(data:font/woff2;base64,d09GMgABAAAAABfkAA4AAAAAKKgAABePAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhYbiFocNAZgAIEcEQgKvgCtewtKAAE2AiQDgRAEIAWDGAcgG2QfsxERbBxAsxlKkf3XCfSYo52qAGrCQkIg49QsakXW4hEnN55sp+suzDGX9JEuBYgR02iqX6Gl4fm19X/OrekEJokcSoaeSHIYGKLS6l0RbYxKQtf8PlbWz0ahjQ1WbTU939+zfef+xDNoAsmTpglKg0AiaDJKofl0Wi/930nvdwOWlYsXbCvJAvqA0I4PKiy36KVWagcEtkMCU5IFJw4uBo5gpHm//wFURla/f/97hBdsgH8Lihp/r4/LcwxOclwAKZboD65Pv9bqifVDo3ZCFq2a6p3/t38x+bt4ckv/wCP1SqOSziQh1sTbQrvplEbqDKERG/Va4hDPygy6DH8/ME0hbQAQAHQAGAKKshr88hRUOkmwwzEtB0gvMzqbgfTWWdMEpI+Kya1ACgPKjVEA79dOAjKxP+XCIICihEwO1+eD/sbGI3SMg4bxrcb32GHOP6PnYL7d+O8bBz6U+7Zt+hxQLSjf5qnlvEdJ2O38LtebvjV8a5Ftb61cdkjmOH9+4INUsz5b8qrecKI7m3rJvtmtEMYZvCh2+Y5RqO30zEJ8KAyOgoaOgYmFjVu4AovJyHlRUlEXZrWKUkQ9oiVDYGAhACgCYdEYHwefqqmBCSQ0XGx0PMsB2YMK0PgACnQcOpCtNlPRQJe1DMr0lAOm2Di4lQoiqEMszXsQAFCx2O9Jy1mr4cgtLR8AqXgUeBbhT1AVimo52TPAEIUZM/XvwrcL1tQ9ADs10bc1/QrkKFGj4MsUoozpieqbs6GYFIysnNK55MhToUaDdlPN8J6dlWdml6Z1F69Sp0mn6V/Vfe6Rhx44tG/Pjk0b1o2NDIMA+uF/optqJLVDFdRNLS61HUnJV98tkHJPWuRA5Isn3vQKegQpZu8ud2lVfHWssi0rsyOSH53ADODnmqXyNG2aWeIdyIo2FKXai5wtS4UbQoUb9goXSDdQhfuua/ZRlwqp6y5e5K/7mr8U7gjLG74VtvHWycFB5YDpLKGRhb1BqUmWfbCyLBSqewmlFFICyLMEkFyh3cAH5Dtvmk2zDOPz6iIkAowFIPRbt3h5r5gtTNp+o6F7CdZ5hDODGhQ+cjO+to0QQ6h3LkRsmAJ3EvfJGAjmpNCTVqZ7x5DaoA8E48vhBZ5NdSuB1qEVubuU3AgLVe3JWTbCCBXBtxzpHoQJzP/Me3mR+H2/T3bIDjjyvOmbb7al8QeYq+Vecf/lQhqFQV7KmceCkg1AK0UExAus3G5zaaqsp/YqPMvwFvam2a+Yn0513mhAf9+eQmLE7oMCROwAIFKZrmdSNrxptgV3S0GVbGsnHmIB/hVBlKn0Mg9nUpekASBgIqrk6ChKJugWYSHqVYSno2Bf2D35iTSuW+8k68btN4/e9XgS78QH5LdDOr2Sv7Uln5FhmhVFfBiaPYtOkwRSnUrIp/DnkgC5PQbfPlZYlAsAQgKQb+P60FytqBuYRXyEMV/DzpqqOj3V2bSy+cAGRHQMm+reae10lmGOpRxn09q6ZJeImLGJ4i93BKArQQcQQkBrcvl8O8ORNGWZae5EI5qL0ln2wMpqeob5CGdQ5xnnFOrJeonEMSGuq7lVF1SJ5hKlI2bIsm/YFMqZl9CEQuqDuOmCm0SbzGn1+OhbtwDiXszTx5XVK9KWpOk4DC3hqe4ZIrssAPG7gDYS3bPkg/KEJpAmlCqKX71FUpRurKIVYSviYFTNvy1A/+beItu4i64LUBA4gUsYSK+H5ugNLjelGUkzw9xjMjAohbBlQy7xvTaLRthLIKQDSHMY2EZO+gjSfmtPddGatcUUDc3wgaH5tEb1RM/T680D6oPxszuiOnn6qEoA8YPS8iVKiA8OvsmkxbOs1M6ddD43RdtF3U01kM5KccLQvVsu2nt9WCz8odAMI8wbhw3vdDq1Rnfrv2hbc5u0SwNFkOawO+MfoEl5dVEqbPNRhvFdM/q1NoDHNQ82qJ6LI+sK9VP+24N4IoAygElegxB6yvzAsLs+IN8LpWhPDUd4Ok/nd9/yctl4UA4LVGh7uBCej17wpjofZfuE/Lr70O88RwNfGdfdVGNxDGIBRFUJdnOY885MR9z86RNfZhI5BXbWnDJDxbRJwM7NXzLMlA9pfJphL0ksT7apPG7RgEBCQOpW3YmmZYx2H1Yic/KnFDJH4P5a8QJiKjZ7i85sT2WzavSgxi/9h57tGgGEFGN+RWYiM8L5/d1TRekoSqBzc/PKvcpa+8YN5JkOxybXvRo1bNi5eLs0boooEeOHJX+O1BlKF9DwXNlhoNmfIyRuxcUCYyELHbwkH7zA8QPda9GEAphAGuHt69C9LH6acSRCKFwUnjzr18OTgrNUfv7lv40v9i8N/udO3G1UnDfaS09eeSAb2e5gPghKPnBjhnqIbYPJXxftTgCD95LcatOBYj87abkLDIWFqcqQW7mBK+0KX4uWcCTVyKK7MDjbtJu+KCnGtWA9hwEgmlu/v/iwKp5H6hzqHpZGUq24CluVb0jDGTz2SyvizVhz36/7ykr+ztYHVvZOZigHN6tiLlRYolOaeJ6ePTCjbTx3pz9X+vEXpemw3LNGaXrdo0HNw9k9mdfJEbnR23vF7Jp8YV/3XrOBrbmIvZ3ujOcIIIRCK0y+vrX8MyMXenIJoq4oCXRuXxxXybdufaMoF/X4t74A42eJ5l5mqhM6HC/J1aK8Vpw/zzjnlOoWxM9h52ekZbo3f5ifzbN2SkjyDUl/QWnPjPZrlFPwB+lLtAO+jBeGqDwkoRSwQQ27G6xAu+MrBubvS4vv8yz+hfWq6wVj8FuUkt/ezUmtDA6a/acLsSFyNI2IUqc6KW9UeBSpvKf+t1fINK9l1+w8TwVKa2traG6rF66qa7KyKs9FujdvU9iiyrIQLsi7XYMC0pqUWaiqP64xgIZ/9klhVN6vJZeUkTQ3ra35Oy2/290Okxilmlb12KoaaqQPQwC8CDAeAXGKmKpqDAiQb20bRUWZ9VAaau5rSoqSX39ZjSR2ykx1RtAzAsXuKHxzE/PRCI+e4ck6NXLXsrtKAEjcOvntbcy2mYP32/u6l7+WDwXIJD5pe1IWWBiyISoiLogB6YOdEvG7Cl2HyVeEMMZQIadwZDnX/u8H4fdyOf9GR7w9KV7WbKEIBqX+N94VqeHJmzvN4LndRvLNvRcevHO6Gig26GtuirTJ3NwYkOP8ZYMGxCcZduh+ebG3vqkZWTyBlCp2q80jfLyjW1vJqe5ZPEJpgRIkRIh6L3wizUu8uL677px7RCZxDIgPd/P53NnNo2C9/e32ArvbM4tKNBuiGLgTlDdPzZbwC9N3sDpqDAyaw1p2mjUaWzP9ilByb4Lct8K+RBOLAzD/CaMap4axDMb9394lk2/dNEUsZLHUKnA/0ZDdR+FqKFWuquw8H2G+pfv03sFYWb5EE6p7F6RVljDXPZ3TBA5mbNCx86e6c6TlMjR8fLnIh6/3TIfIaH2TQyOnEQQDSDF/wDf5KMJR5d4FXqN6mSDMJLfQpx2AwNVdt0XqG6bKX+AfcGmycIgep1V+gesepXCe5ovVOubS4iOegWjlkjiKHRDQrwoQ2KK1xnZ+AOwqi9NQfhPmanH/AJC/QPx/F22o6mY7kpWHXsRd2FsMnTVpLukeHJTI3HIrOKzxbBRtlgXjPOIc58//6Bm82iS+QgToiKuEEL/kVw3EWCTj9OvJTvqonLrBA9dZeoSb+TT6BBFxvrr6IPuQmue6Vpr365TdP+8IsZr/4BfDhbCtvY9/AZAaWGpvAkKkUrqZiXB7hxdrIQ0efqaLTOc4cXIOb8sX+4lfiHaFqevWqWpdKMgL/UNpvqcraN8OzGA9TFjkP3rW5NkhaChaYyo0yuu6FAQylY0hE0rr763z/OZ8non8T1pv5rABvHbKWp+zrGSUQy1+8/5Xa+GmDFeUsVW+FqBuYh3KY9o8MyTTs+anioFT+QP5XHf19QGkz9XhHhwkcM6UPwvrqDaEKiJftlM+g0CdJnL5DcTel9H2/m5W9Pgye7I8NrdNbThXdsf+6Oqd8ivjRp9qznKWZprt7ayd1NqkCK7NQ8KzwlPyqrqLWuUky6SL0JgECzETz9ma4kvJvgh20fn/eYp9xIpEUgHZTCYVeMoix3GDxZfyCds+Knh56NTayye6pbu0kzZs3PQtnhwzsidc29+G2UT7zMJT/9SQ6tprA4LRUrmF1UftGzIPf1M3acw9F7s1RZQp07rVZxc4sOsVHQbEx0Wwq6cQeAaaw7r+ImnmwdUrWaPlXxTsnd+SBTwBFSM/cLzSDQE98iIwpXlSurVS3+Kua5ra6DiRkKUhOizP+NS9GQhp8sU/Fwf2HpJaKkswhyHbwoleKmh1qkwxw6AMq8KN4LTG+2I71PUs8ZDW5KA5ayyKeVROg68zhez/B+jLXUhxadFypwmfqfnaRuWTCcP3Xcn1GsFCvD5I7JrOVhUn5YfRCqAbGUJ5+9i/Spo7yUyKXekNLZBxlks2CcAfofI5qEMUVtTg0sRUS4RHRyCK5LXJYCaTYpUFZPImDyhAgSRmdm+dJCfkEOecldXheMokW6iTmZxT6VHkqKCKeWkCwM5t798RvDsn/PvVj8TOB+4bddqBDwQPlUagbDMbhRa4UPG6nWwgMORzVzODA9dcjj5C8l9aMdbJx3WoDhdU+mwAgeB3NqWb2V9D7nVgoW+dOk1h9HRBSnhQaTCZKIMvN4bQeHsZKQfe5P8cUCLnUQpaX2mUsJDI2+Ve5p4DOLLauRS0KRR86Rid+CrF9Zv+CK9icwgpdzIgBaDzLSJrWWtXvso4KSwOUli5MlfCbpUNnFd+gd6MKBcs2DBjd0aqOpsssBSbeWsvTdBIN+w9/4/M1miTnqCdR3zyijYS+cJ2IcA6e3ez91nPUAwQ9C8kNcXsFGK4PR7U8kpCoIlamCJbmfYMyfY/N0Ovj5YyTnu2eYhtRAuvUrMqyFYqGEwFebbfJ74QV7eZNCsixWnxIqBgEMmpvQjo7+F2cSYMvwxPFS0CNGDLSjjJ0awJ24sZH0erHwG6tBKhysBsSL4Rfe9ZvcfVlWb7k6e88u9G5VW9QPH9v4zd2iNhcwLvBUcZubwf4vLplFZsP4KnRisUPMFP6NEK6hTyScR+AVpUfFaXx/geC6iIPF2birvULnyBujDEY4++I2cdG/zjVU8EbcyELyDyu5Nc9kTLZi6Eitr5ODvAWgMkSBeSoTp4pEjNDn3suzEcHvnZ1wBTKK9Wb8y2hnta/GYlsG5t7+tw6eAs9OyjZfPtoHPNn6ZP11d2G2j46m6kGLp49boTs6NOp8upxKdbU326VkcnZywq/cWY0FID+iXmyBqYgfOwL0KZxTqXhZ0J53Z4/7dwAlaC15+VARuzRcUkA16zCN3VzsbbqQ74OAULImxKHzLczG7f3U7fT+fhhgkD/oqUB74nUylb9qDieaoL7CtgA02btMzJ84ppwGqEJQ3eQb9WWixRf/zKbI6WuKJsivIjTu3HOlv2mbwVO3vF3GVfXVJz6tJ0U6kfXiTbf3/+mSZzEwOBkZAA4/+IBIzJ0WT/3tqhlX4Sh8ymd6hTYygT3/9qzqvMsszq8a3IwBbr9ClpK9599RW4TEwYxb4rGm9negSBMNB6Lrha1etgZZI4DVv0XxcXrRRliFRF4hXMTOwvgAdVHrINa6IOC41+5o4cUwnnFpxd40Cnk6JSsMi1+YPtWIMUFDOGpFk/VrHv/knYWlctFJb5vTCMKGtHk7UjQ+jVX2Kvrn/Z+4N/1AXEFL/vp4i/0uFAE94ZRRJPLwEHuOFfjJ34g8hKJFEyP5qZQX+c4p+EktAZJEInrM7gskuRhLMf3LKNUcClpTdpfNI1hoqwPLnMkOL9OprEb3knaW1l2Z3DI1H72k8rRb/lBSeuF+xG7JScmkjgl/uZprYxKStN/GPVUEdWsi+WVYAKBZHND75gHqxM+4i3YOSuuVOr4o93u4RZmZgpwKIufDUN7/rFSPVCsp9EruwQ8L+KqNsN+FN5tpjClq0GSUaFiHvBsuY3FQrGFlLSS7lYFrJyUnj84jgHNTq//reMGDvAaToaPu0Tq+ufH5tsKkt8vPNI7XnOXY3CxC/zIU/djh7s1gCZflumTwNzmqJ6673gF+AHAdn3elCniGV+cSGgKcpZz6DfpIl6VADFGGGROiSVixYhQubfATxcb+ot74Pgv28nnYf9lEyqsBW2uyOFQqWD75O/U54fCGO41g6w6tPxP7wU1AMLK3gcl3u9oSdipnoGjOXkcRaLv94HGF4ykouUT5J6vfS98p2gpe1aCFkVBpGwgEZwUHanyrrcqHt2gM4h6lDC9vvlya7hj8CbkcTyQqfX5eOjc61M0xgy1a+iWhV4FiJkAikJvxlsHVn8L9rkzyXPIHlFo83QRl2p+0SDI5lEg7ZIDfc4wAKeaOQBnCNmkKi0z6UI7dvgGUK5DzIPR+1URgjjHGm9E6JWxcm9jJkjYCbYV67ZgfwUJ6T3FpVFb/trIj/g7C/8mC5eYvbj2yfXpQyRC2De/7y2+tKnXc75r3W8NGQ2UkUJz7ZM52zP3B6Z9nYtci85j9aFhINJaZFly8AgQpVwa+u4xmLfEw6RiiArjTHRbl5oRsduy0UfJ229F56OUtjmD4rhxiBmDDQP3FDntqnHDMq9jaubBrztHm4W+imUvlLUg3tYYMMl519YQ2Ngwwmj/6bklJ7kkUjb8eSWOfJfatp1ilT/XKFTb6MZd9VBVU5osM+V85ycXgvZL4gGqS0fBprAts729rHc/+P42akHyaBWCt3FFMu8D1/wrAzS2ineOsc/9LnvWpNUY+6o7RgF6VaSGuOq12yetOFSgkzgdNhyTmh/paWyds5hzgSr1GuZFj7er6KLIlUMuHeXaP/yooVlfJwiXeHg0w/Z5yyh8rRLQaeQsTw2fv/j0UQm6kSFiAUYrbwbWn3Ea699/Qaa/Mu10VNDdqSbPFJjtK3yqoxoeV6sycYRJfpD+KBlqkPgXoRnfTg15a9b1xf99n7Q80N+jKl0nMTpUM6dC/j60SLt1U+0tFm+fEeu+fJtild/5d1H5lhePn1IX3e2Xrgv1A9tr9WGj/UbR/cfBQsqfrOx+0UlNpaemnlS+m9NlO4n16DxY+u0htl2bAr/xNDo/SxD+ayYdFeHYi4YoxOkOOOSF/NqxG4kadYMH8W+ge7oyEO9YmileRhE6K88VfgCO6oIRTTauDD3d5X+wxSviym18hR//TfHSShWvpepDAvy1WOJDPuPf3MLwAMSZ/jcF/oSievkNJHXAqCCSqBx/QIiULOkafHiszt+mvhbJc7btV16s+j6s2xiU78CG0pjkR7pjdMtOdaS5TH3jQc41UqzzkiQgCNxd+aWJqwhdWFmrUm/ruCHmtxHa0OE03LJWMdads/RhOwKA7prstsUa16/YY7gBpvjVli5kyCKBFr21xYlGqwCoUPPmaGovB75Z7D0j6GshetWFZhesamI8MpSs0d8Nww86FKs1yrppbErjyWuuXrbFquUVN2QFTUbK6gO0Xx/zm/z7UTQB7ScGWz/fubmR7fy/o7NrMMsCSuyZ4vlqvSZSR1R75A4bbu4L+l4REwcJUktpQIQ4co3+Wbe7TJWwi/k3tDQFyDv8esWEHD883qWj8/aXm3PjYM3IwYcI/6tOy7D5G/P4+xK1Ewl61oNIF8AeRINHTAaFjH0/5QoKCXvfDD6F7/zjD2SUivKFIDDlZCB7/qdohIBScgK4r/dGNF+xk4gxGNRbZQQE8SWT5dEzNomPB/+BYBp7oEYgFEmVlwRnpvSuIUYx6eADAJg8O5akkIouFHumc2uFkF8l4oQTH1FqEDdRRiNyiJcEg0Icl0ArKarUqFZg1ptWk0Wwq1GnSlfxip0yn1inbqYNrJQIVwoHWXx7OyxGdrVS5av6khEIPwX6gjvMQIsL0HBW3Z8i2x2GeIN8WRzJszI2s1goIGL1jsEfw4EIIugOyFqxyrN2HFGWbVOSZ4mdEYHzXSvwjjtekINqmswlUxVC4XO89UaDgA=); }Store Messages inHonchoIngestionHoncho Store Messages in LongTerm MemoryLong Term Memory StoreSends Message toReasoning EngineHoncho APIHonchoReasoningEngineMessagesPeer CardsSummariesRepresentationsSends Derived Insights to LongTerm MemoryMessagesHoncho APIGet ContextChatSearchWorking RepresentationsRetrieval diff --git a/docs/images/overview/honcho-overview-light.svg b/docs/images/overview/honcho-overview-light.svg index 704f7ec9..4eff7051 100644 --- a/docs/images/overview/honcho-overview-light.svg +++ b/docs/images/overview/honcho-overview-light.svg @@ -1,4 +1,4 @@ Store Messages inHonchoIngestionHoncho Store Messages in LongTerm MemoryLong Term Memory StoreSends Message toReasoning EngineHoncho APIHonchoReasoningEngineMessagesPeer CardsSummariesRepresentationsSends Derived Insights to LongTerm MemoryMessagesHoncho APIGet ContextChatSearchWorking RepresentationsRetreival + @font-face { font-family: Excalifont; src: url(data:font/woff2;base64,d09GMgABAAAAABfkAA4AAAAAKKgAABePAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhYbiFocNAZgAIEcEQgKvgCtewtKAAE2AiQDgRAEIAWDGAcgG2QfsxERbBxAsxlKkf3XCfSYo52qAGrCQkIg49QsakXW4hEnN55sp+suzDGX9JEuBYgR02iqX6Gl4fm19X/OrekEJokcSoaeSHIYGKLS6l0RbYxKQtf8PlbWz0ahjQ1WbTU939+zfef+xDNoAsmTpglKg0AiaDJKofl0Wi/930nvdwOWlYsXbCvJAvqA0I4PKiy36KVWagcEtkMCU5IFJw4uBo5gpHm//wFURla/f/97hBdsgH8Lihp/r4/LcwxOclwAKZboD65Pv9bqifVDo3ZCFq2a6p3/t38x+bt4ckv/wCP1SqOSziQh1sTbQrvplEbqDKERG/Va4hDPygy6DH8/ME0hbQAQAHQAGAKKshr88hRUOkmwwzEtB0gvMzqbgfTWWdMEpI+Kya1ACgPKjVEA79dOAjKxP+XCIICihEwO1+eD/sbGI3SMg4bxrcb32GHOP6PnYL7d+O8bBz6U+7Zt+hxQLSjf5qnlvEdJ2O38LtebvjV8a5Ftb61cdkjmOH9+4INUsz5b8qrecKI7m3rJvtmtEMYZvCh2+Y5RqO30zEJ8KAyOgoaOgYmFjVu4AovJyHlRUlEXZrWKUkQ9oiVDYGAhACgCYdEYHwefqqmBCSQ0XGx0PMsB2YMK0PgACnQcOpCtNlPRQJe1DMr0lAOm2Di4lQoiqEMszXsQAFCx2O9Jy1mr4cgtLR8AqXgUeBbhT1AVimo52TPAEIUZM/XvwrcL1tQ9ADs10bc1/QrkKFGj4MsUoozpieqbs6GYFIysnNK55MhToUaDdlPN8J6dlWdml6Z1F69Sp0mn6V/Vfe6Rhx44tG/Pjk0b1o2NDIMA+uF/optqJLVDFdRNLS61HUnJV98tkHJPWuRA5Isn3vQKegQpZu8ud2lVfHWssi0rsyOSH53ADODnmqXyNG2aWeIdyIo2FKXai5wtS4UbQoUb9goXSDdQhfuua/ZRlwqp6y5e5K/7mr8U7gjLG74VtvHWycFB5YDpLKGRhb1BqUmWfbCyLBSqewmlFFICyLMEkFyh3cAH5Dtvmk2zDOPz6iIkAowFIPRbt3h5r5gtTNp+o6F7CdZ5hDODGhQ+cjO+to0QQ6h3LkRsmAJ3EvfJGAjmpNCTVqZ7x5DaoA8E48vhBZ5NdSuB1qEVubuU3AgLVe3JWTbCCBXBtxzpHoQJzP/Me3mR+H2/T3bIDjjyvOmbb7al8QeYq+Vecf/lQhqFQV7KmceCkg1AK0UExAus3G5zaaqsp/YqPMvwFvam2a+Yn0513mhAf9+eQmLE7oMCROwAIFKZrmdSNrxptgV3S0GVbGsnHmIB/hVBlKn0Mg9nUpekASBgIqrk6ChKJugWYSHqVYSno2Bf2D35iTSuW+8k68btN4/e9XgS78QH5LdDOr2Sv7Uln5FhmhVFfBiaPYtOkwRSnUrIp/DnkgC5PQbfPlZYlAsAQgKQb+P60FytqBuYRXyEMV/DzpqqOj3V2bSy+cAGRHQMm+reae10lmGOpRxn09q6ZJeImLGJ4i93BKArQQcQQkBrcvl8O8ORNGWZae5EI5qL0ln2wMpqeob5CGdQ5xnnFOrJeonEMSGuq7lVF1SJ5hKlI2bIsm/YFMqZl9CEQuqDuOmCm0SbzGn1+OhbtwDiXszTx5XVK9KWpOk4DC3hqe4ZIrssAPG7gDYS3bPkg/KEJpAmlCqKX71FUpRurKIVYSviYFTNvy1A/+beItu4i64LUBA4gUsYSK+H5ugNLjelGUkzw9xjMjAohbBlQy7xvTaLRthLIKQDSHMY2EZO+gjSfmtPddGatcUUDc3wgaH5tEb1RM/T680D6oPxszuiOnn6qEoA8YPS8iVKiA8OvsmkxbOs1M6ddD43RdtF3U01kM5KccLQvVsu2nt9WCz8odAMI8wbhw3vdDq1Rnfrv2hbc5u0SwNFkOawO+MfoEl5dVEqbPNRhvFdM/q1NoDHNQ82qJ6LI+sK9VP+24N4IoAygElegxB6yvzAsLs+IN8LpWhPDUd4Ok/nd9/yctl4UA4LVGh7uBCej17wpjofZfuE/Lr70O88RwNfGdfdVGNxDGIBRFUJdnOY885MR9z86RNfZhI5BXbWnDJDxbRJwM7NXzLMlA9pfJphL0ksT7apPG7RgEBCQOpW3YmmZYx2H1Yic/KnFDJH4P5a8QJiKjZ7i85sT2WzavSgxi/9h57tGgGEFGN+RWYiM8L5/d1TRekoSqBzc/PKvcpa+8YN5JkOxybXvRo1bNi5eLs0boooEeOHJX+O1BlKF9DwXNlhoNmfIyRuxcUCYyELHbwkH7zA8QPda9GEAphAGuHt69C9LH6acSRCKFwUnjzr18OTgrNUfv7lv40v9i8N/udO3G1UnDfaS09eeSAb2e5gPghKPnBjhnqIbYPJXxftTgCD95LcatOBYj87abkLDIWFqcqQW7mBK+0KX4uWcCTVyKK7MDjbtJu+KCnGtWA9hwEgmlu/v/iwKp5H6hzqHpZGUq24CluVb0jDGTz2SyvizVhz36/7ykr+ztYHVvZOZigHN6tiLlRYolOaeJ6ePTCjbTx3pz9X+vEXpemw3LNGaXrdo0HNw9k9mdfJEbnR23vF7Jp8YV/3XrOBrbmIvZ3ujOcIIIRCK0y+vrX8MyMXenIJoq4oCXRuXxxXybdufaMoF/X4t74A42eJ5l5mqhM6HC/J1aK8Vpw/zzjnlOoWxM9h52ekZbo3f5ifzbN2SkjyDUl/QWnPjPZrlFPwB+lLtAO+jBeGqDwkoRSwQQ27G6xAu+MrBubvS4vv8yz+hfWq6wVj8FuUkt/ezUmtDA6a/acLsSFyNI2IUqc6KW9UeBSpvKf+t1fINK9l1+w8TwVKa2traG6rF66qa7KyKs9FujdvU9iiyrIQLsi7XYMC0pqUWaiqP64xgIZ/9klhVN6vJZeUkTQ3ra35Oy2/290Okxilmlb12KoaaqQPQwC8CDAeAXGKmKpqDAiQb20bRUWZ9VAaau5rSoqSX39ZjSR2ykx1RtAzAsXuKHxzE/PRCI+e4ck6NXLXsrtKAEjcOvntbcy2mYP32/u6l7+WDwXIJD5pe1IWWBiyISoiLogB6YOdEvG7Cl2HyVeEMMZQIadwZDnX/u8H4fdyOf9GR7w9KV7WbKEIBqX+N94VqeHJmzvN4LndRvLNvRcevHO6Gig26GtuirTJ3NwYkOP8ZYMGxCcZduh+ebG3vqkZWTyBlCp2q80jfLyjW1vJqe5ZPEJpgRIkRIh6L3wizUu8uL677px7RCZxDIgPd/P53NnNo2C9/e32ArvbM4tKNBuiGLgTlDdPzZbwC9N3sDpqDAyaw1p2mjUaWzP9ilByb4Lct8K+RBOLAzD/CaMap4axDMb9394lk2/dNEUsZLHUKnA/0ZDdR+FqKFWuquw8H2G+pfv03sFYWb5EE6p7F6RVljDXPZ3TBA5mbNCx86e6c6TlMjR8fLnIh6/3TIfIaH2TQyOnEQQDSDF/wDf5KMJR5d4FXqN6mSDMJLfQpx2AwNVdt0XqG6bKX+AfcGmycIgep1V+gesepXCe5ovVOubS4iOegWjlkjiKHRDQrwoQ2KK1xnZ+AOwqi9NQfhPmanH/AJC/QPx/F22o6mY7kpWHXsRd2FsMnTVpLukeHJTI3HIrOKzxbBRtlgXjPOIc58//6Bm82iS+QgToiKuEEL/kVw3EWCTj9OvJTvqonLrBA9dZeoSb+TT6BBFxvrr6IPuQmue6Vpr365TdP+8IsZr/4BfDhbCtvY9/AZAaWGpvAkKkUrqZiXB7hxdrIQ0efqaLTOc4cXIOb8sX+4lfiHaFqevWqWpdKMgL/UNpvqcraN8OzGA9TFjkP3rW5NkhaChaYyo0yuu6FAQylY0hE0rr763z/OZ8non8T1pv5rABvHbKWp+zrGSUQy1+8/5Xa+GmDFeUsVW+FqBuYh3KY9o8MyTTs+anioFT+QP5XHf19QGkz9XhHhwkcM6UPwvrqDaEKiJftlM+g0CdJnL5DcTel9H2/m5W9Pgye7I8NrdNbThXdsf+6Oqd8ivjRp9qznKWZprt7ayd1NqkCK7NQ8KzwlPyqrqLWuUky6SL0JgECzETz9ma4kvJvgh20fn/eYp9xIpEUgHZTCYVeMoix3GDxZfyCds+Knh56NTayye6pbu0kzZs3PQtnhwzsidc29+G2UT7zMJT/9SQ6tprA4LRUrmF1UftGzIPf1M3acw9F7s1RZQp07rVZxc4sOsVHQbEx0Wwq6cQeAaaw7r+ImnmwdUrWaPlXxTsnd+SBTwBFSM/cLzSDQE98iIwpXlSurVS3+Kua5ra6DiRkKUhOizP+NS9GQhp8sU/Fwf2HpJaKkswhyHbwoleKmh1qkwxw6AMq8KN4LTG+2I71PUs8ZDW5KA5ayyKeVROg68zhez/B+jLXUhxadFypwmfqfnaRuWTCcP3Xcn1GsFCvD5I7JrOVhUn5YfRCqAbGUJ5+9i/Spo7yUyKXekNLZBxlks2CcAfofI5qEMUVtTg0sRUS4RHRyCK5LXJYCaTYpUFZPImDyhAgSRmdm+dJCfkEOecldXheMokW6iTmZxT6VHkqKCKeWkCwM5t798RvDsn/PvVj8TOB+4bddqBDwQPlUagbDMbhRa4UPG6nWwgMORzVzODA9dcjj5C8l9aMdbJx3WoDhdU+mwAgeB3NqWb2V9D7nVgoW+dOk1h9HRBSnhQaTCZKIMvN4bQeHsZKQfe5P8cUCLnUQpaX2mUsJDI2+Ve5p4DOLLauRS0KRR86Rid+CrF9Zv+CK9icwgpdzIgBaDzLSJrWWtXvso4KSwOUli5MlfCbpUNnFd+gd6MKBcs2DBjd0aqOpsssBSbeWsvTdBIN+w9/4/M1miTnqCdR3zyijYS+cJ2IcA6e3ez91nPUAwQ9C8kNcXsFGK4PR7U8kpCoIlamCJbmfYMyfY/N0Ovj5YyTnu2eYhtRAuvUrMqyFYqGEwFebbfJ74QV7eZNCsixWnxIqBgEMmpvQjo7+F2cSYMvwxPFS0CNGDLSjjJ0awJ24sZH0erHwG6tBKhysBsSL4Rfe9ZvcfVlWb7k6e88u9G5VW9QPH9v4zd2iNhcwLvBUcZubwf4vLplFZsP4KnRisUPMFP6NEK6hTyScR+AVpUfFaXx/geC6iIPF2birvULnyBujDEY4++I2cdG/zjVU8EbcyELyDyu5Nc9kTLZi6Eitr5ODvAWgMkSBeSoTp4pEjNDn3suzEcHvnZ1wBTKK9Wb8y2hnta/GYlsG5t7+tw6eAs9OyjZfPtoHPNn6ZP11d2G2j46m6kGLp49boTs6NOp8upxKdbU326VkcnZywq/cWY0FID+iXmyBqYgfOwL0KZxTqXhZ0J53Z4/7dwAlaC15+VARuzRcUkA16zCN3VzsbbqQ74OAULImxKHzLczG7f3U7fT+fhhgkD/oqUB74nUylb9qDieaoL7CtgA02btMzJ84ppwGqEJQ3eQb9WWixRf/zKbI6WuKJsivIjTu3HOlv2mbwVO3vF3GVfXVJz6tJ0U6kfXiTbf3/+mSZzEwOBkZAA4/+IBIzJ0WT/3tqhlX4Sh8ymd6hTYygT3/9qzqvMsszq8a3IwBbr9ClpK9599RW4TEwYxb4rGm9negSBMNB6Lrha1etgZZI4DVv0XxcXrRRliFRF4hXMTOwvgAdVHrINa6IOC41+5o4cUwnnFpxd40Cnk6JSsMi1+YPtWIMUFDOGpFk/VrHv/knYWlctFJb5vTCMKGtHk7UjQ+jVX2Kvrn/Z+4N/1AXEFL/vp4i/0uFAE94ZRRJPLwEHuOFfjJ34g8hKJFEyP5qZQX+c4p+EktAZJEInrM7gskuRhLMf3LKNUcClpTdpfNI1hoqwPLnMkOL9OprEb3knaW1l2Z3DI1H72k8rRb/lBSeuF+xG7JScmkjgl/uZprYxKStN/GPVUEdWsi+WVYAKBZHND75gHqxM+4i3YOSuuVOr4o93u4RZmZgpwKIufDUN7/rFSPVCsp9EruwQ8L+KqNsN+FN5tpjClq0GSUaFiHvBsuY3FQrGFlLSS7lYFrJyUnj84jgHNTq//reMGDvAaToaPu0Tq+ufH5tsKkt8vPNI7XnOXY3CxC/zIU/djh7s1gCZflumTwNzmqJ6673gF+AHAdn3elCniGV+cSGgKcpZz6DfpIl6VADFGGGROiSVixYhQubfATxcb+ot74Pgv28nnYf9lEyqsBW2uyOFQqWD75O/U54fCGO41g6w6tPxP7wU1AMLK3gcl3u9oSdipnoGjOXkcRaLv94HGF4ykouUT5J6vfS98p2gpe1aCFkVBpGwgEZwUHanyrrcqHt2gM4h6lDC9vvlya7hj8CbkcTyQqfX5eOjc61M0xgy1a+iWhV4FiJkAikJvxlsHVn8L9rkzyXPIHlFo83QRl2p+0SDI5lEg7ZIDfc4wAKeaOQBnCNmkKi0z6UI7dvgGUK5DzIPR+1URgjjHGm9E6JWxcm9jJkjYCbYV67ZgfwUJ6T3FpVFb/trIj/g7C/8mC5eYvbj2yfXpQyRC2De/7y2+tKnXc75r3W8NGQ2UkUJz7ZM52zP3B6Z9nYtci85j9aFhINJaZFly8AgQpVwa+u4xmLfEw6RiiArjTHRbl5oRsduy0UfJ229F56OUtjmD4rhxiBmDDQP3FDntqnHDMq9jaubBrztHm4W+imUvlLUg3tYYMMl519YQ2Ngwwmj/6bklJ7kkUjb8eSWOfJfatp1ilT/XKFTb6MZd9VBVU5osM+V85ycXgvZL4gGqS0fBprAts729rHc/+P42akHyaBWCt3FFMu8D1/wrAzS2ineOsc/9LnvWpNUY+6o7RgF6VaSGuOq12yetOFSgkzgdNhyTmh/paWyds5hzgSr1GuZFj7er6KLIlUMuHeXaP/yooVlfJwiXeHg0w/Z5yyh8rRLQaeQsTw2fv/j0UQm6kSFiAUYrbwbWn3Ea699/Qaa/Mu10VNDdqSbPFJjtK3yqoxoeV6sycYRJfpD+KBlqkPgXoRnfTg15a9b1xf99n7Q80N+jKl0nMTpUM6dC/j60SLt1U+0tFm+fEeu+fJtild/5d1H5lhePn1IX3e2Xrgv1A9tr9WGj/UbR/cfBQsqfrOx+0UlNpaemnlS+m9NlO4n16DxY+u0htl2bAr/xNDo/SxD+ayYdFeHYi4YoxOkOOOSF/NqxG4kadYMH8W+ge7oyEO9YmileRhE6K88VfgCO6oIRTTauDD3d5X+wxSviym18hR//TfHSShWvpepDAvy1WOJDPuPf3MLwAMSZ/jcF/oSievkNJHXAqCCSqBx/QIiULOkafHiszt+mvhbJc7btV16s+j6s2xiU78CG0pjkR7pjdMtOdaS5TH3jQc41UqzzkiQgCNxd+aWJqwhdWFmrUm/ruCHmtxHa0OE03LJWMdads/RhOwKA7prstsUa16/YY7gBpvjVli5kyCKBFr21xYlGqwCoUPPmaGovB75Z7D0j6GshetWFZhesamI8MpSs0d8Nww86FKs1yrppbErjyWuuXrbFquUVN2QFTUbK6gO0Xx/zm/z7UTQB7ScGWz/fubmR7fy/o7NrMMsCSuyZ4vlqvSZSR1R75A4bbu4L+l4REwcJUktpQIQ4co3+Wbe7TJWwi/k3tDQFyDv8esWEHD883qWj8/aXm3PjYM3IwYcI/6tOy7D5G/P4+xK1Ewl61oNIF8AeRINHTAaFjH0/5QoKCXvfDD6F7/zjD2SUivKFIDDlZCB7/qdohIBScgK4r/dGNF+xk4gxGNRbZQQE8SWT5dEzNomPB/+BYBp7oEYgFEmVlwRnpvSuIUYx6eADAJg8O5akkIouFHumc2uFkF8l4oQTH1FqEDdRRiNyiJcEg0Icl0ArKarUqFZg1ptWk0Wwq1GnSlfxip0yn1inbqYNrJQIVwoHWXx7OyxGdrVS5av6khEIPwX6gjvMQIsL0HBW3Z8i2x2GeIN8WRzJszI2s1goIGL1jsEfw4EIIugOyFqxyrN2HFGWbVOSZ4mdEYHzXSvwjjtekINqmswlUxVC4XO89UaDgA=); }Store Messages inHonchoIngestionHoncho Store Messages in LongTerm MemoryLong Term Memory StoreSends Message toReasoning EngineHoncho APIHonchoReasoningEngineMessagesPeer CardsSummariesRepresentationsSends Derived Insights to LongTerm MemoryMessagesHoncho APIGet ContextChatSearchWorking RepresentationsRetrieval diff --git a/docs/package.json b/docs/package.json index 1e7259a0..e905a457 100644 --- a/docs/package.json +++ b/docs/package.json @@ -11,8 +11,7 @@ "author": "", "license": "ISC", "dependencies": { - "@mintlify/scraping": "^4.0.467", - "honcho-ai": "^0.0.11" + "@mintlify/scraping": "^4.0.467" }, "devDependencies": { "mint": "^4.2.204" diff --git a/docs/snippets/cli-commands.mdx b/docs/snippets/cli-commands.mdx new file mode 100644 index 00000000..4eb9d2d3 --- /dev/null +++ b/docs/snippets/cli-commands.mdx @@ -0,0 +1,547 @@ +{/* + GENERATED by honcho-cli/scripts/generate_cli_docs.py — do not edit. + Re-generate with: uv run --package honcho-cli python honcho-cli/scripts/generate_cli_docs.py + Source of truth: honcho-cli/src/honcho_cli/commands/ +*/} + +## honcho conclusion + +List, search, create, and delete peer conclusions (Honcho's memory atoms). + + + +Create a conclusion. + +```bash +honcho conclusion create +``` + + + + Observer peer ID. + + + Observed peer ID. + + + Session context. Short alias: `-s`. + + + +Delete a conclusion. + +```bash +honcho conclusion delete +``` + + + + Observer peer ID. + + + Observed peer ID. + + + Skip confirmation. Short alias: `-y`. + + + +List conclusions. + +```bash +honcho conclusion list +``` + + + Observer peer ID. + + + Observed peer ID. + + + Max results. + + + +Semantic search over conclusions. + +```bash +honcho conclusion search +``` + + + + Observer peer ID. + + + Observed peer ID. + + + Max results. + + + + +## honcho config + +Inspect CLI configuration. + +```bash +honcho config +``` + + +## honcho doctor + +Verify config and connectivity. Scope with -w / -p to check workspace, peer, and queue health. + +```bash +honcho doctor +``` + + +## honcho help + +Show help message. + +```bash +honcho help +``` + + +## honcho init + +Set API key and server URL in ~/.honcho/config.json. + +Press Enter to keep the current value or type a replacement. +Workspace / peer / session scoping is per-command via -w / -p / -s +or HONCHO_* env vars — never persisted. + +```bash +honcho init +``` + + + API key (admin JWT). + + + Honcho API URL (e.g. https://api.honcho.dev, http://localhost:8000). + + +## honcho message + +List, create, and get messages within a session. + + + +Create a message in a session. + +```bash +honcho message create +``` + + + + Peer ID of the message sender. Short alias: `-p`. + + + JSON metadata to associate with the message. + + + Session ID. Short alias: `-s`. + + + +Get a single message by ID. + +```bash +honcho message get +``` + + + + Session ID. Short alias: `-s`. + + + +List messages in a session. Scoped to a peer with -p. + +```bash +honcho message list [] +``` + + + + Number of recent messages. + + + Show oldest first (default is newest first). + + + Show only IDs, peer, token count, and created_at (no content). + + + Filter by peer ID. Short alias: `-p`. + + + + +## honcho peer + +List, create, chat with, search, and manage peers and their representations. + + + +Get raw peer card content. + +```bash +honcho peer card [] +``` + + + + Target peer for relationship card. + + + +Query the dialectic about a peer. + +```bash +honcho peer chat +``` + + + + Target peer for perspective. + + + Reasoning level: minimal, low, medium, high, max. Short alias: `-r`. + + + +Create or get a peer. + +```bash +honcho peer create +``` + + + + Whether Honcho will form a representation of this peer. Negate with `--no-observe-me`. + + + JSON metadata to associate with the peer. + + + +Get metadata for a peer. + +```bash +honcho peer get-metadata [] +``` + + + + +Inspect a peer: card, session count, recent conclusions. + +```bash +honcho peer inspect [] +``` + + + + +List all peers in the workspace. + +```bash +honcho peer list +``` + + + +Get the formatted representation for a peer. + +```bash +honcho peer representation [] +``` + + + + Target peer to get representation about. + + + Semantic search query to filter conclusions. + + + Maximum number of conclusions to include. + + + +Search a peer's messages. + +```bash +honcho peer search +``` + + + + Max results. + + + +Set metadata for a peer. + +```bash +honcho peer set-metadata +``` + + + + Peer ID (uses default if omitted). Short alias: `-p`. + + + + +## honcho session + +List, inspect, create, delete, and manage conversation sessions and their peers. + + + +Add peers to a session. + +```bash +honcho session add-peers +``` + + + + + +Get session context (what an agent would see). + +```bash +honcho session context [] +``` + + + + Token budget. + + + Include summary. Negate with `--no-summary`. + + + +Create or get a session. + +```bash +honcho session create +``` + + + + Comma-separated peer IDs to add to the session. + + + JSON metadata to associate with the session. + + + +Delete a session and all its data. Destructive — requires --yes or interactive confirm. + +```bash +honcho session delete [] +``` + + + + Skip confirmation. Short alias: `-y`. + + + +Get metadata for a session. + +```bash +honcho session get-metadata [] +``` + + + + +Inspect a session: peers, message count, summaries, config. + +```bash +honcho session inspect [] +``` + + + + +List sessions in the workspace. + +```bash +honcho session list +``` + + + Filter by peer. Short alias: `-p`. + + + +List peers in a session. + +```bash +honcho session peers [] +``` + + + + +Remove peers from a session. + +```bash +honcho session remove-peers +``` + + + + + +Get the representation of a peer within a session. + +```bash +honcho session representation [] +``` + + + + + Target peer (what peer_id knows about target). + + + Semantic search query to filter conclusions. + + + Maximum number of conclusions to include. + + + +Search messages in a session. + +```bash +honcho session search [] +``` + + + + + Max results. + + + +Set metadata for a session. + +```bash +honcho session set-metadata [] +``` + + + + JSON metadata to set (e.g. '\{"key": "value"\}'). Short alias: `-d`. + + + +Get session summaries (short + long). + +```bash +honcho session summaries [] +``` + + + + + +## honcho workspace + +List, create, inspect, delete, and search workspaces. + + + +Create or get a workspace. + +```bash +honcho workspace create +``` + + + + JSON metadata to associate with the workspace. + + + +Delete a workspace. Use --dry-run first to see what will be deleted. + +Requires --yes to skip confirmation, or will prompt interactively. +If sessions exist, requires --cascade to delete them first. + +```bash +honcho workspace delete +``` + + + + Skip confirmation prompt (for scripted/agent use). Short alias: `-y`. + + + Delete all sessions before deleting the workspace. + + + Show what would be deleted without deleting. + + + +Inspect a workspace: peers, sessions, config. + +```bash +honcho workspace inspect [] +``` + + + + +List all accessible workspaces. + +```bash +honcho workspace list +``` + + + +Get queue processing status. + +```bash +honcho workspace queue-status +``` + + + Filter by observer peer. + + + Filter by sender peer. + + + +Search messages across workspace. + +```bash +honcho workspace search +``` + + + + Max results. + + + diff --git a/docs/v1/contributing/guidelines.mdx b/docs/v1/contributing/guidelines.mdx index 70bff6e5..a5b64d19 100644 --- a/docs/v1/contributing/guidelines.mdx +++ b/docs/v1/contributing/guidelines.mdx @@ -11,7 +11,7 @@ indicate a feature or bug fix you are working on. Once you have finished your contribution make a PR , and it will be reviewed by a project manager. Feel free to join us in our -[discord](http://discord.gg/plasticlabs) to discuss your changes or get help. +[discord](http://discord.gg/honcho) to discuss your changes or get help. Your changes will undergo a period of testing and discussion before finally being entered into the `main` branch and being staged for release. For more diff --git a/docs/v1/guides/honcho-mcp.mdx b/docs/v1/guides/honcho-mcp.mdx index 7647b6cb..272dbea7 100644 --- a/docs/v1/guides/honcho-mcp.mdx +++ b/docs/v1/guides/honcho-mcp.mdx @@ -59,4 +59,4 @@ Finally, Claude needs instructions on how to use Honcho. The Desktop app doesn't Be sure to update the \ and \ variables in the instructions.txt file. -Claude should then query for insights before responding and write your messages to storage! If you come up with more creative ways to get Claude to manage its own memory with Honcho, feel free to [let us know](https://discord.gg/plasticlabs) or make a PR on this [repo](https://github.com/plastic-labs/honcho-mcp/tree/main)! +Claude should then query for insights before responding and write your messages to storage! If you come up with more creative ways to get Claude to manage its own memory with Honcho, feel free to [let us know](https://discord.gg/honcho) or make a PR on this [repo](https://github.com/plastic-labs/honcho-mcp/tree/main)! 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/guidelines.mdx b/docs/v2/contributing/guidelines.mdx index f064e51b..398a8b09 100644 --- a/docs/v2/contributing/guidelines.mdx +++ b/docs/v2/contributing/guidelines.mdx @@ -11,7 +11,7 @@ Before you start contributing, please: 1. **Set up your development environment** - Follow the [Local Development guide](https://github.com/plastic-labs/honcho/blob/main/CONTRIBUTING.md#local-development) in the Honcho repository to get Honcho running locally. -2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/plasticlabs) to discuss your changes, get help, or ask questions. +2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions. 3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to. @@ -160,7 +160,7 @@ When reporting bugs or requesting features: ## Questions and Support -- **General questions** - Join our [Discord](http://discord.gg/plasticlabs) +- **General questions** - Join our [Discord](http://discord.gg/honcho) - **Bug reports** - Use GitHub issues - **Feature requests** - Use GitHub issues with the feature request template - **Security issues** - Please email us privately rather than opening a public issue diff --git a/docs/v2/contributing/self-hosting.mdx b/docs/v2/contributing/self-hosting.mdx index 3e5e1e42..eda94e7f 100644 --- a/docs/v2/contributing/self-hosting.mdx +++ b/docs/v2/contributing/self-hosting.mdx @@ -59,7 +59,8 @@ 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/honcho +DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres + # Disable auth for local development AUTH_USE_AUTH=false @@ -134,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 @@ -169,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 @@ -279,7 +277,7 @@ const client = new Honcho({ - **Explore the API**: Check out the [API Reference](/v2/api-reference/introduction) - **Try the SDKs**: See our [guides](/v2/guides) for examples - **Configure Honcho**: Visit the [Configuration Guide](./configuration) for detailed settings -- **Join the community**: [Discord](https://discord.gg/plasticlabs) +- **Join the community**: [Discord](https://discord.gg/honcho) ## Troubleshooting @@ -310,7 +308,7 @@ const client = new Honcho({ ### Getting Help - **GitHub Issues**: [Report bugs](https://github.com/plastic-labs/honcho/issues) -- **Discord**: [Join our community](https://discord.gg/plasticlabs) +- **Discord**: [Join our community](https://discord.gg/honcho) - **Documentation**: Check the [Configuration Guide](./configuration) for detailed settings ## Production Considerations diff --git a/docs/v2/documentation/core-concepts/configuration.mdx b/docs/v2/documentation/core-concepts/configuration.mdx index 7edf4230..f0cad4ef 100644 --- a/docs/v2/documentation/core-concepts/configuration.mdx +++ b/docs/v2/documentation/core-concepts/configuration.mdx @@ -353,6 +353,20 @@ import { Honcho } from "@honcho-ai/sdk"; ``` +### Observation and Peer Join Order + +Reasoning tasks are scheduled at the time a message is created, based on which peers are in the session **at that moment**. Honcho does not retroactively schedule reasoning for peers that join later. + +This means: + +- If Peer C joins a session **after** messages from Peer A and Peer B have already been sent, Peer C will **not** receive reasoning tasks for those earlier messages—even if Peer C has `observe_others` enabled. +- Peer C will only begin observing new messages sent after they join the session. +- Similarly, if a peer leaves a session, they stop being included as an observer for any messages sent after their departure. + + +There is no retroactive reasoning. If your application needs an observer peer to reason about prior conversation history, add the peer to the session **before** messages are sent. Alternatively use the .chat() endpoint to include the conversation history in the agent's context, regardless of if they were reasoned against or not + + ## Full Configuration Schema Reference ### Workspace & Session Configuration diff --git a/docs/v2/documentation/introduction/overview.mdx b/docs/v2/documentation/introduction/overview.mdx index e94a85de..c83b1e5b 100644 --- a/docs/v2/documentation/introduction/overview.mdx +++ b/docs/v2/documentation/introduction/overview.mdx @@ -115,5 +115,5 @@ fundamental concepts ## Community & Support - **GitHub**: [plastic-labs/honcho](https://github.com/plastic-labs/honcho) -- **Discord**: [Join our community](http://discord.gg/plasticlabs) +- **Discord**: [Join our community](http://discord.gg/honcho) - **Issues**: Report bugs and request features on GitHub diff --git a/docs/v2/documentation/reference/guided-tutorial.mdx b/docs/v2/documentation/reference/guided-tutorial.mdx index a7cac2f5..fd5331f6 100644 --- a/docs/v2/documentation/reference/guided-tutorial.mdx +++ b/docs/v2/documentation/reference/guided-tutorial.mdx @@ -422,4 +422,4 @@ Congratulations! You've built a complete personal AI assistant with Honcho that - [SDK Reference](/v2/documentation/reference/sdk) - [API Reference](/v2/api-reference/introduction) - [More Examples](/v2/guides/overview) -- [Discord Community](http://discord.gg/plasticlabs) +- [Discord Community](http://discord.gg/honcho) diff --git a/docs/v2/documentation/reference/platform.mdx b/docs/v2/documentation/reference/platform.mdx index 522d714d..d599b951 100644 --- a/docs/v2/documentation/reference/platform.mdx +++ b/docs/v2/documentation/reference/platform.mdx @@ -206,7 +206,7 @@ Dive into our [API Reference](/v2/api-reference) to explore all available endpoi Get started with managed Honcho instances - + Connect with 1000+ developers building with Honcho diff --git a/docs/v2/integrations/n8n.mdx b/docs/v2/guides/n8n.mdx similarity index 100% rename from docs/v2/integrations/n8n.mdx rename to docs/v2/guides/n8n.mdx diff --git a/docs/v2/integrations/mcp.mdx b/docs/v2/integrations/mcp.mdx index 9c828580..8896da9f 100644 --- a/docs/v2/integrations/mcp.mdx +++ b/docs/v2/integrations/mcp.mdx @@ -70,4 +70,4 @@ You may customize your assistant name and/or workspace ID. Both are optional. 4. Finally, Claude needs instructions on how to use Honcho. The Desktop app doesn't allow you to add system prompts directly, but you can create a project and paste these [instructions](https://raw.githubusercontent.com/plastic-labs/honcho/refs/heads/main/mcp/instructions.md) into the "Project Instructions" field. -Claude should then query for insights before responding and write your messages to storage! If you come up with more creative ways to get Claude to manage its own memory with Honcho, feel free to [let us know](https://discord.gg/plasticlabs) or make a PR on this [repo](https://github.com/plastic-labs/honcho/tree/main/mcp)! +Claude should then query for insights before responding and write your messages to storage! If you come up with more creative ways to get Claude to manage its own memory with Honcho, feel free to [let us know](https://discord.gg/honcho) or make a PR on this [repo](https://github.com/plastic-labs/honcho/tree/main/mcp)! diff --git a/docs/v3/contributing/configuration.mdx b/docs/v3/contributing/configuration.mdx index 57b77e40..02916ab6 100644 --- a/docs/v3/contributing/configuration.mdx +++ b/docs/v3/contributing/configuration.mdx @@ -1,741 +1,539 @@ --- 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 top-level section settings (e.g., `DB_CONNECTION_URI` → `[db].CONNECTION_URI`) +- `{KEY}` for app-level settings (e.g., `LOG_LEVEL` → `[app].LOG_LEVEL`) +- Use `__` inside `{KEY}` for nested settings (e.g., `DIALECTIC_LEVELS__minimal__MODEL_CONFIG__TRANSPORT`, `DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL`) -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: either the built-in OpenAI defaults or one OpenAI-compatible endpoint/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 +| Transport value | What it connects to | API key env var | +|---|---|---| +| `openai` | OpenAI or any OpenAI-compatible endpoint (OpenRouter, Together, Fireworks, LiteLLM, vLLM, Ollama) | `LLM_OPENAI_API_KEY` | +| `anthropic` | Anthropic Claude (direct) | `LLM_ANTHROPIC_API_KEY` | +| `gemini` | Google Gemini (direct) | `LLM_GEMINI_API_KEY` | -### Example +For OpenAI-compatible proxies (OpenRouter, vLLM, Ollama, etc.), use `transport = "openai"` and set `MODEL_CONFIG__OVERRIDES__BASE_URL` on each feature to point at your endpoint. -If you have this in `config.toml`: +### Tiered Model Setup + +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 +LLM_OPENAI_API_KEY=sk-or-v1-... + +# All features route through OpenRouter via overrides.base_url +# (You can set this on each feature's MODEL_CONFIG) + +# Light tier — high throughput, cheap +DERIVER_MODEL_CONFIG__TRANSPORT=openai +DERIVER_MODEL_CONFIG__MODEL=google/gemini-2.5-flash-lite +DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1 +SUMMARY_MODEL_CONFIG__TRANSPORT=openai +SUMMARY_MODEL_CONFIG__MODEL=google/gemini-2.5-flash +DIALECTIC_LEVELS__minimal__MODEL_CONFIG__TRANSPORT=openai +DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL=google/gemini-2.5-flash-lite +DIALECTIC_LEVELS__low__MODEL_CONFIG__TRANSPORT=openai +DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL=google/gemini-2.5-flash-lite + +# Medium tier — better reasoning +DIALECTIC_LEVELS__medium__MODEL_CONFIG__TRANSPORT=openai +DIALECTIC_LEVELS__medium__MODEL_CONFIG__MODEL=anthropic/claude-haiku-4-5 +DIALECTIC_LEVELS__high__MODEL_CONFIG__TRANSPORT=openai +DIALECTIC_LEVELS__high__MODEL_CONFIG__MODEL=anthropic/claude-haiku-4-5 +DIALECTIC_LEVELS__max__MODEL_CONFIG__TRANSPORT=openai +DIALECTIC_LEVELS__max__MODEL_CONFIG__MODEL=anthropic/claude-haiku-4-5 + +# Heavy tier — best quality for complex tasks +DREAM_DEDUCTION_MODEL_CONFIG__TRANSPORT=openai +DREAM_DEDUCTION_MODEL_CONFIG__MODEL=anthropic/claude-haiku-4-5 +DREAM_INDUCTION_MODEL_CONFIG__TRANSPORT=openai +DREAM_INDUCTION_MODEL_CONFIG__MODEL=anthropic/claude-haiku-4-5 +``` + +### Direct Vendor Keys + +Instead of an OpenAI-compatible proxy, you can use vendor APIs directly. Each transport picks up its own `LLM_{TRANSPORT}_API_KEY`. + +If you keep the built-in defaults, only `LLM_OPENAI_API_KEY` is required: + +```bash +LLM_OPENAI_API_KEY=... + +# Built-in model defaults +# - deriver: openai / gpt-5.4-mini +# - dialectic (all levels): openai / gpt-5.4-mini +# - summary: openai / gpt-5.4-mini +# - dream specialists: openai / gpt-5.4-mini +# - embeddings: openai / text-embedding-3-small +``` + +To use Gemini or Anthropic directly, override the features you want to move: + +```bash +LLM_GEMINI_API_KEY=... +DERIVER_MODEL_CONFIG__TRANSPORT=gemini +DERIVER_MODEL_CONFIG__MODEL=gemini-2.5-flash + +LLM_ANTHROPIC_API_KEY=... +DREAM_DEDUCTION_MODEL_CONFIG__TRANSPORT=anthropic +DREAM_DEDUCTION_MODEL_CONFIG__MODEL=claude-haiku-4-5 +``` + +### Self-Hosted (vLLM / Ollama) + +Use `transport = "openai"` and set `MODEL_CONFIG__OVERRIDES__BASE_URL` on each feature: + +```bash +# vLLM +LLM_OPENAI_API_KEY=not-needed +DERIVER_MODEL_CONFIG__TRANSPORT=openai +DERIVER_MODEL_CONFIG__MODEL=your-model-name +DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=http://localhost:8000/v1 + +# Ollama +LLM_OPENAI_API_KEY=ollama +DERIVER_MODEL_CONFIG__TRANSPORT=openai +DERIVER_MODEL_CONFIG__MODEL=llama3.3:70b +DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=http://localhost:11434/v1 +``` + +Set `MODEL_CONFIG__TRANSPORT`, `MODEL_CONFIG__MODEL`, and `MODEL_CONFIG__OVERRIDES__BASE_URL` for each feature the same way. + +The same overrides are available in `config.toml`: ```toml -[db] -CONNECTION_URI = "postgresql://localhost/honcho_dev" -POOL_SIZE = 10 +[deriver.model_config] +transport = "openai" +model = "my-local-model" + +[deriver.model_config.overrides] +base_url = "http://localhost:8000/v1" +api_key_env = "DERIVER_LOCAL_API_KEY" ``` -You can override just the connection URI in production: +### Thinking Budget + +Built-in defaults do not set `MODEL_CONFIG__THINKING_BUDGET_TOKENS` or `MODEL_CONFIG__THINKING_EFFORT`. Add one only when your chosen model supports it. + +Use `MODEL_CONFIG__THINKING_EFFORT` for OpenAI reasoning models: ```bash -export DB_CONNECTION_URI="postgresql://prod-server/honcho_prod" +DERIVER_MODEL_CONFIG__THINKING_EFFORT=minimal +DIALECTIC_LEVELS__max__MODEL_CONFIG__THINKING_EFFORT=medium ``` -The application will use the production connection URI while keeping the pool size from config.toml. - -## 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: +Use `MODEL_CONFIG__THINKING_BUDGET_TOKENS` for Anthropic and Gemini models. Set it to `0` or omit it for providers that don't support extended thinking: ```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 - -# OpenAI-compatible endpoints -LLM_OPENAI_COMPATIBLE_API_KEY=your-api-key -LLM_OPENAI_COMPATIBLE_BASE_URL=https://your-openai-compatible-endpoint.com - -# vLLM endpoint (for local models) -LLM_VLLM_API_KEY=your-vllm-api-key -LLM_VLLM_BASE_URL=http://localhost:8000 +SUMMARY_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024 +DREAM_DEDUCTION_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024 ``` +### Provider-Specific Parameters + +Each model config supports an `overrides.provider_params` dict for passing arbitrary parameters to the underlying provider SDK. Use this for vendor-specific features that aren't part of the standard config: + +```toml +[deriver.model_config.overrides.provider_params] +# These are passed directly to the provider SDK +verbosity = "low" +``` + +### Changing Transport + +When changing a feature's `transport`, always specify `model` explicitly. Partial overrides that change transport without model will keep the previous model name, which may not be valid for the new provider. + ### General LLM Settings ```bash -# Default settings for all LLM calls LLM_DEFAULT_MAX_TOKENS=2500 -# Embedding provider (used when EMBED_MESSAGES=true) -LLM_EMBEDDING_PROVIDER=openai # Options: openai, gemini, openrouter - # Tool output limits (to prevent token explosion) LLM_MAX_TOOL_OUTPUT_CHARS=10000 # ~2500 tokens at 4 chars/token LLM_MAX_MESSAGE_CONTENT_CHARS=2000 # Max chars per message in tool results ``` +### Embedding Configuration + +Embeddings use their own nested model config, separate from the main text-generation LLM settings. + +```bash +# Embedding vector settings +EMBEDDING_VECTOR_DIMENSIONS=1536 +EMBEDDING_MAX_INPUT_TOKENS=8192 +EMBEDDING_MAX_TOKENS_PER_REQUEST=300000 + +# Embedding transport/model selection +EMBEDDING_MODEL_CONFIG__TRANSPORT=openai # openai, gemini +EMBEDDING_MODEL_CONFIG__MODEL=text-embedding-3-small + +# Optional endpoint overrides +EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=http://localhost:8000/v1 +EMBEDDING_MODEL_CONFIG__OVERRIDES__API_KEY_ENV=EMBEDDING_CUSTOM_API_KEY +``` + +Current constraint: +- `EMBEDDING_VECTOR_DIMENSIONS` can be changed for fully migrated external vector stores, but pgvector and dual-write mode still require `1536` until the schema migration lands. + ### 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 [dialectic.levels.minimal] -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.minimal.model_config] +transport = "openai" +model = "gpt-5.4-mini" [dialectic.levels.low] -PROVIDER = "google" -MODEL = "gemini-2.5-flash-lite" -THINKING_BUDGET_TOKENS = 0 MAX_TOOL_ITERATIONS = 5 TOOL_CHOICE = "any" +[dialectic.levels.low.model_config] +transport = "openai" +model = "gpt-5.4-mini" + [dialectic.levels.medium] -PROVIDER = "anthropic" -MODEL = "claude-haiku-4-5" -THINKING_BUDGET_TOKENS = 1024 MAX_TOOL_ITERATIONS = 2 +[dialectic.levels.medium.model_config] +transport = "openai" +model = "gpt-5.4-mini" + [dialectic.levels.high] -PROVIDER = "anthropic" -MODEL = "claude-haiku-4-5" -THINKING_BUDGET_TOKENS = 1024 MAX_TOOL_ITERATIONS = 4 +[dialectic.levels.high.model_config] +transport = "openai" +model = "gpt-5.4-mini" + [dialectic.levels.max] -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" + +[dialectic.levels.max.model_config] +transport = "openai" +model = "gpt-5.4-mini" ``` -**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 -DIALECTIC_LEVELS__minimal__THINKING_BUDGET_TOKENS=0 +DIALECTIC_LEVELS__minimal__MODEL_CONFIG__TRANSPORT=openai +DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL=gpt-5.4-mini DIALECTIC_LEVELS__minimal__MAX_TOOL_ITERATIONS=1 +DIALECTIC_LEVELS__minimal__MAX_OUTPUT_TOKENS=250 +DIALECTIC_LEVELS__minimal__TOOL_CHOICE=any ``` **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 -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) +# LLM settings +DERIVER_MODEL_CONFIG__TRANSPORT=openai +DERIVER_MODEL_CONFIG__MODEL=gpt-5.4-mini +DERIVER_MAX_INPUT_TOKENS=23000 +# DERIVER_MODEL_CONFIG__THINKING_EFFORT=minimal +# DERIVER_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024 +# DERIVER_MODEL_CONFIG__TEMPERATURE=0.7 # Optional temperature override -# Backup provider (optional, must set both or neither) -# DERIVER_BACKUP_PROVIDER=anthropic -# DERIVER_BACKUP_MODEL=claude-haiku-4-5 +# Backup model (optional) +# DERIVER_MODEL_CONFIG__FALLBACK__MODEL=claude-haiku-4-5 +# DERIVER_MODEL_CONFIG__FALLBACK__TRANSPORT=anthropic # 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_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_MODEL_CONFIG__TRANSPORT=openai +SUMMARY_MODEL_CONFIG__MODEL=gpt-5.4-mini +SUMMARY_MAX_TOKENS_SHORT=1000 +SUMMARY_MAX_TOKENS_LONG=4000 +# SUMMARY_MODEL_CONFIG__THINKING_EFFORT=minimal +# SUMMARY_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024 +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 - -# 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 -DREAM_PROVIDER=anthropic -DREAM_MODEL=claude-sonnet-4-20250514 -DREAM_MAX_OUTPUT_TOKENS=16384 -DREAM_THINKING_BUDGET_TOKENS=8192 +DREAM_DOCUMENT_THRESHOLD=50 +DREAM_IDLE_TIMEOUT_MINUTES=60 +DREAM_MIN_HOURS_BETWEEN_DREAMS=8 +DREAM_ENABLED_TYPES=["omni"] 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 +# Specialist model configs (each is independent) +DREAM_DEDUCTION_MODEL_CONFIG__TRANSPORT=openai +DREAM_DEDUCTION_MODEL_CONFIG__MODEL=gpt-5.4-mini +DREAM_INDUCTION_MODEL_CONFIG__TRANSPORT=openai +DREAM_INDUCTION_MODEL_CONFIG__MODEL=gpt-5.4-mini ``` **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 +EMBEDDING_MAX_INPUT_TOKENS=8192 +EMBEDDING_MAX_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 - -[deriver] -ENABLED = true -WORKERS = 1 -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 = false - -[telemetry] -ENABLED = false - -[vector_store] -TYPE = "pgvector" - -[sentry] -ENABLED = false -``` - -**Environment variables for development:** -```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" +URL = "redis://localhost:6379/0?suppress=true" DEFAULT_TTL_SECONDS = 300 [deriver] ENABLED = true -WORKERS = 4 -PROVIDER = "google" -MODEL = "gemini-2.5-flash-lite" +WORKERS = 1 + +[deriver.model_config] +transport = "openai" +model = "gpt-5.4-mini" [peer_card] ENABLED = true @@ -744,144 +542,98 @@ ENABLED = true MAX_OUTPUT_TOKENS = 8192 [dialectic.levels.minimal] -PROVIDER = "google" -MODEL = "gemini-2.5-flash-lite" -THINKING_BUDGET_TOKENS = 0 MAX_TOOL_ITERATIONS = 1 +MAX_OUTPUT_TOKENS = 250 +TOOL_CHOICE = "any" + +[dialectic.levels.minimal.model_config] +transport = "openai" +model = "gpt-5.4-mini" [dialectic.levels.low] -PROVIDER = "google" -MODEL = "gemini-2.5-flash-lite" -THINKING_BUDGET_TOKENS = 0 MAX_TOOL_ITERATIONS = 5 +TOOL_CHOICE = "any" + +[dialectic.levels.low.model_config] +transport = "openai" +model = "gpt-5.4-mini" [dialectic.levels.medium] -PROVIDER = "anthropic" -MODEL = "claude-haiku-4-5" -THINKING_BUDGET_TOKENS = 1024 MAX_TOOL_ITERATIONS = 2 +[dialectic.levels.medium.model_config] +transport = "openai" +model = "gpt-5.4-mini" + [dialectic.levels.high] -PROVIDER = "anthropic" -MODEL = "claude-haiku-4-5" -THINKING_BUDGET_TOKENS = 1024 MAX_TOOL_ITERATIONS = 4 +[dialectic.levels.high.model_config] +transport = "openai" +model = "gpt-5.4-mini" + [dialectic.levels.max] -PROVIDER = "anthropic" -MODEL = "claude-haiku-4-5" -THINKING_BUDGET_TOKENS = 2048 MAX_TOOL_ITERATIONS = 10 +[dialectic.levels.max.model_config] +transport = "openai" +model = "gpt-5.4-mini" + [summary] ENABLED = true -PROVIDER = "google" -MODEL = "gemini-2.5-flash" MAX_TOKENS_SHORT = 1000 MAX_TOKENS_LONG = 4000 +[summary.model_config] +transport = "openai" +model = "gpt-5.4-mini" + [dream] ENABLED = true -PROVIDER = "anthropic" -MODEL = "claude-sonnet-4-20250514" + +[dream.deduction_model_config] +transport = "openai" +model = "gpt-5.4-mini" + +[dream.induction_model_config] +transport = "openai" +model = "gpt-5.4-mini" [webhook] MAX_WORKSPACE_LIMIT = 10 [metrics] -ENABLED = true +ENABLED = false [telemetry] -ENABLED = true +ENABLED = false [vector_store] TYPE = "pgvector" [sentry] -ENABLED = true -ENVIRONMENT = "production" -TRACES_SAMPLE_RATE = 0.1 -PROFILES_SAMPLE_RATE = 0.1 +ENABLED = false ``` -**Environment variables for production:** +## Database Migrations + ```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** — Unset level fields inherit from the built-in defaults. For Anthropic, `THINKING_BUDGET_TOKENS` must be >= 1024 when enabled. For providers without budgeted thinking, omit it or set it 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/guidelines.mdx b/docs/v3/contributing/guidelines.mdx index f064e51b..398a8b09 100644 --- a/docs/v3/contributing/guidelines.mdx +++ b/docs/v3/contributing/guidelines.mdx @@ -11,7 +11,7 @@ Before you start contributing, please: 1. **Set up your development environment** - Follow the [Local Development guide](https://github.com/plastic-labs/honcho/blob/main/CONTRIBUTING.md#local-development) in the Honcho repository to get Honcho running locally. -2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/plasticlabs) to discuss your changes, get help, or ask questions. +2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions. 3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to. @@ -160,7 +160,7 @@ When reporting bugs or requesting features: ## Questions and Support -- **General questions** - Join our [Discord](http://discord.gg/plasticlabs) +- **General questions** - Join our [Discord](http://discord.gg/honcho) - **Bug reports** - Use GitHub issues - **Feature requests** - Use GitHub issues with the feature request template - **Security issues** - Please email us privately rather than opening a public issue diff --git a/docs/v3/contributing/self-hosting.mdx b/docs/v3/contributing/self-hosting.mdx index b6f47e94..975f9d76 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,42 @@ 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. + +If you keep the built-in defaults, you only need one API key: all text-generation features default to `openai / gpt-5.4-mini`, and embeddings default to `openai / text-embedding-3-small`. Any OpenAI-compatible endpoint works too — OpenRouter, Together, Fireworks, Ollama, vLLM, or LiteLLM. Models must support tool calling (function calling). + +After copying `.env.template` to `.env`, the default setup is: + +```bash +# Required for the built-in defaults +LLM_OPENAI_API_KEY=sk-... +``` + +If you want a different model or an OpenAI-compatible proxy, uncomment and edit the relevant `*_MODEL_CONFIG__TRANSPORT`, `*_MODEL_CONFIG__MODEL`, and `*_MODEL_CONFIG__OVERRIDES__BASE_URL` lines in the Deriver, Dialectic, Summary, and Dream sections. For example: + +```bash +LLM_OPENAI_API_KEY=sk-or-v1-... + +DERIVER_MODEL_CONFIG__TRANSPORT=openai +DERIVER_MODEL_CONFIG__MODEL=google/gemini-2.5-flash +DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1 +``` + + +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 +84,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/honcho - -# 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 +159,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 +186,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 +205,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 +230,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 +250,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 +297,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 +306,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/plasticlabs) +- **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/plasticlabs) -- **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..bb71a475 --- /dev/null +++ b/docs/v3/contributing/troubleshooting.mdx @@ -0,0 +1,300 @@ +--- +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 calls to an OpenAI-compatible proxy fail: + +1. **Verify the endpoint and key are set.** Use `transport = "openai"` with a base URL override: + ```bash + LLM_OPENAI_API_KEY=sk-or-v1-... + DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/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 + DERIVER_MODEL_CONFIG__OVERRIDES__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, one of your per-component `*_MODEL_CONFIG__THINKING_BUDGET_TOKENS` overrides is likely set to a value > 0 with a provider that doesn't support Anthropic-style extended thinking. The built-in defaults do not set thinking budgets, so this only applies if you added those overrides yourself. + +**Fix:** Set `*_MODEL_CONFIG__THINKING_BUDGET_TOKENS=0` for every component when using models that don't support thinking: + +```bash +DERIVER_MODEL_CONFIG__THINKING_BUDGET_TOKENS=0 +SUMMARY_MODEL_CONFIG__THINKING_BUDGET_TOKENS=0 +DREAM_DEDUCTION_MODEL_CONFIG__THINKING_BUDGET_TOKENS=0 +DREAM_INDUCTION_MODEL_CONFIG__THINKING_BUDGET_TOKENS=0 +DIALECTIC_LEVELS__minimal__MODEL_CONFIG__THINKING_BUDGET_TOKENS=0 +DIALECTIC_LEVELS__low__MODEL_CONFIG__THINKING_BUDGET_TOKENS=0 +DIALECTIC_LEVELS__medium__MODEL_CONFIG__THINKING_BUDGET_TOKENS=0 +DIALECTIC_LEVELS__high__MODEL_CONFIG__THINKING_BUDGET_TOKENS=0 +DIALECTIC_LEVELS__max__MODEL_CONFIG__THINKING_BUDGET_TOKENS=0 +``` + +For OpenAI reasoning models, use `*_MODEL_CONFIG__THINKING_EFFORT` instead of `*_MODEL_CONFIG__THINKING_BUDGET_TOKENS`. + +## 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/documentation/core-concepts/design-patterns.mdx b/docs/v3/documentation/core-concepts/design-patterns.mdx index 9f871cd0..0e35114e 100644 --- a/docs/v3/documentation/core-concepts/design-patterns.mdx +++ b/docs/v3/documentation/core-concepts/design-patterns.mdx @@ -5,7 +5,7 @@ icon: "cubes" --- -If you're using a coding agent (Claude Code, Cursor, etc.), the **`/honcho-integration` skill** walks you through these decisions interactively. It explores your codebase, interviews you about peers and sessions, and generates the integration code. The patterns below are the same ones the skill uses. +If you're using a coding agent (Claude Code, OpenCode, Cursor, etc.), the **`/honcho-integration` skill** walks you through these decisions interactively. It explores your codebase, interviews you about peers and sessions, and generates the integration code. The patterns below are the same ones the skill uses. ## Quick Reference @@ -103,7 +103,7 @@ Not every peer needs a representation. Set `observe_me: false` on peers that beh ```python Python -from honcho import PeerConfig +from honcho.api_types import PeerConfig # The assistant doesn't need a representation assistant = honcho.peer("assistant", configuration=PeerConfig(observe_me=False)) diff --git a/docs/v3/documentation/core-concepts/reasoning.mdx b/docs/v3/documentation/core-concepts/reasoning.mdx index 750ccc77..6d198c5a 100644 --- a/docs/v3/documentation/core-concepts/reasoning.mdx +++ b/docs/v3/documentation/core-concepts/reasoning.mdx @@ -83,7 +83,7 @@ The approach balances quality with practical constraints. Custom models are smal Honcho's reasoning capabilities are actively being improved. Current areas of development include enhanced inductive and abductive reasoning, multi-hop and temporal reasoning, and expanded file types and modalities. The system is designed to be extensible--new reasoning capabilities can be added without breaking existing functionality. -If you find that the data you're uploading to Honcho isn't being reasoned over to your liking, we'd love to improve it for you and ingest your data for free--reach out via [Discord](https://discord.gg/plasticlabs) or [email](mailto:support@plasticlabs.ai)! +If you find that the data you're uploading to Honcho isn't being reasoned over to your liking, we'd love to improve it for you and ingest your data for free--reach out via [Discord](https://discord.gg/honcho) or [email](mailto:support@plasticlabs.ai)! ## Next Steps diff --git a/docs/v3/documentation/features/advanced/representation-scopes.mdx b/docs/v3/documentation/features/advanced/representation-scopes.mdx index f7ae03db..d7c478d7 100644 --- a/docs/v3/documentation/features/advanced/representation-scopes.mdx +++ b/docs/v3/documentation/features/advanced/representation-scopes.mdx @@ -80,7 +80,8 @@ The `target` parameter controls which representation you retrieve: ```python Python -from honcho import Honcho, SessionPeerConfig +from honcho import Honcho +from honcho.api_types import SessionPeerConfig honcho = Honcho() session = honcho.session("game-session") @@ -266,6 +267,20 @@ Directional representations update automatically through the reasoning pipeline The pipeline respects scoping—Honcho's representations reason over messages across all sessions, while directional representations only reason over messages from sessions where the observer was an active participant. +### Peer Join Order Matters + +Reasoning tasks are scheduled at the time a message is created, based on which peers are in the session **at that moment**. Honcho does not retroactively schedule reasoning for peers that join later. + +This means: + +- If Peer C joins a session **after** messages from Peer A and Peer B have already been sent, Peer C will **not** receive reasoning tasks for those earlier messages—even if Peer C has `observe_others=true`. +- Peer C will only begin observing new messages sent after they join the session. +- Similarly, if a peer leaves a session, they stop being included as an observer for any messages sent after their departure. + + +There is no retroactive reasoning. If your application needs an observer peer to reason about prior conversation history, add the peer to the session **before** messages are sent. Alternatively, use `peer.chat()` to include conversation history in the agent's context whether or not those messages were previously reasoned over. + + Conclusions are cached for fast retrieval. Use `representation()` to retrieve stored conclusions for dashboards and analytics. Use `peer.chat()` when you need query-specific reasoning with natural language. diff --git a/docs/v3/documentation/features/get-context.mdx b/docs/v3/documentation/features/get-context.mdx index b28137f1..60ff45bc 100644 --- a/docs/v3/documentation/features/get-context.mdx +++ b/docs/v3/documentation/features/get-context.mdx @@ -165,8 +165,8 @@ context = session.context( const context = await session.context({ tokens: 2000, peerTarget: "user-123", - searchQuery: "What are my coding preferences?", representationOptions: { + searchQuery: "What are my coding preferences?", searchTopK: 10, // Number of relevant conclusions to fetch searchMaxDistance: 0.8, // Max semantic distance (0.0-1.0) includeMostFrequent: true, // Include most frequent conclusions diff --git a/docs/v3/documentation/introduction/vibecoding.mdx b/docs/v3/documentation/introduction/vibecoding.mdx index 0cda6e69..d48c3334 100644 --- a/docs/v3/documentation/introduction/vibecoding.mdx +++ b/docs/v3/documentation/introduction/vibecoding.mdx @@ -1,39 +1,120 @@ --- -title: "AI-Powered Honcho Setup" +title: "Agentic Development" icon: "wand-magic-sparkles" -description: "Agent skills and starter prompt for building with Honcho" -sidebarTitle: 'Vibecoding Setup' +description: "Agent skills, MCP server, and tools for building with Honcho" +sidebarTitle: 'Agentic Development' --- -These docs are designed to be easily consumable by LLMs. Each page has a button that lets you copy the page as Markdown or paste directly into ChatGPT or Claude. -We follow the llms.txt standard. There are both an llms.txt and llms-full.txt available: +## MCP Server -- [llms.txt](/llms.txt) -- [llms-full.txt](/llms-full.txt) +The fastest way to give any AI tool persistent memory is through the Honcho MCP server. It works with any client that supports the Model Context Protocol. + +**Get started in 2 minutes:** + +1. Get an API key at [app.honcho.dev](https://app.honcho.dev) +2. Add the config for your client below +3. Restart your client + +See the [full MCP documentation](/v3/guides/integrations/mcp) for all available tools, advanced configuration, and setup instructions for every supported client. + + +```json Claude Desktop +{ + "mcpServers": { + "honcho": { + "command": "npx", + "args": [ + "mcp-remote", + "https://mcp.honcho.dev", + "--header", + "Authorization:${AUTH_HEADER}", + "--header", + "X-Honcho-User-Name:${USER_NAME}" + ], + "env": { + "AUTH_HEADER": "Bearer hch-your-key-here", + "USER_NAME": "YourName" + } + } + } +} +``` + +```json Cursor +{ + "mcpServers": { + "honcho": { + "url": "https://mcp.honcho.dev", + "headers": { + "Authorization": "Bearer hch-your-key-here", + "X-Honcho-User-Name": "YourName" + } + } + } +} +``` + +```bash Claude Code +claude mcp add honcho \ + --transport http \ + --url "https://mcp.honcho.dev" \ + --header "Authorization: Bearer hch-your-key-here" \ + --header "X-Honcho-User-Name: YourName" +``` + + +--- + +## CLI + +Inspect and debug a running Honcho deployment from your terminal. The honcho CLI wraps the Python SDK with agent-friendly defaults — JSON output, structured errors, and commands for every primitive (workspaces, peers, sessions, messages, conclusions). + +**Get started:** + +```bash +uv tool install honcho-cli +honcho init # configure apiKey + environmentUrl +honcho doctor # verify connectivity +``` + +The CLI also ships an agent skill. Install it with `npx skills add plastic-labs/honcho` and pick `honcho-cli` from the list. + +See the [full CLI reference](/v3/documentation/reference/cli) for all commands, flags, and environment variables. --- ## Claude Code Plugin - -Use Honcho to build with Honcho! The [plugin](/v3/guides/integrations/claudecode) provides claude code persistent memory that survives context wipes and session restarts. +Use Honcho to build with Honcho! The [plugin](/v3/guides/integrations/claudecode) provides Claude Code persistent memory that survives context wipes and session restarts. ```bash /plugin marketplace add plastic-labs/claude-honcho -/plugin install honcho@honcho # Tools for Claude to use Honcho to manage it's own context -/plugin install honcho-dev@honcho # Skills to teach claude how to integrate Honcho +/plugin install honcho@honcho # Tools for Claude to use Honcho to manage its own context +/plugin install honcho-dev@honcho # Skills to teach Claude how to integrate Honcho ``` -The markeplace also includes all the agent skills below, so you can use `/honcho-dev:integrate` directly after installing. +The marketplace also includes all the agent skills below, so you can use `/honcho-dev:integrate` directly after installing. See the [full Claude Code integration guide](/v3/guides/integrations/claudecode) for setup details. --- +## OpenCode Plugin + +The [OpenCode plugin](/v3/guides/integrations/opencode) gives OpenCode sessions persistent memory that survives context wipes, session restarts, and fresh chats. + +```bash +bunx @honcho-ai/opencode-honcho install +``` + +Then run `/honcho:setup` inside OpenCode. See the [full OpenCode integration guide](/v3/guides/integrations/opencode) for setup details. + +--- + ## Agent Skills -We provide agent skills for coding assistants like Claude Code, Cursor, Windsurf, and others. +We provide agent skills for coding assistants like Claude Code, OpenCode, Cursor, Windsurf, and others. ```bash Install via npx (Recommended) @@ -58,6 +139,12 @@ curl -o ~/.claude/skills/honcho-integration.md https://raw.githubusercontent.com Invoke with `/honcho-integration` in your coding agent. +#### honcho-cli + +**For inspection & debugging.** Teaches your coding agent the right commands and flags for the [honcho CLI](#cli) — peer memory, session context, queue status, dialectic quality. + +Invoke implicitly when you ask your agent to inspect a Honcho deployment. + #### migrate-honcho-py / migrate-honcho-ts **For SDK upgrades.** Migrates code from v1.6.0 to v2.0.0 (required for Honcho 3.0.0+). Use when upgrading the SDK or seeing errors about removed APIs like `observations`, `Representation`, `.core`, or `get_config`. @@ -89,6 +176,7 @@ I want to start building with Honcho - an open source memory library for buildin - Core repo: https://github.com/plastic-labs/honcho - Python SDK: https://github.com/plastic-labs/honcho-python - TypeScript SDK: https://github.com/plastic-labs/honcho-node +- CLI (inspect & debug a deployment): https://github.com/plastic-labs/honcho/tree/main/honcho-cli - Discord bot starter: https://github.com/plastic-labs/discord-python-starter - Telegram bot example: https://github.com/plastic-labs/telegram-python-starter diff --git a/docs/v3/documentation/reference/cli.mdx b/docs/v3/documentation/reference/cli.mdx new file mode 100644 index 00000000..7b89bfeb --- /dev/null +++ b/docs/v3/documentation/reference/cli.mdx @@ -0,0 +1,220 @@ +--- +title: 'CLI Reference' +description: 'Command-line interface for Honcho — inspect workspaces, peers, sessions, and memory from your terminal' +icon: 'terminal' +--- + +import CliCommands from "/snippets/cli-commands.mdx"; + +## Install + + +```bash uv (recommended) +uv tool install honcho-cli +``` + +```bash uvx (ephemeral) +uvx honcho-cli +``` + + +## Quick Start + +```bash +honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json +honcho doctor # verify your config + connectivity +honcho # show banner + command list +``` + +## Configuration + +The CLI resolves config in this order: **flag → env var → config file → default**. + +| Value | File key | Env var | Flag | Persisted? | +|-------------|-------------------|------------------------|------------------------|------------| +| API key | `apiKey` | `HONCHO_API_KEY` | — | Yes | +| API URL | `environmentUrl` | `HONCHO_BASE_URL` | — | Yes | +| Workspace | — | `HONCHO_WORKSPACE_ID` | `-w` / `--workspace` | No | +| Peer | — | `HONCHO_PEER_ID` | `-p` / `--peer` | No | +| Session | — | `HONCHO_SESSION_ID` | `-s` / `--session` | No | +| JSON output | — | `HONCHO_JSON` | `--json` | No | + +### Persisted config + +The CLI shares `~/.honcho/config.json` with sibling Honcho tools. It owns only +`apiKey` and `environmentUrl` at the top level — everything else (`hosts`, +`sessions`, etc.) is written by other tools and left untouched on save. + +```json +{ + "apiKey": "hch-v3-...", + "environmentUrl": "https://api.honcho.dev", + "hosts": { "claude_code": { "...": "..." } } +} +``` + +Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s` +flags or `HONCHO_*` env vars. **Not** persisted as CLI defaults. This is +deliberate: every invocation is explicit about what it operates on. + + +### Runtime overrides + +Workspace, peer, and session scoping are **per-command only** — pass flags or +`HONCHO_*` env vars on every invocation. + +```bash +# Per-command flags +honcho peer card -w prod -p user + +# Or export once per shell +export HONCHO_WORKSPACE_ID=prod +export HONCHO_PEER_ID=user +honcho peer card + +# One-off against a different server +HONCHO_BASE_URL=http://localhost:8000 honcho workspace list + +# CI/CD — env vars only, no config file needed +export HONCHO_API_KEY=hch-v3-xxx +export HONCHO_BASE_URL=https://api.honcho.dev +honcho workspace list +``` + +## Output & exit codes + +Every command adapts its output to the context: + +- **TTY** — human-readable tables via Rich. +- **Piped or redirected** — JSON automatically (detected via `isatty`). +- **`--json` flag / `HONCHO_JSON=1`** — force JSON regardless of terminal. + +Collection commands emit JSON arrays; single-resource commands emit JSON objects. Errors are always structured: + +```json +{ + "error": { + "code": "PEER_NOT_FOUND", + "message": "Peer 'abc' not found in workspace 'my-ws'", + "details": {"workspace_id": "my-ws", "peer_id": "abc"} + } +} +``` + +| Exit code | Meaning | +|-----------|---------| +| `0` | Success | +| `1` | Client error (bad input, resource not found) | +| `2` | Server error | +| `3` | Auth error (missing or invalid API key) | + +CI pipelines and agent runtimes can branch on these without parsing stderr. + +## Command reference + + + +## Workflows + +### Inspect an unfamiliar workspace + +When you pick up a workspace and need to orient — start broad, narrow to the peer and session you care about. + + + + ```bash + honcho workspace inspect --json + honcho peer list --json + ``` + + + ```bash + honcho peer inspect --json + honcho peer card --json + ``` + + + ```bash + honcho conclusion list --observer --json + honcho conclusion search "topic" --observer --json + ``` + + + ```bash + honcho session inspect --json + honcho message list --last 20 --json + honcho session context --json + honcho session summaries --json + ``` + + + + + `honcho session context` shows exactly what an agent would receive at inference time — check it before `honcho peer chat` if a response surprises you. + + +### A peer isn't learning + +If new messages aren't producing new conclusions, work down the diagnostic ladder. + +```bash +# Is observation enabled for this peer? +honcho peer inspect --json | jq '.configuration' + +# Is the deriver actually processing? +honcho workspace queue-status --json + +# Do any conclusions exist at all? Any for the expected topic? +honcho conclusion list --observer --json +honcho conclusion search "expected topic" --observer --json +``` + +### Session context looks wrong + +When an agent's responses don't reflect what you expect it to know. + +```bash +honcho session context --json +honcho session summaries --json +honcho message list --last 50 --json +``` + +### Dialectic returns bad answers + +When `honcho peer chat` or the dialectic API is hallucinating or missing context. + +```bash +# What does the peer card actually say? +honcho peer card --json + +# Any conclusions for this topic? +honcho conclusion search "topic" --observer --json + +# Reproduce the query against the CLI +honcho peer chat "what do you know about X?" --json +``` + +## Scripting & automation + +Pipe commands into `jq` for inline transforms, or set `HONCHO_*` env vars for a CI/CD environment with no config file: + +```bash +# Pipe to jq +honcho peer list --json | jq '.[].id' +honcho workspace inspect --json | jq '.peers' + +# Machine-parseable health check — exit code for CI, details for logs +honcho doctor --json + +# CI/CD — env vars only, no ~/.honcho/config.json +export HONCHO_API_KEY=hch-v3-xxx +export HONCHO_BASE_URL=https://api.honcho.dev +honcho workspace list +``` + +Non-interactive onboarding: + +```bash +# Pre-seed via flags / env vars; init still prompts for anything missing +HONCHO_API_KEY=hch-v3-xxx honcho init --base-url https://api.honcho.dev +``` diff --git a/docs/v3/documentation/reference/platform.mdx b/docs/v3/documentation/reference/platform.mdx index 11df5884..75fc9bd5 100644 --- a/docs/v3/documentation/reference/platform.mdx +++ b/docs/v3/documentation/reference/platform.mdx @@ -163,7 +163,7 @@ Dive into our [API Reference](/v3/api-reference) to explore all available endpoi Get started with managed Honcho instances - + Connect with 1000+ developers building with Honcho diff --git a/docs/v3/documentation/reference/sdk.mdx b/docs/v3/documentation/reference/sdk.mdx index 9aa0f509..77652bf0 100644 --- a/docs/v3/documentation/reference/sdk.mdx +++ b/docs/v3/documentation/reference/sdk.mdx @@ -218,6 +218,14 @@ const session = await honcho.session(id); // List all peers in workspace (returns Page) const peers = await honcho.peers(); +// List with pagination and filtering +const filtered = await honcho.peers({ + filters: { metadata: { role: "user" } }, + page: 1, + size: 25, + reverse: true +}); + // List all sessions in workspace (returns Page) const sessions = await honcho.sessions(); @@ -234,7 +242,7 @@ const workspaces = await honcho.workspaces(); -Peer and session creation is **lazy** - no API calls are made until you actually use the peer or session. +`peer()` and `session()` always make a get-or-create API call, returning objects with cached metadata, configuration, and timestamps. ### Peer @@ -243,7 +251,7 @@ Represents an entity that can participate in conversations: ```python Python -# Create peers (lazy creation - no API call yet) +# Create peers (get-or-create API call) alice = honcho.peer("alice") assistant = honcho.peer("assistant") @@ -254,6 +262,7 @@ alice = honcho.peer("bob", config={"role": "user", "active": True}, metadata={"l # Peer properties print(f"Peer ID: {alice.id}") print(f"Workspace: {alice.workspace_id}") +print(f"Created: {alice.created_at}") # Available after API fetch # Chat with peer's representations (supports streaming) response = alice.chat("What did I have for breakfast?") @@ -305,6 +314,7 @@ const assistant = await honcho.peer("assistant"); // Peer properties console.log(`Peer ID: ${alice.id}`); +console.log(`Created: ${alice.createdAt}`); // Available after API fetch // Chat with peer's representations (supports streaming) const response = await alice.chat("What did I have for breakfast?"); @@ -551,7 +561,7 @@ Manages multi-party conversations: ```python Python -# Create session (like peers, lazy creation) +# Create session (get-or-create API call) session = honcho.session("conversation-1") # Create with immediate configuration @@ -561,6 +571,8 @@ session = honcho.session("meeting-1", config={"type": "meeting", "max_peers": 10 # Session properties print(f"Session ID: {session.id}") print(f"Workspace: {session.workspace_id}") +print(f"Created: {session.created_at}") # Available after API fetch +print(f"Active: {session.is_active}") # Available after API fetch # Peer management session.add_peers([alice, assistant]) @@ -579,8 +591,12 @@ session.add_messages([ assistant.message("Hi Alice! How can I help today?") ]) -# Get messages +# Get messages (with optional pagination) messages = session.messages() +messages = session.messages(page=1, size=100, reverse=True) + +# Get a single message by ID +message = session.get_message("message-id") # Get conversation context context = session.context(summary=True, tokens=2000) @@ -641,6 +657,8 @@ const session = await honcho.session("conversation-1"); // Session properties console.log(`Session ID: ${session.id}`); +console.log(`Created: ${session.createdAt}`); // Available after API fetch +console.log(`Active: ${session.isActive}`); // Available after API fetch // Peer management await session.addPeers([alice, assistant]); @@ -658,8 +676,12 @@ await session.addMessages([ assistant.message("Hi Alice! How can I help today?") ]); -// Get messages +// Get messages (with optional pagination) const messages = await session.messages(); +const paged = await session.messages({ page: 1, size: 100, reverse: true }); + +// Get a single message by ID +const message = await session.getMessage("message-id"); // Get conversation context const context = await session.context({ summary: true, tokens: 2000 }); @@ -668,10 +690,10 @@ const context = await session.context({ summary: true, tokens: 2000 }); const richContext = await session.context({ tokens: 2000, peerTarget: "user", - searchQuery: "What are my preferences?", peerPerspective: "assistant", limitToSession: true, representationOptions: { + searchQuery: "What are my preferences?", searchTopK: 10, searchMaxDistance: 0.8, includeMostFrequent: true, @@ -729,7 +751,7 @@ const metadata = await session.getMetadata(); ```python Python -from honcho import SessionPeerConfig +from honcho.api_types import SessionPeerConfig # Configure peer observation settings config = SessionPeerConfig( @@ -811,12 +833,12 @@ The SessionContext object has the following structure: | `tokens` | `int` | Maximum tokens to include | | `peer_target` | `str` | Peer ID to get representation for | | `peer_perspective` | `str` | Peer ID for perspective (requires peer_target) | -| `search_query` | `str` or `Message` | Query string or Message object for semantic search | | `limit_to_session` | `bool` | Limit representation to session only | -| `search_top_k` | `int` | Number of semantic search results (1-100) | -| `search_max_distance` | `float` | Max semantic distance (0.0-1.0) | -| `include_most_frequent` | `bool` | Include most frequent conclusions | -| `max_conclusions` | `int` | Max conclusions to include (1-100) | +| `representationOptions.searchQuery` | `str` or `Message` | Query string or Message object for semantic search | +| `representationOptions.searchTopK` | `int` | Number of semantic search results (1-100) | +| `representationOptions.searchMaxDistance` | `float` | Max semantic distance (0.0-1.0) | +| `representationOptions.includeMostFrequent` | `bool` | Include most frequent conclusions | +| `representationOptions.maxConclusions` | `int` | Max conclusions to include (1-100) | ## Advanced Usage @@ -988,23 +1010,46 @@ const actionItems = await session.messages({ ### Pagination +All list methods support `page`, `size`, and `reverse` parameters: + ```python Python -# Iterate through all sessions +# Default pagination (page 1, size 50) for session in honcho.sessions(): print(f"Session: {session.id}") - # Iterate through session messages - for message in session.messages(): - print(f" {message.peer_id}: {message.content}") +# Custom page size +for message in session.messages(size=100): + print(f" {message.peer_id}: {message.content}") + +# Start at a specific page +page3 = session.messages(page=3, size=25) + +# Reverse ordering +recent_first = session.messages(reverse=True) + +# Combine with filters +filtered = session.messages(filters={"peer_id": "alice"}, size=10) ``` ```typescript TypeScript -// Get paginated results +// Default pagination (page 1, size 50) const peersPage = await honcho.peers(); -// Iterate through all items -for await (const peer of peersPage) { +// Custom page size and filtering +const filtered = await honcho.peers({ + filters: { metadata: { role: "user" } }, + size: 25 +}); + +// Start at a specific page +const page3 = await session.messages({ page: 3, size: 25 }); + +// Reverse ordering +const recent = await session.messages({ reverse: true }); + +// Iterate through all items (auto-paginates) +for await (const peer of await honcho.peers()) { console.log(`Peer: ${peer.id}`); } @@ -1048,8 +1093,8 @@ const supportAgent = await honcho.peer(`agent-${agentId}`); ```python Python -# Lazy creation - no API calls until needed -peers = [honcho.peer(f"user-{i}") for i in range(100)] # Fast +# Create peers (each makes a get-or-create call) +peers = [honcho.peer(f"user-{i}") for i in range(100)] # Batch operations when possible session.add_messages([peer.message(f"Message {i}") for i, peer in enumerate(peers)]) @@ -1059,7 +1104,7 @@ context = session.context(tokens=1500) # Limit context size ``` ```typescript TypeScript -// Lazy creation - no API calls until needed +// Create peers (each makes a get-or-create call) const peers = await Promise.all( Array.from({ length: 100 }, (_, i) => honcho.peer(`user-${i}`)) ); diff --git a/docs/v3/guides/community/hermes.mdx b/docs/v3/guides/community/hermes.mdx deleted file mode 100644 index d22cb2e4..00000000 --- a/docs/v3/guides/community/hermes.mdx +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: "Hermes Agent" -icon: 'bolt' -description: "Add AI-native memory to Hermes Agent" -sidebarTitle: 'Hermes Agent' ---- - -[Hermes Agent](https://github.com/NousResearch/hermes-agent) is an open-source AI agent from Nous Research with advanced tool-calling capabilities, terminal access, a skills system, and multi-platform deployment (Telegram, Discord, Slack, WhatsApp). The Honcho integration gives Hermes persistent cross-session memory and user modeling. - -## Getting Started - -Honcho support is built into Hermes Agent. See the [Hermes Agent README](https://github.com/NousResearch/hermes-agent) for full installation and configuration instructions. - -The integration is opt-in and requires: -1. A Honcho API key from [app.honcho.dev](https://app.honcho.dev) -2. The `honcho-ai` package (`pip install hermes-agent[honcho]`) -3. Enabling Honcho in your Hermes config - -## How It Works - -The integration runs alongside Hermes's existing `USER.md` memory system. Honcho adds cross-session reasoning — prefetching user context into each turn, syncing exchanges for ongoing modeling, and exposing a dialectic tool (`query_user_context`) for the agent to query its understanding mid-conversation. - -## Next Steps - - - - Source code, installation, and full documentation. - - - - Learn about peers, sessions, and dialectic reasoning. - - diff --git a/docs/v3/guides/community/pi-honcho-memory.mdx b/docs/v3/guides/community/pi-honcho-memory.mdx new file mode 100644 index 00000000..7974a94b --- /dev/null +++ b/docs/v3/guides/community/pi-honcho-memory.mdx @@ -0,0 +1,38 @@ +--- +title: "Pi" +icon: 'pi' +description: "Persistent memory extension for the pi coding agent" +sidebarTitle: 'Pi' +--- + +[pi-honcho-memory](https://github.com/agneym/pi-honcho-memory) is a persistent memory extension for [pi](https://pi.dev), a coding agent CLI. It gives pi long-term memory across sessions — user preferences, project context, and past decisions are remembered and automatically injected into the system prompt. + +## Getting Started + +Install the extension inside pi: + +```bash +pi install npm:@agney/pi-honcho-memory +``` + +The integration requires: +1. A Honcho API key from [app.honcho.dev](https://app.honcho.dev) +2. Running `/honcho-setup` inside pi for interactive configuration, or setting `HONCHO_API_KEY` in your environment + +The Honcho plugin is a community integration. See the [plugin README](https://github.com/agneym/pi-honcho-memory/blob/main/README.md) for full installation and configuration instructions. + +## How It Works + +The extension hooks into pi's extension system. It automatically syncs user and assistant messages to Honcho after each agent response, injects cached user profile and project context into the system prompt with zero network latency, and exposes LLM tools (`honcho_search`, `honcho_chat`, `honcho_remember`) for active memory operations. Session scoping is configurable — memory can be shared per repo, per git branch, or per directory. If Honcho is unavailable, pi continues working normally. + +## Next Steps + + + + Source code, installation, and full documentation. + + + + Learn about peers, sessions, and dialectic reasoning. + + diff --git a/docs/v3/guides/gmail.mdx b/docs/v3/guides/gmail.mdx new file mode 100644 index 00000000..a1963652 --- /dev/null +++ b/docs/v3/guides/gmail.mdx @@ -0,0 +1,631 @@ +--- +title: "Gmail" +icon: 'envelope' +description: "Load Gmail threads into Honcho to give your AI agents memory of email conversations." +sidebarTitle: 'Gmail' +--- + +In this tutorial, we'll walk through how to ingest your Gmail emails into Honcho. By the end, each email thread will be a Honcho session and each participant will be a peer — giving your agents memory of who said what across your email history. + +This guide includes a ready-to-run Python script that handles everything: Gmail OAuth, thread fetching, participant extraction, and Honcho ingestion. You can run it as-is or use the full tutorial below to understand each piece as you go. + + +The full script is available on [GitHub](https://github.com/plastic-labs/honcho/tree/main/examples/gmail). This is a developer-focused tutorial — it requires creating a Google Cloud project and OAuth credentials. + + +## TL;DR + +If you just want to get your emails into Honcho, here's everything you need. + +### 1. Set Up Google Cloud Credentials + +Follow Google's official [Gmail API Python Quickstart](https://developers.google.com/gmail/api/quickstart/python) to: + +1. Create a Google Cloud project and enable the Gmail API +2. Configure the OAuth consent screen +3. Create OAuth credentials (select **Desktop app** as the application type) +4. Download the credentials JSON into the same directory as the script + +The script auto-detects Google's default `client_secret_*.json` filename, so no renaming needed. The script only needs the `gmail.readonly` scope. + +### 2. Install Dependencies + + +```bash uv +uv pip install google-api-python-client google-auth-oauthlib honcho-ai +``` + +```bash pip +pip install google-api-python-client google-auth-oauthlib honcho-ai +``` + + +### 3. Preview with a Dry Run + + +```bash uv +uv run honcho_gmail.py --dry-run --max-threads 5 +``` + +```bash python +python honcho_gmail.py --dry-run --max-threads 5 +``` + + +On first run, a browser window opens for OAuth consent. After authorizing, a `token.json` file is created — future runs skip this step. + +### 4. Load into Honcho + + +```bash uv +export HONCHO_API_KEY=your_api_key +uv run honcho_gmail.py --workspace gmail-inbox --max-threads 20 +``` + +```bash python +export HONCHO_API_KEY=your_api_key +python honcho_gmail.py --workspace gmail-inbox --max-threads 20 +``` + + +You can filter threads with Gmail search syntax: + + +```bash uv +uv run honcho_gmail.py --query "from:alice@example.com" +uv run honcho_gmail.py --label INBOX +uv run honcho_gmail.py --query "after:2024/01/01 has:attachment" --max-threads 50 +``` + +```bash python +python honcho_gmail.py --query "from:alice@example.com" +python honcho_gmail.py --label INBOX +python honcho_gmail.py --query "after:2024/01/01 has:attachment" --max-threads 50 +``` + + +That's it — your emails are now queryable in Honcho. Read on if you want to understand how the script works and the design decisions behind it. + +--- + +## Full Tutorial + +### How Gmail Maps to Honcho + +The core idea is straightforward: each Gmail thread becomes a Honcho session, and each email participant becomes a peer. Here's the full mapping: + +| Gmail Concept | Honcho Concept | Details | +|---------------|----------------|---------| +| Your Gmail account | Workspace (`gmail`) | One workspace for all email data | +| Email participant | Peer | Email address as ID for deduplication | +| Email thread | Session (`gmail-thread-{id}`) | One session per thread, all participants attached | +| Individual email | Message | Attributed to the sender with original timestamp | + +### Email as Peer ID + +The script normalizes email addresses into URL-safe peer IDs — `alice@example.com` becomes `alice-example-com`. This means the same person is automatically deduplicated across threads. If Alice emails you in 10 different threads, all of those conversations accumulate under a single peer. + +```python +def peer_id_from_email(email: str) -> str: + """Convert email to a valid Honcho peer ID.""" + return email.replace("@", "-").replace(".", "-") +``` + +This also means peers are consistent across data sources. If you import Granola meetings and Gmail threads for the same person, they merge under the same peer ID. + +### Extracting Participants + +Every email has a sender, recipients, and optionally CC/BCC addresses. The script extracts all of these to build a complete picture of who's involved in each thread: + +```python +for m in msgs: + register_peer(m["from"]) + for addr in parse_address_list(m["to"]): + register_peer(addr) + for addr in parse_address_list(m["cc"]): + register_peer(addr) + for addr in parse_address_list(m["bcc"]): + register_peer(addr) +``` + +Display names are extracted when available (e.g., `Alice Smith ` → name: "Alice Smith"). When only an email is present, the script generates a name from the local part. + +### Message Attribution and Timestamps + +Each email becomes a message attributed to its sender via `peer.message()`. The original email timestamp is preserved using `created_at`, so Honcho sees the conversation in chronological order — not the order you imported it. + +```python +honcho_msgs.append(peer.message( + content, + metadata={ + "gmail_id": m["id"], + "subject": m["subject"], + "from": m["from"], + "to": m["to"], + "labels": m["labels"], + }, + created_at=m["timestamp"], +)) +``` + +### Multi-Peer Sessions + +Each thread's session is linked to all participants using `session.add_peers()`. This means when you query Honcho about a peer, it has context not just from their messages but from the full conversations they participated in. + +```python +session = honcho.session(session_id, metadata={ + "gmail_thread_id": tid, + "subject": subject, + "source": "gmail", + "message_count": len(msgs), +}) +session.add_peers(thread_peers) +``` + +### Stripping Quoted Replies + +Email threads are full of quoted replies — each message repeats everything above it. The script strips these out so only the new content is stored per message, avoiding duplication in Honcho's memory: + +```python +def strip_quoted_replies(text: str) -> str: + """Strip quoted reply text, keeping only the new content.""" + lines = text.split("\n") + clean_lines = [] + for line in lines: + stripped = line.strip() + if re.match(r"^On .+wrote:\s*$", stripped): + break + if stripped.startswith(">"): + break + # ... other reply markers + clean_lines.append(line) + return "\n".join(clean_lines).rstrip() +``` + +### Querying After Import + +Once your emails are in Honcho, you can query any peer: + +```python +import os +from honcho import Honcho + +honcho = Honcho(workspace_id="gmail-inbox", api_key=os.environ["HONCHO_API_KEY"]) + +alice = honcho.peer("alice-example-com") +print(alice.chat("What has Alice been discussing with me?")) +print(alice.chat("What action items has Alice mentioned?")) +``` + +--- + +## CLI Reference + +``` +usage: honcho_gmail.py [-h] [--workspace WORKSPACE] [--query QUERY] + [--label LABEL] [--max-threads N] [--dry-run] + [--credentials PATH] [--token PATH] + +options: + --workspace, -w Honcho workspace ID (default: gmail) + --query, -q Gmail search query (e.g., 'from:alice@example.com') + --label, -l Gmail label to filter by (e.g., INBOX) + --max-threads, -n Max threads to fetch (default: 10) + --dry-run Preview without writing to Honcho + --credentials, -c Path to OAuth credentials JSON (auto-detects client_secret*.json) + --token, -t Path to store access token (default: token.json) +``` + +## Troubleshooting + +### "No client_secret*.json file found" + +Download OAuth credentials from Google Cloud Console and place the `client_secret_*.json` file in the same directory as the script. + +### "Access blocked: This app's request is invalid" + +Your OAuth consent screen may not be configured correctly. Ensure you've added the `gmail.readonly` scope. + +### "Token has been expired or revoked" + +Delete `token.json` and run the script again to re-authenticate. + +### Rate Limits + +The script includes a small delay when creating peers to avoid hitting Honcho's rate limits. For large imports (100+ threads), consider running in batches. + +### Unique Messages + +Use an AI assistant in your inbox? Want to parse out its messages differently? Feel free to modify and improve the structure of this script to fit your bespoke email setup. This script was written for agents and as such is easy to update with your coding assistant. + +## Full Script + + +```python +#!/usr/bin/env python3 +"""Load Gmail messages into Honcho. + +Uses the Gmail API directly (with OAuth) to fetch emails and the Honcho Python SDK to store them. +Each Gmail thread becomes a Honcho session, each sender becomes a peer. + +Prerequisites: +1. Create a Google Cloud project and enable the Gmail API +2. Create OAuth 2.0 credentials (Desktop app type) +3. Download the credentials JSON (client_secret_*.json) into this directory +4. Install dependencies: + pip install google-api-python-client google-auth-oauthlib honcho-ai + +On first run, a browser window will open for OAuth consent. After authorizing, +a 'token.json' file will be created to store your credentials for future runs. +""" + +import argparse +import base64 +import glob +import os +import re +import time +from datetime import datetime, timezone +from email.header import decode_header, make_header +from email.utils import getaddresses, parseaddr + +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"] +PEER_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$") + + +def find_credentials() -> str: + """Find a Google OAuth credentials file in the current directory.""" + matches = glob.glob("client_secret*.json") + if matches: + return matches[0] + raise FileNotFoundError( + "No client_secret*.json file found.\n" + "Download OAuth credentials from Google Cloud Console:\n" + "1. Go to console.cloud.google.com\n" + "2. Create/select a project and enable Gmail API\n" + "3. Create OAuth 2.0 credentials (Desktop app)\n" + "4. Download the JSON into this directory" + ) + + +def get_gmail_service(credentials_file: str | None = None, token_file: str = "token.json"): + """Authenticate and return a Gmail API service instance.""" + creds = None + + if os.path.exists(token_file): + creds = Credentials.from_authorized_user_file(token_file, SCOPES) + + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + print("Refreshing expired credentials...") + creds.refresh(Request()) + else: + if credentials_file is None: + credentials_file = find_credentials() + print(f"Using credentials: {credentials_file}") + print("Opening browser for OAuth consent...") + flow = InstalledAppFlow.from_client_secrets_file(credentials_file, SCOPES) + creds = flow.run_local_server(port=0) + + with open(token_file, "w") as token: + token.write(creds.to_json()) + print(f"Credentials saved to {token_file}") + + return build("gmail", "v1", credentials=creds) + + +def list_threads(service, query: str = None, label_ids: list = None, max_results: int = 10) -> list[dict]: + """List Gmail threads with pagination support.""" + all_threads = [] + page_token = None + + while len(all_threads) < max_results: + try: + params = { + "userId": "me", + "maxResults": min(100, max_results - len(all_threads)), + } + if query: + params["q"] = query + if label_ids: + params["labelIds"] = label_ids + if page_token: + params["pageToken"] = page_token + + response = service.users().threads().list(**params).execute() + threads = response.get("threads", []) + all_threads.extend(threads) + + page_token = response.get("nextPageToken") + if not page_token: + break + + except HttpError as e: + print(f"Error listing threads: {e}") + break + + return all_threads[:max_results] + + +def get_thread(service, thread_id: str) -> dict: + """Fetch a complete Gmail thread with all messages.""" + try: + return service.users().threads().get( + userId="me", + id=thread_id, + format="full" + ).execute() + except HttpError as e: + print(f"Error fetching thread {thread_id}: {e}") + return {} + + +def _decode_header_str(header: str) -> str: + """Decode an RFC 2047 encoded header string to plain Unicode.""" + return str(make_header(decode_header(header))) + + +def extract_email(from_header: str) -> str: + """Extract bare email from an RFC 5322 header value.""" + _, addr = parseaddr(_decode_header_str(from_header)) + return addr.lower().strip() + + +def extract_name(from_header: str) -> str: + """Extract display name from an RFC 5322 header value.""" + name, _ = parseaddr(_decode_header_str(from_header)) + return name.strip() or from_header.strip() + + +def decode_body(payload: dict) -> str: + """Recursively extract plain text from a Gmail message payload.""" + if payload.get("mimeType") == "text/plain": + data = payload.get("body", {}).get("data", "") + if data: + return base64.urlsafe_b64decode(data).decode("utf-8", errors="replace") + + parts = payload.get("parts", []) + for part in parts: + text = decode_body(part) + if text: + return text + return "" + + +def strip_quoted_replies(text: str) -> str: + """Strip quoted reply text from an email body, keeping only the new content.""" + lines = text.split("\n") + clean_lines = [] + for line in lines: + stripped = line.strip() + if re.match(r"^On .+wrote:\s*$", stripped): + break + if stripped.startswith("---------- Forwarded message"): + break + if stripped.startswith(">"): + break + if re.match(r"^[-_]{10,}$", stripped): + break + clean_lines.append(line) + return "\n".join(clean_lines).rstrip() + + +def parse_address_list(header: str) -> list[str]: + """Parse a comma-separated email header into individual addresses.""" + if not header.strip(): + return [] + decoded = _decode_header_str(header) + return [ + f"{name} <{addr}>" if name else addr + for name, addr in getaddresses([decoded]) + if addr + ] + + +def peer_id_from_email(email: str) -> str: + """Convert email to a valid Honcho peer ID.""" + peer_id = re.sub(r"[^A-Za-z0-9_-]+", "-", email).strip("-").lower() + peer_id = re.sub(r"-{2,}", "-", peer_id) + if not peer_id: + peer_id = "unknown-peer" + + if not PEER_ID_PATTERN.fullmatch(peer_id): + raise ValueError(f"Generated peer ID is invalid: {peer_id!r}") + return peer_id + + +def fetch_thread_messages(service, thread_id: str) -> list[dict]: + """Fetch all messages in a Gmail thread with full content.""" + data = get_thread(service, thread_id) + messages = [] + + for msg in data.get("messages", []): + headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} + body = strip_quoted_replies(decode_body(msg.get("payload", {}))) + ts = int(msg.get("internalDate", "0")) / 1000 + + messages.append({ + "id": msg["id"], + "thread_id": msg["threadId"], + "from": headers.get("From", ""), + "to": headers.get("To", ""), + "cc": headers.get("Cc", ""), + "bcc": headers.get("Bcc", ""), + "subject": headers.get("Subject", ""), + "date": headers.get("Date", ""), + "timestamp": datetime.fromtimestamp(ts, tz=timezone.utc), + "body": body.strip(), + "labels": msg.get("labelIds", []), + "snippet": msg.get("snippet", ""), + }) + + return messages + + +def main(): + parser = argparse.ArgumentParser(description="Load Gmail messages into Honcho") + parser.add_argument("--workspace", "-w", default="gmail", help="Honcho workspace ID (default: gmail)") + parser.add_argument("--query", "-q", default=None, help="Gmail search query (e.g. 'from:alice@example.com')") + parser.add_argument("--label", "-l", default=None, help="Gmail label to filter by (e.g. INBOX)") + parser.add_argument("--max-threads", "-n", type=int, default=10, help="Max threads to fetch (default: 10)") + parser.add_argument("--dry-run", action="store_true", help="Print what would be loaded without writing to Honcho") + parser.add_argument("--credentials", "-c", default=None, help="Path to OAuth credentials JSON (auto-detects client_secret*.json)") + parser.add_argument("--token", "-t", default="token.json", help="Path to store/load access token") + args = parser.parse_args() + + # Authenticate + print("Authenticating with Gmail API...") + service = get_gmail_service(args.credentials, args.token) + print(" Authenticated successfully!") + + label_ids = [args.label] if args.label else None + + # List threads + print(f"\nFetching up to {args.max_threads} threads from Gmail...") + threads = list_threads(service, query=args.query, label_ids=label_ids, max_results=args.max_threads) + print(f" Found {len(threads)} threads") + + if not threads: + print("No threads found. Try adjusting --query or --label.") + return + + # Fetch full messages for each thread + all_thread_messages = {} + seen_peers = {} + + def register_peer(addr: str): + email = extract_email(addr) + if email and email not in seen_peers: + name = extract_name(addr) + if name.lower().strip() == email or "@" in name: + name = email.split("@")[0].replace(".", " ").title() + seen_peers[email] = { + "name": name, + "peer_id": peer_id_from_email(email), + "email": email, + } + + for i, t in enumerate(threads): + tid = t["id"] + print(f" Fetching thread {i+1}/{len(threads)}: {tid}") + msgs = fetch_thread_messages(service, tid) + all_thread_messages[tid] = msgs + for m in msgs: + register_peer(m["from"]) + for addr in parse_address_list(m["to"]): + register_peer(addr) + for addr in parse_address_list(m["cc"]): + register_peer(addr) + for addr in parse_address_list(m["bcc"]): + register_peer(addr) + + # Summary + total_msgs = sum(len(v) for v in all_thread_messages.values()) + print("\nSummary:") + print(f" Threads: {len(all_thread_messages)}") + print(f" Messages: {total_msgs}") + print(f" Unique participants: {len(seen_peers)}") + for email, info in seen_peers.items(): + print(f" {info['peer_id']} ({info['name']} <{email}>)") + + if args.dry_run: + print("\n[DRY RUN] Would create the above in Honcho. Showing first message per thread:") + for tid, msgs in all_thread_messages.items(): + m = msgs[0] + body_preview = m["body"][:120].replace("\n", " ") if m["body"] else m["snippet"][:120] + print(f" Thread {tid}: {m['subject']}") + print(f" {m['from']} @ {m['date']}") + print(f" {body_preview}...") + return + + # Load into Honcho + from honcho import Honcho + + print(f"\nLoading into Honcho workspace '{args.workspace}'...") + honcho = Honcho(workspace_id=args.workspace) + + # Create peers + peers = {} + for i, (email, info) in enumerate(seen_peers.items()): + if i > 0 and i % 4 == 0: + time.sleep(1) + peers[email] = honcho.peer(info["peer_id"], metadata={ + "email": email, + "name": info["name"], + "source": "gmail", + }) + print(f" Peer: {info['peer_id']}") + + # Create sessions and messages per thread + for tid, msgs in all_thread_messages.items(): + subject = msgs[0]["subject"] if msgs else "No subject" + session_id = f"gmail-thread-{tid}" + + thread_peer_emails = set() + for m in msgs: + thread_peer_emails.add(extract_email(m["from"])) + for addr in parse_address_list(m["to"]): + thread_peer_emails.add(extract_email(addr)) + for addr in parse_address_list(m["cc"]): + thread_peer_emails.add(extract_email(addr)) + for addr in parse_address_list(m["bcc"]): + thread_peer_emails.add(extract_email(addr)) + thread_peers = [peers[e] for e in thread_peer_emails if e in peers] + + session = honcho.session(session_id, metadata={ + "gmail_thread_id": tid, + "subject": subject, + "source": "gmail", + "message_count": len(msgs), + }) + session.add_peers(thread_peers) + + honcho_msgs = [] + for m in msgs: + email = extract_email(m["from"]) + peer = peers.get(email) + if not peer: + continue + content = m["body"] if m["body"] else m["snippet"] + if not content: + continue + honcho_msgs.append(peer.message( + content, + metadata={ + "gmail_id": m["id"], + "subject": m["subject"], + "from": m["from"], + "to": m["to"], + "labels": m["labels"], + }, + created_at=m["timestamp"], + )) + + if honcho_msgs: + session.add_messages(honcho_msgs) + print(f" Session {session_id}: {len(honcho_msgs)} messages — {subject[:60]}") + + print(f"\nDone! Loaded {total_msgs} messages into workspace '{args.workspace}'.") + + +if __name__ == "__main__": + main() + +``` + +## Next Steps + + + + See how the Granola integration maps to common Honcho patterns. + + + Source code and example script. + + diff --git a/docs/v3/guides/granola.mdx b/docs/v3/guides/granola.mdx new file mode 100644 index 00000000..07be0a01 --- /dev/null +++ b/docs/v3/guides/granola.mdx @@ -0,0 +1,953 @@ +--- +title: "Granola" +icon: 'microphone' +description: "Import meeting notes and transcripts from Granola into Honcho" +sidebarTitle: 'Granola' +--- + +In this tutorial, we'll walk through how to import your [Granola](https://granola.ai) meeting data into Honcho. By the end, your meeting participants, transcripts, and summaries will be mapped onto Honcho's peer and session model — giving your agents queryable memory of the people you meet with. + +This guide includes a ready-to-run Python script that handles everything: Granola OAuth, meeting fetching, participant detection, and interactive import. You can run it as-is or use the full tutorial below to understand each design decision. + + +The full script is available on [GitHub](https://github.com/plastic-labs/honcho/tree/main/examples/granola). + + +## TL;DR + +If you just want to get your meetings into Honcho, here's everything you need. + +### 1. Install Dependencies + + +```bash uv +uv pip install honcho-ai httpx +``` + +```bash pip +pip install honcho-ai httpx +``` + + +### 2. Set Your API Key + +```bash +export HONCHO_API_KEY="your-key-from-app.honcho.dev" +``` + +### 3. Run the Script + + +```bash uv +uv run python honcho_granola.py +``` + +```bash python +python honcho_granola.py +``` + + +The script will: +1. Open your browser for Granola OAuth authentication +2. Fetch all meetings and their content +3. Walk you through each meeting interactively — confirm peers, choose import mode, skip meetings you don't want +4. Print a summary of what was transferred + +That's it — your meetings are now queryable in Honcho. Read on if you want to understand how the script works and the design decisions behind it. + +--- + +## Full Tutorial + +### How Granola Maps to Honcho + +The core idea is straightforward: each Granola meeting becomes a Honcho session, and each participant becomes a peer. Here's the full mapping: + +| Granola Concept | Honcho Concept | Details | +|-----------------|----------------|---------| +| Your Granola account | Workspace (`granola`) | One workspace for all meetings | +| Meeting participant | Peer | Email as ID for deduplication across meetings | +| Individual meeting | Session (`meeting-{id}`) | One session per meeting | +| Transcript turns | Messages with attribution | Two-person calls get full speaker attribution | +| Meeting summary | Message from note creator | Multi-person calls store the summary | + +### Email as Peer ID + +The script uses email addresses as the basis for peer IDs, normalized to a URL-safe format (e.g., `alice@example.com` becomes `alice-example-com`). This ensures consistent identification across meetings — if you meet someone in 5 different calls, all conversations accumulate under the same peer. + +```python +# These all resolve to the same peer: +honcho.peer("alice-example-com") # From Meeting A +honcho.peer("alice-example-com") # From Meeting B +``` + +This also means peers are consistent across data sources. If you import both Granola meetings and Gmail threads for the same person, they merge under the same peer ID. + +### Auto-Detecting "Me" + +Granola marks the note creator in its participant list with `(note creator)`. The script uses this to identify you automatically — no configuration needed. + +``` +Participants: You (note creator) from Your Company , + Alice from Acme Corp +``` + +### Two-Person Calls: Full Attribution + +When exactly one other participant is present *and* the transcript contains `Them:` turns, the script stores the transcript with speaker-attributed messages. Consecutive same-speaker turns are merged before storing, cleaning up the fragmentation that's common in raw transcripts. + +```python +session.add_messages([ + me.message("What's your timeline for the launch?"), + them.message("We're targeting Q2, but it depends on the API integration."), +]) +``` + +### Multi-Person Calls: Summary Mode + +Granola's transcript uses `Them:` for all non-creator speakers with no disambiguation — in a 4-person call, everyone else is just `Them:`. Rather than guess incorrectly, the script stores Granola's summary as your record of the meeting, with participants in metadata. + +```python +session.add_messages([ + me.message( + f"Meeting: Product Planning\n" + f"Date: Mar 5, 2026 2:00 PM\n" + f"Participants: Alice from Acme Corp, Bob from Widgets Inc\n\n" + f"{meeting_summary}", + metadata={ + "participants": "Alice from Acme Corp, Bob from Widgets Inc", + "mode": "summary", + "granola_meeting_id": meeting_id, + } + ) +]) +``` + +The summary is attributed to you because it's *your* record of what happened. Granola captured your notes from a meeting where those people were present. + +### Interactive Confirmation + +For each meeting, you choose the import mode: two-person (full attribution), summary, or skip. For multi-person calls that are actually 1:1s (extra participants listed but didn't speak), you can override the detection and select the actual speaker. + +### Noisy Transcripts Preserved + +Granola's raw transcripts are often fragmented (`Me: Yeah. Them: Yeah. Me: And.`). The script merges consecutive same-speaker turns but otherwise preserves the raw content. Honcho's reasoning extracts signal from noisy data. + +### Querying After Import + +Once your meetings are in Honcho, you can query any peer: + +```python +import os +from honcho import Honcho + +honcho = Honcho(workspace_id="granola", api_key=os.environ["HONCHO_API_KEY"]) + +# Peer IDs are normalized from emails: alice@example.com -> alice-example-com +alice = honcho.peer("alice-example-com") +print(alice.chat("What is Alice working on?")) +print(alice.chat("What concerns has Alice raised?")) + +me = honcho.peer("you-example-com") +print(me.chat("What topics do I discuss most frequently?")) +``` + +### Combining with Other Sources + +Because meetings live in a standard Honcho workspace, you can enrich peer representations with data from other channels: + +```python +# Same workspace, same peer — data accumulates +alice = honcho.peer("alice-example-com") +me = honcho.peer("you-example-com") + +discord_session = honcho.session("discord-general-2024-03") +discord_session.add_messages([ + alice.message("Just shipped the new API version!"), + me.message("Congrats! How's the migration guide coming?"), +]) + +# Queries now draw from both meeting transcripts AND Discord history +alice.chat("What has Alice shipped recently?") +``` + +--- + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| Granola OAuth fails | Ensure you have a paid Granola plan (MCP requires Pro+). Clear cached token and retry. | +| Missing transcripts | Free tier has no transcript access. The script falls back to summary content. | +| 500 errors from Honcho | Check for null bytes or control characters in transcript content. The script sanitizes these automatically. | +| Rate limiting with many meetings | The script processes sequentially with delays. Honcho ingestion is async — don't poll for immediate results. | + +## Full Script + + +```python +#!/usr/bin/env python3 +"""Load Granola meeting notes into Honcho. + +Uses the Granola MCP server (with OAuth) to fetch meetings and the Honcho Python SDK +to store them. Each meeting becomes a Honcho session. Two-person meetings get full +speaker attribution; multi-person meetings are stored as summaries. + +Prerequisites: + pip install honcho-ai httpx + +Environment Variables: + HONCHO_API_KEY - Your Honcho API key (get from app.honcho.dev/api-keys) + +Usage: + python honcho_granola.py +""" + +import asyncio +import base64 +import hashlib +import json +import os +import re +import secrets +import sys +import threading +import traceback +import webbrowser +from dataclasses import dataclass, field +from datetime import datetime, timezone +from http.server import HTTPServer, BaseHTTPRequestHandler +from typing import Any +from urllib.parse import parse_qs, urlencode, urlparse + +import httpx + + +@dataclass +class Participant: + name: str + email: str | None = None + org: str | None = None + + +@dataclass +class ParsedParticipants: + note_creator: Participant | None = None + others: list[Participant] = field(default_factory=list) + + +@dataclass +class TranscriptTurn: + speaker: str + text: str + + +# Granola MCP + OAuth endpoints +GRANOLA_MCP_URL = "https://mcp.granola.ai/mcp" +AUTH_BASE = "https://mcp-auth.granola.ai" +OAUTH_REDIRECT_PORT = 8765 +OAUTH_REDIRECT_URI = f"http://localhost:{OAUTH_REDIRECT_PORT}/callback" + +# Honcho message size limit (25000 max, leave headroom) +MAX_MESSAGE_LEN = 24000 + + +# --------------------------------------------------------------------------- +# OAuth callback handler (must be a class for BaseHTTPRequestHandler) +# --------------------------------------------------------------------------- + +class _OAuthCallback(BaseHTTPRequestHandler): + auth_result: dict[str, str | None] = {"code": None, "error": None} + + def do_GET(self): + params = parse_qs(urlparse(self.path).query) + if "code" in params: + _OAuthCallback.auth_result["code"] = params["code"][0] + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(b"

Authenticated! You can close this window.

") + elif "error" in params: + _OAuthCallback.auth_result["error"] = params.get("error_description", params["error"])[0] + self.send_response(400) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(f"

Error: {_OAuthCallback.auth_result['error']}

".encode()) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, fmt, *args): + pass + + +# --------------------------------------------------------------------------- +# Granola OAuth + MCP +# --------------------------------------------------------------------------- + +async def authenticate(http_client: httpx.AsyncClient) -> str: + """Perform OAuth (DCR + PKCE) with Granola. Returns access token.""" + _OAuthCallback.auth_result = {"code": None, "error": None} + + print("\nAuthenticating with Granola...") + + # Register client (DCR) + resp = await http_client.post( + f"{AUTH_BASE}/oauth2/register", + json={ + "client_name": "Granola to Honcho Transfer", + "redirect_uris": [OAUTH_REDIRECT_URI], + "grant_types": ["authorization_code"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + }, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Client registration failed: {resp.status_code}") + client_id = resp.json().get("client_id") + + # PKCE + verifier = secrets.token_urlsafe(32) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + + # Browser auth + auth_url = f"{AUTH_BASE}/oauth2/authorize?" + urlencode({ + "client_id": client_id, + "redirect_uri": OAUTH_REDIRECT_URI, + "response_type": "code", + "state": "granola-honcho-transfer", + "code_challenge": challenge, + "code_challenge_method": "S256", + }) + + server = HTTPServer(("localhost", OAUTH_REDIRECT_PORT), _OAuthCallback) + thread = threading.Thread(target=server.handle_request) + thread.start() + + print(" Opening browser for authentication...") + webbrowser.open(auth_url) + thread.join(timeout=120) + server.server_close() + + auth_result = _OAuthCallback.auth_result + if auth_result["error"]: + raise RuntimeError(f"Authentication failed: {auth_result['error']}") + if not auth_result["code"]: + raise RuntimeError("Authentication timed out") + + # Exchange code for token + resp = await http_client.post( + f"{AUTH_BASE}/oauth2/token", + data={ + "grant_type": "authorization_code", + "code": auth_result["code"], + "redirect_uri": OAUTH_REDIRECT_URI, + "client_id": client_id, + "code_verifier": verifier, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if resp.status_code != 200: + raise RuntimeError(f"Token exchange failed: {resp.status_code}") + + print(" Authenticated successfully!") + return resp.json()["access_token"] + + +async def call_mcp_tool( + http_client: httpx.AsyncClient, + access_token: str, + tool_name: str, + arguments: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Call a Granola MCP tool, handling both JSON and SSE responses.""" + resp = await http_client.post( + GRANOLA_MCP_URL, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments or {}}, + }, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + ) + if resp.status_code != 200: + raise RuntimeError(f"MCP call failed: {resp.status_code} - {resp.text}") + + # SSE response + if "text/event-stream" in resp.headers.get("content-type", ""): + result = None + for line in resp.text.split("\n"): + if line.strip().startswith("data: "): + try: + parsed = json.loads(line.strip()[6:]) + if "result" in parsed: + result = parsed + elif "error" in parsed: + raise RuntimeError(f"MCP error: {parsed['error']}") + except json.JSONDecodeError: + continue + if result: + final = result.get("result", {}) + return final if isinstance(final, dict) else {"result": final} + raise RuntimeError("No result in SSE response") + + # JSON response + result = resp.json() + if "error" in result: + raise RuntimeError(f"MCP error: {result['error']}") + return result.get("result", {}) + + +def extract_mcp_text(result: dict[str, Any]) -> str: + """Extract text from the first content block of an MCP result. + + Raises ValueError if the response structure is unexpected. + """ + content = result.get("content", []) + if not isinstance(content, list) or not content: + raise ValueError(f"MCP response missing content array: {list(result.keys())}") + first = content[0] + if not isinstance(first, dict) or "text" not in first: + raise ValueError(f"MCP content block missing 'text' field: {first}") + return str(first["text"]) + + +# --------------------------------------------------------------------------- +# Granola data fetching +# --------------------------------------------------------------------------- + +async def list_meetings( + http_client: httpx.AsyncClient, access_token: str, limit: int = 100, +) -> list[dict[str, Any]]: + """List meetings from Granola MCP. Parses Granola's XML-like response format.""" + result = await call_mcp_tool(http_client, access_token, "list_meetings", {"limit": limit}) + text = extract_mcp_text(result) + + meetings: list[dict[str, Any]] = [] + for match in re.finditer(r'", match.end()) + block = text[match.end():block_end] if block_end != -1 else "" + p_match = re.search(r"\s*(.*?)\s*", block, re.DOTALL) + meetings.append({ + "id": mid, + "title": title, + "date": date, + "participants": p_match.group(1).strip() if p_match else "", + }) + + return meetings + + +async def get_meeting_details( + http_client: httpx.AsyncClient, access_token: str, meeting_id: str, +) -> dict[str, Any]: + """Get full meeting details including notes.""" + result = await call_mcp_tool(http_client, access_token, "get_meetings", {"meeting_ids": [meeting_id]}) + text = extract_mcp_text(result) + return {"id": meeting_id, "raw_content": text} + + +async def get_meeting_transcript( + http_client: httpx.AsyncClient, access_token: str, meeting_id: str, + max_retries: int = 3, +) -> str | None: + """Get transcript for a meeting (paid tiers only). + + Retries on rate limit responses with exponential backoff. + """ + for attempt in range(max_retries): + try: + result = await call_mcp_tool(http_client, access_token, "get_meeting_transcript", {"meeting_id": meeting_id}) + text = extract_mcp_text(result) + except Exception as e: + print(f" Transcript unavailable: {e}") + return None + + if not text or "no transcript" in text.lower(): + return None + + # Granola returns rate limit errors as content text, not HTTP errors + if "rate limit" in text.lower(): + wait = 2 ** attempt * 3 # 3s, 6s, 12s + print(f" ⚠ Granola rate limit hit (attempt {attempt + 1}/{max_retries}), waiting {wait}s...") + await asyncio.sleep(wait) + continue + + return text + + print(f" ⚠ Transcript skipped after {max_retries} rate limit retries") + return None + + +async def fetch_all_meetings( + http_client: httpx.AsyncClient, access_token: str, +) -> list[dict[str, Any]]: + """Fetch meeting list and enrich each with transcript and details.""" + print("\nFetching meetings from Granola...") + meetings = await list_meetings(http_client, access_token, limit=500) + if not meetings: + print("No meetings found.") + return [] + print(f" Found {len(meetings)} meetings. Fetching content...\n") + + for i, m in enumerate(meetings, 1): + mid = m.get("id") + if not mid: + continue + + transcript = await get_meeting_transcript(http_client, access_token, mid) + if transcript: + m["transcript"] = transcript + + try: + m.update(await get_meeting_details(http_client, access_token, mid)) + except Exception as exc: + print(f" Failed to fetch details for {mid}: {exc}") + + has_t = "transcript" in m + has_s = bool(extract_summary(m)) + label = "transcript+summary" if has_t and has_s else "transcript only" if has_t else "summary only" if has_s else "basic only" + print(f" [{i}/{len(meetings)}] {label}: {m.get('title', 'Untitled')[:45]}") + await asyncio.sleep(1.5) # rate limit + + return meetings + + +# --------------------------------------------------------------------------- +# Parsing helpers +# --------------------------------------------------------------------------- + +def parse_participants(participants_str: str) -> ParsedParticipants: + """Parse Granola's participant string into structured participants. + + Warns on unparsable entries instead of silently dropping them. + """ + result = ParsedParticipants() + if not participants_str: + return result + + # Split on commas, but not inside angle brackets + entries, current, depth = [], [], 0 + for ch in participants_str: + if ch == "<": + depth += 1 + elif ch == ">": + depth = max(depth - 1, 0) + elif ch == "," and depth == 0: + entries.append("".join(current)) + current = [] + continue + current.append(ch) + if current: + entries.append("".join(current)) + + for entry in entries: + entry = entry.strip() + if not entry: + continue + + is_creator = "(note creator)" in entry + clean = entry.replace("(note creator)", "").strip() + + email_match = re.search(r"<([^>]+)>", clean) + email = email_match.group(1) if email_match else None + name = re.sub(r"\s*<[^>]+>", "", clean).strip() + + if not name: + print(f" Warning: could not parse participant entry: {entry!r}") + continue + + org = None + org_match = re.match(r"(.+?)\s+from\s+(.+)", name) + if org_match: + name, org = org_match.group(1).strip(), org_match.group(2).strip() + + person = Participant(name=name, email=email, org=org) + if is_creator: + result.note_creator = person + else: + result.others.append(person) + + return result + + +def parse_transcript_turns(raw: str) -> list[TranscriptTurn]: + """Split a Granola transcript into speaker turns.""" + # Unwrap JSON wrapper if present + try: + parsed = json.loads(raw) + if isinstance(parsed, dict) and "transcript" in parsed: + raw = str(parsed["transcript"]) + except (json.JSONDecodeError, TypeError): + pass + + parts = re.split(r"(?:^|\s{2,})(Me|Them):\s*", raw) + turns: list[TranscriptTurn] = [] + i = 1 + while i < len(parts) - 1: + text = parts[i + 1].strip() + if text: + turns.append(TranscriptTurn(speaker=parts[i], text=text)) + i += 2 + return turns + + +def extract_summary(meeting: dict[str, Any]) -> str: + """Extract best available summary text from meeting data.""" + candidates = [] + for key in ("summary", "notes", "note", "meeting_notes", "description"): + val = meeting.get(key) + if isinstance(val, str) and val.strip(): + candidates.append(val.strip()) + + raw = meeting.get("raw_content") + if isinstance(raw, str) and raw.strip(): + candidates.append(raw.strip()) + + for c in candidates: + for tag in ("summary", "notes"): + m = re.search(rf"<{tag}>\s*(.*?)\s*", c, re.DOTALL) + if m: + return m.group(1).strip() + + return candidates[0] if candidates else "" + + +def peer_id_from(value: str) -> str: + """Normalize a name or email into a Honcho-safe peer ID.""" + norm = re.sub(r"[^a-z0-9_-]+", "-", value.strip().lower()) + norm = re.sub(r"-{2,}", "-", norm).strip("-_") + return (norm or "peer")[:100] + + +def sanitize(text: str) -> str: + """Remove null bytes and control characters.""" + return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text) + + +def parse_date(date_str: str) -> datetime: + """Parse Granola's date format into a timezone-aware datetime. + + Raises ValueError if the date string doesn't match any known format. + """ + for fmt in ["%b %d, %Y %I:%M %p", "%b %d, %Y %I:%M:%S %p", "%B %d, %Y %I:%M %p"]: + try: + return datetime.strptime(date_str, fmt).replace(tzinfo=timezone.utc) + except ValueError: + continue + raise ValueError(f"Unrecognized date format: {date_str!r}") + + +# --------------------------------------------------------------------------- +# Honcho import helpers +# --------------------------------------------------------------------------- + +def build_messages( + peer: Any, + content: str, + metadata: dict[str, object] | None, + created_at: datetime, +) -> list[Any]: + """Build chunked messages for a single peer, attaching metadata to the first chunk.""" + messages = [] + content = sanitize(content) + for start in range(0, len(content), MAX_MESSAGE_LEN): + chunk = content[start:start + MAX_MESSAGE_LEN] + msg_meta = metadata if start == 0 else None + messages.append(peer.message(chunk, metadata=msg_meta, created_at=created_at)) + return messages + + +def send_messages(session: Any, messages: list[Any]) -> None: + """Send messages to a session in batches of 100.""" + for batch_start in range(0, len(messages), 100): + session.add_messages(messages[batch_start:batch_start + 100]) + + +def import_two_person( + honcho: Any, + session: Any, + me_peer_id: str, + them_peer_id: str, + turns: list[TranscriptTurn], + metadata: dict[str, object], + created_at: datetime, +) -> None: + """Import a two-person meeting with speaker attribution.""" + me_peer = honcho.peer(me_peer_id) + them_peer = honcho.peer(them_peer_id) + + # Merge consecutive same-speaker turns + merged: list[TranscriptTurn] = [] + for t in turns: + if merged and merged[-1].speaker == t.speaker: + merged[-1].text += " " + t.text + else: + merged.append(TranscriptTurn(speaker=t.speaker, text=t.text)) + + messages: list[Any] = [] + for i, t in enumerate(merged): + peer = me_peer if t.speaker == "Me" else them_peer + msg_meta = metadata if i == 0 else None + messages.extend(build_messages(peer, t.text, msg_meta, created_at)) + + send_messages(session, messages) + print(f" -> Imported as 2-person ({me_peer_id} + {them_peer_id})") + + +def import_summary( + honcho: Any, + session: Any, + me_peer_id: str, + meeting: dict[str, Any], + metadata: dict[str, object], + created_at: datetime, +) -> None: + """Import a meeting as a summary message.""" + me_peer = honcho.peer(me_peer_id) + summary = extract_summary(meeting) + if not summary: + raw_t = meeting.get("transcript", "") + try: + parsed = json.loads(raw_t) + summary = str(parsed.get("transcript", "")) if isinstance(parsed, dict) else raw_t + except (json.JSONDecodeError, TypeError): + summary = raw_t + summary = summary or "No content available" + + title = meeting.get("title", "Untitled") + date = meeting.get("date", "") + header = f"Meeting: {title}\nDate: {date}\nParticipants: {meeting.get('participants', '')}\n\n" + + messages = build_messages(me_peer, header + summary, metadata, created_at) + send_messages(session, messages) + print(" -> Imported as summary") + + +def resolve_them_participant(others: list[Participant]) -> Participant | None: + """Ask user to pick which participant is 'Them' from a multi-person meeting.""" + for j, p in enumerate(others, 1): + email_str = f" <{p.email}>" if p.email else "" + print(f" {j}. {p.name}{email_str}") + idx_str = input(f" Who is 'Them'? [1-{len(others)}]: ").strip() + try: + return others[int(idx_str) - 1] + except (ValueError, IndexError): + print(" Invalid selection.") + return None + + +def review_meeting( + index: int, + total: int, + meeting: dict[str, Any], + participants: ParsedParticipants, + turns: list[TranscriptTurn], +) -> tuple[str, Participant | None]: + """Display meeting info and get user's import choice. + + Returns (mode, them_participant) where mode is one of: + - "two_person": import with speaker attribution using them_participant + - "summary": import as a single summary message + - "skip": skip this meeting + """ + title = meeting.get("title", "Untitled") + date = meeting.get("date", "") + creator = participants.note_creator + others = participants.others + + me_turns = sum(1 for t in turns if t.speaker == "Me") + them_turns = len(turns) - me_turns + total_words = sum(len(t.text.split()) for t in turns) + + print(f"\n{'─' * 60}") + print(f" [{index}/{total}] {title}") + print(f" Date: {date}") + if creator: + print(f" You: {creator.name} <{creator.email}>") + for j, p in enumerate(others, 1): + email_str = f" <{p.email}>" if p.email else "" + org_str = f" ({p.org})" if p.org else "" + print(f" {j}. {p.name}{email_str}{org_str}") + + has_transcript = bool(meeting.get("transcript")) + if turns: + print(f" Transcript: {me_turns} Me, {them_turns} Them, ~{total_words} words") + if them_turns == 0: + print(" ** No 'Them' turns — nobody else spoke **") + if total_words < 30: + print(" ** Very short — might be empty **") + elif has_transcript: + raw = meeting["transcript"] + print(f" Transcript: present ({len(raw)} chars) but could not parse speaker turns") + print(f" Preview: {raw[:200]!r}") + else: + print(f" Content: {'summary available' if extract_summary(meeting) else 'metadata only'}") + + # Two-person default: exactly one other participant with transcript + if len(others) == 1 and them_turns > 0: + them_label = others[0].name + (f" <{others[0].email}>" if others[0].email else "") + print(f"\n Detected: 2-person call (you + {them_label})") + choice = input(" [Enter] 2-person / [s]ummary / [k] skip: ").strip().lower() + while choice not in ("", "s", "k"): + choice = input(" [Enter] 2-person / [s]ummary / [k] skip: ").strip().lower() + if choice == "k": + return ("skip", None) + if choice == "s": + return ("summary", None) + return ("two_person", others[0]) + + # Multi-person with transcript + if len(others) > 1 and them_turns > 0: + print(f"\n {len(others)} participants") + choice = input(" [Enter] summary / [2] 2-person / [k] skip: ").strip().lower() + while choice not in ("", "2", "k"): + choice = input(" [Enter] summary / [2] 2-person / [k] skip: ").strip().lower() + if choice == "k": + return ("skip", None) + if choice == "2": + them = resolve_them_participant(others) + if them is None: + return ("summary", None) + return ("two_person", them) + return ("summary", None) + + # No transcript or no other speakers + choice = input(" [Enter] summary / [k] skip: ").strip().lower() + while choice not in ("", "k"): + choice = input(" [Enter] summary / [k] skip: ").strip().lower() + if choice == "k": + return ("skip", None) + return ("summary", None) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +async def main(): + print("=" * 60) + print(" Granola -> Honcho Meeting Notes Transfer") + print("=" * 60) + + if not os.environ.get("HONCHO_API_KEY"): + print("\nError: HONCHO_API_KEY not set.") + print(" Get your key at: https://app.honcho.dev/api-keys") + sys.exit(1) + + async with httpx.AsyncClient(timeout=60.0) as http_client: + try: + access_token = await authenticate(http_client) + meetings = await fetch_all_meetings(http_client, access_token) + if not meetings: + sys.exit(0) + + from honcho import Honcho + + honcho = Honcho(workspace_id="granola") + seen_peers: set[str] = set() + results = {"imported": 0, "skipped": 0, "failed": 0} + + print("\n" + "=" * 60) + print(" Review each meeting") + print("=" * 60) + + for i, m in enumerate(meetings, 1): + mid = m.get("id") + if not mid: + continue + + participants = parse_participants(m.get("participants", "")) + turns = parse_transcript_turns(m["transcript"]) if m.get("transcript") else [] + + mode, them = review_meeting(i, len(meetings), m, participants, turns) + + if mode == "skip": + print(" -> Skipped") + results["skipped"] += 1 + continue + + # Resolve creator peer + creator = participants.note_creator + me_source = (creator.email or creator.name) if creator else None + if not me_source: + print(" -> Skipped (no creator identifier)") + results["skipped"] += 1 + continue + + me_peer_id = peer_id_from(me_source) + if me_peer_id not in seen_peers: + print(f" New peer: {me_source} ({me_peer_id})") + seen_peers.add(me_peer_id) + + try: + created_at = parse_date(m.get("date", "")) + session = honcho.session(f"meeting-{mid}") + metadata: dict[str, object] = { + "title": m.get("title", "Untitled"), + "date": m.get("date", ""), + "granola_meeting_id": mid, + "mode": mode, + } + + if mode == "two_person" and them is not None: + them_source = them.email or them.name + them_peer_id = peer_id_from(them_source) + if them_peer_id not in seen_peers: + print(f" New peer: {them_source} ({them_peer_id})") + seen_peers.add(them_peer_id) + import_two_person(honcho, session, me_peer_id, them_peer_id, turns, metadata, created_at) + else: + import_summary(honcho, session, me_peer_id, m, metadata, created_at) + + results["imported"] += 1 + + except ValueError as e: + print(f" -> FAILED: {e}") + results["failed"] += 1 + except Exception as e: + print(f" -> FAILED: {e}") + traceback.print_exc() + results["failed"] += 1 + + # Done + print("\n" + "=" * 60) + print(" Transfer Complete!") + print("=" * 60) + print(f"\n Imported: {results['imported']}") + print(f" Skipped: {results['skipped']}") + print(f" Failed: {results['failed']}") + print(" Workspace: granola") + print(f" Peers: {sorted(seen_peers)}") + + except KeyboardInterrupt: + print("\n\nAborted.") + sys.exit(0) + except Exception as e: + print(f"\nTransfer failed: {e}") + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) +``` +
+ + +## Next Steps + + + + See how the Granola integration maps to common Honcho patterns. + + + Source code and example script. + + diff --git a/docs/v3/guides/integrations/hermes.mdx b/docs/v3/guides/integrations/hermes.mdx new file mode 100644 index 00000000..9fe46973 --- /dev/null +++ b/docs/v3/guides/integrations/hermes.mdx @@ -0,0 +1,139 @@ +--- +title: "Hermes Agent + Honcho" +sidebarTitle: "Hermes Agent" +description: "How Hermes Agent uses Honcho for persistent cross-session memory and user modeling" +icon: "message-bot" +--- + +[Hermes Agent](https://github.com/NousResearch/hermes-agent) is an open-source AI agent from [Nous Research](https://nousresearch.com) with tool-calling, terminal access, a skills system, and multi-platform deployment (Telegram, Discord, Slack, WhatsApp). Honcho gives Hermes persistent cross-session memory and user modeling. + +For setup, configuration, and CLI commands, see the [Hermes Agent Honcho docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/honcho). + +## What Honcho provides + +Honcho acts as a long-term memory and user-model layer alongside Hermes' built-in memory files (`MEMORY.md` and `USER.md`). + +It gives Hermes three capabilities: + +1. **Prompt-time context injection** -- durable context about a user loaded into the prompt before generating a response. +2. **Cross-session continuity** -- recall of stable preferences, project history, and working context across conversations. +3. **Durable writeback** -- stable facts learned during a conversation stored back for future turns. + +These sit alongside Hermes' local session history. Session history remembers the current conversation. Honcho remembers what should still matter later. + +## Dual-peer architecture + +Both the user and the AI agent have peer representations in Honcho: + +- **User peer**: observed from user messages. Learns preferences, goals, communication style. +- **AI peer**: observed from assistant messages. Builds the agent's knowledge representation. + +Both representations are injected into the system prompt, giving Hermes awareness of both who it's talking to and what it knows. + +## Available tools + +Hermes exposes four Honcho tools to the agent: + +| Tool | What it does | +|---|---| +| `honcho_profile` | Fast peer card retrieval (no LLM). Returns curated key facts about the user. | +| `honcho_search` | Semantic search over memory. Returns raw excerpts ranked by relevance. | +| `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. | + +## Running Honcho locally with Hermes + +Follow the [Self-Hosting Guide](/v3/contributing/self-hosting) to get Honcho running locally. Once it's up, point Hermes at your instance: + +```bash +hermes memory setup # select "honcho", enter http://localhost:8000 as the base URL +``` + +Or manually create/edit the config file (checked in order: `$HERMES_HOME/honcho.json` > `~/.hermes/honcho.json` > `~/.honcho/config.json`): + +```json +{ + "baseUrl": "http://localhost:8000", + "hosts": { + "hermes": { + "enabled": true, + "aiPeer": "hermes", + "peerName": "your-name", + "workspace": "hermes" + } + } +} +``` + +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). + + +**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 + +### 1. Check status + +```bash +hermes memory status +``` + +This should show Honcho as the active memory provider with your base URL. + +### 2. Store a fact and recall it across sessions + +In one conversation, tell Hermes something specific: + +```text +My favorite programming language is Rust and I always use dark mode. +``` + +Start a **new session** (different thread, new CLI invocation, or a different platform). Ask: + +```text +What do you know about my preferences? +``` + +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 tool calling directly + +Ask Hermes to use a specific Honcho tool: + +```text +Use your honcho_search tool to find anything you know about me. +``` + +If Hermes calls the tool and returns results, the full tool pipeline (API connection, vector search, embedding) is functional. + +## 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 + + + + Setup, configuration, CLI commands, and all config options. + + + + Source code, installation, and full documentation. + + + + Peers, sessions, and how reasoning works. + + + + Full local environment setup, provider configuration, and troubleshooting. + + diff --git a/docs/v3/guides/integrations/mcp.mdx b/docs/v3/guides/integrations/mcp.mdx index 9c828580..37d2f977 100644 --- a/docs/v3/guides/integrations/mcp.mdx +++ b/docs/v3/guides/integrations/mcp.mdx @@ -1,19 +1,33 @@ --- title: "Model Context Protocol (MCP)" icon: 'star-of-life' -description: "Use Honcho in Claude Desktop" +description: "Give any AI tool persistent memory with the Honcho MCP server" sidebarTitle: 'MCP' --- -You can let Claude use Honcho to manage its own memory in the native desktop app by using the Honcho MCP integration! Follow these steps: +The Honcho MCP server gives any MCP-compatible AI tool persistent memory and personalization. Connect it once and your AI assistant learns who you are, remembers your preferences, and gets better over time — across every conversation. -1. Go to https://app.honcho.dev and get an API key. Then go to Claude Desktop and navigate to custom MCP servers. +**Server URL:** `https://mcp.honcho.dev` -If you don't have node installed you will need to do that. Claude Desktop or Claude Code can help! +You'll need an API key from [app.honcho.dev](https://app.honcho.dev) to use the hosted MCP server. -2. Add Honcho to your Claude desktop config. You must provide a username for Honcho to refer to you as -- preferably what you want Claude to actually call you. +## Client Setup + +Pick your client below and add the config. After adding, **restart the client fully** for changes to take effect. + +### Claude Desktop + + + +Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: + + +Edit `%APPDATA%\Claude\claude_desktop_config.json`: + + + ```json { "mcpServers": { @@ -28,15 +42,202 @@ If you don't have node installed you will need to do that. Claude Desktop or Cla "X-Honcho-User-Name:${USER_NAME}" ], "env": { - "AUTH_HEADER": "Bearer ", - "USER_NAME": "" + "AUTH_HEADER": "Bearer hch-your-key-here", + "USER_NAME": "YourName" } } } } ``` -You may customize your assistant name and/or workspace ID. Both are optional. + +After saving, fully quit and relaunch Claude Desktop. The Honcho tools should appear in the tool picker. + + +For best results, create a project and paste these [instructions](https://raw.githubusercontent.com/plastic-labs/honcho/refs/heads/main/mcp/instructions.md) into the "Project Instructions" field so Claude knows how to use the memory tools. + +### Claude Code + +```bash +claude mcp add honcho \ + --transport http \ + --url "https://mcp.honcho.dev" \ + --header "Authorization: Bearer hch-your-key-here" \ + --header "X-Honcho-User-Name: YourName" +``` + +Or if you prefer the [Claude Code Honcho plugin](/v3/guides/integrations/claudecode) for a deeper integration with persistent memory, git awareness, and agent skills: + +```bash +/plugin marketplace add plastic-labs/claude-honcho +``` + +### Codex + +Add to `~/.codex/config.toml`: + +```toml +[mcp_servers.honcho] +command = "npx" +args = [ + "mcp-remote", + "https://mcp.honcho.dev", + "--header", + "Authorization:Bearer hch-your-key-here", + "--header", + "X-Honcho-User-Name:YourName" +] +``` + + +Codex only supports stdio transport, so it uses `mcp-remote` as a bridge. Restart both the Codex CLI and VS Code extension after editing. + + +### Cursor + +Cursor supports MCP servers natively via HTTP. Add to your global config at `~/.cursor/mcp.json` or per-project at `.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "honcho": { + "url": "https://mcp.honcho.dev", + "headers": { + "Authorization": "Bearer hch-your-key-here", + "X-Honcho-User-Name": "YourName" + } + } + } +} +``` + +Alternatively, go to **Cursor Settings → MCP** and add a new HTTP server with the URL and headers above. + +### Windsurf + +Add to `~/.codeium/windsurf/mcp_config.json`: + +```json +{ + "mcpServers": { + "honcho": { + "serverUrl": "https://mcp.honcho.dev", + "headers": { + "Authorization": "Bearer hch-your-key-here", + "X-Honcho-User-Name": "YourName" + } + } + } +} +``` + + +Windsurf uses `serverUrl` instead of `url`. + + +### VS Code (Copilot Chat) + +Add to your workspace `.vscode/mcp.json`: + +```json +{ + "servers": { + "honcho": { + "type": "http", + "url": "https://mcp.honcho.dev", + "headers": { + "Authorization": "Bearer hch-your-key-here", + "X-Honcho-User-Name": "YourName" + } + } + } +} +``` + +Or add to your User Settings JSON (`Cmd+Shift+P` → "Preferences: Open User Settings (JSON)"): + +```json +{ + "mcp": { + "servers": { + "honcho": { + "type": "http", + "url": "https://mcp.honcho.dev", + "headers": { + "Authorization": "Bearer hch-your-key-here", + "X-Honcho-User-Name": "YourName" + } + } + } + } +} +``` + +### Cline + +Cline supports remote MCP servers natively. Open Cline's MCP settings at: + + + +`~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` + + +`%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json` + + + +```json +{ + "mcpServers": { + "honcho": { + "url": "https://mcp.honcho.dev", + "headers": { + "Authorization": "Bearer hch-your-key-here", + "X-Honcho-User-Name": "YourName" + } + } + } +} +``` + +Or add it via the Cline sidebar: click the MCP Servers icon → **Configure** → **Remote Servers**. + +### Zed + +Add to `~/.config/zed/settings.json`: + +```json +{ + "context_servers": { + "honcho": { + "url": "https://mcp.honcho.dev", + "headers": { + "Authorization": "Bearer hch-your-key-here", + "X-Honcho-User-Name": "YourName" + } + } + } +} +``` + + +Zed uses `context_servers` instead of `mcpServers`. Native HTTP support requires Zed v0.214.5 or later. + + +--- + +## Optional Configuration + +You can customize the assistant name and workspace ID by adding extra headers. Both are optional. + +| Header | Default | Description | +|--------|---------|-------------| +| `Authorization` | *required* | `Bearer hch-your-key-here` | +| `X-Honcho-User-Name` | *required* | What the AI should call you | +| `X-Honcho-Assistant-Name` | `"Assistant"` | Name for the AI peer | +| `X-Honcho-Workspace-ID` | `"default"` | Isolate memory per project | + +Example with all headers (Claude Desktop format): ```json { @@ -56,18 +257,52 @@ You may customize your assistant name and/or workspace ID. Both are optional. "X-Honcho-Workspace-ID:${WORKSPACE_ID}" ], "env": { - "AUTH_HEADER": "Bearer ", - "USER_NAME": "", - "ASSISTANT_NAME": "", - "WORKSPACE_ID": "" + "AUTH_HEADER": "Bearer hch-your-key-here", + "USER_NAME": "YourName", + "ASSISTANT_NAME": "Claude", + "WORKSPACE_ID": "my-project" } } } } ``` -3. Restart the Claude Desktop app. Upon relaunch, it should start Honcho and the tools should be available! +--- -4. Finally, Claude needs instructions on how to use Honcho. The Desktop app doesn't allow you to add system prompts directly, but you can create a project and paste these [instructions](https://raw.githubusercontent.com/plastic-labs/honcho/refs/heads/main/mcp/instructions.md) into the "Project Instructions" field. +## Available Tools -Claude should then query for insights before responding and write your messages to storage! If you come up with more creative ways to get Claude to manage its own memory with Honcho, feel free to [let us know](https://discord.gg/plasticlabs) or make a PR on this [repo](https://github.com/plastic-labs/honcho/tree/main/mcp)! +The recommended flow for a standard conversation uses `create_session` + `add_messages_to_session` + `chat`. See the [full instructions](https://raw.githubusercontent.com/plastic-labs/honcho/refs/heads/main/mcp/instructions.md) for a complete walkthrough. + +**Workspace** — `inspect_workspace`, `list_workspaces`, `search`, `get_metadata`, `set_metadata` + +**Peers** — `create_peer`, `list_peers`, `chat`, `get_peer_card`, `set_peer_card`, `get_peer_context`, `get_representation` + +**Sessions** — `create_session`, `list_sessions`, `delete_session`, `clone_session`, `add_peers_to_session`, `remove_peers_from_session`, `get_session_peers`, `inspect_session`, `add_messages_to_session`, `get_session_messages`, `get_session_message`, `get_session_context` + +**Conclusions** — `list_conclusions`, `query_conclusions`, `create_conclusions`, `delete_conclusion` + +**System** — `schedule_dream`, `get_queue_status` + +--- + +## Verify It Works + +After setup, try asking your AI assistant: + +> "What do you know about me?" + +On the first conversation there won't be much — but after a few exchanges, Honcho's background reasoning will start building a representation of you. Ask again after a couple of conversations and you'll see the difference. + +--- + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| Tools don't show up | Make sure you fully restarted the client after adding the config. | +| Authorization errors | Check your API key at [app.honcho.dev](https://app.honcho.dev). It should start with `hch-`. | +| `npx` not found | Install Node.js — your AI assistant can help with this. | +| "No personalization insights found" | Normal for new users. Honcho needs a few conversations to build context. | +| Connection timeouts | Check that `https://mcp.honcho.dev` is accessible from your network. | + +Need help? Join us on [Discord](https://discord.gg/honcho) or open an issue on [GitHub](https://github.com/plastic-labs/honcho/tree/main/mcp). diff --git a/docs/v3/guides/integrations/openclaw.mdx b/docs/v3/guides/integrations/openclaw.mdx index 7f7b9af5..17c569ac 100644 --- a/docs/v3/guides/integrations/openclaw.mdx +++ b/docs/v3/guides/integrations/openclaw.mdx @@ -11,6 +11,7 @@ sidebarTitle: 'OpenClaw' Honcho can run entirely locally with OpenClaw — no external API required. Keep your data on your machine while getting full memory capabilities across all channels. See the [self-hosting guide](/v3/contributing/self-hosting) to get started. +For OpenClaw's own documentation on Honcho, see the [Honcho Memory guide](https://docs.openclaw.ai/concepts/memory-honcho). ## Install the Plugin @@ -67,7 +68,7 @@ Files are uploaded via `session.uploadFile()`. User/owner files go to the owner Once installed, the plugin runs automatically: * **Message Observation** — After every AI turn, the conversation is persisted to Honcho. Both user and agent messages are observed, allowing Honcho to build and refine its models. -* **Tool-Based Context Access** — The AI can query Honcho mid-conversation using tools like `honcho_recall`, `honcho_search`, and `honcho_analyze` to retrieve relevant context. Context is injected during OpenClaw's `before_prompt_build` phase, ensuring accurate turn boundaries. +* **Tool-Based Context Access** — The AI can query Honcho mid-conversation using tools like `honcho_context`, `honcho_search_conclusions`, `honcho_search_messages`, and `honcho_ask` to retrieve relevant context. Context is injected during OpenClaw's `before_prompt_build` phase, ensuring accurate turn boundaries. * **Dual Peer Model** — Honcho maintains separate representations: one for the user (preferences, facts, communication style) and one for the agent (personality, learned behaviors). Each OpenClaw agent gets its own Honcho peer (`agent-{id}`), so multi-agent workspaces maintain isolated memory. * **Clean Persistence** — Platform metadata (conversation info, sender headers, thread context, forwarded messages) is stripped before saving to Honcho, ensuring only meaningful content is persisted. @@ -84,17 +85,16 @@ OpenClaw uses a multi-agent architecture where a primary agent can spawn **subag | Tool | Description | | ---- | ----------- | -| `honcho_session` | Conversation history and summaries from the current session. | -| `honcho_profile` | User's peer card — key facts (name, preferences, role). | -| `honcho_search` | Semantic search over stored observations. | -| `honcho_context` | Full user representation across all sessions. | +| `honcho_context` | User knowledge across all sessions. `detail='card'` for key facts, `'full'` for broad representation. | +| `honcho_search_conclusions` | Semantic vector search over stored conclusions ranked by relevance. | +| `honcho_search_messages` | Find specific messages across all sessions. Filter by sender, date, or metadata. | +| `honcho_session` | Current session history and summary. Supports semantic search within the session. | ### Q&A (LLM-powered) | Tool | Description | | ---- | ----------- | -| `honcho_recall` | Simple factual question — minimal reasoning. | -| `honcho_analyze` | Complex question requiring synthesis — medium reasoning. | +| `honcho_ask` | Ask Honcho a question about the user. `depth='quick'` for facts, `'thorough'` for synthesis. | ## CLI Commands @@ -126,32 +126,27 @@ openclaw honcho setup ## Local File Search (QMD Integration) -The plugin automatically exposes OpenClaw's `memory_search` and `memory_get` tools when a memory backend is configured, allowing both Honcho cloud memory and local file search together. +The plugin automatically exposes OpenClaw's `memory_search` and `memory_get` tools when a [memory backend](https://docs.openclaw.ai/concepts/memory) is configured, allowing both Honcho memory and local file search together. ### Setup 1. Install [QMD](https://github.com/tobi/qmd) on your server -2. Configure OpenClaw in `~/.openclaw/openclaw.json`: +2. Configure OpenClaw to use QMD as the memory backend in `~/.openclaw/openclaw.json`: ```json { "memory": { - "backend": "qmd", - "qmd": { - "limits": { - "timeoutMs": 120000 - } - } + "backend": "qmd" } } ``` -3. Set up QMD collections and restart: +OpenClaw manages QMD collections automatically from your workspace memory files and any extra paths in `memory.qmd.paths`. See the [QMD Memory Engine docs](https://docs.openclaw.ai/concepts/memory-qmd) for full setup. + +3. Restart the gateway: ```bash -qmd collection add ~/Documents/notes --name notes -qmd update openclaw gateway restart ``` @@ -172,6 +167,10 @@ When QMD is configured, you get both Honcho and local file tools: Source code, issues, and README.
+ + Memory backends, search, and configuration in the OpenClaw docs. + + Learn about peers, sessions, and dialectic reasoning. diff --git a/docs/v3/guides/integrations/opencode.mdx b/docs/v3/guides/integrations/opencode.mdx new file mode 100644 index 00000000..837bb106 --- /dev/null +++ b/docs/v3/guides/integrations/opencode.mdx @@ -0,0 +1,196 @@ +--- +title: "OpenCode" +icon: 'code' +description: "Add AI-native memory to OpenCode" +sidebarTitle: 'OpenCode' +--- + +Give OpenCode long-term memory that survives context wipes, session restarts, and fresh chats. OpenCode remembers what you're working on, your durable preferences, and prior context across every project you touch. + +## Quick Start + +### Step 1: Get Your Honcho API Key + +1. Go to **[app.honcho.dev](https://app.honcho.dev)** +2. Sign up or log in +3. Copy your API key (starts with `hch-`) + +### Step 2: Install the Plugin + + +This plugin requires [Bun](https://bun.sh) and the [OpenCode CLI](https://opencode.ai). If `opencode` isn't on your `PATH`, install it first, then restart your shell. + + +Run the installer: + +```bash +bunx @honcho-ai/opencode-honcho install +``` + +The installer: + +- registers `@honcho-ai/opencode-honcho` with OpenCode +- enables both the native server and TUI plugin targets +- writes the Honcho command templates into your global OpenCode config +- activates the plugin globally for every OpenCode project + +### Step 3: Run Setup in OpenCode + +1. Start OpenCode +2. Run `/honcho:setup` +3. Keep the default **Honcho Cloud** option unless you want a self-hosted or local endpoint +4. Paste your Honcho API key +5. Run `/honcho:status` to verify the runtime + +### Step 4: (Optional) Kickstart with an Interview + +``` +/honcho:interview +``` + +OpenCode will interview you about stable preferences and project context, then persist what it learns to Honcho so every future session can draw on it. + +## What You Get + +- **Persistent Memory** — OpenCode retains durable context across sessions +- **Cloud or Local Deployments** — Point at Honcho Cloud or a self-hosted / local instance +- **Workspace Mapping** — OpenCode projects map cleanly to Honcho workspaces +- **Flexible Session Mapping** — Scope sessions per directory, repo, branch, chat instance, or globally +- **Durable Writes** — Save stable conclusions and retain session context across OpenCode runs +- **Memory Retrieval** — Search session messages, query Honcho's reasoning, and inject relevant context into prompts +- **Agent Tools** — First-class tools for search, chat, and conclusion-writing inside OpenCode + +## Configuration + +Configuration lives in a single shared file at `~/.honcho/config.json`, shared with other Honcho hosts (Claude Code, Cursor, etc.). OpenCode reads and writes this file directly, and OpenCode-specific defaults live under `hosts.opencode`. Edit the file direct or use `/honcho:config` to change it via OpenCode's chat, or call the `honcho_set_config` tool for other settings. + +```jsonc +{ + "apiKey": "hch-...", + "peerName": "alice", + "baseUrl": "https://api.honcho.dev", + "hosts": { + "opencode": { + "workspace": "opencode", + "aiPeer": "opencode", + "recallMode": "hybrid", + "observationMode": "directional", + "sessionStrategy": "per-directory" + } + } +} +``` + +Top-level shared fields are `apiKey`, `peerName`, and `baseUrl`. OpenCode's host-scoped settings live under `hosts.opencode`: `workspace`, `aiPeer`, `recallMode`, `observationMode`, and `sessionStrategy`. + +### Cloud vs Local + +For **Honcho Cloud**: + +- `apiKey` is required +- `baseUrl` should stay at `https://api.honcho.dev` + +For **self-hosted or local Honcho**: + +- `baseUrl` should point to your deployment (e.g. `http://127.0.0.1:8000`) +- `apiKey` is only required if the deployment is authenticated + + +If OpenCode is running inside Docker or another remote environment, `localhost` won't refer to your host machine. The `baseUrl` must be reachable from the OpenCode runtime. + + +### Recall Modes + +| Mode | Behavior | Best for | +| --- | --- | --- | +| `hybrid` (default) | Context injection **and** tool access | Most users — balanced memory coverage | +| `context` | Only inject memory into system prompts | Predictable prompts, no tool calls | +| `tools` | Only expose memory as tools | Explicit, on-demand retrieval | + +### Session Strategies + +| Strategy | Behavior | Best for | +| --- | --- | --- | +| `per-directory` (default) | One session per working directory | Most projects | +| `per-repo` | One session per repository | Repos with multiple entry directories | +| `git-branch` | Session follows the current git branch | Branch-specific workflows | +| `per-session` | New session per OpenCode session id | Short-lived isolated work | +| `chat-instance` | Session tied to the current chat instance | Highly ephemeral usage | +| `global` | One session for everything | Shared memory across all work | + +## Operator Commands + +| Command | Description | +| --- | --- | +| `/honcho:setup` | First-time setup for cloud or local Honcho | +| `/honcho:status` | Show effective Honcho status for the current OpenCode project | +| `/honcho:settings` | Show effective config values and config paths | +| `/honcho:config` | Change `recallMode` | +| `/honcho:interview` | Capture durable preferences or project context into memory | + +## Agent Tools + +The plugin exposes these tools inside OpenCode: + +| Tool | Description | +| --- | --- | +| `honcho_setup` | Validate setup and persist shared credentials or endpoint settings | +| `honcho_status` | Show effective runtime status | +| `honcho_get_config` | Read effective and persisted settings | +| `honcho_set_config` | Update a persisted shared setting | +| `honcho_search` | Search Honcho session messages | +| `honcho_chat` | Query Honcho for reasoning-backed context | +| `honcho_create_conclusion` | Save a durable memory conclusion | + +## Plugin Surfaces + +The plugin hooks into these OpenCode plugin capabilities: + +- `event` +- `chat.message` +- `tool.execute.after` +- `command.execute.before` +- `experimental.chat.system.transform` +- `experimental.session.compacting` +- `shell.env` +- `tool` + +## Building with Teammates + +Because `~/.honcho/config.json` is shared across Honcho hosts, teammates can collaborate by pointing at the same workspace while keeping their own identities. Sessions are automatically prefixed by `peerName` to avoid collisions. + +**Alice** (`~/.honcho/config.json`): +```json +{ + "apiKey": "hch-team-key...", + "peerName": "alice", + "hosts": { + "opencode": { "workspace": "team-acme", "aiPeer": "opencode" } + } +} +``` + +**Bob** (`~/.honcho/config.json`): +```json +{ + "apiKey": "hch-team-key...", + "peerName": "bob", + "hosts": { + "opencode": { "workspace": "team-acme", "aiPeer": "opencode" } + } +} +``` + +Both write to `team-acme`; Honcho's dialectic reasoning draws on context from both peers. + +## Next Steps + + + + Source code, issues, and README. + + + + Learn about peers, sessions, and dialectic reasoning. + + diff --git a/docs/v3/guides/integrations/paperclip.mdx b/docs/v3/guides/integrations/paperclip.mdx new file mode 100644 index 00000000..3b2aa156 --- /dev/null +++ b/docs/v3/guides/integrations/paperclip.mdx @@ -0,0 +1,136 @@ +--- +title: "Paperclip" +icon: "paperclip" +description: "Add Honcho memory to Paperclip" +sidebarTitle: "Paperclip" +--- + +Honcho for [Paperclip](https://paperclip.ing) adds persistent Honcho memory to Paperclip while keeping Paperclip as the system of record. + + +This page covers the current public-host-compatible Paperclip plugin. It supports tools, sync, migration import, and manual prompt previews. It does not depend on automatic prompt-context injection hooks, run transcript import, or legacy workspace file import. + + +## Install the Plugin + +1. In Paperclip, open `Instance Settings` -> `Plugins`. +2. Click `Install Plugin`. +3. Enter `@honcho-ai/paperclip-honcho`. +4. Complete the install from the Paperclip UI. + - Plugin download does not currently work on Windows because of a Paperclip host-side issue. + +## Quick Setup + +### Minimal Path + +1. Create a Paperclip secret containing the Honcho API key. + - For a local Honcho, use whatever credential your local startup expects & `honchoApiKey` is not needed. +2. Open the Honcho plugin settings page in Paperclip. +3. If you are using Honcho Cloud, leave the deployment on the default cloud setting. +4. If you are using a local Honcho instance, switch the deployment to `Self-hosted / local` and set `honchoApiBaseUrl`. +5. Set `honchoApiKey`. +6. Save the settings. +7. Run `Initialize Honcho memory`. + +`honchoApiKey` is the only field required for the standard setup path. The other settings already have defaults. + + +If you use a local Honcho deployment, `honchoApiBaseUrl` must be reachable from the Paperclip host runtime. If Paperclip is running in Docker, `localhost` may not point at your machine. + + +## Multi-Agent Hierarchy + +### What Maps Where + +Paperclip memory is organized around company, issue, and agent boundaries: + +- **Company -> workspace**: each Paperclip company maps to one Honcho workspace. +- **Issue -> session**: each Paperclip issue maps to one Honcho session inside that workspace. +- **Humans and agents -> peers**: human actors and Paperclip agents map to Honcho peers. + +This gives the plugin a natural hierarchy: company-level memory lives at the workspace level, issue-level memory lives at the session level, and people or agents are modeled as peers that participate across those scopes. + +### How Agent Observation Works + +The current plugin gives agent peers explicit observation settings: + +- `observe_me` defaults to `true` +- `observe_others` defaults to `true` + +In practice, that means agent peers can both be observed by Honcho and form representations of other peers they interact with. + +## How It Works + +### Identity And Scope + +The integration breaks down into four parts: + +- **Identity and scope** - each Paperclip company maps to a Honcho workspace, agents and human actors map to peers, and issues map to sessions. +- **What gets copied into Honcho** - issue comments and document revisions sync into Honcho, with document content sectioned and normalized message content capped before ingestion. +- **What operators get** - operators get a plugin settings page, migration preview/status data, including a per-issue migration mapping preview, repair tools, and an issue-level `Memory` tab. +- **What agents get** - agents get Honcho retrieval and peer-chat tools inside Paperclip. + +## Operator Actions + +The settings page exposes the main operator workflow directly: + +| Action | What it does | +| --- | --- | +| `Validate config` | Validates the current plugin configuration before any sync or import work runs. | +| `Test connection` | Resolves the API key secret, checks the Honcho connection, and returns the mapped workspace ID. | +| `Initialize memory for this company` | Connects Honcho, creates core mappings, imports baseline issue memory, and verifies manual prompt previews. | +| `Rescan migration sources` | Scans issue comments and issue documents and writes a fresh import preview. | +| `Import history` | Imports the approved historical preview into Honcho with idempotent ledger checks. | +| `Preview prompt context` | Builds a manual prompt-context preview for a company or issue without relying on automatic host hooks. | +| `Repair mappings` | Recreates missing workspace, peer, and session mappings for the current company. | +| `Resync this issue` | Replays sync for the current issue from the issue Memory tab. | + +## Configuration Defaults And Overrides + +### Default Behavior + +| Setting | Default | Use when | +| --- | --- | --- | +| `honchoApiKey` | — | Required. Points the plugin at the Paperclip secret containing your Honcho API key. | +| `honchoApiBaseUrl` | `https://api.honcho.dev` | Override this for self-hosted or non-default Honcho deployments. | +| `workspacePrefix` | `paperclip` | Change this if you want a different workspace namespace. | +| `syncIssueComments` | `true` | Turn this off if you do not want comment history imported into Honcho. | +| `syncIssueDocuments` | `true` | Turn this off if you do not want issue document revisions imported. | +| `enablePeerChat` | `true` | Required for the peer chat tool surface. | +| `enablePromptContext` | `false` | Keep this off on the public-host-compatible path and use manual prompt previews instead. | +| `observe_me` | `true` | Controls whether agent peers are observed by Honcho. | +| `observe_others` | `true` | Controls whether agent peers form representations of other peers they interact with. | + +The plugin also accepts additional advanced fields in the settings page, including noise-pattern and metadata-strip controls. Most setups can ignore those and start with the defaults above. + +## Agent Tools + +The plugin registers the following Honcho tools for Paperclip agents: + +| Tool | Description | +| --- | --- | +| `honcho_get_issue_context` | Retrieve compact Honcho context for the current issue session. | +| `honcho_search_memory` | Search Honcho memory within the current workspace, narrowing to the current issue by default. | +| `honcho_search_messages` | Search raw Honcho messages. | +| `honcho_search_conclusions` | Search high-signal summarized Honcho memory. | +| `honcho_get_workspace_context` | Retrieve broad workspace recall from Honcho. | +| `honcho_get_session` | Retrieve issue session context from Honcho. | +| `honcho_get_agent_context` | Retrieve peer context for a specific agent. | +| `honcho_get_hierarchy_context` | Retrieve delegated-work context when the host provides lineage metadata. | +| `honcho_ask_peer` | Query Honcho peer chat for a target peer. Requires peer chat to be enabled in plugin config. | + +## Next Steps + + + + Open the repository for source and setup details. + + + + Review how workspaces, peers, and sessions fit together. + + + + Review how `observe_me` and `observe_others` change what peers can model. + + diff --git a/docs/v3/guides/integrations/reachy-mini.mdx b/docs/v3/guides/integrations/reachy-mini.mdx index 8ce40ec6..7213c0d0 100644 --- a/docs/v3/guides/integrations/reachy-mini.mdx +++ b/docs/v3/guides/integrations/reachy-mini.mdx @@ -149,7 +149,7 @@ uv run python main.py Retrieve formatted conversation history - + Dig into the code 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 d6f4454a..4411629d 100644 --- a/docs/v3/guides/overview.mdx +++ b/docs/v3/guides/overview.mdx @@ -2,30 +2,37 @@ title: "Guides, Cookbooks, and Integrations" sidebarTitle: 'Overview' description: 'Helpful guides and design patterns for building with Honcho' -icon: 'hat-wizard' +icon: 'puzzle-piece' --- - Before you start a guide, follow [Quickstart](/v3/documentation/introduction/quickstart) to get up and running with Honcho in your language of choice. +Honcho plugs into whatever you're already building. Add memory to an AI assistant, connect an external data source, wire Honcho into your agent framework, or migrate from another provider. -These guides provide concrete examples and implementation patterns for building with Honcho. Whether you're integrating Honcho into existing platforms, exploring advanced features, or getting up and running quickly, you'll find working code you can adapt to your needs. - -Each guide focuses on a specific use case with practical examples. The goal is to get you from idea to working prototype as quickly as possible, then provide the depth you need to scale and customize. - - -## Getting Started -Quick integration guides to get up and running: +## AI Assistants +Add persistent memory to AI assistants and agents: - - Get Honcho running with a single prompt in Claude Code + + Long-term memory that survives context wipes, session restarts, and project switches - - Add persistent memory and theory of mind to your LangGraph agents + + Persistent memory for OpenCode sessions, with per-directory, per-repo, or branch-scoped session mapping + + + Add Honcho memory to Claude Desktop, Cursor, Windsurf, Cline, and any MCP client + + + Cross-session memory for Nous Research's Hermes agent + + + Memory across every channel — WhatsApp, Telegram, Discord, Slack, and more + + + Persistent memory plugin for the Agent Zero framework -## Showcase -Real-world examples of what you can build with Honcho: +## Platform Connectors +Connect external platforms to Honcho: @@ -34,7 +41,43 @@ Real-world examples of what you can build with Honcho: Create a Telegram bot with persistent user understanding + + Import email threads into Honcho — peers, sessions, and messages from your inbox + + + Ingest meeting transcripts with speaker turns and participant data + + + Add Honcho memory to Paperclip companies, agents, issues, and documents + - Build an embodied voice robot that remembers users across sessions + Build an embodied voice robot with long-term memory + + + +## Agent Frameworks +Use Honcho as a memory layer in your agent orchestration stack: + + + + Add persistent memory and theory of mind to your LangGraph agents + + + Give CrewAI agents memory that persists across sessions + + + Persistent memory skill for Zo Computer AI workflows + + + Build intelligent automation workflows with persistent memory + + + +## Migrations +Coming from another memory provider? + + + + Transfer your data and update your integration code diff --git a/examples/gmail/honcho_gmail.py b/examples/gmail/honcho_gmail.py new file mode 100644 index 00000000..ec7bb8bd --- /dev/null +++ b/examples/gmail/honcho_gmail.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +"""Load Gmail messages into Honcho. + +Uses the Gmail API directly (with OAuth) to fetch emails and the Honcho Python SDK to store them. +Each Gmail thread becomes a Honcho session, each sender becomes a peer. + +Prerequisites: +1. Create a Google Cloud project and enable the Gmail API +2. Create OAuth 2.0 credentials (Desktop app type) +3. Download the credentials JSON (client_secret_*.json) into this directory +4. Install dependencies: + pip install google-api-python-client google-auth-oauthlib honcho-ai + +On first run, a browser window will open for OAuth consent. After authorizing, +a 'token.json' file will be created to store your credentials for future runs. +""" + +import argparse +import base64 +import glob +import os +import re +import time +from datetime import datetime, timezone +from email.header import decode_header, make_header +from email.utils import getaddresses, parseaddr + +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"] +PEER_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$") + + +def find_credentials() -> str: + """Find a Google OAuth credentials file in the current directory.""" + matches = glob.glob("client_secret*.json") + if matches: + return matches[0] + raise FileNotFoundError( + "No client_secret*.json file found.\n" + "Download OAuth credentials from Google Cloud Console:\n" + "1. Go to console.cloud.google.com\n" + "2. Create/select a project and enable Gmail API\n" + "3. Create OAuth 2.0 credentials (Desktop app)\n" + "4. Download the JSON into this directory" + ) + + +def get_gmail_service(credentials_file: str | None = None, token_file: str = "token.json"): + """Authenticate and return a Gmail API service instance.""" + creds = None + + if os.path.exists(token_file): + creds = Credentials.from_authorized_user_file(token_file, SCOPES) + + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + print("Refreshing expired credentials...") + creds.refresh(Request()) + else: + if credentials_file is None: + credentials_file = find_credentials() + print(f"Using credentials: {credentials_file}") + print("Opening browser for OAuth consent...") + flow = InstalledAppFlow.from_client_secrets_file(credentials_file, SCOPES) + creds = flow.run_local_server(port=0) + + with open(token_file, "w") as token: + token.write(creds.to_json()) + print(f"Credentials saved to {token_file}") + + return build("gmail", "v1", credentials=creds) + + +def list_threads(service, query: str = None, label_ids: list = None, max_results: int = 10) -> list[dict]: + """List Gmail threads with pagination support.""" + all_threads = [] + page_token = None + + while len(all_threads) < max_results: + try: + params = { + "userId": "me", + "maxResults": min(100, max_results - len(all_threads)), + } + if query: + params["q"] = query + if label_ids: + params["labelIds"] = label_ids + if page_token: + params["pageToken"] = page_token + + response = service.users().threads().list(**params).execute() + threads = response.get("threads", []) + all_threads.extend(threads) + + page_token = response.get("nextPageToken") + if not page_token: + break + + except HttpError as e: + print(f"Error listing threads: {e}") + break + + return all_threads[:max_results] + + +def get_thread(service, thread_id: str) -> dict: + """Fetch a complete Gmail thread with all messages.""" + try: + return service.users().threads().get( + userId="me", + id=thread_id, + format="full" + ).execute() + except HttpError as e: + print(f"Error fetching thread {thread_id}: {e}") + return {} + + +def _decode_header_str(header: str) -> str: + """Decode an RFC 2047 encoded header string to plain Unicode.""" + return str(make_header(decode_header(header))) + + +def extract_email(from_header: str) -> str: + """Extract bare email from an RFC 5322 header value.""" + _, addr = parseaddr(_decode_header_str(from_header)) + return addr.lower().strip() + + +def extract_name(from_header: str) -> str: + """Extract display name from an RFC 5322 header value.""" + name, _ = parseaddr(_decode_header_str(from_header)) + return name.strip() or from_header.strip() + + +def decode_body(payload: dict) -> str: + """Recursively extract plain text from a Gmail message payload.""" + if payload.get("mimeType") == "text/plain": + data = payload.get("body", {}).get("data", "") + if data: + return base64.urlsafe_b64decode(data).decode("utf-8", errors="replace") + + parts = payload.get("parts", []) + for part in parts: + text = decode_body(part) + if text: + return text + return "" + + +def strip_quoted_replies(text: str) -> str: + """Strip quoted reply text from an email body, keeping only the new content.""" + lines = text.split("\n") + clean_lines = [] + for line in lines: + stripped = line.strip() + if re.match(r"^On .+wrote:\s*$", stripped): + break + if stripped.startswith("---------- Forwarded message"): + break + if stripped.startswith(">"): + break + if re.match(r"^[-_]{10,}$", stripped): + break + clean_lines.append(line) + return "\n".join(clean_lines).rstrip() + + +def parse_address_list(header: str) -> list[str]: + """Parse a comma-separated email header into individual addresses.""" + if not header.strip(): + return [] + decoded = _decode_header_str(header) + return [ + f"{name} <{addr}>" if name else addr + for name, addr in getaddresses([decoded]) + if addr + ] + + +def peer_id_from_email(email: str) -> str: + """Convert email to a valid Honcho peer ID.""" + peer_id = re.sub(r"[^A-Za-z0-9_-]+", "-", email).strip("-").lower() + peer_id = re.sub(r"-{2,}", "-", peer_id) + if not peer_id: + peer_id = "unknown-peer" + + if not PEER_ID_PATTERN.fullmatch(peer_id): + raise ValueError(f"Generated peer ID is invalid: {peer_id!r}") + return peer_id + + +def fetch_thread_messages(service, thread_id: str) -> list[dict]: + """Fetch all messages in a Gmail thread with full content.""" + data = get_thread(service, thread_id) + messages = [] + + for msg in data.get("messages", []): + headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} + body = strip_quoted_replies(decode_body(msg.get("payload", {}))) + ts = int(msg.get("internalDate", "0")) / 1000 + + messages.append({ + "id": msg["id"], + "thread_id": msg["threadId"], + "from": headers.get("From", ""), + "to": headers.get("To", ""), + "cc": headers.get("Cc", ""), + "bcc": headers.get("Bcc", ""), + "subject": headers.get("Subject", ""), + "date": headers.get("Date", ""), + "timestamp": datetime.fromtimestamp(ts, tz=timezone.utc), + "body": body.strip(), + "labels": msg.get("labelIds", []), + "snippet": msg.get("snippet", ""), + }) + + return messages + + +def main(): + parser = argparse.ArgumentParser(description="Load Gmail messages into Honcho") + parser.add_argument("--workspace", "-w", default="gmail", help="Honcho workspace ID (default: gmail)") + parser.add_argument("--query", "-q", default=None, help="Gmail search query (e.g. 'from:alice@example.com')") + parser.add_argument("--label", "-l", default=None, help="Gmail label to filter by (e.g. INBOX)") + parser.add_argument("--max-threads", "-n", type=int, default=10, help="Max threads to fetch (default: 10)") + parser.add_argument("--dry-run", action="store_true", help="Print what would be loaded without writing to Honcho") + parser.add_argument("--credentials", "-c", default=None, help="Path to OAuth credentials JSON (auto-detects client_secret*.json)") + parser.add_argument("--token", "-t", default="token.json", help="Path to store/load access token") + args = parser.parse_args() + + # Authenticate + print("Authenticating with Gmail API...") + service = get_gmail_service(args.credentials, args.token) + print(" Authenticated successfully!") + + label_ids = [args.label] if args.label else None + + # List threads + print(f"\nFetching up to {args.max_threads} threads from Gmail...") + threads = list_threads(service, query=args.query, label_ids=label_ids, max_results=args.max_threads) + print(f" Found {len(threads)} threads") + + if not threads: + print("No threads found. Try adjusting --query or --label.") + return + + # Fetch full messages for each thread + all_thread_messages = {} + seen_peers = {} + + def register_peer(addr: str): + email = extract_email(addr) + if email and email not in seen_peers: + name = extract_name(addr) + if name.lower().strip() == email or "@" in name: + name = email.split("@")[0].replace(".", " ").title() + seen_peers[email] = { + "name": name, + "peer_id": peer_id_from_email(email), + "email": email, + } + + for i, t in enumerate(threads): + tid = t["id"] + print(f" Fetching thread {i+1}/{len(threads)}: {tid}") + msgs = fetch_thread_messages(service, tid) + all_thread_messages[tid] = msgs + for m in msgs: + register_peer(m["from"]) + for addr in parse_address_list(m["to"]): + register_peer(addr) + for addr in parse_address_list(m["cc"]): + register_peer(addr) + for addr in parse_address_list(m["bcc"]): + register_peer(addr) + + # Summary + total_msgs = sum(len(v) for v in all_thread_messages.values()) + print("\nSummary:") + print(f" Threads: {len(all_thread_messages)}") + print(f" Messages: {total_msgs}") + print(f" Unique participants: {len(seen_peers)}") + for email, info in seen_peers.items(): + print(f" {info['peer_id']} ({info['name']} <{email}>)") + + if args.dry_run: + print("\n[DRY RUN] Would create the above in Honcho. Showing first message per thread:") + for tid, msgs in all_thread_messages.items(): + m = msgs[0] + body_preview = m["body"][:120].replace("\n", " ") if m["body"] else m["snippet"][:120] + print(f" Thread {tid}: {m['subject']}") + print(f" {m['from']} @ {m['date']}") + print(f" {body_preview}...") + return + + # Load into Honcho + from honcho import Honcho + + print(f"\nLoading into Honcho workspace '{args.workspace}'...") + honcho = Honcho(workspace_id=args.workspace) + + # Create peers + peers = {} + for i, (email, info) in enumerate(seen_peers.items()): + if i > 0 and i % 4 == 0: + time.sleep(1) + peers[email] = honcho.peer(info["peer_id"], metadata={ + "email": email, + "name": info["name"], + "source": "gmail", + }) + print(f" Peer: {info['peer_id']}") + + # Create sessions and messages per thread + for tid, msgs in all_thread_messages.items(): + subject = msgs[0]["subject"] if msgs else "No subject" + session_id = f"gmail-thread-{tid}" + + thread_peer_emails = set() + for m in msgs: + thread_peer_emails.add(extract_email(m["from"])) + for addr in parse_address_list(m["to"]): + thread_peer_emails.add(extract_email(addr)) + for addr in parse_address_list(m["cc"]): + thread_peer_emails.add(extract_email(addr)) + for addr in parse_address_list(m["bcc"]): + thread_peer_emails.add(extract_email(addr)) + thread_peers = [peers[e] for e in thread_peer_emails if e in peers] + + session = honcho.session(session_id, metadata={ + "gmail_thread_id": tid, + "subject": subject, + "source": "gmail", + "message_count": len(msgs), + }) + session.add_peers(thread_peers) + + honcho_msgs = [] + for m in msgs: + email = extract_email(m["from"]) + peer = peers.get(email) + if not peer: + continue + content = m["body"] if m["body"] else m["snippet"] + if not content: + continue + honcho_msgs.append(peer.message( + content, + metadata={ + "gmail_id": m["id"], + "subject": m["subject"], + "from": m["from"], + "to": m["to"], + "labels": m["labels"], + }, + created_at=m["timestamp"], + )) + + if honcho_msgs: + session.add_messages(honcho_msgs) + print(f" Session {session_id}: {len(honcho_msgs)} messages — {subject[:60]}") + + print(f"\nDone! Loaded {total_msgs} messages into workspace '{args.workspace}'.") + + +if __name__ == "__main__": + main() diff --git a/examples/granola/honcho_granola.py b/examples/granola/honcho_granola.py new file mode 100644 index 00000000..abb20fe8 --- /dev/null +++ b/examples/granola/honcho_granola.py @@ -0,0 +1,751 @@ +#!/usr/bin/env python3 +"""Load Granola meeting notes into Honcho. + +Uses the Granola MCP server (with OAuth) to fetch meetings and the Honcho Python SDK +to store them. Each meeting becomes a Honcho session. Two-person meetings get full +speaker attribution; multi-person meetings are stored as summaries. + +Prerequisites: + pip install honcho-ai httpx + +Environment Variables: + HONCHO_API_KEY - Your Honcho API key (get from app.honcho.dev/api-keys) + +Usage: + python honcho_granola.py +""" + +import asyncio +import base64 +import hashlib +import json +import os +import re +import secrets +import sys +import threading +import traceback +import webbrowser +from dataclasses import dataclass, field +from datetime import datetime, timezone +from http.server import HTTPServer, BaseHTTPRequestHandler +from typing import Any +from urllib.parse import parse_qs, urlencode, urlparse + +import httpx + + +@dataclass +class Participant: + name: str + email: str | None = None + org: str | None = None + + +@dataclass +class ParsedParticipants: + note_creator: Participant | None = None + others: list[Participant] = field(default_factory=list) + + +@dataclass +class TranscriptTurn: + speaker: str + text: str + + +# Granola MCP + OAuth endpoints +GRANOLA_MCP_URL = "https://mcp.granola.ai/mcp" +AUTH_BASE = "https://mcp-auth.granola.ai" +OAUTH_REDIRECT_PORT = 8765 +OAUTH_REDIRECT_URI = f"http://localhost:{OAUTH_REDIRECT_PORT}/callback" + +# Honcho message size limit (25000 max, leave headroom) +MAX_MESSAGE_LEN = 24000 + + +# --------------------------------------------------------------------------- +# OAuth callback handler (must be a class for BaseHTTPRequestHandler) +# --------------------------------------------------------------------------- + +class _OAuthCallback(BaseHTTPRequestHandler): + auth_result: dict[str, str | None] = {"code": None, "error": None} + + def do_GET(self): + params = parse_qs(urlparse(self.path).query) + if "code" in params: + _OAuthCallback.auth_result["code"] = params["code"][0] + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(b"

Authenticated! You can close this window.

") + elif "error" in params: + _OAuthCallback.auth_result["error"] = params.get("error_description", params["error"])[0] + self.send_response(400) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(f"

Error: {_OAuthCallback.auth_result['error']}

".encode()) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, fmt, *args): + pass + + +# --------------------------------------------------------------------------- +# Granola OAuth + MCP +# --------------------------------------------------------------------------- + +async def authenticate(http_client: httpx.AsyncClient) -> str: + """Perform OAuth (DCR + PKCE) with Granola. Returns access token.""" + _OAuthCallback.auth_result = {"code": None, "error": None} + + print("\nAuthenticating with Granola...") + + # Register client (DCR) + resp = await http_client.post( + f"{AUTH_BASE}/oauth2/register", + json={ + "client_name": "Granola to Honcho Transfer", + "redirect_uris": [OAUTH_REDIRECT_URI], + "grant_types": ["authorization_code"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + }, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Client registration failed: {resp.status_code}") + client_id = resp.json().get("client_id") + + # PKCE + verifier = secrets.token_urlsafe(32) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + + # Browser auth + auth_url = f"{AUTH_BASE}/oauth2/authorize?" + urlencode({ + "client_id": client_id, + "redirect_uri": OAUTH_REDIRECT_URI, + "response_type": "code", + "state": "granola-honcho-transfer", + "code_challenge": challenge, + "code_challenge_method": "S256", + }) + + server = HTTPServer(("localhost", OAUTH_REDIRECT_PORT), _OAuthCallback) + thread = threading.Thread(target=server.handle_request) + thread.start() + + print(" Opening browser for authentication...") + webbrowser.open(auth_url) + thread.join(timeout=120) + server.server_close() + + auth_result = _OAuthCallback.auth_result + if auth_result["error"]: + raise RuntimeError(f"Authentication failed: {auth_result['error']}") + if not auth_result["code"]: + raise RuntimeError("Authentication timed out") + + # Exchange code for token + resp = await http_client.post( + f"{AUTH_BASE}/oauth2/token", + data={ + "grant_type": "authorization_code", + "code": auth_result["code"], + "redirect_uri": OAUTH_REDIRECT_URI, + "client_id": client_id, + "code_verifier": verifier, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if resp.status_code != 200: + raise RuntimeError(f"Token exchange failed: {resp.status_code}") + + print(" Authenticated successfully!") + return resp.json()["access_token"] + + +async def call_mcp_tool( + http_client: httpx.AsyncClient, + access_token: str, + tool_name: str, + arguments: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Call a Granola MCP tool, handling both JSON and SSE responses.""" + resp = await http_client.post( + GRANOLA_MCP_URL, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments or {}}, + }, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + ) + if resp.status_code != 200: + raise RuntimeError(f"MCP call failed: {resp.status_code} - {resp.text}") + + # SSE response + if "text/event-stream" in resp.headers.get("content-type", ""): + result = None + for line in resp.text.split("\n"): + if line.strip().startswith("data: "): + try: + parsed = json.loads(line.strip()[6:]) + if "result" in parsed: + result = parsed + elif "error" in parsed: + raise RuntimeError(f"MCP error: {parsed['error']}") + except json.JSONDecodeError: + continue + if result: + final = result.get("result", {}) + return final if isinstance(final, dict) else {"result": final} + raise RuntimeError("No result in SSE response") + + # JSON response + result = resp.json() + if "error" in result: + raise RuntimeError(f"MCP error: {result['error']}") + return result.get("result", {}) + + +def extract_mcp_text(result: dict[str, Any]) -> str: + """Extract text from the first content block of an MCP result. + + Raises ValueError if the response structure is unexpected. + """ + content = result.get("content", []) + if not isinstance(content, list) or not content: + raise ValueError(f"MCP response missing content array: {list(result.keys())}") + first = content[0] + if not isinstance(first, dict) or "text" not in first: + raise ValueError(f"MCP content block missing 'text' field: {first}") + return str(first["text"]) + + +# --------------------------------------------------------------------------- +# Granola data fetching +# --------------------------------------------------------------------------- + +async def list_meetings( + http_client: httpx.AsyncClient, access_token: str, limit: int = 100, +) -> list[dict[str, Any]]: + """List meetings from Granola MCP. Parses Granola's XML-like response format.""" + result = await call_mcp_tool(http_client, access_token, "list_meetings", {"limit": limit}) + text = extract_mcp_text(result) + + meetings: list[dict[str, Any]] = [] + for match in re.finditer(r'", match.end()) + block = text[match.end():block_end] if block_end != -1 else "" + p_match = re.search(r"\s*(.*?)\s*", block, re.DOTALL) + meetings.append({ + "id": mid, + "title": title, + "date": date, + "participants": p_match.group(1).strip() if p_match else "", + }) + + return meetings + + +async def get_meeting_details( + http_client: httpx.AsyncClient, access_token: str, meeting_id: str, +) -> dict[str, Any]: + """Get full meeting details including notes.""" + result = await call_mcp_tool(http_client, access_token, "get_meetings", {"meeting_ids": [meeting_id]}) + text = extract_mcp_text(result) + return {"id": meeting_id, "raw_content": text} + + +async def get_meeting_transcript( + http_client: httpx.AsyncClient, access_token: str, meeting_id: str, + max_retries: int = 3, +) -> str | None: + """Get transcript for a meeting (paid tiers only). + + Retries on rate limit responses with exponential backoff. + """ + for attempt in range(max_retries): + try: + result = await call_mcp_tool(http_client, access_token, "get_meeting_transcript", {"meeting_id": meeting_id}) + text = extract_mcp_text(result) + except Exception as e: + print(f" Transcript unavailable: {e}") + return None + + if not text or "no transcript" in text.lower(): + return None + + # Granola returns rate limit errors as content text, not HTTP errors + if "rate limit" in text.lower(): + wait = 2 ** attempt * 3 # 3s, 6s, 12s + print(f" ⚠ Granola rate limit hit (attempt {attempt + 1}/{max_retries}), waiting {wait}s...") + await asyncio.sleep(wait) + continue + + return text + + print(f" ⚠ Transcript skipped after {max_retries} rate limit retries") + return None + + +async def fetch_all_meetings( + http_client: httpx.AsyncClient, access_token: str, +) -> list[dict[str, Any]]: + """Fetch meeting list and enrich each with transcript and details.""" + print("\nFetching meetings from Granola...") + meetings = await list_meetings(http_client, access_token, limit=500) + if not meetings: + print("No meetings found.") + return [] + print(f" Found {len(meetings)} meetings. Fetching content...\n") + + for i, m in enumerate(meetings, 1): + mid = m.get("id") + if not mid: + continue + + transcript = await get_meeting_transcript(http_client, access_token, mid) + if transcript: + m["transcript"] = transcript + + try: + m.update(await get_meeting_details(http_client, access_token, mid)) + except Exception as exc: + print(f" Failed to fetch details for {mid}: {exc}") + + has_t = "transcript" in m + has_s = bool(extract_summary(m)) + label = "transcript+summary" if has_t and has_s else "transcript only" if has_t else "summary only" if has_s else "basic only" + print(f" [{i}/{len(meetings)}] {label}: {m.get('title', 'Untitled')[:45]}") + await asyncio.sleep(1.5) # rate limit + + return meetings + + +# --------------------------------------------------------------------------- +# Parsing helpers +# --------------------------------------------------------------------------- + +def parse_participants(participants_str: str) -> ParsedParticipants: + """Parse Granola's participant string into structured participants. + + Warns on unparsable entries instead of silently dropping them. + """ + result = ParsedParticipants() + if not participants_str: + return result + + # Split on commas, but not inside angle brackets + entries, current, depth = [], [], 0 + for ch in participants_str: + if ch == "<": + depth += 1 + elif ch == ">": + depth = max(depth - 1, 0) + elif ch == "," and depth == 0: + entries.append("".join(current)) + current = [] + continue + current.append(ch) + if current: + entries.append("".join(current)) + + for entry in entries: + entry = entry.strip() + if not entry: + continue + + is_creator = "(note creator)" in entry + clean = entry.replace("(note creator)", "").strip() + + email_match = re.search(r"<([^>]+)>", clean) + email = email_match.group(1) if email_match else None + name = re.sub(r"\s*<[^>]+>", "", clean).strip() + + if not name: + print(f" Warning: could not parse participant entry: {entry!r}") + continue + + org = None + org_match = re.match(r"(.+?)\s+from\s+(.+)", name) + if org_match: + name, org = org_match.group(1).strip(), org_match.group(2).strip() + + person = Participant(name=name, email=email, org=org) + if is_creator: + result.note_creator = person + else: + result.others.append(person) + + return result + + +def parse_transcript_turns(raw: str) -> list[TranscriptTurn]: + """Split a Granola transcript into speaker turns.""" + # Unwrap JSON wrapper if present + try: + parsed = json.loads(raw) + if isinstance(parsed, dict) and "transcript" in parsed: + raw = str(parsed["transcript"]) + except (json.JSONDecodeError, TypeError): + pass + + parts = re.split(r"(?:^|\s{2,})(Me|Them):\s*", raw) + turns: list[TranscriptTurn] = [] + i = 1 + while i < len(parts) - 1: + text = parts[i + 1].strip() + if text: + turns.append(TranscriptTurn(speaker=parts[i], text=text)) + i += 2 + return turns + + +def extract_summary(meeting: dict[str, Any]) -> str: + """Extract best available summary text from meeting data.""" + candidates = [] + for key in ("summary", "notes", "note", "meeting_notes", "description"): + val = meeting.get(key) + if isinstance(val, str) and val.strip(): + candidates.append(val.strip()) + + raw = meeting.get("raw_content") + if isinstance(raw, str) and raw.strip(): + candidates.append(raw.strip()) + + for c in candidates: + for tag in ("summary", "notes"): + m = re.search(rf"<{tag}>\s*(.*?)\s*", c, re.DOTALL) + if m: + return m.group(1).strip() + + return candidates[0] if candidates else "" + + +def peer_id_from(value: str) -> str: + """Normalize a name or email into a Honcho-safe peer ID.""" + norm = re.sub(r"[^a-z0-9_-]+", "-", value.strip().lower()) + norm = re.sub(r"-{2,}", "-", norm).strip("-_") + return (norm or "peer")[:100] + + +def sanitize(text: str) -> str: + """Remove null bytes and control characters.""" + return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text) + + +def parse_date(date_str: str) -> datetime: + """Parse Granola's date format into a timezone-aware datetime. + + Raises ValueError if the date string doesn't match any known format. + """ + for fmt in ["%b %d, %Y %I:%M %p", "%b %d, %Y %I:%M:%S %p", "%B %d, %Y %I:%M %p"]: + try: + return datetime.strptime(date_str, fmt).replace(tzinfo=timezone.utc) + except ValueError: + continue + raise ValueError(f"Unrecognized date format: {date_str!r}") + + +# --------------------------------------------------------------------------- +# Honcho import helpers +# --------------------------------------------------------------------------- + +def build_messages( + peer: Any, + content: str, + metadata: dict[str, object] | None, + created_at: datetime, +) -> list[Any]: + """Build chunked messages for a single peer, attaching metadata to the first chunk.""" + messages = [] + content = sanitize(content) + for start in range(0, len(content), MAX_MESSAGE_LEN): + chunk = content[start:start + MAX_MESSAGE_LEN] + msg_meta = metadata if start == 0 else None + messages.append(peer.message(chunk, metadata=msg_meta, created_at=created_at)) + return messages + + +def send_messages(session: Any, messages: list[Any]) -> None: + """Send messages to a session in batches of 100.""" + for batch_start in range(0, len(messages), 100): + session.add_messages(messages[batch_start:batch_start + 100]) + + +def import_two_person( + honcho: Any, + session: Any, + me_peer_id: str, + them_peer_id: str, + turns: list[TranscriptTurn], + metadata: dict[str, object], + created_at: datetime, +) -> None: + """Import a two-person meeting with speaker attribution.""" + me_peer = honcho.peer(me_peer_id) + them_peer = honcho.peer(them_peer_id) + + # Merge consecutive same-speaker turns + merged: list[TranscriptTurn] = [] + for t in turns: + if merged and merged[-1].speaker == t.speaker: + merged[-1].text += " " + t.text + else: + merged.append(TranscriptTurn(speaker=t.speaker, text=t.text)) + + messages: list[Any] = [] + for i, t in enumerate(merged): + peer = me_peer if t.speaker == "Me" else them_peer + msg_meta = metadata if i == 0 else None + messages.extend(build_messages(peer, t.text, msg_meta, created_at)) + + send_messages(session, messages) + print(f" -> Imported as 2-person ({me_peer_id} + {them_peer_id})") + + +def import_summary( + honcho: Any, + session: Any, + me_peer_id: str, + meeting: dict[str, Any], + metadata: dict[str, object], + created_at: datetime, +) -> None: + """Import a meeting as a summary message.""" + me_peer = honcho.peer(me_peer_id) + summary = extract_summary(meeting) + if not summary: + raw_t = meeting.get("transcript", "") + try: + parsed = json.loads(raw_t) + summary = str(parsed.get("transcript", "")) if isinstance(parsed, dict) else raw_t + except (json.JSONDecodeError, TypeError): + summary = raw_t + summary = summary or "No content available" + + title = meeting.get("title", "Untitled") + date = meeting.get("date", "") + header = f"Meeting: {title}\nDate: {date}\nParticipants: {meeting.get('participants', '')}\n\n" + + messages = build_messages(me_peer, header + summary, metadata, created_at) + send_messages(session, messages) + print(" -> Imported as summary") + + +def resolve_them_participant(others: list[Participant]) -> Participant | None: + """Ask user to pick which participant is 'Them' from a multi-person meeting.""" + for j, p in enumerate(others, 1): + email_str = f" <{p.email}>" if p.email else "" + print(f" {j}. {p.name}{email_str}") + idx_str = input(f" Who is 'Them'? [1-{len(others)}]: ").strip() + try: + return others[int(idx_str) - 1] + except (ValueError, IndexError): + print(" Invalid selection.") + return None + + +def review_meeting( + index: int, + total: int, + meeting: dict[str, Any], + participants: ParsedParticipants, + turns: list[TranscriptTurn], +) -> tuple[str, Participant | None]: + """Display meeting info and get user's import choice. + + Returns (mode, them_participant) where mode is one of: + - "two_person": import with speaker attribution using them_participant + - "summary": import as a single summary message + - "skip": skip this meeting + """ + title = meeting.get("title", "Untitled") + date = meeting.get("date", "") + creator = participants.note_creator + others = participants.others + + me_turns = sum(1 for t in turns if t.speaker == "Me") + them_turns = len(turns) - me_turns + total_words = sum(len(t.text.split()) for t in turns) + + print(f"\n{'─' * 60}") + print(f" [{index}/{total}] {title}") + print(f" Date: {date}") + if creator: + print(f" You: {creator.name} <{creator.email}>") + for j, p in enumerate(others, 1): + email_str = f" <{p.email}>" if p.email else "" + org_str = f" ({p.org})" if p.org else "" + print(f" {j}. {p.name}{email_str}{org_str}") + + has_transcript = bool(meeting.get("transcript")) + if turns: + print(f" Transcript: {me_turns} Me, {them_turns} Them, ~{total_words} words") + if them_turns == 0: + print(" ** No 'Them' turns — nobody else spoke **") + if total_words < 30: + print(" ** Very short — might be empty **") + elif has_transcript: + raw = meeting["transcript"] + print(f" Transcript: present ({len(raw)} chars) but could not parse speaker turns") + print(f" Preview: {raw[:200]!r}") + else: + print(f" Content: {'summary available' if extract_summary(meeting) else 'metadata only'}") + + # Two-person default: exactly one other participant with transcript + if len(others) == 1 and them_turns > 0: + them_label = others[0].name + (f" <{others[0].email}>" if others[0].email else "") + print(f"\n Detected: 2-person call (you + {them_label})") + choice = input(" [Enter] 2-person / [s]ummary / [k] skip: ").strip().lower() + while choice not in ("", "s", "k"): + choice = input(" [Enter] 2-person / [s]ummary / [k] skip: ").strip().lower() + if choice == "k": + return ("skip", None) + if choice == "s": + return ("summary", None) + return ("two_person", others[0]) + + # Multi-person with transcript + if len(others) > 1 and them_turns > 0: + print(f"\n {len(others)} participants") + choice = input(" [Enter] summary / [2] 2-person / [k] skip: ").strip().lower() + while choice not in ("", "2", "k"): + choice = input(" [Enter] summary / [2] 2-person / [k] skip: ").strip().lower() + if choice == "k": + return ("skip", None) + if choice == "2": + them = resolve_them_participant(others) + if them is None: + return ("summary", None) + return ("two_person", them) + return ("summary", None) + + # No transcript or no other speakers + choice = input(" [Enter] summary / [k] skip: ").strip().lower() + while choice not in ("", "k"): + choice = input(" [Enter] summary / [k] skip: ").strip().lower() + if choice == "k": + return ("skip", None) + return ("summary", None) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +async def main(): + print("=" * 60) + print(" Granola -> Honcho Meeting Notes Transfer") + print("=" * 60) + + if not os.environ.get("HONCHO_API_KEY"): + print("\nError: HONCHO_API_KEY not set.") + print(" Get your key at: https://app.honcho.dev/api-keys") + sys.exit(1) + + async with httpx.AsyncClient(timeout=60.0) as http_client: + try: + access_token = await authenticate(http_client) + meetings = await fetch_all_meetings(http_client, access_token) + if not meetings: + sys.exit(0) + + from honcho import Honcho + + honcho = Honcho(workspace_id="granola_test") + seen_peers: set[str] = set() + results = {"imported": 0, "skipped": 0, "failed": 0} + + print("\n" + "=" * 60) + print(" Review each meeting") + print("=" * 60) + + for i, m in enumerate(meetings, 1): + mid = m.get("id") + if not mid: + continue + + participants = parse_participants(m.get("participants", "")) + turns = parse_transcript_turns(m["transcript"]) if m.get("transcript") else [] + + mode, them = review_meeting(i, len(meetings), m, participants, turns) + + if mode == "skip": + print(" -> Skipped") + results["skipped"] += 1 + continue + + # Resolve creator peer + creator = participants.note_creator + me_source = (creator.email or creator.name) if creator else None + if not me_source: + print(" -> Skipped (no creator identifier)") + results["skipped"] += 1 + continue + + me_peer_id = peer_id_from(me_source) + if me_peer_id not in seen_peers: + print(f" New peer: {me_source} ({me_peer_id})") + seen_peers.add(me_peer_id) + + try: + created_at = parse_date(m.get("date", "")) + session = honcho.session(f"meeting-{mid}") + metadata: dict[str, object] = { + "title": m.get("title", "Untitled"), + "date": m.get("date", ""), + "granola_meeting_id": mid, + "mode": mode, + } + + if mode == "two_person" and them is not None: + them_source = them.email or them.name + them_peer_id = peer_id_from(them_source) + if them_peer_id not in seen_peers: + print(f" New peer: {them_source} ({them_peer_id})") + seen_peers.add(them_peer_id) + import_two_person(honcho, session, me_peer_id, them_peer_id, turns, metadata, created_at) + else: + import_summary(honcho, session, me_peer_id, m, metadata, created_at) + + results["imported"] += 1 + + except ValueError as e: + print(f" -> FAILED: {e}") + results["failed"] += 1 + except Exception as e: + print(f" -> FAILED: {e}") + traceback.print_exc() + results["failed"] += 1 + + # Done + print("\n" + "=" * 60) + print(" Transfer Complete!") + print("=" * 60) + print(f"\n Imported: {results['imported']}") + print(f" Skipped: {results['skipped']}") + print(f" Failed: {results['failed']}") + print(" Workspace: granola") + print(f" Peers: {sorted(seen_peers)}") + + except KeyboardInterrupt: + print("\n\nAborted.") + sys.exit(0) + except Exception as e: + print(f"\nTransfer failed: {e}") + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) 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" }, +] diff --git a/honcho-cli/README.md b/honcho-cli/README.md new file mode 100644 index 00000000..804e2285 --- /dev/null +++ b/honcho-cli/README.md @@ -0,0 +1,211 @@ +``` +██╗ ██╗ ██████╗ ███╗ ██╗ ██████╗██╗ ██╗ ██████╗ +██║ ██║██╔═══██╗████╗ ██║██╔════╝██║ ██║██╔═══██╗ +███████║██║ ██║██╔██╗ ██║██║ ███████║██║ ██║ +██╔══██║██║ ██║██║╚██╗██║██║ ██╔══██║██║ ██║ +██║ ██║╚██████╔╝██║ ╚████║╚██████╗██║ ██║╚██████╔╝ +╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ +``` + +# honcho-cli + +A terminal for [Honcho](https://honcho.dev) — memory that reasons. + +## Install + +As a standalone tool (recommended): + +```bash +uv tool install honcho-cli +``` + +## Quick Start + +```bash +honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json +honcho doctor # verify your config + connectivity +honcho # show banner + command list +``` + +`honcho init` reads `apiKey` and `environmentUrl` from the top-level of `~/.honcho/config.json` (the same file other Honcho tools — plugins, host integrations — share). If both are present, it confirms them with you; if either is missing (or you decline), it prompts for the missing value(s) and writes them back. Host-specific entries under `hosts` are left untouched. + +Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s` flags or `HONCHO_*` env vars — not persisted as CLI defaults. + +## Commands + +### Onboarding + +| Command | Description | +|---------|-------------| +| `honcho init` | Confirm/set `apiKey` + `environmentUrl` in `~/.honcho/config.json` | +| `honcho doctor` | Health check: config, connectivity, workspace, peer, queue | + +### Workspaces + +| Command | Description | +|---------|-------------| +| `honcho workspace list` | List accessible workspaces | +| `honcho workspace create ` | Create or get a workspace | +| `honcho workspace inspect` | Peers, sessions, config for a workspace | +| `honcho workspace search ` | Search messages across workspace | +| `honcho workspace queue-status` | Deriver queue status (filter with `--observer` / `--sender`) | +| `honcho workspace delete ` | Delete a workspace. Use `--dry-run` to preview, `--cascade` to also delete sessions, `--yes` to skip the confirm prompt | + +### Peers + +| Command | Description | +|---------|-------------| +| `honcho peer list` | List peers in the workspace | +| `honcho peer create ` | Create or get a peer | +| `honcho peer inspect ` | Card, session count, recent conclusions | +| `honcho peer card ` | Raw peer card content | +| `honcho peer chat ` | Query the dialectic about a peer (peer via `-p` / `HONCHO_PEER_ID`) | +| `honcho peer representation ` | Formatted representation | +| `honcho peer search ` | Search a peer's messages (peer via `-p` / `HONCHO_PEER_ID`) | +| `honcho peer get-metadata ` / `set-metadata` | Metadata operations | + +### Sessions + +| Command | Description | +|---------|-------------| +| `honcho session list` | List sessions in the workspace (filter with `--peer/-p`) | +| `honcho session create ` | Create or get a session (optionally `--peers` to add peers, `--metadata`) | +| `honcho session inspect ` | Peers, message count, summaries, config | +| `honcho session context ` | What an agent would see | +| `honcho session summaries ` | Short + long summaries | +| `honcho session peers ` / `add-peers` / `remove-peers` | Peer management | +| `honcho session search ` | Search messages in a session | +| `honcho session representation ` | Peer representation in a session | +| `honcho session get-metadata ` / `set-metadata` | Metadata operations | +| `honcho session delete ` | Destructive; requires `--yes` | + +### Messages + +| Command | Description | +|---------|-------------| +| `honcho message list` | List messages in a session (session via `-s` / `HONCHO_SESSION_ID`) | +| `honcho message create ` | Create a message (requires `--peer/-p`, session via `-s`) | +| `honcho message get ` | Get a single message (session via `-s` / `HONCHO_SESSION_ID`) | + +### Conclusions (observations) + +| Command | Description | +|---------|-------------| +| `honcho conclusion list` | List conclusions (filter with `--observer` / `--observed`) | +| `honcho conclusion search ` | Semantic search (filter with `--observer` / `--observed`) | +| `honcho conclusion create` | Create a conclusion | +| `honcho conclusion delete ` | Delete a conclusion | + +### Config + +| Command | Description | +|---------|-------------| +| `honcho config` | Show current config (API key redacted) | + +## Agent Usage + +All commands output JSON when stdout isn't a TTY, or when `--json` is forced. +Collection commands emit JSON arrays, and single-resource commands emit JSON objects: + +```bash +honcho peer list --json +honcho workspace inspect --json | jq '.peers' +honcho doctor --json # machine-parseable health checklist +``` + +Errors are structured: + +```json +{ + "error": { + "code": "PEER_NOT_FOUND", + "message": "Peer 'abc' not found in workspace 'my-ws'", + "details": {"workspace_id": "my-ws", "peer_id": "abc"} + } +} +``` + +Non-interactive onboarding: + +```bash +# Pre-seed via flags / env vars; init still prompts for anything missing +HONCHO_API_KEY=hch-v3-xxx honcho init --base-url https://api.honcho.dev +``` + +## Agent skill + +`honcho-cli` ships with a skill that teaches agents the right commands and conventions for inspecting and debugging a Honcho deployment. Install it anywhere skills are accepted (Claude Code, other skill-aware agents): + +```bash +npx skills add plastic-labs/honcho +``` + +The picker lists every skill for Honcho — select `honcho-cli` . + +## Environment Variables + +All `HONCHO_*` env vars work at runtime — no config file required. + +Precedence (highest first): **flag → env var → config file → default**. + +| Variable | Flag | Description | +|----------|------|-------------| +| `HONCHO_API_KEY` | `--api-key` (init) | Admin JWT | +| `HONCHO_BASE_URL` | `--base-url` (init) | API URL | +| `HONCHO_WORKSPACE_ID` | `-w` / `--workspace` | Workspace scope | +| `HONCHO_PEER_ID` | `-p` / `--peer` | Peer scope | +| `HONCHO_SESSION_ID` | `-s` / `--session` | Session scope | +| `HONCHO_JSON` | `--json` | Force JSON output (`1` / `true`) | + +```bash +# Per-command flags +honcho peer card -w prod -p user + +# Or export once per shell +export HONCHO_WORKSPACE_ID=prod +export HONCHO_PEER_ID=user +honcho peer card + +# One-off against a different server +HONCHO_BASE_URL=http://localhost:8000 honcho workspace list + +# CI/CD — env vars only, no config file needed +export HONCHO_API_KEY=hch-v3-xxx +export HONCHO_BASE_URL=https://api.honcho.dev +honcho workspace list +``` + +## Configuration + +The CLI shares `~/.honcho/config.json` with sibling Honcho tools. It owns two +top-level keys: `apiKey` and `environmentUrl` (the full Honcho API URL, e.g. +`https://api.honcho.dev` or `http://localhost:8000`). Everything else at the +top level — `hosts`, `sessions`, `saveMessages`, `sessionStrategy`, etc. — +is left untouched. + +```json +{ + "apiKey": "hch-v3-...", + "environmentUrl": "https://api.honcho.dev", + "hosts": { "claude_code": { "...": "..." } } +} +``` + +`workspace_id` / `peer_id` / `session_id` are per-command only — never +persisted to the config file. + +## Development + +Install from source in editable mode so changes are picked up live: + +```bash +git clone https://github.com/plastic-labs/honcho +cd honcho +uv tool install --force --editable --from ./honcho-cli honcho-cli +``` + +Re-run any time — changes to `honcho-cli/src/` are reflected immediately without reinstalling. + +## License + +MIT diff --git a/honcho-cli/pyproject.toml b/honcho-cli/pyproject.toml new file mode 100644 index 00000000..c06f22e0 --- /dev/null +++ b/honcho-cli/pyproject.toml @@ -0,0 +1,53 @@ +[project] +name = "honcho-cli" +version = "0.1.0" +description = "A terminal for Honcho — memory that reasons." +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +authors = [ + { name = "Plastic Labs", email = "hello@plasticlabs.ai" }, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", +] +dependencies = [ + "typer>=0.15.0", + "honcho-ai>=2.0.0", + "rich>=13.0.0", + "httpx>=0.27.0", +] + +[project.urls] +Homepage = "https://github.com/plastic-labs/honcho" +Repository = "https://github.com/plastic-labs/honcho" + +[project.scripts] +honcho = "honcho_cli.main:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/honcho_cli"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-mock>=3.14.0", +] + +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true diff --git a/honcho-cli/scripts/generate_cli_docs.py b/honcho-cli/scripts/generate_cli_docs.py new file mode 100644 index 00000000..8fa4e223 --- /dev/null +++ b/honcho-cli/scripts/generate_cli_docs.py @@ -0,0 +1,261 @@ +"""Generate ``docs/snippets/cli-commands.mdx`` from the Typer app. + +Walks the ``honcho`` Typer app and emits a Mintlify snippet using native +Mintlify components: ```` / ```` for subcommand +grouping and ```` for each argument and option. The output is a +single snippet included by ``docs/v3/documentation/reference/cli.mdx``. + +Usage:: + + uv run --package honcho-cli python honcho-cli/scripts/generate_cli_docs.py + + # Or as a drift check (non-zero exit if the committed snippet is stale): + uv run --package honcho-cli python honcho-cli/scripts/generate_cli_docs.py --check +""" + +from __future__ import annotations + +import sys +from argparse import ArgumentParser +from pathlib import Path + +import click +import typer.main + +from honcho_cli.main import app + +REPO_ROOT = Path(__file__).resolve().parents[2] +OUTPUT = REPO_ROOT / "docs" / "snippets" / "cli-commands.mdx" + +HEADER = """{/* + GENERATED by honcho-cli/scripts/generate_cli_docs.py — do not edit. + Re-generate with: uv run --package honcho-cli python honcho-cli/scripts/generate_cli_docs.py + Source of truth: honcho-cli/src/honcho_cli/commands/ +*/} + +""" + +# Documented once in cli.mdx's Configuration table. Skip at the per-command +# level so each Accordion only shows options specific to that subcommand. +GLOBAL_OPTIONS: set[tuple[str, str]] = { + ("--workspace", "Override workspace ID"), + ("--peer", "Override peer ID"), + ("--session", "Override session ID"), + ("--json", "Force JSON output"), +} + + +def _escape_mdx(text: str) -> str: + """Escape MDX-sensitive characters in prose so Mintlify's parser doesn't + mistake ``{...}`` for a JSX expression or ```` for a JSX tag.""" + return ( + text.replace("\\", "\\\\") + .replace("{", "\\{") + .replace("}", "\\}") + .replace("<", "\\<") + ) + + +def _attr(value: str) -> str: + """Escape a string for use inside a JSX double-quoted attribute value.""" + return value.replace("\\", "\\\\").replace('"', "'") + + +def _long_opt(param: click.Option) -> str | None: + return next((o for o in param.opts if o.startswith("--")), None) + + +def _short_opt(param: click.Option) -> str | None: + return next( + (o for o in param.opts if o.startswith("-") and not o.startswith("--")), + None, + ) + + +def _is_global(param: click.Parameter) -> bool: + if not isinstance(param, click.Option) or not param.help: + return False + return (_long_opt(param), param.help) in GLOBAL_OPTIONS + + +def _param_type(param: click.Parameter) -> str: + if isinstance(param, click.Option) and param.is_flag: + return "boolean" + if isinstance(param.type, click.Choice): + return "string" + name = getattr(param.type, "name", "") + if name in ("integer", "int"): + return "number" + if name in ("float", "decimal"): + return "number" + if name == "boolean": + return "boolean" + return "string" + + +def _param_path(param: click.Parameter) -> str: + if isinstance(param, click.Argument): + return param.name or "" + return _long_opt(param) or (param.opts[0] if param.opts else "") + + +def _param_required(param: click.Parameter) -> bool: + if isinstance(param, click.Argument): + return param.required + if isinstance(param, click.Option): + return bool(param.required) + return False + + +def _default_attr(param: click.Parameter) -> str | None: + default = param.default + if default is None or default is False or callable(default): + return None + if isinstance(default, (list, tuple)) and not default: + return None + if default is True: + return "true" + return _attr(str(default)) + + +def _ensure_period(text: str) -> str: + return text if text.endswith((".", "?", "!", ":")) else text + "." + + +def _param_body(param: click.Parameter) -> str: + parts: list[str] = [] + if isinstance(param, click.Option): + if param.help: + parts.append(_ensure_period(_escape_mdx(param.help.strip()))) + short = _short_opt(param) + if short: + parts.append(f"Short alias: `{short}`.") + if param.secondary_opts: + neg = " / ".join(f"`{o}`" for o in param.secondary_opts) + parts.append(f"Negate with {neg}.") + if isinstance(param.type, click.Choice): + choices = ", ".join(f"`{c}`" for c in param.type.choices) + parts.append(f"One of: {choices}.") + return " ".join(parts) + + +def _render_param(param: click.Parameter) -> list[str]: + props = [ + f'path="{_attr(_param_path(param))}"', + f'type="{_param_type(param)}"', + ] + if _param_required(param): + props.append("required") + default_attr = _default_attr(param) + if default_attr is not None: + props.append(f'default="{default_attr}"') + body = _param_body(param).strip() + open_tag = f"" + if body: + return [open_tag, f" {body}", ""] + return [open_tag.replace(">", " />")] + + +def _params_of( + cmd: click.Command, *, strip_globals: bool +) -> list[click.Parameter]: + args = [p for p in cmd.params if isinstance(p, click.Argument)] + opts = [ + p + for p in cmd.params + if isinstance(p, click.Option) + and not p.hidden + and not (strip_globals and _is_global(p)) + ] + return args + opts + + +def _invocation_line(cmd: click.Command, path: list[str]) -> str: + args = [p for p in cmd.params if isinstance(p, click.Argument)] + parts = [" ".join(path)] + for a in args: + placeholder = f"<{a.name}>" + if not a.required: + placeholder = f"[{placeholder}]" + parts.append(placeholder) + return " ".join(parts) + + +def _render_accordion(cmd: click.Command, path: list[str]) -> list[str]: + lines = [f''] + if cmd.help: + lines.append(_escape_mdx(cmd.help.strip())) + lines.append("") + lines.append("```bash") + lines.append(_invocation_line(cmd, path)) + lines.append("```") + lines.append("") + for p in _params_of(cmd, strip_globals=True): + lines.extend(_render_param(p)) + lines.append("") + return lines + + +def _render_top(cmd: click.Command, path: list[str]) -> list[str]: + lines = [f"## {' '.join(path)}", ""] + if cmd.help: + lines.append(_escape_mdx(cmd.help.strip())) + lines.append("") + + if isinstance(cmd, click.Group) and cmd.commands: + lines.append("") + for sub_name in sorted(cmd.commands): + lines.extend( + _render_accordion(cmd.commands[sub_name], path + [sub_name]) + ) + lines.append("") + lines.append("") + return lines + + lines.append("```bash") + lines.append(_invocation_line(cmd, path)) + lines.append("```") + lines.append("") + for p in _params_of(cmd, strip_globals=True): + lines.extend(_render_param(p)) + lines.append("") + return lines + + +def build() -> str: + root: click.Command = typer.main.get_command(app) + if not isinstance(root, click.Group): + raise SystemExit("Expected root command to be a Group") + + body: list[str] = [] + for name in sorted(root.commands): + body.extend(_render_top(root.commands[name], ["honcho", name])) + return HEADER + "\n".join(body) + "\n" + + +def main() -> int: + parser = ArgumentParser() + parser.add_argument( + "--check", + action="store_true", + help="Exit non-zero if the committed snippet differs from generated output.", + ) + ns = parser.parse_args() + generated = build() + if ns.check: + current = OUTPUT.read_text() if OUTPUT.exists() else "" + if current != generated: + print( + f"::error::{OUTPUT} is stale. Re-run without --check to regenerate.", + file=sys.stderr, + ) + return 1 + return 0 + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text(generated) + print(f"Wrote {OUTPUT}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/honcho-cli/src/honcho_cli/__init__.py b/honcho-cli/src/honcho_cli/__init__.py new file mode 100644 index 00000000..f38546a9 --- /dev/null +++ b/honcho-cli/src/honcho_cli/__init__.py @@ -0,0 +1,3 @@ +"""Honcho CLI — a terminal for Honcho.""" + +__version__ = "0.1.0" diff --git a/honcho-cli/src/honcho_cli/_help.py b/honcho-cli/src/honcho_cli/_help.py new file mode 100644 index 00000000..d5dcb976 --- /dev/null +++ b/honcho-cli/src/honcho_cli/_help.py @@ -0,0 +1,130 @@ +"""Themed help rendering for honcho CLI. + +Single source of truth for: + +- Rich-utils theme constants (dim borders, brand color) +- HonchoTyperGroup: subclass applied via ``cls=`` at every Typer app in + this package — replaces Click's terse ``Usage: …`` line with + pattern/example rows and prints a curated welcome at the top-level. + +Lives in its own module so every ``commands/*.py`` can import it without +pulling in ``main.py`` (which would create an import cycle). +""" + +from __future__ import annotations + +import click +import typer.rich_utils as ru +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from typer.core import TyperGroup + +from honcho_cli import __version__ +from honcho_cli.branding import BANNER, BRAND +from honcho_cli.output import use_json + + +# Theme Typer's rich help renderer. Module-level side effect limited to +# styling — no behavior changes that could surprise other Typer users. +ru.STYLE_COMMANDS_PANEL_BORDER = "dim" +ru.STYLE_OPTIONS_PANEL_BORDER = "dim" +ru.STYLE_ERRORS_PANEL_BORDER = "dim" +ru.STYLE_OPTION = f"bold {BRAND}" +ru.STYLE_SWITCH = f"bold {BRAND}" +ru.STYLE_USAGE = "dim" +ru.STYLE_USAGE_COMMAND = f"bold {BRAND}" + + +def _cmd_table(rows: list[tuple[str, str]]) -> Table: + t = Table(show_header=False, box=None, padding=(0, 2, 0, 0), expand=False) + t.add_column("cmd", style=f"bold {BRAND}", no_wrap=True) + t.add_column("desc", style="default") + for cmd, desc in rows: + t.add_row(cmd, desc) + return t + + +def _welcome_panel(title: str, rows: list[tuple[str, str]]) -> Panel: + return Panel( + _cmd_table(rows), + title=f"[dim]{title}[/dim]", + title_align="left", + border_style="dim", + box=box.ROUNDED, + padding=(0, 1), + expand=False, + ) + + +def print_welcome(console: Console) -> None: + """Render the curated 3-panel welcome (banner + getting started / memory / commands).""" + if use_json(): + return + console.print(f"[bold {BRAND}]{BANNER}[/bold {BRAND}]") + console.print(f" [dim]v{__version__}[/dim]\n", highlight=False) + + start_rows = [ + ("honcho init", "configure API key and server URL"), + ("honcho doctor", "verify connection and workspace health"), + ] + cmd_rows = [ + ("[dim]pattern[/dim]", r"[dim]honcho \[args] \[-w workspace] \[-p peer] \[-s session][/dim]"), + ("[dim]example[/dim]", "[dim]honcho peer chat \"what does alice prefer?\" -p alice -w agents[/dim]"), + ("", ""), + ("workspace", "list · create · search · delete · inspect · queue-status"), + ("peer", "list · create · search · inspect · card · chat"), + ("", "get-metadata · set-metadata · representation"), + ("session", "list · create · search · delete · inspect · add-peers"), + ("", "context · get-metadata · set-metadata · peers"), + ("", "remove-peers · representation · summaries"), + ("message", "list · create · get"), + ("conclusion", "list · create · search · delete"), + ("config", "inspect current configuration"), + ] + memory_rows = [ + ("honcho peer chat \"...\" -p -w ","query the Dialectic about a peer"), + ("honcho peer inspect -p -w ","dashboard: peer card + recent conclusions + configuration"), + ("honcho peer representation -p -w ", "global peer representation"), + ("honcho peer representation -p -w -s ", "session-scoped peer representation"), + ("honcho peer card -p -w ", "synthesized identity: traits, preferences, instructions"), + ("honcho conclusion list -p -w ", "browse peer conclusions"), + ] + + option_rows = [ + ("-w / --workspace", "scope to a workspace"), + ("-p / --peer", "scope to a peer"), + ("-s / --session", "scope to a session"), + ("--json", "force JSON output for scripts and agents"), + ("--help", "show help for any command (e.g. honcho peer --help)"), + ] + + console.print(_welcome_panel("getting started", start_rows)) + console.print(_welcome_panel("commands", cmd_rows)) + console.print(_welcome_panel("memory", memory_rows)) + console.print(_welcome_panel("options", option_rows)) + console.print() + + +class HonchoTyperGroup(TyperGroup): + """Typer group with pattern/example usage and top-level welcome. + + Applied via ``cls=`` on every ``typer.Typer(...)`` in this package, + so no class-level monkey-patching is needed. + """ + + def get_usage(self, ctx): + """Replace Click's 'Usage: …' with pattern/example rows.""" + original = click.Command.get_usage(self, ctx) + pattern = original.replace("Usage: ", "", 1) if original.startswith("Usage: ") else original + subs = self.list_commands(ctx) + example = f"{ctx.command_path} {subs[0]}" if subs else f"{ctx.command_path} --help" + return f"pattern: {pattern}\nexample: {example}" + + def format_help(self, ctx, formatter): + """Top-level --help renders the welcome; sub-groups fall through to Typer.""" + if ctx.parent is None: + print_welcome(Console()) + return + super().format_help(ctx, formatter) diff --git a/honcho-cli/src/honcho_cli/branding.py b/honcho-cli/src/honcho_cli/branding.py new file mode 100644 index 00000000..2534a0b2 --- /dev/null +++ b/honcho-cli/src/honcho_cli/branding.py @@ -0,0 +1,17 @@ +"""Honcho CLI brand constants — colours, icons, and the ASCII banner. +""" + +BRAND = "#B6DAFD" + +BANNER = """ +██╗ ██╗ ██████╗ ███╗ ██╗ ██████╗██╗ ██╗ ██████╗ +██║ ██║██╔═══██╗████╗ ██║██╔════╝██║ ██║██╔═══██╗ +███████║██║ ██║██╔██╗ ██║██║ ███████║██║ ██║ +██╔══██║██║ ██║██║╚██╗██║██║ ██╔══██║██║ ██║ +██║ ██║╚██████╔╝██║ ╚████║╚██████╗██║ ██║╚██████╔╝ +╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ +""".strip("\n") + +ICON_OK = "[green]✓[/green]" +ICON_FAIL = "[red]✗[/red]" +ICON_RUN = f"[{BRAND}]→[/{BRAND}]" diff --git a/honcho-cli/src/honcho_cli/commands/__init__.py b/honcho-cli/src/honcho_cli/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/honcho-cli/src/honcho_cli/commands/conclusion.py b/honcho-cli/src/honcho_cli/commands/conclusion.py new file mode 100644 index 00000000..1a158de6 --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/conclusion.py @@ -0,0 +1,219 @@ +"""Conclusion commands: list, search, create, delete.""" + +from __future__ import annotations + +import json +from typing import Optional + +import typer + +from honcho_cli.commands.workspace import _handle_error +from honcho_cli.output import print_error, print_result, status, use_json +from honcho_cli.validation import validate_resource_id + +from honcho_cli._help import HonchoTyperGroup +from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags + +app = typer.Typer(cls=HonchoTyperGroup, help="List, search, create, and delete peer conclusions (Honcho's memory atoms).") +add_common_options(app) + + +def _require_observer(observer: str | None) -> str: + """Resolve observer peer ID; emit combined error if peer+workspace both missing.""" + config = get_resolved_config() + obs = observer or config.peer_id + if not obs: + if not config.workspace_id: + print_error( + "NO_SCOPE", + "No peer or workspace scoped. Pass --peer/-p and --workspace/-w, or set HONCHO_PEER_ID and HONCHO_WORKSPACE_ID.", + ) + else: + print_error("NO_PEER", "Peer required. Pass --peer/-p: honcho conclusion -p ") + raise typer.Exit(1) + return obs + + +@app.command("list") +def list_conclusions( + observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"), + observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"), + limit: int = typer.Option(10, "--limit", help="Max results"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """List conclusions.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer) + observer = _require_observer(observer) + client, config = get_client() + + p = client.peer(observer) + + try: + if observed: + scope = p.conclusions_of(observed) + else: + scope = p.conclusions + + conclusions = scope.list(size=limit).items + items = [ + { + "id": c.id, + "content": c.content if use_json() else c.content[:200], + "workspace_id": config.workspace_id, + "observer_id": c.observer_id, + "observed_id": c.observed_id, + "session_id": c.session_id, + "created_at": str(c.created_at), + } + for c in conclusions + ] + print_result(items, columns=["id", "content", "workspace_id", "observer_id", "observed_id", "session_id", "created_at"], title="Conclusions") + except Exception as e: + _handle_error(e, "conclusion", "list") + + +@app.command() +def search( + query: str = typer.Argument(help="Search query"), + observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"), + observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"), + top_k: int = typer.Option(10, help="Max results"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Semantic search over conclusions.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer) + observer = _require_observer(observer) + client, config = get_client() + + p = client.peer(observer) + + try: + if observed: + scope = p.conclusions_of(observed) + else: + scope = p.conclusions + + results = scope.query(query, top_k=top_k) + items = [ + { + "id": c.id, + "content": c.content if use_json() else c.content[:200], + "workspace_id": config.workspace_id, + "observer_id": c.observer_id, + "observed_id": c.observed_id, + "session_id": c.session_id, + "created_at": str(c.created_at), + } + for c in results + ] + print_result(items, columns=["id", "content", "workspace_id", "session_id", "created_at"], title=f"Conclusion search: {query}") + except Exception as e: + _handle_error(e, "conclusion", "search") + + +@app.command() +def create( + content: str = typer.Argument(help="Conclusion content or JSON payload"), + observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"), + observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"), + session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session context"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Create a conclusion.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session_id) + observer = _require_observer(observer) + client, config = get_client() + + # If content looks like JSON, try to parse it + try: + payload = json.loads(content) + if isinstance(payload, dict): + content = payload.get("content", content) + except json.JSONDecodeError: + pass + + p = client.peer(observer) + + try: + if observed: + scope = p.conclusions_of(observed) + else: + scope = p.conclusions + + params: dict[str, object] = {"content": content} + if config.session_id: + params["session_id"] = config.session_id + results = scope.create([params]) + result = results[0] if results else None + if result is None: + print_error("CREATE_FAILED", "Conclusion create returned no results") + raise typer.Exit(1) + print_result({ + "id": result.id, + "content": result.content, + "workspace_id": config.workspace_id, + "observer_id": result.observer_id, + "observed_id": result.observed_id, + "session_id": result.session_id, + "created_at": str(result.created_at), + }) + except Exception as e: + _handle_error(e, "conclusion", "create") + + +@app.command() +def delete( + conclusion_id: str = typer.Argument(help="Conclusion ID to delete"), + observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"), + observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Delete a conclusion.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer) + validate_resource_id(conclusion_id, "conclusion") + client, config = get_client() + + if not observer: + observer = config.peer_id + if not observer: + print_error("NO_PEER", "Peer required. Pass --peer/-p: honcho conclusion -p ") + raise typer.Exit(1) + + p = client.peer(observer) + + if not yes: + # SDK doesn't expose a get-by-id on ConclusionScope, so we can't + # preview content cheaply — don't paginate the list just to + # decorate the prompt. Show identifying fields only. + if not use_json(): + typer.echo( + f" id: {conclusion_id}\n" + f" observer: {observer}\n" + f" observed: {observed or '(self)'}" + ) + typer.confirm(f"Delete conclusion '{conclusion_id}'?", abort=True) + + try: + if observed: + scope = p.conclusions_of(observed) + else: + scope = p.conclusions + + scope.delete(conclusion_id) + status(f"Conclusion '{conclusion_id}' deleted") + print_result({"deleted": conclusion_id}) + except Exception as e: + _handle_error(e, "conclusion", conclusion_id) diff --git a/honcho-cli/src/honcho_cli/commands/config_cmd.py b/honcho-cli/src/honcho_cli/commands/config_cmd.py new file mode 100644 index 00000000..814685fc --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/config_cmd.py @@ -0,0 +1,31 @@ +"""Config inspection command: ``honcho config``. + +Writing to ``~/.honcho/config.json`` is done only via ``honcho init``, which +manages the two CLI-owned keys (``apiKey`` + ``environmentUrl``). +Workspace / peer / session scoping is per-command via flags / env vars, not +persisted defaults. +""" + +from __future__ import annotations + +import typer + +from honcho_cli._help import HonchoTyperGroup +from honcho_cli.common import handle_cmd_flags +from honcho_cli.config import CLIConfig +from honcho_cli.output import print_result + +app = typer.Typer(cls=HonchoTyperGroup, help="Inspect CLI configuration.", invoke_without_command=True) + + +@app.callback(invoke_without_command=True) +def config( + ctx: typer.Context, + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Show current config (api key redacted).""" + if ctx.invoked_subcommand is not None: + return + handle_cmd_flags(json_output=json_output) + cfg = CLIConfig.load() + print_result(cfg.redacted()) diff --git a/honcho-cli/src/honcho_cli/commands/message.py b/honcho-cli/src/honcho_cli/commands/message.py new file mode 100644 index 00000000..b810917a --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/message.py @@ -0,0 +1,161 @@ +"""Message commands: list, get, create.""" + +from __future__ import annotations + +import hashlib +import json +from typing import Optional + +import typer + +from honcho.api_types import MessageCreateParams + +from honcho_cli.commands.session import _get_session_id +from honcho_cli.commands.workspace import _handle_error +from honcho_cli.output import print_error, print_result, status +from honcho_cli.validation import validate_resource_id + +from honcho_cli._help import HonchoTyperGroup +from honcho_cli.common import add_common_options, get_client, handle_cmd_flags + +app = typer.Typer(cls=HonchoTyperGroup, help="List, create, and get messages within a session.") +add_common_options(app) + + +@app.command("list") +def list_messages( + session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), + last: int = typer.Option(20, "--last", help="Number of recent messages"), + reverse: bool = typer.Option(False, "--reverse", help="Show oldest first (default is newest first)"), + brief: bool = typer.Option(False, "--brief", help="Show only IDs, peer, token count, and created_at (no content)"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Filter by peer ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """List messages in a session. Scoped to a peer with -p.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session) + sid = _get_session_id(session_id) + client, config = get_client() + sess = client.session(sid) + + try: + filters = {"peer_id": config.peer_id} if config.peer_id else None + # Fetch newest-first so [:last] always gives the most recent N messages, + # then flip to oldest-at-top / newest-at-bottom for readable display. + # --reverse keeps the raw server order (oldest first, descending in table). + msgs = sess.messages(filters=filters, reverse=True).items[:last] + if not reverse: + msgs = list(reversed(msgs)) + + # Detect duplicate content + content_hashes: dict[str, list[str]] = {} + for m in msgs: + h = hashlib.md5(m.content.encode()).hexdigest() + content_hashes.setdefault(h, []).append(m.id) + dupes = {h: ids for h, ids in content_hashes.items() if len(ids) > 1} + if dupes: + dupe_count = sum(len(ids) - 1 for ids in dupes.values()) + status(f"Warning: {dupe_count} duplicate message(s) detected (identical content, different IDs)") + + if brief: + items = [ + { + "id": m.id, + "peer_id": m.peer_id, + "token_count": m.token_count, + "created_at": str(m.created_at), + } + for m in msgs + ] + print_result(items, columns=["id", "peer_id", "token_count", "created_at"], title="Messages") + else: + items = [ + { + "id": m.id, + "peer_id": m.peer_id, + "content": m.content, + "token_count": m.token_count, + "metadata": m.metadata, + "created_at": str(m.created_at), + } + for m in msgs + ] + print_result(items, columns=["id", "peer_id", "content", "created_at"], title="Messages") + except Exception as e: + _handle_error(e, "message", "list") + + +@app.command("create") +def create_message( + content: str = typer.Argument(help="Message content"), + peer_id: str = typer.Option(..., "--peer", "-p", help="Peer ID of the message sender"), + metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata to associate with the message"), + session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Create a message in a session.""" + handle_cmd_flags(json_output=json_output, workspace=workspace, session=session_id) + sid = _get_session_id(None) + validate_resource_id(peer_id, "peer") + client, config = get_client() + sess = client.session(sid) + + parsed_metadata = None + if metadata: + try: + parsed_metadata = json.loads(metadata) + except json.JSONDecodeError as e: + print_error("INVALID_JSON", f"--metadata must be valid JSON: {e}", {}) + raise typer.Exit(1) + + try: + msgs = sess.add_messages(MessageCreateParams( + peer_id=peer_id, + content=content, + metadata=parsed_metadata, + )) + msg = msgs[0] + print_result({ + "id": msg.id, + "peer_id": msg.peer_id, + "content": msg.content, + "token_count": msg.token_count, + "created_at": str(msg.created_at), + }) + except Exception as e: + _handle_error(e, "message", "create") + + +@app.command("get") +def get_message( + message_id: str = typer.Argument(help="Message ID"), + session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Get a single message by ID.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace) + validate_resource_id(message_id, "message") + sid = _get_session_id(session_id) + client, config = get_client() + + try: + sess = client.session(sid) + msg = sess.get_message(message_id) + + print_result({ + "id": msg.id, + "peer_id": msg.peer_id, + "content": msg.content, + "token_count": msg.token_count, + "metadata": msg.metadata, + "created_at": str(msg.created_at), + }) + except SystemExit: + raise + except Exception as e: + _handle_error(e, "message", message_id) diff --git a/honcho-cli/src/honcho_cli/commands/peer.py b/honcho-cli/src/honcho_cli/commands/peer.py new file mode 100644 index 00000000..856fab22 --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/peer.py @@ -0,0 +1,308 @@ +"""Peer commands: list, inspect, card, chat, search, create, metadata, representation.""" + +from __future__ import annotations + +import json +from typing import Optional + +import typer + +from honcho.api_types import PeerConfig + +from honcho_cli.commands.workspace import _config_to_dict, _handle_error, _raw_list +from honcho_cli.output import print_error, print_result, use_json +from honcho_cli.validation import validate_resource_id + +from honcho_cli._help import HonchoTyperGroup +from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags + +app = typer.Typer(cls=HonchoTyperGroup, help="List, create, chat with, search, and manage peers and their representations.") +add_common_options(app) + + +def _get_peer_id(peer_id: str | None) -> str: + + config = get_resolved_config() + pid = peer_id or config.peer_id + if not pid: + if not config.workspace_id: + print_error( + "NO_SCOPE", + "No peer or workspace scoped. Pass --peer/-p and --workspace/-w, or set HONCHO_PEER_ID and HONCHO_WORKSPACE_ID.", + ) + else: + print_error("NO_PEER", "No peer ID provided. Pass --peer/-p or set HONCHO_PEER_ID.") + raise typer.Exit(1) + return validate_resource_id(pid, "peer") + + +@app.command("list") +def list_peers( + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """List all peers in the workspace.""" + handle_cmd_flags(json_output=json_output, workspace=workspace) + client, config = get_client() + + try: + raw_peers = _raw_list(client.peers()) + items = [ + { + "id": p.id, + "metadata": p.metadata, + "configuration": _config_to_dict(p.configuration) if p.configuration else None, + "created_at": str(p.created_at), + } + for p in raw_peers + ] + print_result(items, columns=["id", "metadata", "created_at"], title="Peers") + except Exception as e: + _handle_error(e, "peer", "list") + + +@app.command() +def inspect( + peer_id: Optional[str] = typer.Argument(None, help="Peer ID (uses default if omitted)"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Inspect a peer: card, session count, recent conclusions.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer) + pid = _get_peer_id(peer_id) + client, config = get_client() + p = client.peer(pid) + + try: + card = p.get_card() + peer_config = p.get_configuration() + # First page only; SyncPage.total (when the server supplies it) is + # authoritative for counts without walking every page. + session_page = p.sessions() + conclusion_page = p.conclusions.list(size=10) + + session_items = session_page.items + conclusion_items = conclusion_page.items + + result = { + "id": pid, + "card": card, + "configuration": _config_to_dict(peer_config) if peer_config else None, + "session_count": session_page.total, + "conclusion_count": conclusion_page.total, + "recent_conclusions": [ + {"id": c.id, "content": c.content if use_json() else c.content[:200], "created_at": str(c.created_at)} + for c in conclusion_items + ], + "sessions": [{"id": s.id} for s in session_items[:10]], + } + print_result(result) + except Exception as e: + _handle_error(e, "peer", pid) + + +@app.command() +def card( + peer_id: Optional[str] = typer.Argument(None, help="Peer ID (uses default if omitted)"), + target: Optional[str] = typer.Option(None, help="Target peer for relationship card"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Get raw peer card content.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer) + pid = _get_peer_id(peer_id) + client, config = get_client() + p = client.peer(pid) + + try: + result = p.get_card(target=target) + print_result({"peer_id": pid, "target": target, "card": result}) + except Exception as e: + _handle_error(e, "peer", pid) + + +@app.command() +def chat( + query: str = typer.Argument(help="Question to ask about the peer"), + target: Optional[str] = typer.Option(None, help="Target peer for perspective"), + reasoning: Optional[str] = typer.Option(None, "--reasoning", "-r", help="Reasoning level: minimal, low, medium, high, max"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Query the dialectic about a peer.""" + + _REASONING_LEVELS = ("minimal", "low", "medium", "high", "max") + if reasoning and reasoning not in _REASONING_LEVELS: + from honcho_cli.output import print_error + print_error("INVALID_REASONING", f"--reasoning must be one of: {', '.join(_REASONING_LEVELS)}") + raise typer.Exit(1) + + handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session) + pid = _get_peer_id(None) + client, config = get_client() + p = client.peer(pid) + + try: + response = p.chat( + query, + target=target, + session=config.session_id or None, + reasoning_level=reasoning or None, + ) + print_result({"peer_id": pid, "query": query, "response": response}) + except Exception as e: + _handle_error(e, "peer", pid) + + +@app.command() +def search( + query: str = typer.Argument(help="Search query"), + limit: int = typer.Option(10, help="Max results"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Search a peer's messages.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer) + pid = _get_peer_id(None) + client, config = get_client() + p = client.peer(pid) + + try: + results = p.search(query, limit=limit) + items = [ + { + "id": m.id, + "content": m.content if use_json() else m.content[:200], + "session_id": m.session_id, + "created_at": str(m.created_at), + } + for m in results + ] + print_result(items, columns=["id", "session_id", "content", "created_at"], title=f"Peer search: {query}") + except Exception as e: + _handle_error(e, "peer", pid) + + +@app.command("create") +def create_peer( + peer_id: str = typer.Argument(help="Peer ID to create or get"), + observe_me: Optional[bool] = typer.Option(None, "--observe-me/--no-observe-me", help="Whether Honcho will form a representation of this peer"), + metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata to associate with the peer"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Create or get a peer.""" + handle_cmd_flags(json_output=json_output, workspace=workspace) + pid = validate_resource_id(peer_id, "peer") + client, config = get_client() + + parsed_metadata = None + if metadata: + try: + parsed_metadata = json.loads(metadata) + except json.JSONDecodeError as e: + print_error("INVALID_JSON", f"--metadata must be valid JSON: {e}", {}) + raise typer.Exit(1) + + peer_config = PeerConfig(observe_me=observe_me) if observe_me is not None else None + + try: + p = client.peer(pid, configuration=peer_config, metadata=parsed_metadata) + # Only round-trip to the server when the caller passed config or + # metadata — in that case get-or-create may have returned a + # pre-existing peer and the echoed output would lie. When no input + # was passed, skip the two extra API calls entirely. + result: dict[str, object] = {"peer_id": p.id} + if peer_config is not None or parsed_metadata is not None: + result["metadata"] = p.get_metadata() + result["configuration"] = _config_to_dict(p.get_configuration()) + print_result(result) + except Exception as e: + _handle_error(e, "peer", pid) + + +@app.command("get-metadata") +def get_metadata( + peer_id: Optional[str] = typer.Argument(None, help="Peer ID (uses default if omitted)"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Get metadata for a peer.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer) + pid = _get_peer_id(peer_id) + client, config = get_client() + p = client.peer(pid) + + try: + result = p.get_metadata() + print_result({"peer_id": pid, "metadata": result}) + except Exception as e: + _handle_error(e, "peer", pid) + + +@app.command("set-metadata") +def set_metadata( + metadata: str = typer.Argument(help="JSON metadata to set (e.g. '{\"key\": \"value\"}')"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Peer ID (uses default if omitted)"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Set metadata for a peer.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer) + pid = _get_peer_id(None) + client, config = get_client() + + try: + parsed = json.loads(metadata) + except json.JSONDecodeError as e: + print_error("INVALID_JSON", f"metadata must be valid JSON: {e}", {}) + raise typer.Exit(1) + + p = client.peer(pid) + + try: + p.set_metadata(parsed) + print_result({"peer_id": pid, "metadata": parsed}) + except Exception as e: + _handle_error(e, "peer", pid) + + +@app.command() +def representation( + peer_id: Optional[str] = typer.Argument(None, help="Peer ID (uses default if omitted)"), + target: Optional[str] = typer.Option(None, help="Target peer to get representation about"), + search_query: Optional[str] = typer.Option(None, help="Semantic search query to filter conclusions"), + max_conclusions: Optional[int] = typer.Option(None, help="Maximum number of conclusions to include"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Get the formatted representation for a peer.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session) + pid = _get_peer_id(peer_id) + client, config = get_client() + p = client.peer(pid) + + try: + result = p.representation( + target=target, + session=config.session_id or None, + search_query=search_query, + max_conclusions=max_conclusions, + ) + print_result({"peer_id": pid, "target": target, "representation": result}) + except Exception as e: + _handle_error(e, "peer", pid) diff --git a/honcho-cli/src/honcho_cli/commands/session.py b/honcho-cli/src/honcho_cli/commands/session.py new file mode 100644 index 00000000..2c5d811b --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/session.py @@ -0,0 +1,404 @@ +"""Session commands: list, inspect, context, summaries, peers, search, representation, metadata.""" + +from __future__ import annotations + +import json +from typing import List, Optional + +import typer + +from honcho import HonchoError + +from honcho_cli.commands.workspace import _config_to_dict, _handle_error, _raw_list +from honcho_cli.output import print_error, print_result, status, use_json +from honcho_cli.validation import validate_resource_id + +from honcho_cli._help import HonchoTyperGroup +from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags + +app = typer.Typer(cls=HonchoTyperGroup, help="List, inspect, create, delete, and manage conversation sessions and their peers.") +add_common_options(app) + + +def _get_session_id(session_id: str | None) -> str: + + config = get_resolved_config() + sid = session_id or config.session_id + if not sid: + print_error("NO_SESSION", "No session ID provided. Pass --session/-s or set HONCHO_SESSION_ID.") + raise typer.Exit(1) + return validate_resource_id(sid, "session") + + +@app.command("list") +def list_sessions( + peer_id: Optional[str] = typer.Option(None, "--peer", "-p", help="Filter by peer"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """List sessions in the workspace.""" + handle_cmd_flags(json_output=json_output, workspace=workspace) + client, config = get_client() + + try: + if peer_id: + peer = client.peer(peer_id) + raw_sessions = _raw_list(peer.sessions()) + else: + raw_sessions = _raw_list(client.sessions()) + + items = [ + { + "id": s.id, + "is_active": s.is_active, + "metadata": s.metadata, + "created_at": str(s.created_at), + } + for s in raw_sessions + ] + print_result(items, columns=["id", "is_active", "metadata", "created_at"], title="Sessions") + except Exception as e: + _handle_error(e, "session", "list") + + +@app.command("create") +def create_session( + session_id: str = typer.Argument(help="Session ID to create or get"), + peers: Optional[str] = typer.Option(None, "--peers", help="Comma-separated peer IDs to add to the session"), + metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata to associate with the session"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Create or get a session.""" + handle_cmd_flags(json_output=json_output, workspace=workspace) + sid = validate_resource_id(session_id, "session") + client, config = get_client() + + parsed_metadata = None + if metadata: + try: + parsed_metadata = json.loads(metadata) + except json.JSONDecodeError as e: + print_error("INVALID_JSON", f"--metadata must be valid JSON: {e}", {}) + raise typer.Exit(1) + + peer_ids = [p.strip() for p in peers.split(",") if p.strip()] if peers else [] + for pid in peer_ids: + validate_resource_id(pid, "peer") + + try: + sess = client.session(sid, metadata=parsed_metadata) + if peer_ids: + sess.add_peers(peer_ids) + result: dict[str, object] = {"session_id": sess.id} + if parsed_metadata is not None: + result["metadata"] = parsed_metadata + if peer_ids: + result["peers"] = peer_ids + print_result(result) + except Exception as e: + _handle_error(e, "session", sid) + + +@app.command() +def inspect( + session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Inspect a session: peers, message count, summaries, config.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, session=session) + sid = _get_session_id(session_id) + client, config = get_client() + sess = client.session(sid) + + try: + peers = sess.peers() + msg_page = sess.messages() + summaries = sess.summaries() + sess_config = sess.get_configuration() + + result = { + "session_id": sid, + "peers": [{"id": p.id} for p in peers], + "message_count": msg_page.total, + "summaries": { + "short": summaries.short_summary if hasattr(summaries, "short_summary") else None, + "long": summaries.long_summary if hasattr(summaries, "long_summary") else None, + }, + "configuration": _config_to_dict(sess_config) if sess_config else None, + } + print_result(result) + except Exception as e: + _handle_error(e, "session", sid) + + +@app.command() +def context( + session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), + tokens: Optional[int] = typer.Option(None, help="Token budget"), + summary: bool = typer.Option(True, help="Include summary"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Get session context (what an agent would see).""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, session=session) + sid = _get_session_id(session_id) + client, config = get_client() + sess = client.session(sid) + + try: + ctx = sess.context(tokens=tokens, summary=summary) + result = ctx.__dict__ if hasattr(ctx, "__dict__") else ctx + print_result(result) + except Exception as e: + _handle_error(e, "session", sid) + + +@app.command() +def summaries( + session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Get session summaries (short + long).""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, session=session) + sid = _get_session_id(session_id) + client, config = get_client() + sess = client.session(sid) + + try: + s = sess.summaries() + result = { + "session_id": sid, + "short_summary": s.short_summary if hasattr(s, "short_summary") else None, + "long_summary": s.long_summary if hasattr(s, "long_summary") else None, + } + print_result(result) + except Exception as e: + _handle_error(e, "session", sid) + + +@app.command() +def delete( + session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Delete a session and all its data. Destructive — requires --yes or interactive confirm.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, session=session) + sid = _get_session_id(session_id) + client, config = get_client() + sess = client.session(sid) + + if not yes: + # Show a short preview so the user knows what's about to disappear. + # Only in interactive/TTY mode — scripted (--json) callers already + # know what they're deleting, and they still need to pass --yes. + # Narrow the except to HonchoError so auth/network failures surface + # before the user types 'y' on a destructive op. + if not use_json(): + try: + peers = sess.peers() + msg_page = sess.messages() + peer_ids = [p.id for p in peers] + typer.echo( + f" session: {sid}\n" + f" peers: {', '.join(peer_ids) if peer_ids else '(none)'}\n" + f" messages: {msg_page.total}" + ) + except HonchoError as preview_err: + status(f"preview unavailable: {preview_err}") + typer.confirm(f"Delete session '{sid}' and all its messages, conclusions, and queue items?", abort=True) + + try: + sess.delete() + status(f"Session '{sid}' deleted") + print_result({"deleted": sid}) + except Exception as e: + _handle_error(e, "session", sid) + + +@app.command("peers") +def session_peers( + session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """List peers in a session.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, session=session) + sid = _get_session_id(session_id) + client, config = get_client() + sess = client.session(sid) + + try: + peers = sess.peers() + items = [{"id": p.id} for p in peers] + print_result(items, columns=["id"], title=f"Session peers ({sid})") + except Exception as e: + _handle_error(e, "session", sid) + + +@app.command("add-peers") +def add_peers( + session_id: str = typer.Argument(help="Session ID"), + peer_ids: List[str] = typer.Argument(help="Peer IDs to add to the session"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Add peers to a session.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace) + sid = _get_session_id(session_id) + client, config = get_client() + sess = client.session(sid) + + try: + sess.add_peers(peer_ids) + print_result({"session_id": sid, "added_peers": peer_ids}) + except Exception as e: + _handle_error(e, "session", sid) + + +@app.command("remove-peers") +def remove_peers( + session_id: str = typer.Argument(help="Session ID"), + peer_ids: List[str] = typer.Argument(help="Peer IDs to remove from the session"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Remove peers from a session.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace) + sid = _get_session_id(session_id) + client, config = get_client() + sess = client.session(sid) + + try: + sess.remove_peers(peer_ids) + print_result({"session_id": sid, "removed_peers": peer_ids}) + except Exception as e: + _handle_error(e, "session", sid) + + +@app.command() +def search( + query: str = typer.Argument(help="Search query"), + session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), + limit: int = typer.Option(10, help="Max results"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Search messages in a session.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, session=session) + sid = _get_session_id(session_id) + client, config = get_client() + sess = client.session(sid) + + try: + results = sess.search(query, limit=limit) + items = [ + { + "id": m.id, + "peer_id": m.peer_id, + "content": m.content if use_json() else m.content[:200], + "created_at": str(m.created_at), + } + for m in results + ] + print_result(items, columns=["id", "peer_id", "content", "created_at"], title=f"Session search: {query}") + except Exception as e: + _handle_error(e, "session", sid) + + +@app.command() +def representation( + peer_id: str = typer.Argument(help="Peer ID to get representation for"), + session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), + target: Optional[str] = typer.Option(None, help="Target peer (what peer_id knows about target)"), + search_query: Optional[str] = typer.Option(None, help="Semantic search query to filter conclusions"), + max_conclusions: Optional[int] = typer.Option(None, help="Maximum number of conclusions to include"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Get the representation of a peer within a session.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, session=session) + sid = _get_session_id(session_id) + client, config = get_client() + sess = client.session(sid) + + try: + result = sess.representation( + peer_id, + target=target, + search_query=search_query, + max_conclusions=max_conclusions, + ) + print_result({"session_id": sid, "peer_id": peer_id, "target": target, "representation": result}) + except Exception as e: + _handle_error(e, "session", sid) + + +@app.command("get-metadata") +def get_metadata( + session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Get metadata for a session.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, session=session) + sid = _get_session_id(session_id) + client, config = get_client() + sess = client.session(sid) + + try: + result = sess.get_metadata() + print_result({"session_id": sid, "metadata": result}) + except Exception as e: + _handle_error(e, "session", sid) + + +@app.command("set-metadata") +def set_metadata( + session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), + metadata: str = typer.Option(..., "--data", "-d", help="JSON metadata to set (e.g. '{\"key\": \"value\"}')"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Set metadata for a session.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, session=session) + sid = _get_session_id(session_id) + client, config = get_client() + + try: + parsed = json.loads(metadata) + except json.JSONDecodeError as e: + print_error("INVALID_JSON", f"metadata must be valid JSON: {e}", {}) + raise typer.Exit(1) + + sess = client.session(sid) + + try: + sess.set_metadata(parsed) + print_result({"session_id": sid, "metadata": parsed}) + except Exception as e: + _handle_error(e, "session", sid) diff --git a/honcho-cli/src/honcho_cli/commands/setup.py b/honcho-cli/src/honcho_cli/commands/setup.py new file mode 100644 index 00000000..6073b3e4 --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/setup.py @@ -0,0 +1,287 @@ +"""Top-level onboarding and health-check commands. + +`honcho init` — confirm or set apiKey + Honcho URL in ~/.honcho/config.json +`honcho doctor` — verify connectivity, config validity, queue health +""" + +from __future__ import annotations + +import json + +import typer +from honcho import ( + APIError, + AuthenticationError, + ConnectionError as HonchoConnectionError, + Honcho, + TimeoutError as HonchoTimeoutError, +) +from rich.console import Console +from rich.panel import Panel + +from honcho_cli import __version__ +from honcho_cli.branding import BANNER, BRAND, ICON_FAIL, ICON_OK, ICON_RUN +from honcho_cli.common import get_resolved_config +from honcho_cli.config import ( + CONFIG_FILE, + DEFAULT_BASE_URL, + CLIConfig, +) +from honcho_cli.output import print_error, print_result, set_json_mode, use_json + +_console = Console(stderr=True) + + +# --------------------------------------------------------------------------- # +# shared helpers + +def _redact(api_key: str) -> str: + """Show ``***`` — enough to compare keys without leaking the body.""" + if not api_key: + return "" + if len(api_key) <= 4: + return "***" + return "***" + api_key[-4:] + + +def _read_file_values() -> tuple[str, str]: + """Return (apiKey, environmentUrl) persisted on disk (or empty strings).""" + if not CONFIG_FILE.exists(): + return "", "" + try: + with open(CONFIG_FILE, encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return "", "" + if not isinstance(data, dict): + return "", "" + key = data.get("apiKey") if isinstance(data.get("apiKey"), str) else "" + url = data.get("environmentUrl") if isinstance(data.get("environmentUrl"), str) else "" + return key, url + + +def _test_connection(base_url: str, api_key: str) -> tuple[bool, str]: + """Probe the Honcho API by listing workspaces. Returns (ok, detail). + + Dispatches on the SDK's typed exception hierarchy instead of matching + substrings of error messages — robust to SDK message changes and locale. + """ + try: + list(Honcho(base_url=base_url, api_key=api_key).workspaces()) + return True, "OK" + except AuthenticationError: + return False, "Unauthorized — check your API key" + except HonchoConnectionError: + return False, "Connection refused — is the server running?" + except HonchoTimeoutError: + return False, "Request timed out" + except APIError as e: + return False, f"API error ({e.status}): {e}" + except Exception as e: + return False, str(e) + + +def _pick(flag_val: str | None, file_val: str) -> str: + """Return best available value. Flag/env wins over file.""" + return flag_val or file_val or "" + + +# --------------------------------------------------------------------------- # +# honcho init + +def init( + api_key: str | None = typer.Option(None, "--api-key", envvar="HONCHO_API_KEY", help="API key (admin JWT)"), + base_url: str | None = typer.Option(None, "--base-url", envvar="HONCHO_BASE_URL", help="Honcho API URL (e.g. https://api.honcho.dev, http://localhost:8000)"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Set API key and server URL in ~/.honcho/config.json. + + Press Enter to keep the current value or type a replacement. + Workspace / peer / session scoping is per-command via -w / -p / -s + or HONCHO_* env vars — never persisted. + """ + + if json_output: + set_json_mode(True) + + file_key, file_url = _read_file_values() + key_val = _pick(api_key, file_key) + url_val = _pick(base_url, file_url).strip() + + if not use_json(): + _console.print() + _console.print(Panel( + f"[bold {BRAND}]{BANNER}[/bold {BRAND}]\n\n Memory that reasons", + expand=False, subtitle=f"Honcho CLI · v{__version__}", + )) + _console.print() + _console.print() + + final_key = _prompt_api_key(key_val) + final_url = _prompt_url(url_val) + + # Persist if anything changed or if the value came from env/flag. + if final_key != file_key or final_url != file_url: + CLIConfig(base_url=final_url, api_key=final_key).save() + if not use_json(): + _console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]") + + _check_connection(final_url, final_key) + + if use_json(): + print_result({"apiKey": _redact(final_key), "baseUrl": final_url}) + + +def _prompt_api_key(value: str) -> str: + """Prompt for API key. + + When a key already exists (from env var or config file), the user picks + between keeping it or entering a replacement. When no key exists, the + user can paste one or press Enter to skip (local dev with auth disabled + doesn't need a key). + """ + if use_json(): + return value + + if value: + redacted = _redact(value) + _console.print(f" [dim]Current API key: {redacted}[/dim]") + _console.print(" [dim](1)[/dim] Keep current key") + _console.print(" [dim](2)[/dim] Enter a new key") + choice = typer.prompt(" Choice", default="1", show_default=True, prompt_suffix=": ").strip() + if choice == "2": + raw = typer.prompt(" API key", default="", show_default=False, prompt_suffix=": ").strip() + return raw + return value + else: + _console.print(" [dim]Not needed for local dev — press Enter to skip[/dim]") + raw = typer.prompt(" API key", default="", show_default=False, prompt_suffix=": ").strip() + return raw + + +def _normalize_url(url: str) -> str: + """Strip whitespace from the URL.""" + return url.strip() + + +def _prompt_url(value: str) -> str: + """Prompt for Honcho URL. Shows current value as the default; Enter keeps it. + + First run defaults to DEFAULT_BASE_URL. After that, whatever is saved + in config becomes the default so the user isn't fighting back to their + custom URL every time. + """ + if use_json(): + if value: + return _normalize_url(value) + print_error("MISSING_VALUE", "Honcho URL is required", {}) + raise typer.Exit(1) + + default = _normalize_url(value) if value else DEFAULT_BASE_URL + _console.print(" [dim]Use https://api.honcho.dev for the hosted Honcho instance[/dim]") + while True: + raw = typer.prompt(" Honcho URL", default=default, show_default=True, prompt_suffix=": ").strip() + url = _normalize_url(raw) + if url.startswith(("http://", "https://")): + return url + _console.print(" [red]URL must start with http:// or https://[/red]") + + +def _check_connection(base_url: str, api_key: str) -> None: + + + if not use_json(): + _console.print(f"\n {ICON_RUN} [dim]Testing connection to {base_url}...[/dim]", end=" ") + ok, detail = _test_connection(base_url, api_key) + if not ok: + if use_json(): + print_error("CONNECTION_FAILED", detail, {"base_url": base_url}) + else: + _console.print(f"{ICON_FAIL} [red]Failed[/red]: {detail}") + raise typer.Exit(1) + if not use_json(): + _console.print(f"{ICON_OK} [green]Connected[/green]") + + +# --------------------------------------------------------------------------- # +# honcho doctor + +def doctor( + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Verify config and connectivity. Scope with -w / -p to check workspace, peer, and queue health.""" + + + if json_output: + set_json_mode(True) + + checks: list[dict] = [] + + def _add(name: str, ok: bool, detail: str = "") -> None: + checks.append({"check": name, "ok": ok, "detail": detail}) + if not use_json(): + icon = ICON_OK if ok else ICON_FAIL + line = f" {icon} {name:<22}" + if detail: + line += f" [dim]{detail}[/dim]" + _console.print(line) + + if not use_json(): + _console.print(f"\n[bold {BRAND}]Honcho Doctor[/bold {BRAND}]\n") + + config = get_resolved_config() + _add("Config file", CONFIG_FILE.exists(), + str(CONFIG_FILE) if CONFIG_FILE.exists() else f"{CONFIG_FILE} not found") + _add("API key configured", bool(config.api_key), + "set" if config.api_key else "missing — run `honcho init`") + + if config.base_url and config.api_key: + _add("API connectivity", *_test_connection(config.base_url, config.api_key)) + else: + _add("API connectivity", False, "skipped — no base_url or api_key") + + # Workspace / peer / queue run only when scoped via -w / -p. + ws_ok, client = False, None + if config.workspace_id and config.api_key: + try: + + + client = Honcho(base_url=config.base_url, api_key=config.api_key, workspace_id=config.workspace_id) + client.get_configuration() + ws_ok = True + _add("Workspace reachable", True, config.workspace_id) + except Exception as e: + _add("Workspace reachable", False, f"{config.workspace_id}: {e}") + if ws_ok: + try: + q = client.queue_status() + _add("Queue health", True, f"{q.completed_work_units}/{q.total_work_units} completed, {q.pending_work_units} pending") + except Exception: + _add("Queue health", True, "endpoint not available (non-critical)") + + if config.peer_id: + if ws_ok and client is not None: + try: + client.peer(config.peer_id).get_card() + _add("Peer exists", True, config.peer_id) + except Exception as e: + _add("Peer exists", False, f"{config.peer_id}: {e}") + else: + _add("Peer exists", False, "skipped — workspace not reachable") + + passed = sum(1 for c in checks if c["ok"]) + total = len(checks) + + if use_json(): + print_result({"checks": checks, "passed": passed, "total": total}) + else: + color = BRAND if passed == total else ("yellow" if passed > total // 2 else "red") + hint = "" if config.workspace_id else " [dim](pass -w / -p to include workspace, peer, queue checks)[/dim]" + _console.print(f"\n [{color}]{passed}/{total}[/{color}] checks passed{hint}\n") + + # Config file + API connectivity are hard requirements. + critical = {"Config file", "API key configured", "API connectivity"} + if config.workspace_id: + critical.add("Workspace reachable") + if any(not c["ok"] for c in checks if c["check"] in critical): + raise typer.Exit(1) diff --git a/honcho-cli/src/honcho_cli/commands/workspace.py b/honcho-cli/src/honcho_cli/commands/workspace.py new file mode 100644 index 00000000..6aeda00e --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/workspace.py @@ -0,0 +1,322 @@ +"""Workspace commands: list, inspect, create, delete, search, queue-status.""" + +from __future__ import annotations + +import json +from typing import Optional + +import typer + +from honcho import ( + APIError, + AuthenticationError, + Honcho, + NotFoundError, + PermissionDeniedError, + ServerError, +) + +from honcho_cli.output import print_error, print_result, status, use_json +from honcho_cli.validation import validate_resource_id + +from honcho_cli._help import HonchoTyperGroup +from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags + +app = typer.Typer(cls=HonchoTyperGroup, help="List, create, inspect, delete, and search workspaces.") +add_common_options(app) + + +def _get_workspace_id(workspace_id: str | None) -> str: + + config = get_resolved_config() + wid = workspace_id or config.workspace_id + if not wid: + print_error("NO_WORKSPACE", "No workspace ID provided. Pass --workspace/-w or set HONCHO_WORKSPACE_ID.") + raise typer.Exit(1) + return validate_resource_id(wid, "workspace") + + +def _raw_list(page) -> list: + """Collect all raw API response items across all pages of a SyncPage.""" + items = list(page._raw_items) + while page.has_next_page(): + page = page.get_next_page() + if page is None: + break + items.extend(page._raw_items) + return items + + +@app.command("list") +def list_workspaces( + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """List all accessible workspaces.""" + + handle_cmd_flags(json_output=json_output) + client, config = get_client(require_workspace=False) + + try: + workspaces = list(client.workspaces()) + items = [{"id": w} for w in workspaces] + print_result(items, columns=["id"], title="Workspaces") + except Exception as e: + _handle_error(e, "workspace", "list") + + +@app.command("create") +def create_workspace( + workspace_id: str = typer.Argument(help="Workspace ID to create or get"), + metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata to associate with the workspace"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Create or get a workspace.""" + handle_cmd_flags(json_output=json_output) + wid = validate_resource_id(workspace_id, "workspace") + client, config = get_client(require_workspace=False) + ws_client = _with_workspace(client, wid) + + parsed_metadata = None + if metadata: + try: + parsed_metadata = json.loads(metadata) + except json.JSONDecodeError as e: + print_error("INVALID_JSON", f"--metadata must be valid JSON: {e}", {}) + raise typer.Exit(1) + + try: + # Trigger get-or-create via the workspace ensure mechanism + ws_client.get_configuration() + result: dict[str, object] = {"workspace_id": wid} + if parsed_metadata is not None: + ws_client.set_metadata(parsed_metadata) + result["metadata"] = parsed_metadata + print_result(result) + except Exception as e: + _handle_error(e, "workspace", wid) + + +@app.command() +def inspect( + workspace_id: Optional[str] = typer.Argument(None, help="Workspace ID (uses default if omitted)"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Inspect a workspace: peers, sessions, config.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace) + + wid = _get_workspace_id(workspace_id) + client, config = get_client(require_workspace=False) + + # Override workspace if positional arg given + if workspace_id: + client = _with_workspace(client, workspace_id) + + try: + ws_config = client.get_configuration() + ws_metadata = client.get_metadata() + peer_page = client.peers() + session_page = client.sessions() + + raw_peers = peer_page._raw_items + raw_sessions = session_page._raw_items + + result = { + "workspace_id": wid, + "metadata": ws_metadata, + "configuration": _config_to_dict(ws_config) if ws_config else None, + "peer_count": peer_page.total, + "session_count": session_page.total, + "peers": [ + {"id": p.id, "metadata": p.metadata, "created_at": str(p.created_at)} + for p in raw_peers[:20] + ], + "sessions": [ + {"id": s.id, "is_active": s.is_active, "metadata": s.metadata, "created_at": str(s.created_at)} + for s in raw_sessions[:20] + ], + } + print_result(result) + except Exception as e: + _handle_error(e, "workspace", wid) + + +@app.command() +def delete( + workspace_id: str = typer.Argument(help="Workspace ID to delete"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt (for scripted/agent use)"), + cascade: bool = typer.Option(False, "--cascade", help="Delete all sessions before deleting the workspace"), + dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be deleted without deleting"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Delete a workspace. Use --dry-run first to see what will be deleted. + + Requires --yes to skip confirmation, or will prompt interactively. + If sessions exist, requires --cascade to delete them first. + """ + + handle_cmd_flags(json_output=json_output) + + validate_resource_id(workspace_id, "workspace") + # workspace_id is a required positional and we rebuild the client with it + # immediately, so the default-workspace guard isn't needed here. + client, config = get_client(require_workspace=False) + ws_client = _with_workspace(client, workspace_id) + + # Verify workspace exists before prompting for confirmation + try: + ws_client.get_metadata() + except Exception as e: + _handle_error(e, "workspace", workspace_id) + return + + # Always fetch sessions for dry-run or cascade + raw_sessions = _raw_list(ws_client.sessions()) if (dry_run or cascade) else [] + + if dry_run: + print_result({ + "dry_run": True, + "workspace_id": workspace_id, + "sessions_to_delete": len(raw_sessions), + "session_ids": [s.id for s in raw_sessions], + "warning": "This action cannot be undone.", + }) + return + + if not yes: + if cascade and raw_sessions: + typer.confirm( + f"Delete workspace '{workspace_id}' and {len(raw_sessions)} session(s)? This cannot be undone.", + abort=True, + ) + else: + typer.confirm(f"Delete workspace '{workspace_id}'? This cannot be undone.", abort=True) + + try: + deleted_sessions = [] + if cascade and raw_sessions: + for s in raw_sessions: + ws_client.session(s.id).delete() + deleted_sessions.append(s.id) + status(f"Deleted session '{s.id}'") + + ws_client.delete_workspace(workspace_id) + status(f"Workspace '{workspace_id}' deletion accepted (processing in background)") + result = {"deleted_workspace": workspace_id, "status": "accepted"} + if cascade: + result["deleted_sessions"] = deleted_sessions + print_result(result) + except Exception as e: + _handle_error(e, "workspace", workspace_id) + + +@app.command() +def search( + query: str = typer.Argument(help="Search query"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + limit: int = typer.Option(10, help="Max results"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Search messages across workspace.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace) + + wid = _get_workspace_id(None) + client, config = get_client() + + try: + results = client.search(query, limit=limit) + items = [ + { + "id": m.id, + "content": m.content if use_json() else m.content[:200], + "peer_id": m.peer_id, + "session_id": m.session_id, + "created_at": str(m.created_at), + } + for m in results + ] + print_result(items, columns=["id", "peer_id", "session_id", "content"], title=f"Search: {query}") + except Exception as e: + _handle_error(e, "workspace", wid) + + +@app.command("queue-status") +def queue_status( + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"), + observer: Optional[str] = typer.Option(None, help="Filter by observer peer"), + sender: Optional[str] = typer.Option(None, help="Filter by sender peer"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Get queue processing status.""" + + handle_cmd_flags(json_output=json_output, workspace=workspace, session=session) + + _get_workspace_id(None) + client, config = get_client() + + try: + result = client.queue_status(observer=observer, sender=sender, session=config.session_id or None) + print_result(result.__dict__ if hasattr(result, "__dict__") else result) + except Exception as e: + _handle_error(e, "queue", "status") + + +def _with_workspace(client, workspace_id: str): + """Return a new client pointed at a different workspace.""" + return Honcho( + base_url=str(client.base_url), + api_key=client._http.api_key if hasattr(client._http, "api_key") else None, + workspace_id=workspace_id, + ) + + +def _config_to_dict(config) -> dict: + """Convert a config object to a dict, handling nested objects.""" + if hasattr(config, "__dict__"): + result = {} + for k, v in config.__dict__.items(): + if k.startswith("_"): + continue + result[k] = _config_to_dict(v) if hasattr(v, "__dict__") and not isinstance(v, str) else v + return result + return config + + +def _handle_error(e: Exception, resource: str, resource_id: str) -> None: + """Handle SDK exceptions with structured error output. + + Dispatches on the SDK's typed exception hierarchy + (``honcho.http.exceptions``) and falls back to its ``status`` field for + any APIError subclass we don't enumerate. Substring matching on the + message is used only as a last-ditch fallback for non-SDK exceptions. + """ + if isinstance(e, NotFoundError): + print_error( + f"{resource.upper()}_NOT_FOUND", + f"{resource.title()} '{resource_id}' not found", + {resource: resource_id}, + ) + raise typer.Exit(1) + if isinstance(e, AuthenticationError): + print_error("AUTH_ERROR", f"Authentication failed: {e}", {}) + raise typer.Exit(3) + if isinstance(e, PermissionDeniedError): + print_error("PERMISSION_ERROR", f"Permission denied: {e}", {}) + raise typer.Exit(3) + if isinstance(e, ServerError): + print_error("SERVER_ERROR", f"Server error: {e}", {resource: resource_id}) + raise typer.Exit(2) + if isinstance(e, APIError): + # Catch-all for typed API errors we haven't special-cased + # (BadRequest, Conflict, UnprocessableEntity, RateLimit, ...). + print_error( + "API_ERROR", + f"API error ({e.status}): {e}", + {resource: resource_id, "status": e.status}, + ) + raise typer.Exit(1) + print_error("UNKNOWN_ERROR", str(e), {resource: resource_id}) + raise typer.Exit(1) diff --git a/honcho-cli/src/honcho_cli/common.py b/honcho-cli/src/honcho_cli/common.py new file mode 100644 index 00000000..2680a869 --- /dev/null +++ b/honcho-cli/src/honcho_cli/common.py @@ -0,0 +1,112 @@ +"""Shared runtime state, client factory, and command-level flag helpers. + +Flags --json, -w, -p, -s are documented at **command-level** (the canonical +form demonstrated in the welcome panel, README, and skill files): + + honcho workspace list -w granola --json + +They also parse at group-level and top-level for flexibility. All three +positions resolve identically and are idempotent — command-level is a +no-op if the same flag was already set at an outer level. +""" + +from __future__ import annotations + +from typing import Optional + +import typer + +from honcho import Honcho + +from honcho_cli.config import CLIConfig, get_client_kwargs +from honcho_cli.output import print_error, set_json_mode +from honcho_cli.validation import validate_resource_id + + +# Global overrides from flags (commands read these) +_global_overrides: dict[str, str | None] = { + "workspace": None, + "peer": None, + "session": None, +} + + +def get_resolved_config(): + """Get config with global flag overrides applied. + + Overrides flow through ``validate_resource_id`` so that a malformed + ``-w``/``-p``/``-s`` value fails fast with a structured error rather than + reaching the API and surfacing as an opaque ``UNKNOWN_ERROR``. + """ + config = CLIConfig.load() + + if _global_overrides["workspace"]: + config.workspace_id = validate_resource_id(_global_overrides["workspace"], "workspace") + if _global_overrides["peer"]: + config.peer_id = validate_resource_id(_global_overrides["peer"], "peer") + if _global_overrides["session"]: + config.session_id = validate_resource_id(_global_overrides["session"], "session") + + return config + + +def get_client(*, require_workspace: bool = True): + """Create a Honcho client from resolved config. + + By default, refuses to build a client when no workspace is scoped — the + SDK's get-or-create semantics would otherwise silently operate on an empty + workspace. Commands that legitimately run without a workspace (e.g. + ``workspace list``) pass ``require_workspace=False``. + """ + config = get_resolved_config() + if require_workspace and not config.workspace_id: + print_error( + "NO_WORKSPACE", + "No workspace scoped. Pass --workspace/-w or set HONCHO_WORKSPACE_ID.", + ) + raise typer.Exit(1) + return Honcho(**get_client_kwargs(config)), config + + +def handle_cmd_flags( + json_output: bool = False, + workspace: str | None = None, + peer: str | None = None, + session: str | None = None, + **_kwargs, +) -> None: + """Apply command-level flags. Idempotent if already set by group callback.""" + if json_output: + set_json_mode(True) + + if workspace: + _global_overrides["workspace"] = workspace + if peer: + _global_overrides["peer"] = peer + if session: + _global_overrides["session"] = session + + +def add_common_options(app: typer.Typer) -> None: + """Add a callback to a sub-app that accepts --json, -w, -p, -s.""" + + @app.callback(invoke_without_command=True) + def _callback( + ctx: typer.Context, + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"), + ) -> None: + if json_output: + set_json_mode(True) + + if workspace: + _global_overrides["workspace"] = workspace + if peer: + _global_overrides["peer"] = peer + if session: + _global_overrides["session"] = session + + if ctx.invoked_subcommand is None: + typer.echo(ctx.get_help()) diff --git a/honcho-cli/src/honcho_cli/config.py b/honcho-cli/src/honcho_cli/config.py new file mode 100644 index 00000000..a0c64864 --- /dev/null +++ b/honcho-cli/src/honcho_cli/config.py @@ -0,0 +1,150 @@ +"""Configuration management for Honcho CLI. + +Config stored at ``~/.honcho/config.json`` with env var overrides. + +The CLI owns exactly two top-level keys in that file: + + apiKey -- Honcho admin JWT + environmentUrl -- Honcho API URL (full URL, e.g. https://api.honcho.dev) + +All other top-level keys (``hosts``, ``sessions``, ``saveMessages``, +``sessionStrategy``, …) are written by sibling Honcho tools and are +preserved untouched on save. + +Workspace / peer / session scoping is intentionally *not* persisted here — +pass ``-w`` / ``-p`` / ``-s`` flags or set ``HONCHO_WORKSPACE_ID`` / +``HONCHO_PEER_ID`` / ``HONCHO_SESSION_ID`` per command instead. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, fields +from pathlib import Path + +CONFIG_DIR = Path.home() / ".honcho" +CONFIG_FILE = CONFIG_DIR / "config.json" + +DEFAULT_BASE_URL = "https://api.honcho.dev" + +# Env var mapping for runtime overrides. +# +# Resolution order: flag > env var > config file > default. +ENV_MAP: dict[str, str] = { + "api_key": "HONCHO_API_KEY", + "base_url": "HONCHO_BASE_URL", + "workspace_id": "HONCHO_WORKSPACE_ID", + "peer_id": "HONCHO_PEER_ID", + "session_id": "HONCHO_SESSION_ID", +} + + +@dataclass +class CLIConfig: + """CLI configuration with layered resolution: flag > env > file > default. + + ``workspace_id`` / ``peer_id`` / ``session_id`` exist on this dataclass so + flag/env overrides flow through ``get_client_kwargs()``, but they are + never read from or written to the config file — they're per-command. + """ + + base_url: str = DEFAULT_BASE_URL + api_key: str = "" + workspace_id: str = "" + peer_id: str = "" + session_id: str = "" + + @classmethod + def load(cls) -> CLIConfig: + """Load config from file, then overlay env vars.""" + config = cls() + + if CONFIG_FILE.exists(): + try: + with open(CONFIG_FILE, encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + data = {} + + if isinstance(data, dict): + url = data.get("environmentUrl") + if isinstance(url, str) and url: + config.base_url = url + key = data.get("apiKey") + if isinstance(key, str): + config.api_key = key + + for fld_name, env_var in ENV_MAP.items(): + val = os.environ.get(env_var) + if val: + setattr(config, fld_name, val) + elif val == "": + # SDK reads these env vars directly and crashes on empty + # strings with a Pydantic ValidationError. Drop them so the + # SDK falls back to kwargs / defaults. + os.environ.pop(env_var, None) + + return config + + def save(self) -> None: + """Write ``apiKey`` + ``environmentUrl`` to config.json. + + Preserves unrelated top-level keys (``hosts``, ``sessions``, + ``saveMessages``, ``sessionStrategy``, …) that other tools write. + """ + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + data: dict = {} + if CONFIG_FILE.exists(): + try: + with open(CONFIG_FILE, encoding="utf-8") as f: + loaded = json.load(f) + if isinstance(loaded, dict): + data = loaded + except (json.JSONDecodeError, OSError): + data = {} + + data["environmentUrl"] = self.base_url + if self.api_key: + data["apiKey"] = self.api_key + else: + data.pop("apiKey", None) + + CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n") + # API key in plaintext — restrict to the owner on multi-user hosts. + try: + os.chmod(CONFIG_FILE, 0o600) + except OSError: + pass + + def redacted(self) -> dict[str, str]: + """Return config dict with api_key redacted. + + Only includes fields that have a value set — per-command fields + (workspace_id, peer_id, session_id) are omitted when empty. + """ + d: dict[str, str] = {} + for fld in fields(self): + val = getattr(self, fld.name) + if not val: + continue + if fld.name == "api_key": + # Show ``***`` only — enough to compare keys without + # leaking the header or body of the JWT. + d[fld.name] = "***" + val[-4:] if len(val) > 4 else "***" + else: + d[fld.name] = val + return d + + +def get_client_kwargs(config: CLIConfig) -> dict: + """Build kwargs for Honcho client from config.""" + kwargs: dict = {} + if config.base_url: + kwargs["base_url"] = config.base_url + if config.api_key: + kwargs["api_key"] = config.api_key + if config.workspace_id: + kwargs["workspace_id"] = config.workspace_id + return kwargs diff --git a/honcho-cli/src/honcho_cli/main.py b/honcho-cli/src/honcho_cli/main.py new file mode 100644 index 00000000..7ed8aa9b --- /dev/null +++ b/honcho-cli/src/honcho_cli/main.py @@ -0,0 +1,96 @@ +"""Honcho CLI — a terminal for Honcho. + +Entry point and top-level command group. +""" + +from __future__ import annotations + +import os +import sys + +import typer +from rich.console import Console + +from honcho_cli import __version__ +from honcho_cli._help import HonchoTyperGroup, print_welcome +from honcho_cli.branding import BANNER +from honcho_cli.output import set_json_mode + + +app = typer.Typer( + name="honcho", + cls=HonchoTyperGroup, + help="A terminal for Honcho — memory that reasons.", + invoke_without_command=True, + pretty_exceptions_enable=False, + add_completion=False, +) + + +def _json_requested_early() -> bool: + """Best-effort JSON detection before Typer parses flags. + + version_callback is eager and fires before set_json_mode() runs, so we + can't call use_json() here. Mirror its logic against argv/env/TTY. + """ + return ( + "--json" in sys.argv + or os.environ.get("HONCHO_JSON", "").lower() in ("1", "true") + or not sys.stdout.isatty() + ) + + +def version_callback(value: bool) -> None: + if value: + if not _json_requested_early(): + print(BANNER) + print(f" honcho-cli {__version__}") + raise typer.Exit() + + +@app.callback() +def main( + ctx: typer.Context, + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), + version: bool = typer.Option(False, "--version", "-V", callback=version_callback, is_eager=True, help="Show version"), +) -> None: + """Honcho CLI — admin & debugging tool for Honcho workspaces.""" + set_json_mode(json_output) + + if ctx.invoked_subcommand is None: + print_welcome(Console()) + raise typer.Exit() + + +# Register top-level commands +from honcho_cli.commands.setup import doctor, init + +app.command()(init) +app.command()(doctor) + + +@app.command("help", hidden=True) +def help_cmd(ctx: typer.Context) -> None: + """Show help message.""" + Console().print(ctx.parent.get_help() if ctx.parent else "") + raise typer.Exit() + + +# Register command groups +from honcho_cli.commands.config_cmd import app as config_app +from honcho_cli.commands.conclusion import app as conclusion_app +from honcho_cli.commands.message import app as message_app +from honcho_cli.commands.peer import app as peer_app +from honcho_cli.commands.session import app as session_app +from honcho_cli.commands.workspace import app as workspace_app + +app.add_typer(peer_app, name="peer") +app.add_typer(session_app, name="session") +app.add_typer(message_app, name="message") +app.add_typer(conclusion_app, name="conclusion") +app.add_typer(workspace_app, name="workspace") +app.add_typer(config_app, name="config") + + +if __name__ == "__main__": + app() diff --git a/honcho-cli/src/honcho_cli/output.py b/honcho-cli/src/honcho_cli/output.py new file mode 100644 index 00000000..de7902b8 --- /dev/null +++ b/honcho-cli/src/honcho_cli/output.py @@ -0,0 +1,104 @@ +"""Output formatting: JSON, tables, and structured errors. + +Detects TTY to auto-switch between human-readable and machine-parseable output. +""" + +from __future__ import annotations + +import json +import os +import sys +from typing import Any + +from rich.console import Console +from rich.table import Table + +console = Console(stderr=True) +stdout_console = Console() + + +def is_tty() -> bool: + """Check if stdout is a TTY.""" + return sys.stdout.isatty() + + +# Global state for --json flag +_force_json = False + + +def set_json_mode(enabled: bool) -> None: + global _force_json + _force_json = enabled + + + +def use_json() -> bool: + """Should we output JSON?""" + return _force_json or os.environ.get("HONCHO_JSON", "").lower() in ("1", "true") or not is_tty() + + +def print_json(data: Any) -> None: + """Print a single JSON value to stdout.""" + print(json.dumps(data, indent=2, default=str)) + + +def print_table(columns: list[str], rows: list[list[str]], title: str | None = None) -> None: + """Print a rich table to stdout.""" + table = Table(title=title, show_header=True, header_style="bold") + for col in columns: + table.add_column(col) + for row in rows: + table.add_row(*row) + stdout_console.print(table) + + +def print_result(data: Any, columns: list[str] | None = None, title: str | None = None) -> None: + """Print data as JSON or table depending on mode. + + For lists, uses JSON arrays in JSON mode or tables in TTY mode. + For dicts, uses JSON or key-value display. + """ + if use_json(): + print_json(data) + else: + if isinstance(data, list) and columns: + rows = [] + for item in data: + row = [str(item.get(col, "")) if isinstance(item, dict) else str(item) for col in columns] + rows.append(row) + print_table(columns, rows, title=title) + elif isinstance(data, dict): + table = Table(show_header=False) + table.add_column("Field", style="bold") + table.add_column("Value") + for k, v in data.items(): + val = json.dumps(v, default=str) if isinstance(v, (dict, list)) else str(v) + table.add_row(k, val) + stdout_console.print(table) + else: + stdout_console.print(data) + + +def print_error(code: str, message: str, details: dict | None = None) -> None: + """Print structured error.""" + err = { + "error": { + "code": code, + "message": message, + } + } + if details: + err["error"]["details"] = details + + if use_json(): + print(json.dumps(err, default=str), file=sys.stderr) + else: + console.print(f"[red]Error[/red] ({code}): {message}") + if details: + for k, v in details.items(): + console.print(f" {k}: {v}") + + +def status(msg: str) -> None: + """Print a status message to stderr.""" + console.print(f"[dim]{msg}[/dim]") diff --git a/honcho-cli/src/honcho_cli/validation.py b/honcho-cli/src/honcho_cli/validation.py new file mode 100644 index 00000000..b1111802 --- /dev/null +++ b/honcho-cli/src/honcho_cli/validation.py @@ -0,0 +1,44 @@ +"""Input hardening: validate resource IDs and workspace names. + +Agents hallucinate bad IDs. Catch them early with clear errors. +""" + +from __future__ import annotations + +import re + +from honcho_cli.output import print_error + +UNSAFE_CHARS = re.compile(r'[?#%\x00-\x1f\x7f/\\]') + + +def validate_resource_id(value: str, resource_type: str = "resource") -> str: + """Validate a resource ID. Returns the value if valid, raises SystemExit on invalid.""" + if not value: + _fail( + "EMPTY_ID", + f"Empty {resource_type} ID provided", + {resource_type: ""}, + ) + + if UNSAFE_CHARS.search(value): + _fail( + "INVALID_ID", + f"Invalid {resource_type} ID: contains unsafe characters (?, #, %, control chars, path separators)", + {resource_type: value}, + ) + + if ".." in value: + _fail( + "INVALID_ID", + f"Invalid {resource_type} ID: contains path traversal", + {resource_type: value}, + ) + + return value + + +def _fail(code: str, message: str, details: dict) -> None: + """Print structured error and exit.""" + print_error(code, message, details) + raise SystemExit(1) diff --git a/honcho-cli/tests/__init__.py b/honcho-cli/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/honcho-cli/tests/test_commands.py b/honcho-cli/tests/test_commands.py new file mode 100644 index 00000000..61de7d47 --- /dev/null +++ b/honcho-cli/tests/test_commands.py @@ -0,0 +1,195 @@ +"""Command-level tests: init flow, destructive confirms, JSON output contract, exit codes. + +Uses Typer's CliRunner against the real `app`. stdout is not a TTY under +CliRunner, so `use_json()` returns True and the CLI emits JSON — +which is exactly what scripts and agents consume. +""" + +from __future__ import annotations + +import json +import os +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from honcho_cli.main import app + + +@pytest.fixture +def cfg(tmp_path, monkeypatch): + """Isolated config file + clean HONCHO_* env.""" + f = tmp_path / "config.json" + monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path) + monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f) + monkeypatch.setattr("honcho_cli.commands.setup.CONFIG_FILE", f) + for k in [k for k in os.environ if k.startswith("HONCHO_")]: + monkeypatch.delenv(k) + return f + + +@pytest.fixture +def runner(): + return CliRunner() + + +# --------------------------------------------------------------------------- # +# 1. `honcho init` end-to-end + +class TestInit: + def test_first_run_writes_exact_shape(self, cfg, runner): + """First run with --api-key + --base-url writes apiKey + environmentUrl only.""" + with patch("honcho_cli.commands.setup._test_connection", return_value=(True, "OK")): + result = runner.invoke( + app, + ["init", "--api-key", "test-key-123", "--base-url", "http://localhost:8000"], + ) + assert result.exit_code == 0, result.stderr + assert json.loads(cfg.read_text()) == { + "environmentUrl": "http://localhost:8000", + "apiKey": "test-key-123", + } + + def test_preserves_foreign_keys(self, cfg, runner): + """Second run must not clobber sibling-tool keys (`hosts`, `sessions`, ...).""" + cfg.write_text(json.dumps({ + "apiKey": "old", + "environmentUrl": "http://old.example", + "hosts": {"claude_code": {"peerName": "user"}}, + "sessions": {"/Users/user": "home-chat"}, + "sessionStrategy": "chat-instance", + })) + with patch("honcho_cli.commands.setup._test_connection", return_value=(True, "OK")): + result = runner.invoke( + app, + ["init", "--api-key", "new-key", "--base-url", "https://api.honcho.dev"], + ) + assert result.exit_code == 0, result.stderr + on_disk = json.loads(cfg.read_text()) + assert on_disk["apiKey"] == "new-key" + assert on_disk["environmentUrl"] == "https://api.honcho.dev" + assert on_disk["hosts"] == {"claude_code": {"peerName": "user"}} + assert on_disk["sessions"] == {"/Users/user": "home-chat"} + assert on_disk["sessionStrategy"] == "chat-instance" + + +# --------------------------------------------------------------------------- # +# 2. Destructive-confirm guards + +class TestDestructiveConfirm: + def test_workspace_delete_aborts_on_no(self, cfg, runner): + """`workspace delete` without --yes: 'n' at prompt → no API call, non-zero exit.""" + cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + fake = MagicMock() + fake.sessions.return_value = MagicMock(has_next_page=lambda: False, _raw_items=[]) + with patch("honcho_cli.commands.workspace.get_client", return_value=(fake, MagicMock())), \ + patch("honcho_cli.commands.workspace._with_workspace", return_value=fake): + result = runner.invoke(app, ["workspace", "delete", "ws1"], input="n\n") + assert result.exit_code != 0 + fake.delete_workspace.assert_not_called() + + def test_session_delete_aborts_on_no(self, cfg, runner): + cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + session = MagicMock() + client = MagicMock() + client.session.return_value = session + config = MagicMock(session_id="s1", workspace_id="ws1") + with patch("honcho_cli.commands.workspace.get_client", return_value=(client, config)): + result = runner.invoke(app, ["session", "delete", "s1"], input="n\n") + assert result.exit_code != 0 + session.delete.assert_not_called() + + +# --------------------------------------------------------------------------- # +# 3. JSON output contract — scripts pipe these + +class TestJsonContract: + def test_workspace_list_json_array_shape(self, cfg, runner): + cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + client = MagicMock() + client.workspaces.return_value = ["ws-a", "ws-b"] + with patch("honcho_cli.commands.workspace.get_client", return_value=(client, MagicMock())): + result = runner.invoke(app, ["workspace", "list"]) + assert result.exit_code == 0, result.stderr + assert json.loads(result.stdout) == [{"id": "ws-a"}, {"id": "ws-b"}] + + def test_workspace_search_preserves_full_content_in_json_mode(self, cfg, runner): + cfg.write_text(json.dumps({ + "apiKey": "k", + "environmentUrl": "http://localhost:8000", + "workspace_id": "ws1", + })) + message = MagicMock( + id="msg1", + content="x" * 250, + peer_id="peer1", + session_id="sess1", + created_at="2026-01-01T00:00:00Z", + ) + client = MagicMock() + client.search.return_value = [message] + config = MagicMock(workspace_id="ws1") + with patch("honcho_cli.commands.workspace.get_client", return_value=(client, config)): + result = runner.invoke(app, ["workspace", "search", "topic", "-w", "ws1"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert payload == [{ + "id": "msg1", + "content": "x" * 250, + "peer_id": "peer1", + "session_id": "sess1", + "created_at": "2026-01-01T00:00:00Z", + }] + + def test_message_get_returns_single_json_object(self, cfg, runner): + cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + msg = MagicMock( + id="msg1", + peer_id="peer1", + content="hello", + token_count=7, + metadata={"kind": "demo"}, + created_at="2026-01-01T00:00:00Z", + ) + session = MagicMock() + session.get_message.return_value = msg + client = MagicMock() + client.session.return_value = session + config = MagicMock(session_id="sess1", workspace_id="ws1") + with patch("honcho_cli.commands.message.get_client", return_value=(client, config)): + result = runner.invoke(app, ["message", "get", "msg1", "-s", "sess1", "-w", "ws1"]) + assert result.exit_code == 0, result.stderr + assert json.loads(result.stdout) == { + "id": "msg1", + "peer_id": "peer1", + "content": "hello", + "token_count": 7, + "metadata": {"kind": "demo"}, + "created_at": "2026-01-01T00:00:00Z", + } + + +# --------------------------------------------------------------------------- # +# 4. Exit codes on error + +class TestExitCodes: + def test_no_workspace_scoped_exits_nonzero_with_code(self, cfg, runner): + """Running a workspace-scoped command with no workspace → NO_WORKSPACE on stderr, exit 1.""" + cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + result = runner.invoke(app, ["peer", "list"]) + assert result.exit_code == 1 + assert json.loads(result.stderr)["error"]["code"] == "NO_WORKSPACE" + + def test_not_found_exits_nonzero_with_code(self, cfg, runner): + """SDK NotFoundError → structured error, exit 1.""" + from honcho import NotFoundError + + cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + client = MagicMock() + client.peer.return_value.get_card.side_effect = NotFoundError("not found") + config = MagicMock(peer_id="missing", session_id="", workspace_id="ws1") + with patch("honcho_cli.commands.peer.get_client", return_value=(client, config)): + result = runner.invoke(app, ["peer", "inspect", "missing", "-w", "ws1"]) + assert result.exit_code == 1 + assert json.loads(result.stderr)["error"]["code"] == "PEER_NOT_FOUND" diff --git a/honcho-cli/tests/test_config.py b/honcho-cli/tests/test_config.py new file mode 100644 index 00000000..bdac2bc4 --- /dev/null +++ b/honcho-cli/tests/test_config.py @@ -0,0 +1,113 @@ +"""Tests for config management.""" + +import json +import os + +import pytest +from honcho_cli.config import CLIConfig + + +@pytest.fixture +def cfg_path(tmp_path, monkeypatch): + """Redirect CONFIG_FILE to tmp_path and clear HONCHO_* env vars.""" + f = tmp_path / "config.json" + monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f) + monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path) + for key in [k for k in os.environ if k.startswith("HONCHO_")]: + monkeypatch.delenv(key) + return f + + +class TestLoad: + def test_defaults_when_no_file(self, cfg_path): + loaded = CLIConfig.load() + assert loaded.base_url == "https://api.honcho.dev" + assert loaded.api_key == "" + assert loaded.workspace_id == "" + + def test_malformed_file_uses_defaults(self, cfg_path): + cfg_path.write_text("not-json{{{") + assert CLIConfig.load().api_key == "" + + def test_reads_environment_url(self, cfg_path): + cfg_path.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + loaded = CLIConfig.load() + assert loaded.base_url == "http://localhost:8000" + assert loaded.api_key == "k" + + def test_api_key_and_base_url_from_env(self, cfg_path, monkeypatch): + """HONCHO_API_KEY and HONCHO_BASE_URL override config file at runtime.""" + cfg_path.write_text(json.dumps({"environmentUrl": "https://api.honcho.dev"})) + monkeypatch.setenv("HONCHO_API_KEY", "env-key") + monkeypatch.setenv("HONCHO_BASE_URL", "http://localhost:8000") + loaded = CLIConfig.load() + assert loaded.api_key == "env-key" + assert loaded.base_url == "http://localhost:8000" + + +class TestSave: + def test_writes_only_cli_owned_keys(self, cfg_path): + """apiKey + environmentUrl are written; workspace/peer/session are not.""" + CLIConfig( + base_url="http://localhost:8000", + api_key="test-key-123", + workspace_id="my-ws", # must NOT be persisted + peer_id="user", + session_id="s1", + ).save() + assert json.loads(cfg_path.read_text()) == { + "environmentUrl": "http://localhost:8000", + "apiKey": "test-key-123", + } + + def test_preserves_foreign_keys(self, cfg_path): + """Other tools' top-level keys (hosts, sessions, ...) are untouched.""" + seed = { + "apiKey": "old-key", + "environmentUrl": "https://api.honcho.dev", + "saveMessages": True, + "sessions": {"/Users/user": "home-chat"}, + "hosts": {"claude_code": {"peerName": "user", "workspace": "agents"}}, + "sessionStrategy": "chat-instance", + } + cfg_path.write_text(json.dumps(seed)) + cfg = CLIConfig.load() + cfg.api_key = "new-key" + cfg.save() + + on_disk = json.loads(cfg_path.read_text()) + assert on_disk["apiKey"] == "new-key" + assert on_disk["environmentUrl"] == "https://api.honcho.dev" + for k in ("saveMessages", "sessions", "hosts", "sessionStrategy"): + assert on_disk[k] == seed[k] + + +@pytest.mark.parametrize( + "api_key, expected", + [ + # Long JWT: only last 4 chars visible, masked prefix. + ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abcdef", "***cdef"), + # Short value > 4 chars: still only last 4. + ("abcdef", "***cdef"), + # 4 or fewer chars: fully masked — don't leak the whole key. + ("abcd", "***"), + ("x", "***"), + ], +) +def test_api_key_redaction_shows_last4_only(api_key, expected): + """Redacted api_key must show ``***`` at most, never the header/body.""" + assert CLIConfig(api_key=api_key).redacted()["api_key"] == expected + + +def test_api_key_redaction_empty_omitted(): + """Empty api_key is omitted from redacted output entirely.""" + assert "api_key" not in CLIConfig(api_key="").redacted() + + +def test_save_sets_600_permissions(cfg_path): + """Config with plaintext API key must be owner-readable only on POSIX.""" + import stat + CLIConfig(base_url="http://localhost:8000", api_key="sekret").save() + mode = stat.S_IMODE(os.stat(cfg_path).st_mode) + # chmod(0o600) → rw- --- --- + assert mode == 0o600, f"expected 0o600, got {oct(mode)}" diff --git a/honcho-cli/tests/test_validation.py b/honcho-cli/tests/test_validation.py new file mode 100644 index 00000000..c117c9ec --- /dev/null +++ b/honcho-cli/tests/test_validation.py @@ -0,0 +1,57 @@ +"""Tests for resource-ID validation. + +Agents hallucinate IDs; ``validate_resource_id`` is the defense in depth +between that and the API. These tests pin the accept/reject rules. +""" + +from __future__ import annotations + +import pytest + +from honcho_cli.validation import validate_resource_id + + +class TestAccepts: + @pytest.mark.parametrize( + "value", + [ + "eri", + "my-peer-01", + "workspace_name", + "UPPER", + "with.dots", + "123abc", + "a", + "long-id-with-many-parts_v2", + ], + ) + def test_safe_id_round_trips(self, value): + assert validate_resource_id(value, "peer") == value + + +class TestRejects: + def test_empty_string(self): + with pytest.raises(SystemExit): + validate_resource_id("", "peer") + + @pytest.mark.parametrize( + "value", + [ + "bad/slash", + "bad\\backslash", + "bad?query", + "bad#hash", + "bad%encoded", + "with\x00null", + "with\x1fctrl", + "with\x7fdel", + ], + ) + def test_unsafe_chars(self, value): + with pytest.raises(SystemExit): + validate_resource_id(value, "peer") + + @pytest.mark.parametrize("value", ["..", "../etc", "foo/..", "a..b"]) + def test_path_traversal(self, value): + with pytest.raises(SystemExit): + validate_resource_id(value, "peer") diff --git a/honcho-cli/uv.lock b/honcho-cli/uv.lock new file mode 100644 index 00000000..dcadb6f3 --- /dev/null +++ b/honcho-cli/uv.lock @@ -0,0 +1,407 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[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" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +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 = "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 = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[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 = "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.0.1" +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/93/30/d30ba159404050d53b4b1b1c4477f9591f43af18758be1fb7dab6afbfe7d/honcho_ai-2.0.1.tar.gz", hash = "sha256:6fdeebf9454e62bc523d57888e50359e67baafdb21f68621f9c14e08dc00623a", size = 46732, upload-time = "2026-02-09T21:03:26.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/de/83fda0c057cfa11d6b5ed532623184591aa7dcff4a067934ba6811026229/honcho_ai-2.0.1-py3-none-any.whl", hash = "sha256:94887e61d59f353e1e1e20b395858040780f5d67ca1e9d450538646544e4e42f", size = 56780, upload-time = "2026-02-09T21:03:25.992Z" }, +] + +[[package]] +name = "honcho-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "honcho-ai" }, + { name = "httpx" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "honcho-ai", specifier = ">=0.1.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.14.0" }, + { name = "rich", specifier = ">=13.0.0" }, + { name = "typer", specifier = ">=0.15.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" }, + { 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.3.0" +source = { registry = "https://pypi.org/simple" } +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 = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[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/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/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/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.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +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 = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[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" }, +] diff --git a/mcp/.dev.vars.example b/mcp/.dev.vars.example new file mode 100644 index 00000000..b61d97bf --- /dev/null +++ b/mcp/.dev.vars.example @@ -0,0 +1,4 @@ +# Uncomment and point at a self-hosted Honcho to bypass the managed API. +# wrangler dev reads this file automatically when it exists as `.dev.vars`. +# For deployed Workers, use: wrangler secret put HONCHO_API_URL +# HONCHO_API_URL=http://127.0.0.1:28000 diff --git a/mcp/.gitignore b/mcp/.gitignore index 51fb8691..492977a5 100644 --- a/mcp/.gitignore +++ b/mcp/.gitignore @@ -12,6 +12,7 @@ dist/ .env .env.local .env.production +.dev.vars # IDE files .vscode/ diff --git a/mcp/README.md b/mcp/README.md index 26986fe4..ba90710b 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -1,12 +1,11 @@ # Honcho MCP Server -## Quickstart: Use the Hosted MCP Server +A Cloudflare Worker that implements the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) for [Honcho](https://honcho.dev), providing AI memory and personalization tools to LLM clients like Claude Desktop. -Go to and get an API key. Then go to Claude Desktop and navigate to custom MCP servers. +## Quickstart: Use the Hosted Server -If you don't have node/bun installed you will need to do that. You can also use npm if you already have that installed. If not, Claude Desktop or Claude Code can help! - -Add Honcho to your Claude desktop config. You must provide a username for Honcho to refer to you as -- preferably what you want Claude to actually call you. +1. Get an API key at +2. Add Honcho to your Claude Desktop config: ```json { @@ -30,356 +29,99 @@ Add Honcho to your Claude desktop config. You must provide a username for Honcho } ``` -You may customize your assistant name and/or workspace ID. Both are optional. +### Optional Headers -```json -{ - "mcpServers": { - "honcho": { - "command": "bunx", - "args": [ - "mcp-remote", - "https://mcp.honcho.dev", - "--header", - "Authorization:${AUTH_HEADER}", - "--header", - "X-Honcho-User-Name:${USER_NAME}", - "--header", - "X-Honcho-Assistant-Name:${ASSISTANT_NAME}", - "--header", - "X-Honcho-Workspace-ID:${WORKSPACE_ID}" - ], - "env": { - "AUTH_HEADER": "Bearer ", - "USER_NAME": "", - "ASSISTANT_NAME": "", - "WORKSPACE_ID": "" - } - } - } -} -``` +| Header | Default | Description | +| --- | --- | --- | +| `X-Honcho-Workspace-ID` | `"default"` | Workspace to operate in | ## Available Tools -### start_conversation +**Workspace:** `inspect_workspace` (aggregates metadata, configuration, and peer/session IDs), `list_workspaces` (enumerates accessible workspaces), `search` (semantic search scoped by optional peer/session params), `get_metadata`, `set_metadata` -Start a new conversation session with Honcho. This initializes a session for tracking conversation history and context. +**Peers:** `create_peer`, `list_peers`, `chat`, `get_peer_card`, `set_peer_card`, `get_peer_context`, `get_representation` -**Returns:** A session ID that you must store and use for all subsequent interactions in this conversation. +**Sessions:** `create_session`, `list_sessions`, `delete_session`, `clone_session`, `add_peers_to_session`, `remove_peers_from_session`, `get_session_peers`, `inspect_session`, `add_messages_to_session`, `get_session_messages`, `get_session_message`, `get_session_context` -### add_turn +**Conclusions:** `list_conclusions`, `query_conclusions`, `create_conclusions`, `delete_conclusion` -Add a conversation turn (user and assistant messages) to the current session. This stores the conversation in Honcho for context tracking. +**System:** `schedule_dream`, `get_queue_status` -**Parameters:** +## Architecture -- `session_id`: The ID of the session to add the turn to -- `messages`: Array of message objects with `role` ("user" or "assistant") and `content` - -**Example usage:** - -```json -{ - "session_id": "session-uuid", - "messages": [ - { - "role": "user", - "content": "Hello, how are you?" - }, - { - "role": "assistant", - "content": "I'm doing well, thank you!" - } - ] -} +``` +src/ + index.ts # Worker entry point — parse config, delegate to MCP handler + server.ts # createServer() — registers all tools on an McpServer + config.ts # HonchoConfig, parseConfig(), createClient() + types.ts # ToolContext, result helpers + tools/ + workspace.ts # inspect, list, search, metadata + peers.ts # CRUD, chat, card, context, representation + sessions.ts # CRUD, peers, messages, inspect, context, clone + conclusions.ts # list, query, create, delete + system.ts # dream, queue status ``` -### get_personalization_insights +Built on: -Get personalization insights from Honcho based on conversation history. This queries the user's conversation context to provide personalized responses. +- **[agents](https://www.npmjs.com/package/agents)** — `createMcpHandler` for Cloudflare Workers +- **[@modelcontextprotocol/sdk](https://www.npmjs.com/package/@modelcontextprotocol/sdk)** — `McpServer` for tool registration +- **[@honcho-ai/sdk](https://www.npmjs.com/package/@honcho-ai/sdk)** v2 — Honcho TypeScript SDK -**Parameters:** +## Self-Hosted Honcho -- `session_id`: The ID of the session for context -- `query`: The question about the user's preferences, habits, etc. +If you run Honcho yourself (for privacy, latency, or offline use), deploy the +MCP Worker alongside your instance and set `HONCHO_API_URL` in its +environment. -**Example queries:** +**Local dev (`bun run dev`):** create `mcp/.dev.vars`: -- "What does this message reveal about the user's communication preferences?" -- "How formal or casual should I be with the user based on our history?" -- "What emotional state might the user be in right now?" +``` +HONCHO_API_URL=http://127.0.0.1:28000 +``` -### search_workspace - -Search for messages across the entire workspace. - -**Parameters:** - -- `query`: The search query to use - -### get_workspace_metadata - -Get metadata for the current workspace. - -**Parameters:** None - -### set_workspace_metadata - -Set metadata for the current workspace. - -**Parameters:** - -- `metadata`: A dictionary of metadata to associate with the workspace - -### create_peer - -Create or get a peer with the specified ID and optional configuration. - -**Parameters:** - -- `peer_id`: Unique identifier for the peer -- `config`: Optional configuration dictionary for the peer - -### get_peer_metadata - -Get metadata for a specific peer. - -**Parameters:** - -- `peer_id`: The ID of the peer to get metadata for - -### set_peer_metadata - -Set metadata for a specific peer. - -**Parameters:** - -- `peer_id`: The ID of the peer to set metadata for -- `metadata`: A dictionary of metadata to associate with the peer - -### search_peer_messages - -Search for messages sent by a peer. - -**Parameters:** - -- `peer_id`: The ID of the peer to search messages for -- `query`: The search query to use - -### chat - -Query a peer's representation with natural language questions. - -**Parameters:** - -- `peer_id`: The ID of the peer to query -- `query`: The natural language question to ask -- `target_peer_id`: Optional target peer ID for local representation queries -- `session_id`: Optional session ID to scope the query to a specific session - -### list_peers - -Get all peers in the current workspace. - -**Parameters:** None - -### create_session - -Create or get a session with the specified ID and optional configuration. - -**Parameters:** - -- `session_id`: Unique identifier for the session -- `config`: Optional configuration dictionary for the session - -### get_session_metadata - -Get metadata for a specific session. - -**Parameters:** - -- `session_id`: The ID of the session to get metadata for - -### set_session_metadata - -Set metadata for a specific session. - -**Parameters:** - -- `session_id`: The ID of the session to set metadata for -- `metadata`: A dictionary of metadata to associate with the session - -### add_peers_to_session - -Add peers to a session. - -**Parameters:** - -- `session_id`: The ID of the session to add peers to -- `peer_ids`: List of peer IDs to add to the session - -### remove_peers_from_session - -Remove peers from a session. - -**Parameters:** - -- `session_id`: The ID of the session to remove peers from -- `peer_ids`: List of peer IDs to remove from the session - -### get_session_peers - -Get all peer IDs in a session. - -**Parameters:** - -- `session_id`: The ID of the session to get peers from - -### add_messages_to_session - -Add messages to a session. - -**Parameters:** - -- `session_id`: The ID of the session to add messages to -- `messages`: List of message dictionaries with `peer_id`, `content`, and optional `metadata` - -### get_session_messages - -Get messages from a session with optional filtering. - -**Parameters:** - -- `session_id`: The ID of the session to get messages from -- `filters`: Optional dictionary of filter criteria - -### get_session_context - -Get optimized context for a session within a token limit. - -**Parameters:** - -- `session_id`: The ID of the session to get context for -- `summary`: Whether to include summary information (default: true) -- `tokens`: Maximum number of tokens to include in the context - -### search_session_messages - -Search for messages in a specific session. - -**Parameters:** - -- `session_id`: The ID of the session to search messages in -- `query`: The search query to use - -### get_working_representation - -Get the current working representation of a peer in a session. - -**Parameters:** - -- `session_id`: The ID of the session -- `peer_id`: The ID of the peer to get the working representation of -- `target_peer_id`: Optional target peer ID to get the representation of what peer_id knows about target_peer_id - -### list_sessions - -Get all sessions in the current workspace. - -**Parameters:** None - -## Contributing or Self Hosting - -A Cloudflare Worker that implements the Model Context Protocol (MCP) to provide Honcho functionality as tools for AI assistants like Claude Desktop. - -### Deploy MCP Worker - -1. **Install dependencies:** - - ```bash - bun i - ``` - -2. **Login to Cloudflare (if not already done):** - - ```bash - bun wrangler login - ``` - -3. **Configure your worker name in `wrangler.toml`:** - - Update the `name` field to your desired worker name - - Update the worker names in the `[env.production]` and `[env.staging]` sections - -4. **Test locally:** - - ```bash - bun dev - ``` - -5. **Deploy to production:** - - ```bash - bun run deploy - ``` - -### Configuration Options - -You can customize the behavior using HTTP headers: - -**Available Configuration:** - -- `apiKey`: Your Honcho API key -- `baseUrl`: Custom Honcho API base URL (default: ) -- `workspaceId`: Workspace ID (default: "default") -- `userName`: User identifier (default: "User") -- `assistantName`: Assistant identifier (default: "Assistant") - -#### Using HTTP Headers - -Pass configuration to mcp-remote via custom headers: +**Deployed Worker:** ```bash -bunx mcp-remote https://YOUR_WORKER_NAME.YOUR_SUBDOMAIN.workers.dev \ - --header "Authorization:Bearer YOUR_HONCHO_API_KEY" \ - --header "X-Honcho-Workspace-ID:my-workspace" \ - --header "X-Honcho-User-Name:john" \ - --header "X-Honcho-Assistant-Name:Claude" \ - --header "X-Honcho-Base-URL:https://custom.honcho.dev" +wrangler secret put HONCHO_API_URL +# paste your URL when prompted ``` -**Supported Custom Headers:** +When `HONCHO_API_URL` is unset the Worker routes to `https://api.honcho.dev`, +so this change is backward-compatible. -- `Authorization: Bearer YOUR_API_KEY` - Your Honcho API key -- `X-Honcho-Base-URL` - Custom Honcho API base URL -- `X-Honcho-Workspace-ID` - Workspace identifier -- `X-Honcho-User-Name` - User identifier -- `X-Honcho-Assistant-Name` - Assistant identifier +## Development -### Authentication - -The MCP server requires a valid Honcho API key provided via the Authorization header. - -### Testing - -You can test the MCP server using `mcp-remote` with the local URL: +### Setup ```bash -bunx mcp-remote http://localhost:8787 --header "Authorization:Bearer your-api-key" +bun install ``` -### Error Handling +### Local dev -The server provides proper JSON-RPC 2.0 error responses: +```bash +bun dev +``` -- `-32700`: Parse error -- `-32600`: Invalid Request -- `-32601`: Method not found -- `-32602`: Invalid params -- `-32603`: Internal error +### Type-check -Common issues: +```bash +bun run tsc --noEmit +``` -- **Missing API key**: Ensure you provide a valid Honcho API key via header or URL parameter -- **Invalid tool parameters**: Check that required parameters are provided and properly formatted -- **Network errors**: Verify the worker is deployed and accessible +### Test locally + +```bash +bunx mcp-remote http://localhost:8787 \ + --header "Authorization:Bearer " \ + --header "X-Honcho-User-Name:test" +``` + +### Deploy + +```bash +bun run deploy # production +bun run deploy:staging # staging +``` diff --git a/mcp/bun.lock b/mcp/bun.lock index 26363de9..29d050c2 100644 --- a/mcp/bun.lock +++ b/mcp/bun.lock @@ -1,10 +1,15 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "honcho-mcp-proxy", "dependencies": { - "@honcho-ai/sdk": "^1.6.0", + "@honcho-ai/sdk": "^2.0.0", + "@modelcontextprotocol/sdk": "^1.26.0", + "agents": "^0.4.0", + "nanoid": "^5.1.7", + "zod": "^4.3.6", }, "devDependencies": { "@cloudflare/workers-types": "^4.20241002.0", @@ -14,6 +19,24 @@ }, }, "packages": { + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.39", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.14", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SeCZBAdDNbWpVUXiYgOAqis22p5MEYfrjRw0hiBa5hM+7sDGYQpMinUjkM8kbPXMkY+AhKLrHleBl+SuqpzlgA=="], + + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.14", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7bzKd9lgiDeXM7O4U4nQ8iTxguAOkg8LZGD9AfDVZYjO5cKYRwBPwVjboFcVrxncRHu0tYxZtXZtiLKpG4pEng=="], + + "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.9.3", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ=="], + + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + + "@babel/runtime-corejs3": ["@babel/runtime-corejs3@7.29.0", "", { "dependencies": { "core-js-pure": "^3.48.0" } }, "sha512-TgUkdp71C9pIbBcHudc+gXZnihEDOjUAmXO1VO4HHGES7QLZcShR0stfKIxLSNIYx2fqhmJChOjm/wkF8wv4gA=="], + + "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], + + "@cloudflare/ai-chat": ["@cloudflare/ai-chat@0.0.7", "", { "peerDependencies": { "agents": "^0.4.0", "ai": "^6.0.0", "react": "^19.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-yjRoM8AIFJccgSWvR7LZdf435E63oYXquyh2VpFpXsgYO3okYL1ONTIHG6QBDAnJrMf213/z6Vy47V7Py9slYw=="], + + "@cloudflare/codemode": ["@cloudflare/codemode@0.0.7", "", { "dependencies": { "zod-to-ts": "^2.0.0" }, "peerDependencies": { "agents": "^0.4.0", "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-hzT8WWMel7CaCtEuFG1jtw1jRb6tBpIqQje5DG/dtAWobFnh+UGplGqSyLFEqD8BLknHYrMT/qxdguR94d03dg=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.0", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="], "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.4.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.17", "workerd": "^1.20250521.0" }, "optionalPeers": ["workerd"] }, "sha512-70mk5GPv+ozJ5XcIhFpq4ps7HvQYu+As7vwasUy9LcBadsTcWA2iFis/7aFJmQehfKerDwVOHfMYpgTTC+u24Q=="], @@ -84,9 +107,9 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], - "@honcho-ai/core": ["@honcho-ai/core@1.8.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-qxBNoXLezH8yx4iBoz4Bsxkm9zp4Gm1fNwuP8gHRdSelxhR0dXpvLffx8B5V0XFWsx+SfPaJFyaKw0X2sYMwLA=="], + "@honcho-ai/sdk": ["@honcho-ai/sdk@2.0.1", "", { "dependencies": { "@types/node": "^24.0.1", "zod": "4.0.0" } }, "sha512-y/Wk49C0N1miI9BZTNWFIbzdUkMZfP4Do/EJ1q4lEIK+FAOKxQgces/zET3kPKV3zF9sOUl2pXrFb/XKYayeYw=="], - "@honcho-ai/sdk": ["@honcho-ai/sdk@1.6.0", "", { "dependencies": { "@honcho-ai/core": "^1.6.1", "@types/node": "^24.0.1", "zod": "4.0.0" } }, "sha512-6HSjTidVwchEWw18p5Gqp4e2/I1Um0EppTZYZ22+hG1UemKRcJX+VDRvlu5ja8inD/WWYTEOJ/HBSdT8OC9biw=="], + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], @@ -132,6 +155,12 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "@poppinss/colors": ["@poppinss/colors@4.1.5", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="], "@poppinss/dumper": ["@poppinss/dumper@0.6.4", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-iG0TIdqv8xJ3Lt9O8DrPRxw1MRLjNpoqiSGU03P/wNLP/s0ra0udPJ1J2Tx5M0J3H/cVyEgpbn8xUKRY9j59kQ=="], @@ -142,24 +171,48 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.7", "", {}, "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="], + "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], - "@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="], + "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], - "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], "acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="], - "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + "agents": ["agents@0.4.0", "", { "dependencies": { "@cfworker/json-schema": "^4.1.1", "@modelcontextprotocol/sdk": "1.26.0", "cron-schedule": "^6.0.0", "escape-html": "^1.0.3", "json-schema": "^0.4.0", "json-schema-to-typescript": "^15.0.4", "mimetext": "^3.0.28", "nanoid": "^5.1.6", "partyserver": "^0.1.4", "partysocket": "1.1.13", "yargs": "^18.0.0" }, "peerDependencies": { "@ai-sdk/openai": "^3.0.0", "@ai-sdk/react": "^3.0.0", "@cloudflare/ai-chat": "^0.0.7", "@cloudflare/codemode": "^0.0.7", "@x402/core": "^2.0.0", "@x402/evm": "^2.0.0", "ai": "^6.0.0", "react": "^19.0.0", "viem": ">=2.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@ai-sdk/openai", "@ai-sdk/react", "@x402/core", "@x402/evm", "viem"], "bin": { "agents": "dist/cli/index.js" } }, "sha512-YZPNCpO9KxHsyE1HyGFHPKs2MrRiOaQ6GJ9R8uPGqjmsfGZ0WWB7mayiQDgSx7tezwckrX4ShtGGftffDCMx0Q=="], - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "ai": ["ai@6.0.78", "", { "dependencies": { "@ai-sdk/gateway": "3.0.39", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.14", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-eriIX/NLWfWNDeE/OJy8wmIp9fyaH7gnxTOCPT5bp0MNkvORstp1TwRUql9au8XjXzH7o2WApqbwgxJDDV0Rbw=="], + + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -168,18 +221,38 @@ "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], - "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "core-js-pure": ["core-js-pure@3.48.0", "", {}, "sha512-1slJgk89tWC51HQ1AEqG+s2VuwpTRr8ocu4n20QUcH1v9lAN0RXen0Q0AABa/DK1I7RrNWLucplOHMx8hfTGTw=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cron-schedule": ["cron-schedule@6.0.0", "", {}, "sha512-BoZaseYGXOo5j5HUwTaegIog3JJbuH4BbrY9A1ArLjXpy+RWb3mV28F/9Gv1dDA7E2L8kngWva4NWisnLTyfgQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -188,26 +261,48 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], - "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "event-target-polyfill": ["event-target-polyfill@0.0.4", "", {}, "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], + "exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="], - "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], @@ -218,52 +313,158 @@ "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], + "hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + + "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "json-schema-to-typescript": ["json-schema-to-typescript@15.0.4", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.5.5", "@types/json-schema": "^7.0.15", "@types/lodash": "^4.17.7", "is-glob": "^4.0.3", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "minimist": "^1.2.8", "prettier": "^3.2.5", "tinyglobby": "^0.2.9" }, "bin": { "json2ts": "dist/src/cli.js" } }, "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "mimetext": ["mimetext@3.0.28", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@babel/runtime-corejs3": "^7.26.0", "js-base64": "^3.7.7", "mime-types": "^2.1.35" } }, "sha512-eQXpbNrtxLCjUtiVbR/qR09dbPgZ2o+KR1uA7QKqGhbn8QV7HIL16mXXsobBL4/8TqoYh1us31kfz+dNfCev9g=="], "miniflare": ["miniflare@4.20250712.2", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "^7.10.0", "workerd": "1.20250712.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-cZ8WyQBwqfjYLjd61fDR4/j0nAVbjB3Wxbun/brL9S5FAi4RlTR0LyMTKsIVA0s+nL4Pg9VjVMki4M/Jk2cz+Q=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + "nanoid": ["nanoid@5.1.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ=="], - "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "partyserver": ["partyserver@0.1.5", "", { "dependencies": { "nanoid": "^5.1.6" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20240729.0" } }, "sha512-kaE3GYaYWFc70EJQDQEhyYbO2Wczz/NgsFXerfjRo0t2s7ZxL1XggWT+HkMrdEyqbZOv3b66CV93WG0Lcg/ThQ=="], + + "partysocket": ["partysocket@1.1.13", "", { "dependencies": { "event-target-polyfill": "^0.0.4" } }, "sha512-RNXGzc6j0NISGE84+VTHHtbPwmnzZuOYJm9XZ+en+aZlIA2vC4AfwPlYxAHmGGGko3pQF7xRNhoe7bu1Brej4Q=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "supports-color": ["supports-color@10.0.0", "", {}, "sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ=="], - "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], @@ -274,28 +475,52 @@ "unenv": ["unenv@2.0.0-rc.17", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.4", "ohash": "^2.0.11", "pathe": "^2.0.3", "ufo": "^1.6.1" } }, "sha512-B06u0wXkEd+o5gOCMl/ZHl5cfpYbDZKAT+HWTL+Hws6jWu7dCiqBBXXXzMFcFVJb8D4ytAnYmxJA83uwOQRSsg=="], - "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "workerd": ["workerd@1.20250712.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250712.0", "@cloudflare/workerd-darwin-arm64": "1.20250712.0", "@cloudflare/workerd-linux-64": "1.20250712.0", "@cloudflare/workerd-linux-arm64": "1.20250712.0", "@cloudflare/workerd-windows-64": "1.20250712.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-7h+k1OxREpiZW0849g0uQNexRWMcs5i5gUGhJzCY8nIx6Tv4D/ndlXJ47lEFj7/LQdp165IL9dM2D5uDiedZrg=="], "wrangler": ["wrangler@4.26.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.4.1", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", "miniflare": "4.20250712.2", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.17", "workerd": "1.20250712.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250712.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-EXuwyWlgYQZv6GJlyE0lVGk9hHqASssuECECT1XC5aIijTwNLQhsj/TOZ0hKSFlMbVr1E+OAdevAxd0kaF4ovA=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], + + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], - "zod": ["zod@4.0.0", "", {}, "sha512-9diLdTPc/L7w/5jI4C3gHYNiGHDV9IZYxo1e5LSD8cabi65WVTWWb+g2BGPEpUUCOxR4D+6O5B0AzyMdUAXwrw=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "@honcho-ai/core/@types/node": ["@types/node@18.19.120", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-WtCGHFXnVI8WHLxDAt5TbnCM4eSE+nI0QN2NJtwzcgMhht2eNz6V9evJrk+lwC8bCY8OWV5Ym8Jz7ZEyGnKnMA=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + + "zod-to-ts": ["zod-to-ts@2.0.0", "", { "peerDependencies": { "typescript": "^5.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-aHsUgIl+CQutKAxtRNeZslLCLXoeuSq+j5HU7q3kvi/c2KIAo6q4YjT7/lwFfACxLB923ELHYMkHmlxiqFy4lw=="], + + "@honcho-ai/sdk/zod": ["zod@4.0.0", "", {}, "sha512-9diLdTPc/L7w/5jI4C3gHYNiGHDV9IZYxo1e5LSD8cabi65WVTWWb+g2BGPEpUUCOxR4D+6O5B0AzyMdUAXwrw=="], + + "agents/nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], + + "mimetext/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], - "@honcho-ai/core/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "partyserver/nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], + + "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + + "youch/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + + "mimetext/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], } } diff --git a/mcp/instructions.md b/mcp/instructions.md index 4ef90946..10cae48d 100644 --- a/mcp/instructions.md +++ b/mcp/instructions.md @@ -1,156 +1,162 @@ -# Comprehensive Honcho MCP Integration Instructions +# Honcho MCP Server — Instructions -## What is Honcho? +## Quick Start: Recommended Flow -Honcho is an infrastructure layer for building AI agents with memory and social cognition. It enables personalized AI interactions by building coherent models of user psychology over time. The Honcho MCP server simplifies the integration to just 3 essential functions. Here's how to use them: +The simplest way to use Honcho for a standard user/assistant conversation. Three steps using the general tools. -### Step 1: Start New Conversation (First Message Only) +### 1. Start a conversation (once per conversation) -When a user begins a new conversation, always call `start_conversation`: +Create a session and set up the user and assistant peers: -```text -start_conversation +``` +create_session + session_id: "" ``` -**Returns**: A session ID that you must store and use for all subsequent interactions in this conversation. +Then add peers to the session: -### Step 2: Get Personalized Insights (When Helpful) +``` +create_peer + peer_id: "" -Before responding to any user message, you can query for personalization insights: +create_peer + peer_id: "Assistant" -```text -get_personalization_insights -session_id: [SESSION_ID_FROM_STEP_1] -query: [YOUR_QUESTION] +add_peers_to_session + session_id: "" + peers: + - peer_id: "" + observe_me: true + observe_others: true + - peer_id: "Assistant" + observe_me: false + observe_others: true ``` -This query takes a bit of time, so it's best to only perform it when you need personalized insights. If the query can be responded to effectively using what you already know about the user, just go ahead and answer it. However, the insights endpoint is extremely perceptive. It has the capability to reveal aspects of the user's personality, historical use of the application you are operating in, and more. +Store the `session_id` for the rest of this conversation. -**Returns**: Personalized insights about the user based on accumulated knowledge. +### 2. Get personalization insights (before responding, when helpful) -**Example Queries**: +``` +chat + peer_id: "Assistant" + query: "What communication style does this user prefer?" + target_peer_id: "" + session_id: "" +``` + +This calls Honcho's reasoning system to answer your question about the user, grounded in everything Honcho has learned across all their conversations. It takes a few seconds, so use it when personalization would genuinely improve your response. + +**Good queries:** - "What does this message reveal about the user's communication preferences?" -- "How formal or casual should I be with the user based on our history?" -- "What is the user really asking for beyond her explicit question?" +- "How formal or casual should I be?" +- "What is the user really asking for beyond their explicit question?" - "What emotional state might the user be in right now?" -- "How can I best help the user with her current request?" -### Step 3: Respond to User +### 3. Record the turn (after every exchange) -Craft your response using any insights gained from Step 2. - -### Step 4: Store the Conversation Turn (After Each Exchange) - -**CRITICAL**: Always store both the user's message AND your response using `add_turn`: - -```text -add_turn -session_id: [SESSION_ID_FROM_STEP_1] -messages: [ - { - "role": "user", - "content": "[USER'S_EXACT_MESSAGE]" - }, - { - "role": "assistant", - "content": "[YOUR_EXACT_RESPONSE]" - } -] +``` +add_messages_to_session + session_id: "" + messages: + - peer_id: "" + content: "" + - peer_id: "Assistant" + content: "" ``` -## Complete Example Flow +**Always** call this after responding so Honcho can learn from the conversation. -Here's exactly what to do for a new conversation: +--- -1. **User says**: "Hi Claude! My name is Sarah and I'm feeling overwhelmed with work" +## General Tools -2. **Start conversation**: +The full API for advanced use cases. - ```text - start_conversation - ``` +### Workspace Tools - → Returns: `session_abc123` +| Tool | When to use | +| --- | --- | +| `inspect_workspace` | Inspect a single workspace's details | +| `list_workspaces` | Enumerate available workspaces | +| `search` | Semantic search across messages — scope with optional `peer_id` or `session_id` params | +| `get_metadata` | Read metadata for workspace, peer, or session (scope with optional `peer_id` or `session_id`) | +| `set_metadata` | Store metadata for workspace, peer, or session (scope with optional `peer_id` or `session_id`) | -3. **Get insights** (optional but recommended): +### Peer Tools - ```text - get_personalization_insights - session_id: "session_abc123" - query: "What does the user's message about feeling overwhelmed tell me about her current state and how should I respond?" - ``` +| Tool | When to use | +| --- | --- | +| `create_peer` | Register a new participant (user or agent) | +| `list_peers` | See all participants in the workspace | +| `chat` | Ask Honcho what it knows about any peer. Accepts optional `reasoning_level` (`minimal`–`max`) to control depth vs. speed. | +| `get_peer_card` | Get compact biographical facts about a peer | +| `set_peer_card` | Manually set/correct facts about a peer | +| `get_peer_context` | Get full context (representation + peer card) | +| `get_representation` | Get the textual representation from conclusions | - → Returns insights about Sarah's emotional state and preferred communication style +### Session Tools -4. **Respond to Sarah**: "Hi Sarah! I can hear that you're feeling overwhelmed with work right now..." +| Tool | When to use | +| --- | --- | +| `create_session` | Create or get a session with the given ID | +| `list_sessions` | Discover existing conversations | +| `delete_session` | Permanently remove a session | +| `clone_session` | Fork a conversation (optionally up to a specific message) | +| `add_peers_to_session` | Add peers to a session with optional per-session config | +| `remove_peers_from_session` | Remove peers from a session | +| `get_session_peers` | See who is in a session | +| `inspect_session` | Inspect detailed session structure/metadata | +| `add_messages_to_session` | Add messages from specific peers | +| `get_session_messages` | Read conversation history (paginated, with optional metadata filters) | +| `get_session_message` | Get a single message from a session by ID | +| `get_session_context` | Get LLM-ready context (messages + summary) | -5. **Store the turn**: +### Conclusion Tools - ```text - add_turn - session_id: "session_abc123" - messages: [ - { - "role": "user", - "content": "Hi Claude! My name is Sarah and I'm feeling overwhelmed with work" - }, - { - "role": "assistant", - "content": "Hi Sarah! I can hear that you're feeling overwhelmed with work right now..." - } - ] - ``` +| Tool | When to use | +| --- | --- | +| `list_conclusions` | See what Honcho has derived about a peer | +| `query_conclusions` | Semantic search across derived facts | +| `create_conclusions` | Inject facts manually | +| `delete_conclusion` | Remove incorrect or outdated facts | -## Continuing an Existing Conversation +### System Tools -For subsequent messages in the same conversation: +| Tool | When to use | +| --- | --- | +| `schedule_dream` | Trigger memory consolidation for better insights | +| `get_queue_status` | Check if background processing is complete | -1. **User says**: "Thanks for listening. Can you help me prioritize my tasks?" +--- -2. **Respond**: "Based on our conversation, I can see you value..." +## Key Concepts -3. **Store the turn**: +### Peers - ```text - add_turn - session_id: "session_abc123" - messages: [ - { - "role": "user", - "content": "Thanks for listening. Can you help me prioritize my tasks?" - }, - { - "role": "assistant", - "content": "Based on our conversation, I can see you value..." - } - ] - ``` +A **peer** is any participant — human or AI. Each peer has a unique ID within the workspace. -## Best Practices for Personalization Queries +### Sessions -Ask questions that reveal: +A **session** is a conversation context. Sessions track message history, manage which peers participate, and provide context retrieval for LLMs. -**Communication Style**: "How formal/casual should I be?" "What does this reveal about their preferences?" +### Conclusions -**User Needs**: "What are they really asking for?" "What emotional state are they in?" +**Conclusions** are facts and observations that Honcho derives from conversations. They power the representation — Honcho's understanding of a peer. -**Relationship**: "How can I build rapport?" "What engages them most?" +### Representations -**Task Approach**: "How do they prefer problem-solving?" "What detail level do they want?" +A **representation** is a formatted text summary built from a peer's conclusions. Query it with `get_representation` or `chat`. -## Error Handling +### Peer Cards -- **Authorization Errors and Timeouts**: Make sure user has configured API key and URL for Honcho -- **ValueError**: Messages were incorrectly formatted, make sure to include role and content -- **"No personalization insights found"**: Normal when there's limited history with the user -- **Session management**: The MCP server handles all session persistence automatically +A **peer card** is a compact list of biographical facts about a peer, automatically maintained by Honcho (or manually via `set_peer_card`). -## Key Principles +### Reasoning Level -1. **Always start with `start_conversation` for new conversations** -2. **Store every message exchange with `add_turn`** -3. **Use `get_personalization_insights` strategically for better responses** -4. **Ask thoughtful questions about `peer` representation** -5. **Never expose technical details to the user** -6. **The system maintains context automatically between sessions** +Several tools accept an optional `reasoning_level` parameter (`minimal`, `low`, `medium`, `high`, `max`). Higher levels produce more thorough answers but take longer and cost more. Default is `low`. Use `minimal` for the fastest lookups; use `high` or `max` when depth matters. + +### Dreams + +A **dream** is a background memory-consolidation process. It reviews conclusions, merges redundancies, and generates higher-level insights. Schedule one with `schedule_dream` after long conversations. diff --git a/mcp/package.json b/mcp/package.json index d4014ce2..2311020c 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,8 +1,8 @@ { - "name": "honcho-mcp-proxy", - "version": "1.0.0", - "description": "Cloudflare Worker proxy for Honcho MCP Server", - "main": "worker.ts", + "name": "honcho-mcp", + "version": "3.0.0", + "description": "Honcho MCP Server — Cloudflare Worker", + "main": "src/index.ts", "packageManager": "bun@1.2.0", "engines": { "node": ">=18.0.0", @@ -15,7 +15,11 @@ "deploy:staging": "wrangler deploy --env staging" }, "dependencies": { - "@honcho-ai/sdk": "^2.0.0" + "@honcho-ai/sdk": "^2.1.0", + "@modelcontextprotocol/sdk": "^1.26.0", + "agents": "^0.4.0", + "nanoid": "^5.1.7", + "zod": "^4.3.6" }, "devDependencies": { "@cloudflare/workers-types": "^4.20241002.0", diff --git a/mcp/src/config.ts b/mcp/src/config.ts new file mode 100644 index 00000000..58e4307e --- /dev/null +++ b/mcp/src/config.ts @@ -0,0 +1,61 @@ +import { Honcho } from "@honcho-ai/sdk"; + +export interface HonchoConfig { + apiKey: string; + userName: string; + assistantName: string; + baseUrl: string; + workspaceId: string; +} + +export interface Env { + HONCHO_API_URL?: string; +} + +/** + * Parse configuration from request headers and Worker env bindings. + * Throws on missing required fields so callers get clear errors. + * + * The Honcho API URL is read from the `HONCHO_API_URL` env var when set, + * allowing operators to run this Worker alongside a self-hosted Honcho + * instance (see the "Self-Hosted Honcho" section in README.md). It is + * intentionally not exposed as a request header: routing public requests + * to an internal URL would be a latency and security regression. + */ +export function parseConfig(request: Request, env: Env = {}): HonchoConfig { + const authHeader = request.headers.get("Authorization"); + const trimmedAuthHeader = authHeader?.trim(); + if (!trimmedAuthHeader?.startsWith("Bearer ")) { + throw new Error( + "Missing Authorization header. Provide 'Authorization: Bearer '.", + ); + } + const apiKey = trimmedAuthHeader.substring(7).trim(); + if (!apiKey) { + throw new Error("Authorization header is empty after 'Bearer '."); + } + + const rawUserName = request.headers.get("X-Honcho-User-Name"); + const userName = rawUserName?.trim(); + if (!userName) { + throw new Error( + "Missing X-Honcho-User-Name header. Provide 'X-Honcho-User-Name: '.", + ); + } + + return { + apiKey, + userName, + assistantName: request.headers.get("X-Honcho-Assistant-Name")?.trim() || "Assistant", + baseUrl: env.HONCHO_API_URL?.trim() || "https://api.honcho.dev", + workspaceId: request.headers.get("X-Honcho-Workspace-ID")?.trim() || "default", + }; +} + +export function createClient(config: HonchoConfig): Honcho { + return new Honcho({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + workspaceId: config.workspaceId, + }); +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts new file mode 100644 index 00000000..4e895198 --- /dev/null +++ b/mcp/src/index.ts @@ -0,0 +1,59 @@ +import { createMcpHandler } from "agents/mcp"; +import { parseConfig, createClient, type Env } from "./config.js"; +import { createServer } from "./server.js"; + +const CORS_ORIGIN = "*"; +const CORS_METHODS = "GET, POST, DELETE, OPTIONS"; +const CORS_ALLOWED_HEADERS = + "Content-Type, Authorization, X-Honcho-User-Name, X-Honcho-Workspace-ID, X-Honcho-Assistant-Name"; + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": CORS_ORIGIN, + "Access-Control-Allow-Methods": CORS_METHODS, + "Access-Control-Allow-Headers": CORS_ALLOWED_HEADERS, +}; + +export default { + async fetch( + request: Request, + env: Env, + executionCtx: ExecutionContext, + ): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + let config; + try { + config = parseConfig(request, env); + } catch (e) { + const message = + e instanceof Error ? e.message : "Invalid request"; + return new Response(JSON.stringify({ error: message }), { + status: 401, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } + + try { + const honcho = createClient(config); + const server = createServer({ honcho, config }); + const handler = createMcpHandler(server, { + route: "/", + corsOptions: { + origin: CORS_ORIGIN, + methods: CORS_METHODS, + headers: CORS_ALLOWED_HEADERS, + }, + }); + return await handler(request, env, executionCtx); + } catch (e) { + const message = + e instanceof Error ? e.message : "Internal server error"; + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } + }, +}; diff --git a/mcp/src/server.ts b/mcp/src/server.ts new file mode 100644 index 00000000..6bbd5e08 --- /dev/null +++ b/mcp/src/server.ts @@ -0,0 +1,22 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ToolContext } from "./types.js"; +import { register as registerWorkspaceTools } from "./tools/workspace.js"; +import { register as registerPeerTools } from "./tools/peers.js"; +import { register as registerSessionTools } from "./tools/sessions.js"; +import { register as registerConclusionTools } from "./tools/conclusions.js"; +import { register as registerSystemTools } from "./tools/system.js"; + +export function createServer(ctx: ToolContext): McpServer { + const server = new McpServer({ + name: "Honcho MCP Server", + version: "3.0.0", + }); + + registerWorkspaceTools(server, ctx); + registerPeerTools(server, ctx); + registerSessionTools(server, ctx); + registerConclusionTools(server, ctx); + registerSystemTools(server, ctx); + + return server; +} diff --git a/mcp/src/tools/conclusions.ts b/mcp/src/tools/conclusions.ts new file mode 100644 index 00000000..e6fedf76 --- /dev/null +++ b/mcp/src/tools/conclusions.ts @@ -0,0 +1,175 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ToolContext } from "../types.js"; +import { textResult, errorResult } from "../types.js"; + +export function register(server: McpServer, ctx: ToolContext) { + // ── list_conclusions ──────────────────────────────────────────────── + server.registerTool( + "list_conclusions", + { + description: [ + "List conclusions (facts and observations) that Honcho has derived about a peer (paginated).", + "Use this to see what Honcho has learned. If no target is given, returns self-conclusions.", + "Returns conclusion objects with pagination metadata.", + ].join("\n"), + inputSchema: { + peer_id: z.string().describe("The observer peer."), + target_peer_id: z + .string() + .optional() + .describe( + "Optional: list conclusions about this target. Omit for self-conclusions.", + ), + }, + }, + async ({ peer_id, target_peer_id }) => { + try { + const peer = await ctx.honcho.peer(peer_id); + const scope = target_peer_id + ? peer.conclusionsOf(target_peer_id) + : peer.conclusions; + const page = await scope.list(); + return textResult({ + conclusions: page.items.map((c) => ({ + id: c.id, + content: c.content, + observer_id: c.observerId, + observed_id: c.observedId, + session_id: c.sessionId, + created_at: c.createdAt, + })), + total: page.total, + page: page.page, + pages: page.pages, + }); + } catch (e) { + return errorResult( + `Failed to list conclusions: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── query_conclusions ─────────────────────────────────────────────── + server.registerTool( + "query_conclusions", + { + description: [ + "Semantic search across a peer's conclusions.", + "Use this to find specific knowledge Honcho has derived — more targeted than list_conclusions.", + "Returns an array of matching conclusions ranked by relevance.", + ].join("\n"), + inputSchema: { + peer_id: z.string().describe("The observer peer."), + query: z.string().describe("Semantic search query."), + target_peer_id: z + .string() + .optional() + .describe("Optional: search conclusions about this target."), + top_k: z + .number() + .optional() + .describe("Max results to return."), + }, + }, + async ({ peer_id, query, target_peer_id, top_k }) => { + try { + const peer = await ctx.honcho.peer(peer_id); + const scope = target_peer_id + ? peer.conclusionsOf(target_peer_id) + : peer.conclusions; + const conclusions = await scope.query(query, top_k); + return textResult( + conclusions.map((c) => ({ + id: c.id, + content: c.content, + observer_id: c.observerId, + observed_id: c.observedId, + session_id: c.sessionId, + created_at: c.createdAt, + })), + ); + } catch (e) { + return errorResult( + `Query failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── create_conclusions ────────────────────────────────────────────── + server.registerTool( + "create_conclusions", + { + description: [ + "Manually create conclusions (facts/observations) about a peer.", + "Use this to inject knowledge into Honcho that wasn't derived from conversation.", + "Returns the number of conclusions created.", + ].join("\n"), + inputSchema: { + peer_id: z.string().describe("The observer peer."), + target_peer_id: z + .string() + .describe("The peer the conclusions are about."), + conclusions: z + .array(z.string()) + .describe("Conclusion content strings to create."), + session_id: z + .string() + .optional() + .describe( + "Optional: associate conclusions with a session. Omit for global conclusions.", + ), + }, + }, + async ({ peer_id, target_peer_id, conclusions, session_id }) => { + try { + const peer = await ctx.honcho.peer(peer_id); + const scope = peer.conclusionsOf(target_peer_id); + const params = conclusions.map((content) => ({ + content, + sessionId: session_id, + })); + await scope.create(params); + return textResult( + `Created ${conclusions.length} conclusion${conclusions.length === 1 ? "" : "s"} successfully`, + ); + } catch (e) { + return errorResult( + `Failed to create conclusions: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── delete_conclusion ─────────────────────────────────────────────── + server.registerTool( + "delete_conclusion", + { + description: [ + "Delete a specific conclusion by ID.", + "Use this to remove incorrect or outdated knowledge.", + ].join("\n"), + inputSchema: { + peer_id: z.string().describe("The observer peer."), + target_peer_id: z + .string() + .describe("The peer the conclusion is about."), + conclusion_id: z.string().describe("The conclusion to delete."), + }, + }, + async ({ peer_id, target_peer_id, conclusion_id }) => { + try { + const peer = await ctx.honcho.peer(peer_id); + const scope = peer.conclusionsOf(target_peer_id); + await scope.delete(conclusion_id); + return textResult("Conclusion deleted successfully"); + } catch (e) { + return errorResult( + `Failed to delete conclusion: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); +} diff --git a/mcp/src/tools/peers.ts b/mcp/src/tools/peers.ts new file mode 100644 index 00000000..26457bcf --- /dev/null +++ b/mcp/src/tools/peers.ts @@ -0,0 +1,279 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ToolContext } from "../types.js"; +import { textResult, errorResult } from "../types.js"; + +export function register(server: McpServer, ctx: ToolContext) { + // ── create_peer ───────────────────────────────────────────────────── + server.registerTool( + "create_peer", + { + description: [ + "Get or create a peer with the given ID.", + "Use this to register a new participant (user or agent) in the workspace.", + "Returns the peer ID and any configuration that was set.", + ].join("\n"), + inputSchema: { + peer_id: z.string().describe("Unique identifier for the peer."), + configuration: z + .object({ + observeMe: z.boolean().nullable().optional().describe( + "Whether derivation tasks should be created for this peer's messages. Default: true.", + ), + }) + .optional() + .describe("Optional peer configuration."), + }, + }, + async ({ peer_id, configuration }) => { + try { + const peer = await ctx.honcho.peer(peer_id, { configuration }); + return textResult({ peer_id: peer.id, configuration: peer.configuration }); + } catch (e) { + return errorResult( + `Failed to create peer: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── list_peers ────────────────────────────────────────────────────── + server.registerTool( + "list_peers", + { + description: [ + "List peers in the current workspace (paginated).", + "Use this to discover which users and agents exist.", + "Returns peer IDs with pagination metadata.", + ].join("\n"), + inputSchema: {}, + }, + async () => { + try { + const page = await ctx.honcho.peers(); + return textResult({ + peers: page.items.map((p) => ({ id: p.id })), + total: page.total, + page: page.page, + pages: page.pages, + }); + } catch (e) { + return errorResult( + `Failed to list peers: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── chat ──────────────────────────────────────────────────────────── + server.registerTool( + "chat", + { + description: [ + "Ask a natural-language question about a peer's knowledge and get an answer from Honcho's reasoning system.", + "Use this to query what Honcho knows about any peer — their preferences, history, personality, etc.", + "Returns a natural-language answer, or 'None' if no relevant information exists.", + ].join("\n"), + inputSchema: { + peer_id: z.string().describe("The peer to query about."), + query: z.string().describe("Natural-language question."), + target_peer_id: z + .string() + .optional() + .describe( + "Optional: query what peer_id knows about this target peer instead of their global representation.", + ), + session_id: z + .string() + .optional() + .describe("Optional: scope the query to a specific session."), + reasoning_level: z + .enum(["minimal", "low", "medium", "high", "max"]) + .optional() + .describe("Reasoning effort. Higher = more detailed but slower."), + }, + }, + async ({ peer_id, query, target_peer_id, session_id, reasoning_level }) => { + try { + const peer = await ctx.honcho.peer(peer_id); + const result = await peer.chat(query, { + target: target_peer_id, + session: session_id, + reasoningLevel: reasoning_level, + }); + return textResult(result ?? "None"); + } catch (e) { + return errorResult( + `Chat failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── get_peer_card ─────────────────────────────────────────────────── + server.registerTool( + "get_peer_card", + { + description: [ + "Get the peer card — a compact set of biographical facts about a peer.", + "Use this when you need a quick summary of who someone is.", + "Returns an array of fact strings, or null if no card exists yet.", + ].join("\n"), + inputSchema: { + peer_id: z.string().describe("The observer peer."), + target_peer_id: z + .string() + .optional() + .describe( + "Optional: get this peer's card about the target instead of their own.", + ), + }, + }, + async ({ peer_id, target_peer_id }) => { + try { + const peer = await ctx.honcho.peer(peer_id); + const card = await peer.getCard(target_peer_id); + return textResult(card ?? "No peer card found."); + } catch (e) { + return errorResult( + `Failed to get peer card: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── set_peer_card ─────────────────────────────────────────────────── + server.registerTool( + "set_peer_card", + { + description: [ + "Set or update the peer card — a list of biographical facts about a peer.", + "Use this to manually establish or correct facts about a peer.", + "Returns the updated peer card.", + ].join("\n"), + inputSchema: { + peer_id: z.string().describe("The observer peer."), + peer_card: z + .array(z.string()) + .describe("Array of fact strings to set as the peer card."), + target_peer_id: z + .string() + .optional() + .describe( + "Optional: set this peer's card about the target instead of their own.", + ), + }, + }, + async ({ peer_id, peer_card, target_peer_id }) => { + try { + const peer = await ctx.honcho.peer(peer_id); + const result = await peer.setCard(peer_card, target_peer_id); + return textResult(result ?? "Peer card set successfully"); + } catch (e) { + return errorResult( + `Failed to set peer card: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── get_peer_context ──────────────────────────────────────────────── + server.registerTool( + "get_peer_context", + { + description: [ + "Get comprehensive context for a peer — combines their representation (conclusions) and peer card.", + "Use this when you need the full picture of what Honcho knows about someone.", + "Returns an object with representation, peer_card, peer_id, and target_id.", + ].join("\n"), + inputSchema: { + peer_id: z.string().describe("The observer peer."), + target_peer_id: z + .string() + .optional() + .describe("Optional: get context about this target peer."), + search_query: z + .string() + .optional() + .describe("Optional: semantic search to filter relevant conclusions."), + max_conclusions: z + .number() + .optional() + .describe("Optional: max number of conclusions to include."), + }, + }, + async ({ peer_id, target_peer_id, search_query, max_conclusions }) => { + try { + const peer = await ctx.honcho.peer(peer_id); + const context = await peer.context({ + target: target_peer_id, + searchQuery: search_query, + maxConclusions: max_conclusions, + }); + return textResult({ + peer_id: context.peerId, + target_id: context.targetId, + representation: context.representation, + peer_card: context.peerCard, + }); + } catch (e) { + return errorResult( + `Failed to get peer context: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── get_representation ────────────────────────────────────────────── + server.registerTool( + "get_representation", + { + description: [ + "Get the formatted representation for a peer — a text summary built from their conclusions.", + "Use this when you want the textual representation without the peer card.", + "Returns a formatted string of conclusions.", + ].join("\n"), + inputSchema: { + peer_id: z.string().describe("The observer peer."), + target_peer_id: z + .string() + .optional() + .describe("Optional: get representation about this target peer."), + session_id: z + .string() + .optional() + .describe("Optional: scope to a specific session."), + search_query: z + .string() + .optional() + .describe("Optional: semantic search to filter conclusions."), + max_conclusions: z + .number() + .optional() + .describe("Optional: max number of conclusions."), + }, + }, + async ({ + peer_id, + target_peer_id, + session_id, + search_query, + max_conclusions, + }) => { + try { + const peer = await ctx.honcho.peer(peer_id); + const rep = await peer.representation({ + target: target_peer_id, + session: session_id, + searchQuery: search_query, + maxConclusions: max_conclusions, + }); + return textResult(rep); + } catch (e) { + return errorResult( + `Failed to get representation: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); +} diff --git a/mcp/src/tools/sessions.ts b/mcp/src/tools/sessions.ts new file mode 100644 index 00000000..1270ea86 --- /dev/null +++ b/mcp/src/tools/sessions.ts @@ -0,0 +1,418 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ToolContext } from "../types.js"; +import { + textResult, + errorResult, + formatMessage, + formatMessages, + formatSessionSummaries, +} from "../types.js"; + +export function register(server: McpServer, ctx: ToolContext) { + // ── create_session ────────────────────────────────────────────────── + server.registerTool( + "create_session", + { + description: [ + "Get or create a session with the given ID.", + "Use this to create or get a session with the given ID.", + "Returns the session ID.", + ].join("\n"), + inputSchema: { + session_id: z.string().describe("Unique identifier for the session."), + }, + }, + async ({ session_id }) => { + try { + const session = await ctx.honcho.session(session_id); + return textResult({ session_id: session.id }); + } catch (e) { + return errorResult( + `Failed to create session: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── list_sessions ─────────────────────────────────────────────────── + server.registerTool( + "list_sessions", + { + description: [ + "List sessions in the current workspace (paginated).", + "Use this to discover existing conversations.", + "Returns session IDs with pagination metadata.", + ].join("\n"), + inputSchema: {}, + }, + async () => { + try { + const page = await ctx.honcho.sessions(); + return textResult({ + sessions: page.items.map((s) => ({ id: s.id })), + total: page.total, + page: page.page, + pages: page.pages, + }); + } catch (e) { + return errorResult( + `Failed to list sessions: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── delete_session ────────────────────────────────────────────────── + server.registerTool( + "delete_session", + { + description: [ + "Delete a session and all its messages.", + "This cannot be undone.", + ].join("\n"), + inputSchema: { + session_id: z.string().describe("The session to delete."), + }, + }, + async ({ session_id }) => { + try { + const session = await ctx.honcho.session(session_id); + await session.delete(); + return textResult("Session deleted successfully"); + } catch (e) { + return errorResult( + `Failed to delete session: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── clone_session ─────────────────────────────────────────────────── + server.registerTool( + "clone_session", + { + description: [ + "Clone a session, optionally up to a specific message.", + "Use this to fork a conversation — e.g. to explore a different branch.", + "Returns the new cloned session ID.", + ].join("\n"), + inputSchema: { + session_id: z.string().describe("The session to clone."), + message_id: z + .string() + .optional() + .describe( + "Optional: clone only up to and including this message. Omit to clone everything.", + ), + }, + }, + async ({ session_id, message_id }) => { + try { + const session = await ctx.honcho.session(session_id); + const cloned = await session.clone(message_id); + return textResult({ session_id: cloned.id }); + } catch (e) { + return errorResult( + `Failed to clone session: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── add_peers_to_session ──────────────────────────────────────────── + server.registerTool( + "add_peers_to_session", + { + description: [ + "Add one or more peers to a session.", + "Use this to bring participants into a conversation.", + ].join("\n"), + inputSchema: { + session_id: z.string().describe("The session to add peers to."), + peers: z + .array( + z.union([ + z.string().describe("Peer ID with default config."), + z.object({ + peer_id: z.string().describe("Peer ID."), + observe_me: z + .boolean() + .nullable() + .optional() + .describe("Whether this peer's messages trigger derivation in this session."), + observe_others: z + .boolean() + .nullable() + .optional() + .describe("Whether this peer observes other peers' messages in this session."), + }).describe("Peer with per-session config."), + ]), + ) + .describe("Peers to add — plain IDs or objects with per-session config."), + }, + }, + async ({ session_id, peers }) => { + try { + const session = await ctx.honcho.session(session_id); + const additions = peers.map((p) => { + if (typeof p === "string") return p; + const config: { observeMe?: boolean | null; observeOthers?: boolean | null } = {}; + if (p.observe_me !== undefined) config.observeMe = p.observe_me; + if (p.observe_others !== undefined) config.observeOthers = p.observe_others; + return Object.keys(config).length > 0 + ? [p.peer_id, config] as [string, typeof config] + : p.peer_id; + }); + await session.addPeers(additions); + return textResult("Peers added to session successfully"); + } catch (e) { + return errorResult( + `Failed to add peers: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── remove_peers_from_session ─────────────────────────────────────── + server.registerTool( + "remove_peers_from_session", + { + description: [ + "Remove one or more peers from a session.", + ].join("\n"), + inputSchema: { + session_id: z.string().describe("The session to remove peers from."), + peer_ids: z + .array(z.string()) + .describe("Peer IDs to remove."), + }, + }, + async ({ session_id, peer_ids }) => { + try { + const session = await ctx.honcho.session(session_id); + await session.removePeers(peer_ids); + return textResult("Peers removed from session successfully"); + } catch (e) { + return errorResult( + `Failed to remove peers: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── get_session_peers ─────────────────────────────────────────────── + server.registerTool( + "get_session_peers", + { + description: [ + "Get all peers participating in a session.", + "Use this to see who is in a conversation.", + "Returns an array of peer IDs.", + ].join("\n"), + inputSchema: { + session_id: z.string().describe("The session to query."), + }, + }, + async ({ session_id }) => { + try { + const session = await ctx.honcho.session(session_id); + const peers = await session.peers(); + return textResult(peers.map((p) => p.id)); + } catch (e) { + return errorResult( + `Failed to get session peers: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── inspect_session ───────────────────────────────────────────────── + server.registerTool( + "inspect_session", + { + description: [ + "Inspect a session at a glance.", + "Aggregates peer IDs, message count, and available summaries.", + "Returns a single JSON object.", + ].join("\n"), + inputSchema: { + session_id: z.string().describe("The session to inspect."), + }, + }, + async ({ session_id }) => { + try { + const session = await ctx.honcho.session(session_id); + const [peers, messagePage, summaries] = await Promise.all([ + session.peers(), + session.messages(), + session.summaries(), + ]); + + return textResult({ + session_id, + peers: peers.map((peer) => ({ id: peer.id })), + message_count: messagePage.total, + summaries: formatSessionSummaries(summaries), + }); + } catch (e) { + return errorResult( + `Failed to inspect session: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── add_messages_to_session ───────────────────────────────────────── + server.registerTool( + "add_messages_to_session", + { + description: [ + "Add messages to a session from specific peers.", + "Use this to record conversation turns. Each message must specify the peer_id of the author.", + "Each message must specify the peer_id of the author.", + ].join("\n"), + inputSchema: { + session_id: z.string().describe("The session to add messages to."), + messages: z + .array( + z.object({ + peer_id: z.string().describe("Peer ID authoring this message."), + content: z.string().describe("Message text."), + metadata: z + .record(z.string(), z.unknown()) + .optional() + .describe("Optional metadata."), + }), + ) + .describe("Messages to add."), + }, + }, + async ({ session_id, messages }) => { + try { + const session = await ctx.honcho.session(session_id); + const peerCache = new Map>>(); + const sessionMessages = []; + for (const msg of messages) { + let peer = peerCache.get(msg.peer_id); + if (!peer) { + peer = await ctx.honcho.peer(msg.peer_id); + peerCache.set(msg.peer_id, peer); + } + sessionMessages.push( + msg.metadata + ? peer.message(msg.content, { metadata: msg.metadata }) + : peer.message(msg.content), + ); + } + await session.addMessages(sessionMessages); + return textResult("Messages added to session successfully"); + } catch (e) { + return errorResult( + `Failed to add messages: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── get_session_messages ──────────────────────────────────────────── + server.registerTool( + "get_session_messages", + { + description: [ + "Get messages from a session (paginated), with optional metadata filtering.", + "Use this to read the conversation history.", + "Returns the first page of messages with pagination metadata.", + ].join("\n"), + inputSchema: { + session_id: z.string().describe("The session to get messages from."), + filters: z + .record(z.string(), z.unknown()) + .optional() + .describe("Optional metadata filter criteria."), + }, + }, + async ({ session_id, filters }) => { + try { + const session = await ctx.honcho.session(session_id); + const page = await session.messages(filters); + return textResult({ + messages: formatMessages(page.items), + total: page.total, + page: page.page, + pages: page.pages, + }); + } catch (e) { + return errorResult( + `Failed to get messages: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── get_session_message ───────────────────────────────────────────── + server.registerTool( + "get_session_message", + { + description: [ + "Get a single message from a session by ID.", + "Use this when you already know the message ID and need the exact record.", + "Returns the message object.", + ].join("\n"), + inputSchema: { + session_id: z.string().describe("The session the message belongs to."), + message_id: z.string().describe("The message ID to fetch."), + }, + }, + async ({ session_id, message_id }) => { + try { + const session = await ctx.honcho.session(session_id); + const message = await session.getMessage(message_id); + return textResult(formatMessage(message)); + } catch (e) { + return errorResult( + `Failed to get message: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── get_session_context ───────────────────────────────────────────── + server.registerTool( + "get_session_context", + { + description: [ + "Get optimized context for a session, suitable for LLM prompts.", + "Includes recent messages and an optional summary of older ones.", + "Use this to build a context window for the next LLM call.", + "Returns messages, summary, and session ID.", + ].join("\n"), + inputSchema: { + session_id: z.string().describe("The session to get context for."), + summary: z + .boolean() + .optional() + .describe("Include a summary of older messages? Default: true."), + tokens: z + .number() + .optional() + .describe("Target token budget for the context window."), + }, + }, + async ({ session_id, summary, tokens }) => { + try { + const session = await ctx.honcho.session(session_id); + const context = await session.context({ summary, tokens }); + return textResult({ + session_id: context.sessionId, + summary: context.summary, + messages: formatMessages(context.messages), + }); + } catch (e) { + return errorResult( + `Failed to get context: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); +} diff --git a/mcp/src/tools/system.ts b/mcp/src/tools/system.ts new file mode 100644 index 00000000..4c2ba673 --- /dev/null +++ b/mcp/src/tools/system.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ToolContext } from "../types.js"; +import { textResult, errorResult } from "../types.js"; + +export function register(server: McpServer, ctx: ToolContext) { + // ── schedule_dream ────────────────────────────────────────────────── + server.registerTool( + "schedule_dream", + { + description: [ + "Schedule a dream — a background memory-consolidation task for a peer.", + "Dreams consolidate observations into higher-level insights and update peer cards.", + "Use this after a long conversation to improve Honcho's memory quality.", + ].join("\n"), + inputSchema: { + peer_id: z.string().describe("The observer peer to dream for."), + target_peer_id: z + .string() + .optional() + .describe( + "Optional: dream about this target peer. Omit for self-reflection.", + ), + session_id: z + .string() + .optional() + .describe("Optional: scope the dream to a session."), + }, + }, + async ({ peer_id, target_peer_id, session_id }) => { + try { + await ctx.honcho.scheduleDream({ + observer: peer_id, + observed: target_peer_id, + session: session_id, + }); + return textResult("Dream scheduled successfully"); + } catch (e) { + return errorResult( + `Failed to schedule dream: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── get_queue_status ──────────────────────────────────────────────── + server.registerTool( + "get_queue_status", + { + description: [ + "Get the current processing queue status for background tasks (message derivation, dreams).", + "Use this to check if Honcho is still processing messages before querying for insights.", + "Returns work unit counts: total, completed, in-progress, and pending.", + ].join("\n"), + inputSchema: {}, + }, + async () => { + try { + const status = await ctx.honcho.queueStatus(); + return textResult(status); + } catch (e) { + return errorResult( + `Failed to get queue status: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); +} diff --git a/mcp/src/tools/workspace.ts b/mcp/src/tools/workspace.ts new file mode 100644 index 00000000..04524bbb --- /dev/null +++ b/mcp/src/tools/workspace.ts @@ -0,0 +1,204 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ToolContext } from "../types.js"; +import { textResult, errorResult, formatMessages } from "../types.js"; + +export function register(server: McpServer, ctx: ToolContext) { + // ── inspect_workspace ─────────────────────────────────────────────── + server.registerTool( + "inspect_workspace", + { + description: [ + "Inspect the current workspace at a glance.", + "Aggregates workspace metadata, configuration, peer IDs, and session IDs.", + "Returns the first page of peers/sessions with total counts.", + ].join("\n"), + inputSchema: {}, + }, + async () => { + try { + const [metadata, configuration, peerPage, sessionPage] = await Promise.all([ + ctx.honcho.getMetadata(), + ctx.honcho.getConfiguration(), + ctx.honcho.peers(), + ctx.honcho.sessions(), + ]); + + return textResult({ + workspace_id: ctx.honcho.workspaceId, + metadata, + configuration, + peer_count: peerPage.total, + peers: peerPage.items.map((p) => ({ id: p.id })), + session_count: sessionPage.total, + sessions: sessionPage.items.map((s) => ({ id: s.id })), + }); + } catch (e) { + return errorResult( + `Failed to inspect workspace: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── list_workspaces ───────────────────────────────────────────────── + server.registerTool( + "list_workspaces", + { + description: [ + "List workspaces accessible to the current credentials (paginated).", + "Use this to discover available workspaces before selecting or switching context.", + "Returns workspace IDs with pagination metadata.", + ].join("\n"), + inputSchema: {}, + }, + async () => { + try { + const page = await ctx.honcho.workspaces(); + return textResult({ + workspaces: page.items.map((id) => ({ id })), + total: page.total, + page: page.page, + pages: page.pages, + }); + } catch (e) { + return errorResult( + `Failed to list workspaces: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── search ──────────────────────────────────────────────────────── + server.registerTool( + "search", + { + description: [ + "Semantic search across messages. Scope is determined by which optional params are provided:", + "- No scope params: search all messages in the workspace.", + "- peer_id only: search messages authored by that peer across all sessions.", + "- session_id only: search messages within that session.", + "Returns an array of matching messages with their content, peer, and session info.", + ].join("\n"), + inputSchema: { + query: z.string().describe("Search query."), + peer_id: z + .string() + .optional() + .describe("Optional: scope search to messages by this peer."), + session_id: z + .string() + .optional() + .describe("Optional: scope search to messages in this session."), + }, + }, + async ({ query, peer_id, session_id }) => { + try { + let messages; + if (session_id) { + const session = await ctx.honcho.session(session_id); + messages = await session.search(query); + } else if (peer_id) { + const peer = await ctx.honcho.peer(peer_id); + messages = await peer.search(query); + } else { + messages = await ctx.honcho.search(query); + } + return textResult(formatMessages(messages)); + } catch (e) { + return errorResult( + `Search failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── get_metadata ────────────────────────────────────────────────── + server.registerTool( + "get_metadata", + { + description: [ + "Get metadata for a resource. Scope is determined by which optional params are provided:", + "- No scope params: get workspace metadata.", + "- peer_id only: get peer metadata.", + "- session_id only: get session metadata.", + ].join("\n"), + inputSchema: { + peer_id: z + .string() + .optional() + .describe("Optional: get metadata for this peer."), + session_id: z + .string() + .optional() + .describe("Optional: get metadata for this session."), + }, + }, + async ({ peer_id, session_id }) => { + try { + let metadata; + if (session_id) { + const session = await ctx.honcho.session(session_id); + metadata = await session.getMetadata(); + } else if (peer_id) { + const peer = await ctx.honcho.peer(peer_id); + metadata = await peer.getMetadata(); + } else { + metadata = await ctx.honcho.getMetadata(); + } + return textResult(metadata); + } catch (e) { + return errorResult( + `Failed to get metadata: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); + + // ── set_metadata ────────────────────────────────────────────────── + server.registerTool( + "set_metadata", + { + description: [ + "Set metadata for a resource. Overwrites existing metadata.", + "Scope is determined by which optional params are provided:", + "- No scope params: set workspace metadata.", + "- peer_id only: set peer metadata.", + "- session_id only: set session metadata.", + ].join("\n"), + inputSchema: { + metadata: z + .record(z.string(), z.unknown()) + .describe("Key-value pairs to set as metadata."), + peer_id: z + .string() + .optional() + .describe("Optional: set metadata for this peer."), + session_id: z + .string() + .optional() + .describe("Optional: set metadata for this session."), + }, + }, + async ({ metadata, peer_id, session_id }) => { + try { + if (session_id) { + const session = await ctx.honcho.session(session_id); + await session.setMetadata(metadata); + return textResult("Session metadata set successfully"); + } else if (peer_id) { + const peer = await ctx.honcho.peer(peer_id); + await peer.setMetadata(metadata); + return textResult("Peer metadata set successfully"); + } else { + await ctx.honcho.setMetadata(metadata); + return textResult("Workspace metadata set successfully"); + } + } catch (e) { + return errorResult( + `Failed to set metadata: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + ); +} diff --git a/mcp/src/types.ts b/mcp/src/types.ts new file mode 100644 index 00000000..413b75b1 --- /dev/null +++ b/mcp/src/types.ts @@ -0,0 +1,60 @@ +import type { Honcho, Message, Summary, SessionSummaries } from "@honcho-ai/sdk"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { HonchoConfig } from "./config.js"; + +export interface ToolContext { + honcho: Honcho; + config: HonchoConfig; +} + +export function textResult( + data: string | object | unknown[], +): CallToolResult { + const text = typeof data === "string" ? data : JSON.stringify(data); + return { content: [{ type: "text", text }] }; +} + +export function errorResult(msg: string): CallToolResult { + return { content: [{ type: "text", text: msg }], isError: true }; +} + +/** Serialize a Message to a plain JSON-safe object. */ +export function formatMessage(message: Message) { + return { + id: message.id, + content: message.content, + peer_id: message.peerId, + session_id: message.sessionId, + metadata: message.metadata, + created_at: message.createdAt, + }; +} + +/** Serialize a Summary to a plain JSON-safe object. */ +export function formatSummary(summary: Summary) { + return { + content: summary.content, + message_id: summary.messageId, + summary_type: summary.summaryType, + created_at: summary.createdAt, + token_count: summary.tokenCount, + }; +} + +/** Serialize SessionSummaries to a plain JSON-safe object. */ +export function formatSessionSummaries(summaries: SessionSummaries) { + return { + session_id: summaries.sessionId, + short_summary: summaries.shortSummary + ? formatSummary(summaries.shortSummary) + : null, + long_summary: summaries.longSummary + ? formatSummary(summaries.longSummary) + : null, + }; +} + +/** Serialize a Message[] to a plain JSON-safe array. */ +export function formatMessages(messages: Message[]) { + return messages.map(formatMessage); +} diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json index 0cac208a..8ca7bfae 100644 --- a/mcp/tsconfig.json +++ b/mcp/tsconfig.json @@ -9,6 +9,6 @@ "skipLibCheck": true, "types": ["@cloudflare/workers-types"] }, - "include": ["**/*.ts"], + "include": ["src/**/*.ts"], "exclude": ["node_modules"] } diff --git a/mcp/worker.ts b/mcp/worker.ts deleted file mode 100644 index bf08f417..00000000 --- a/mcp/worker.ts +++ /dev/null @@ -1,2245 +0,0 @@ -import { Honcho } from "@honcho-ai/sdk"; - -interface HonchoConfig { - apiKey: string; - userName: string; - baseUrl?: string; - workspaceId?: string; - assistantName?: string; -} - -interface Message { - role: "user" | "assistant"; - content: string; - metadata?: Record; -} - -/** - * JSON-RPC 2.0 request interface - */ -interface JsonRpcRequest { - jsonrpc: "2.0"; - method: string; - params?: any; - id?: string | number; -} - -/** - * JSON-RPC 2.0 response interface - */ -interface JsonRpcResponse { - jsonrpc: "2.0"; - id?: string | number | null; - result?: any; - error?: { - code: number; - message: string; - data?: any; - }; -} - -// MCP Tool definitions -interface Tool { - name: string; - description: string; - inputSchema: { - type: "object"; - properties: Record; - required?: string[]; - }; -} - -/** - * Helper function to validate required arguments and create error responses - */ -function validateArguments( - args: Record, - required: string[], - requestId: string | number | null, -): Response | null { - for (const param of required) { - if (!args[param]) { - return createErrorResponse(requestId, -32602, `${param} is required`); - } - } - - // Special validation for arrays - if (args.messages && !Array.isArray(args.messages)) { - return createErrorResponse(requestId, -32602, "messages must be an array"); - } - if (args.peer_ids && !Array.isArray(args.peer_ids)) { - return createErrorResponse(requestId, -32602, "peer_ids must be an array"); - } - - return null; -} - -/** - * Helper function to create error responses - */ -function createErrorResponse( - id: string | number | null, - code: number, - message: string, -): Response { - return new Response( - JSON.stringify( - createJsonRpcResponse(id, undefined, createJsonRpcError(code, message)), - ), - { - status: code === -32602 ? 400 : code === -32601 ? 404 : 500, - headers: { "Content-Type": "application/json" }, - }, - ); -} - -/** - * Helper function to format messages for async iteration - */ -async function formatMessages(messagesPage: any): Promise { - const messages = []; - for await (const message of messagesPage) { - messages.push({ - id: message.id, - content: message.content, - peer_id: message.peer_id, - session_id: message.session_id, - metadata: message.metadata, - created_at: message.created_at, - }); - } - return messages; -} - -class HonchoWorker { - private honcho: Honcho; - private config: HonchoConfig; - - constructor(config: HonchoConfig) { - this.config = { - baseUrl: "https://api.honcho.dev", - workspaceId: "default", - assistantName: "Assistant", - ...config, - }; - - this.honcho = new Honcho({ - apiKey: this.config.apiKey, - baseURL: this.config.baseUrl, - workspaceId: this.config.workspaceId, - }); - } - - //////////////////////////////////////////////////////////////////////////////// - /// /// - /// "Bespoke" tools: easy to use for user-assistant conversation paradigms /// - /// /// - //////////////////////////////////////////////////////////////////////////////// - - /** - * Start a new conversation with a user. Call this when a user starts a new conversation. - * @returns A session ID for the conversation - */ - async startConversation(): Promise { - // Get/create the peers first, before session creation - // This avoids a race condition where peer creation during session.addPeers - // could rollback the session if there's an IntegrityError - const userPeer = await this.honcho.peer(this.config.userName); - const assistantPeer = await this.honcho.peer(this.config.assistantName!, { - config: { observe_me: false }, - }); - - // Create a new session - pass empty config to force API call - const sessionId = crypto.randomUUID(); - const session = await this.honcho.session(sessionId, { config: {} }); - - // Add the user and assistant peers to the session - await session.addPeers([ - userPeer, - [assistantPeer, { observe_me: null, observe_others: false }], - ]); - - return sessionId; - } - - /** - * Add a turn to a conversation. Call this after a user has sent a message and the assistant has responded. - * @param sessionId - The ID of the session to add the turn to - * @param messages - A list of messages to add to the session - */ - async addTurn(sessionId: string, messages: Message[]): Promise { - const session = await this.honcho.session(sessionId); - const userPeer = await this.honcho.peer(this.config.userName); - const assistantPeer = await this.honcho.peer(this.config.assistantName!); - - const sessionMessages = []; - - for (let i = 0; i < messages.length; i++) { - const message = messages[i]; - - // Validate required fields - if (!message || typeof message !== "object") { - throw new Error(`Message at index ${i} must be a dictionary`); - } - - if (!message.role) { - throw new Error( - `Message at index ${i} is missing required field 'role'`, - ); - } - - if (!message.content) { - throw new Error( - `Message at index ${i} is missing required field 'content'`, - ); - } - - const { role, content, metadata } = message; - - // Create message with appropriate peer - if (role === "user") { - if (metadata) { - sessionMessages.push(userPeer.message(content, { metadata })); - } else { - sessionMessages.push(userPeer.message(content)); - } - } else if (role === "assistant") { - if (metadata) { - sessionMessages.push(assistantPeer.message(content, { metadata })); - } else { - sessionMessages.push(assistantPeer.message(content)); - } - } else { - throw new Error( - `Invalid role '${role}' at message index ${i}. Role must be one of: 'user' or 'assistant'`, - ); - } - } - - await session.addMessages(sessionMessages); - } - - /** - * Get personalization insights about the user, based on the query and the accumulated knowledge of the user across all conversations. - * @param sessionId - The ID of the session for context - * @param query - The question about the user's preferences, habits, etc. - * @param reasoningLevel - Optional reasoning level: "minimal", "low", "medium", "high", or "max" - * @returns A string with the personalization insights - */ - async getPersonalizationInsights( - sessionId: string, - query: string, - reasoningLevel?: string, - ): Promise { - const userPeer = await this.honcho.peer(this.config.userName); - - // Get the personalization insights (non-streaming returns string | null) - const personalizationInsights = await userPeer.chat(query, { - session: sessionId, - reasoningLevel, - }); - - if (!personalizationInsights || typeof personalizationInsights !== 'string') { - return "No personalization insights found."; - } - - return personalizationInsights; - } - - //////////////////////////////////////////////////////////////////////////////// - /// /// - /// General tools for using Honcho /// - /// /// - //////////////////////////////////////////////////////////////////////////////// - - ////////////////////////////////////////////////////// - /// /// - /// Workspace operations /// - /// /// - ////////////////////////////////////////////////////// - - /** - * Search for messages across the entire workspace. - * @param query - The search query to use - * @returns A list of message dictionaries matching the search query - */ - async searchWorkspace(query: string): Promise { - const messagesPage = await this.honcho.search(query); - return await formatMessages(messagesPage); - } - - /** - * Get metadata for the current workspace. - * @returns A dictionary containing the workspace's metadata - */ - async getWorkspaceMetadata(): Promise> { - return await this.honcho.getMetadata(); - } - - /** - * Set metadata for the current workspace. - * @param metadata - A dictionary of metadata to associate with the workspace - */ - async setWorkspaceMetadata(metadata: Record): Promise { - await this.honcho.setMetadata(metadata); - } - - ////////////////////////////////////////////////////// - /// /// - /// Peer operations /// - /// /// - ////////////////////////////////////////////////////// - - /** - * Create or get a peer with the specified ID and optional configuration. - * @param peerId - Unique identifier for the peer - * @param config - Optional configuration dictionary for the peer - * @returns A dictionary with the peer ID and confirmation of creation - */ - async createPeer( - peerId: string, - config?: Record, - ): Promise<{ peer_id: string; config?: Record }> { - const peer = await this.honcho.peer(peerId, { config }); - return { - peer_id: peer.id, - config, - }; - } - - /** - * Get metadata for a specific peer. - * @param peerId - The ID of the peer to get metadata for - * @returns A dictionary containing the peer's metadata - */ - async getPeerMetadata(peerId: string): Promise> { - const peer = await this.honcho.peer(peerId); - return await peer.getMetadata(); - } - - /** - * Set metadata for a specific peer. - * @param peerId - The ID of the peer to set metadata for - * @param metadata - A dictionary of metadata to associate with the peer - */ - async setPeerMetadata( - peerId: string, - metadata: Record, - ): Promise { - const peer = await this.honcho.peer(peerId); - await peer.setMetadata(metadata); - } - - /** - * Search for messages sent by a peer. - * @param peerId - The ID of the peer to search messages for - * @param query - The search query to use - * @returns A list of message dictionaries matching the search query - */ - async searchPeerMessages(peerId: string, query: string): Promise { - const peer = await this.honcho.peer(peerId); - const messagesPage = await peer.search(query); - return await formatMessages(messagesPage); - } - - /** - * Query a peer's representation with natural language questions. - * @param peerId - The ID of the peer to query - * @param query - The natural language question to ask - * @param targetPeerId - Optional target peer ID for local representation queries - * @param sessionId - Optional session ID to scope the query to a specific session - * @param reasoningLevel - Optional reasoning level: "minimal", "low", "medium", "high", or "max" - * @returns Response string containing the answer to the query, or "None" if no relevant information - */ - async chat( - peerId: string, - query: string, - targetPeerId?: string, - sessionId?: string, - reasoningLevel?: string, - ): Promise { - const peer = await this.honcho.peer(peerId); - let targetPeer; - if (targetPeerId) { - targetPeer = await this.honcho.peer(targetPeerId); - } - - const result = await peer.chat(query, { - target: targetPeer, - session: sessionId, - reasoningLevel, - }); - - if (!result || typeof result !== 'string') { - return "None"; - } - return result; - } - - /** - * Get all peers in the current workspace. - * @returns A list of peer dictionaries with their IDs - */ - async listPeers(): Promise<{ id: string }[]> { - const peersPage = await this.honcho.getPeers(); - const peers = []; - - for await (const peer of peersPage) { - peers.push({ - id: peer.id, - }); - } - - return peers; - } - - /** - * Get the peer card for a peer. - * @param peerId - The ID of the observer peer - * @param targetPeerId - Optional target peer ID to get the card about - * @returns The peer card content or null if not found - */ - async getPeerCard( - peerId: string, - targetPeerId?: string, - ): Promise { - const peer = await this.honcho.peer(peerId); - let targetPeer; - if (targetPeerId) { - targetPeer = await this.honcho.peer(targetPeerId); - } - return await peer.card(targetPeer); - } - - /** - * Get the peer context (combined representation and peer card). - * @param peerId - The ID of the observer peer - * @param targetPeerId - Optional target peer ID - * @param sessionId - Optional session ID to scope the context - * @param searchQuery - Optional semantic search query - * @param maxConclusions - Optional maximum number of conclusions to include - * @returns Context object with representation and peer card - */ - async getPeerContext( - peerId: string, - targetPeerId?: string, - sessionId?: string, - searchQuery?: string, - maxConclusions?: number, - ): Promise> { - const peer = await this.honcho.peer(peerId); - let targetPeer; - if (targetPeerId) { - targetPeer = await this.honcho.peer(targetPeerId); - } - const context = await peer.context({ - target: targetPeer, - session: sessionId, - searchQuery, - maxConclusions, - }); - return { - peer_id: context.peerId, - target_id: context.targetId, - representation: context.representation, - peer_card: context.peerCard, - }; - } - - /** - * Get the formatted representation for a peer. - * @param peerId - The ID of the observer peer - * @param targetPeerId - Optional target peer ID - * @param sessionId - Optional session ID to scope the representation - * @param searchQuery - Optional semantic search query - * @param maxConclusions - Optional maximum number of conclusions - * @returns Formatted representation string - */ - async getRepresentation( - peerId: string, - targetPeerId?: string, - sessionId?: string, - searchQuery?: string, - maxConclusions?: number, - ): Promise { - const peer = await this.honcho.peer(peerId); - let targetPeer; - if (targetPeerId) { - targetPeer = await this.honcho.peer(targetPeerId); - } - return await peer.representation({ - target: targetPeer, - session: sessionId, - searchQuery, - maxConclusions, - }); - } - - ////////////////////////////////////////////////////// - /// /// - /// Conclusions operations /// - /// /// - ////////////////////////////////////////////////////// - - /** - * List conclusions for a peer. - * @param peerId - The ID of the observer peer - * @param targetPeerId - Optional target peer to get conclusions about - * @returns List of conclusions - */ - async listConclusions( - peerId: string, - targetPeerId?: string, - ): Promise { - const peer = await this.honcho.peer(peerId); - let conclusionScope; - if (targetPeerId) { - const targetPeer = await this.honcho.peer(targetPeerId); - conclusionScope = peer.conclusionsOf(targetPeer); - } else { - conclusionScope = peer.conclusions; - } - - const conclusionsPage = await conclusionScope.list(); - const conclusions = []; - for await (const conclusion of conclusionsPage) { - conclusions.push({ - id: conclusion.id, - content: conclusion.content, - observer_id: conclusion.observerId, - observed_id: conclusion.observedId, - session_id: conclusion.sessionId, - created_at: conclusion.createdAt, - }); - } - return conclusions; - } - - /** - * Query conclusions using semantic search. - * @param peerId - The ID of the observer peer - * @param query - The semantic search query - * @param targetPeerId - Optional target peer to search conclusions about - * @param topK - Maximum number of results to return - * @returns List of matching conclusions - */ - async queryConclusions( - peerId: string, - query: string, - targetPeerId?: string, - topK?: number, - ): Promise { - const peer = await this.honcho.peer(peerId); - let conclusionScope; - if (targetPeerId) { - const targetPeer = await this.honcho.peer(targetPeerId); - conclusionScope = peer.conclusionsOf(targetPeer); - } else { - conclusionScope = peer.conclusions; - } - - // SDK query returns Conclusion[] directly, not a Page - const conclusions = await conclusionScope.query(query, topK); - return conclusions.map((conclusion) => ({ - id: conclusion.id, - content: conclusion.content, - observer_id: conclusion.observerId, - observed_id: conclusion.observedId, - session_id: conclusion.sessionId, - created_at: conclusion.createdAt, - })); - } - - /** - * Create conclusions manually. - * @param peerId - The ID of the observer peer - * @param targetPeerId - The target peer the conclusions are about - * @param conclusions - List of conclusion content strings - * @param sessionId - Optional session ID to associate with conclusions - * @returns Number of conclusions created - */ - async createConclusions( - peerId: string, - targetPeerId: string, - conclusions: string[], - sessionId?: string, - ): Promise { - const peer = await this.honcho.peer(peerId); - const targetPeer = await this.honcho.peer(targetPeerId); - const conclusionScope = peer.conclusionsOf(targetPeer); - - // Convert string array to ConclusionCreateParams array - const conclusionParams = conclusions.map((content) => ({ - content, - sessionId, - })); - - await conclusionScope.create(conclusionParams); - return conclusions.length; - } - - /** - * Delete a conclusion. - * @param peerId - The ID of the observer peer - * @param targetPeerId - The target peer the conclusion is about - * @param conclusionId - The ID of the conclusion to delete - */ - async deleteConclusion( - peerId: string, - targetPeerId: string, - conclusionId: string, - ): Promise { - const peer = await this.honcho.peer(peerId); - const targetPeer = await this.honcho.peer(targetPeerId); - const conclusionScope = peer.conclusionsOf(targetPeer); - - await conclusionScope.delete(conclusionId); - } - - ////////////////////////////////////////////////////// - /// /// - /// System operations /// - /// /// - ////////////////////////////////////////////////////// - - /** - * Schedule a dream (memory consolidation) for a peer. - * @param peerId - The ID of the observer peer - * @param targetPeerId - Optional target peer to dream about (defaults to observer for self-reflection) - * @param sessionId - Optional session ID to scope the dream to - * @returns Confirmation message - */ - async scheduleDream( - peerId: string, - targetPeerId?: string, - sessionId?: string, - ): Promise { - const peer = await this.honcho.peer(peerId); - const session = sessionId ? await this.honcho.session(sessionId) : undefined; - const targetPeer = targetPeerId ? await this.honcho.peer(targetPeerId) : undefined; - - await this.honcho.scheduleDream({ - observer: peer, - session: session, - observed: targetPeer, - }); - return "Dream scheduled successfully"; - } - - /** - * Get the current queue status for the deriver. - * @returns Queue status information - */ - async getQueueStatus(): Promise> { - return await this.honcho.queueStatus(); - } - - ////////////////////////////////////////////////////// - /// /// - /// Session operations /// - /// /// - ////////////////////////////////////////////////////// - - /** - * Create or get a session with the specified ID and optional configuration. - * @param sessionId - Unique identifier for the session - * @param config - Optional configuration dictionary for the session - * @returns A dictionary with the session ID and confirmation of creation - */ - async createSession( - sessionId: string, - config?: Record, - ): Promise<{ session_id: string; config?: Record }> { - // Always pass config (even if empty) to force the API call to create the session - // Without this, the SDK just creates a local Session object without making an API call - const session = await this.honcho.session(sessionId, { config: config ?? {} }); - return { - session_id: session.id, - config, - }; - } - - /** - * Get metadata for a specific session. - * @param sessionId - The ID of the session to get metadata for - * @returns A dictionary containing the session's metadata - */ - async getSessionMetadata(sessionId: string): Promise> { - const session = await this.honcho.session(sessionId); - return await session.getMetadata(); - } - - /** - * Set metadata for a specific session. - * @param sessionId - The ID of the session to set metadata for - * @param metadata - A dictionary of metadata to associate with the session - */ - async setSessionMetadata( - sessionId: string, - metadata: Record, - ): Promise { - const session = await this.honcho.session(sessionId); - await session.setMetadata(metadata); - } - - /** - * Add peers to a session. - * @param sessionId - The ID of the session to add peers to - * @param peerIds - List of peer IDs to add to the session - */ - async addPeersToSession(sessionId: string, peerIds: string[]): Promise { - const session = await this.honcho.session(sessionId); - await session.addPeers(peerIds); - } - - /** - * Remove peers from a session. - * @param sessionId - The ID of the session to remove peers from - * @param peerIds - List of peer IDs to remove from the session - */ - async removePeersFromSession( - sessionId: string, - peerIds: string[], - ): Promise { - const session = await this.honcho.session(sessionId); - await session.removePeers(peerIds); - } - - /** - * Get all peer IDs in a session. - * @param sessionId - The ID of the session to get peers from - * @returns A list of peer IDs that are members of the session - */ - async getSessionPeers(sessionId: string): Promise { - const session = await this.honcho.session(sessionId); - const peers = await session.getPeers(); - return peers.map((peer) => peer.id); - } - - /** - * Add messages to a session. - * @param sessionId - The ID of the session to add messages to - * @param messages - List of message dictionaries - */ - async addMessagesToSession( - sessionId: string, - messages: { - peer_id: string; - content: string; - metadata?: Record; - }[], - ): Promise { - const session = await this.honcho.session(sessionId); - - const sessionMessages = []; - for (const message of messages) { - const peer = await this.honcho.peer(message.peer_id); - if (message.metadata) { - sessionMessages.push( - peer.message(message.content, { metadata: message.metadata }), - ); - } else { - sessionMessages.push(peer.message(message.content)); - } - } - - await session.addMessages(sessionMessages); - } - - /** - * Get messages from a session with optional filtering. - * @param sessionId - The ID of the session to get messages from - * @param filters - Optional dictionary of filter criteria - * @returns A list of message dictionaries - */ - async getSessionMessages( - sessionId: string, - filters?: Record, - ): Promise { - const session = await this.honcho.session(sessionId); - const messagesPage = await session.getMessages({ filter: filters }); - return await formatMessages(messagesPage); - } - - /** - * Get optimized context for a session within a token limit. - * @param sessionId - The ID of the session to get context for - * @param summary - Whether to include summary information - * @param tokens - Maximum number of tokens to include in the context - * @returns A dictionary containing the session context with messages and optional summary - */ - async getSessionContext( - sessionId: string, - summary: boolean = true, - tokens?: number, - ): Promise { - const session = await this.honcho.session(sessionId); - const context = await session.getContext({ summary, tokens }); - - return { - session_id: context.sessionId, - summary: context.summary, - messages: context.messages.map((msg) => ({ - id: msg.id, - content: msg.content, - peer_id: msg.peer_id, - metadata: msg.metadata, - created_at: msg.created_at, - })), - }; - } - - /** - * Search for messages in a specific session. - * @param sessionId - The ID of the session to search messages in - * @param query - The search query to use - * @returns A list of message dictionaries matching the search query - */ - async searchSessionMessages( - sessionId: string, - query: string, - ): Promise { - const session = await this.honcho.session(sessionId); - const messagesPage = await session.search(query); - return await formatMessages(messagesPage); - } - - /** - * Get the current working representation of a peer in a session. - * @param sessionId - The ID of the session - * @param peerId - The ID of the peer to get the working representation of - * @param targetPeerId - Optional target peer ID to get the representation of what peer_id knows about target_peer_id - * @returns A dictionary containing information about the peer - */ - async getWorkingRepresentation( - sessionId: string, - peerId: string, - targetPeerId?: string, - ): Promise> { - const session = await this.honcho.session(sessionId); - if (targetPeerId) { - return await session.workingRep(peerId, targetPeerId); - } else { - return await session.workingRep(peerId); - } - } - - /** - * Get all sessions in the current workspace. - * @returns A list of session dictionaries with their IDs - */ - async listSessions(): Promise<{ id: string }[]> { - const sessionsPage = await this.honcho.getSessions(); - const sessions = []; - - for await (const session of sessionsPage) { - sessions.push({ - id: session.id, - }); - } - - return sessions; - } -} - -/** - * Parse configuration from request headers - * @param request - The incoming request - * @returns Configuration object or null if invalid - */ -function parseConfig(request: Request): HonchoConfig | null { - // Get API key from Authorization header - const authHeader = request.headers.get("Authorization"); - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return null; - } - const apiKey = authHeader.substring(7); - - if (!apiKey) { - return null; - } - - const userName = request.headers.get("X-Honcho-User-Name"); - if (!userName) { - return null; - } - - // Get configuration from headers with proper defaults - const config: HonchoConfig = { - apiKey, - userName, - baseUrl: - request.headers.get("X-Honcho-Base-URL") || "https://api.honcho.dev", - workspaceId: request.headers.get("X-Honcho-Workspace-ID") || "default", - assistantName: - request.headers.get("X-Honcho-Assistant-Name") || "Assistant", - }; - - return config; -} - -/** - * Create a JSON-RPC 2.0 response - * @param id - Request ID - * @param result - Response result - * @param error - Error object if any - * @returns JSON-RPC response object - */ -function createJsonRpcResponse( - id: string | number | null, - result?: any, - error?: { code: number; message: string; data?: any }, -): JsonRpcResponse { - const response: JsonRpcResponse = { - jsonrpc: "2.0", - id, - }; - - if (error) { - response.error = error; - } else { - response.result = result; - } - - return response; -} - -/** - * Create a JSON-RPC 2.0 error object - * @param code - Error code - * @param message - Error message - * @param data - Optional error data - * @returns Error object - */ -function createJsonRpcError( - code: number, - message: string, - data?: any, -): { code: number; message: string; data?: any } { - return { code, message, data }; -} - -// Define all MCP tools based on the Python server.py functions -const tools: Tool[] = [ - // Bespoke tools - { - name: "start_conversation", - description: - "Start a new conversation with a user. Call this when a user starts a new conversation.", - inputSchema: { - type: "object", - properties: {}, - required: [], - }, - }, - { - name: "add_turn", - description: - "Add a turn to a conversation. Call this after a user has sent a message and the assistant has responded.", - inputSchema: { - type: "object", - properties: { - session_id: { - type: "string", - description: "The ID of the session to add the turn to.", - }, - messages: { - type: "array", - description: "A list of messages to add to the session.", - items: { - type: "object", - properties: { - role: { - type: "string", - enum: ["user", "assistant"], - description: "The role of the message author.", - }, - content: { - type: "string", - description: "The content of the message.", - }, - metadata: { - type: "object", - description: "Optional metadata about the message.", - }, - }, - required: ["role", "content"], - }, - }, - }, - required: ["session_id", "messages"], - }, - }, - { - name: "get_personalization_insights", - description: - "Get personalization insights about the user, based on the query and the accumulated knowledge of the user across all conversations.", - inputSchema: { - type: "object", - properties: { - session_id: { - type: "string", - description: "The ID of the session for context.", - }, - query: { - type: "string", - description: - "The question about the user's preferences, habits, etc.", - }, - reasoning_level: { - type: "string", - enum: ["minimal", "low", "medium", "high", "max"], - description: - "The reasoning level for the response. Higher levels provide more detailed analysis.", - }, - }, - required: ["session_id", "query"], - }, - }, - - // Workspace operations - { - name: "search_workspace", - description: "Search for messages across the entire workspace.", - inputSchema: { - type: "object", - properties: { - query: { - type: "string", - description: "The search query to use.", - }, - }, - required: ["query"], - }, - }, - { - name: "get_workspace_metadata", - description: "Get metadata for the current workspace.", - inputSchema: { - type: "object", - properties: {}, - required: [], - }, - }, - { - name: "set_workspace_metadata", - description: "Set metadata for the current workspace.", - inputSchema: { - type: "object", - properties: { - metadata: { - type: "object", - description: - "A dictionary of metadata to associate with the workspace.", - }, - }, - required: ["metadata"], - }, - }, - - // Peer operations - { - name: "create_peer", - description: - "Create or get a peer with the specified ID and optional configuration.", - inputSchema: { - type: "object", - properties: { - peer_id: { - type: "string", - description: "Unique identifier for the peer.", - }, - config: { - type: "object", - description: "Optional configuration dictionary for the peer.", - }, - }, - required: ["peer_id"], - }, - }, - { - name: "get_peer_metadata", - description: "Get metadata for a specific peer.", - inputSchema: { - type: "object", - properties: { - peer_id: { - type: "string", - description: "The ID of the peer to get metadata for.", - }, - }, - required: ["peer_id"], - }, - }, - { - name: "set_peer_metadata", - description: "Set metadata for a specific peer.", - inputSchema: { - type: "object", - properties: { - peer_id: { - type: "string", - description: "The ID of the peer to set metadata for.", - }, - metadata: { - type: "object", - description: "A dictionary of metadata to associate with the peer.", - }, - }, - required: ["peer_id", "metadata"], - }, - }, - { - name: "search_peer_messages", - description: "Search for messages sent by a peer.", - inputSchema: { - type: "object", - properties: { - peer_id: { - type: "string", - description: "The ID of the peer to search messages for.", - }, - query: { - type: "string", - description: "The search query to use.", - }, - }, - required: ["peer_id", "query"], - }, - }, - { - name: "chat", - description: - "Query a peer's representation with natural language questions.", - inputSchema: { - type: "object", - properties: { - peer_id: { - type: "string", - description: "The ID of the peer to query.", - }, - query: { - type: "string", - description: "The natural language question to ask.", - }, - target_peer_id: { - type: "string", - description: - "Optional target peer ID for local representation queries.", - }, - session_id: { - type: "string", - description: - "Optional session ID to scope the query to a specific session.", - }, - reasoning_level: { - type: "string", - enum: ["minimal", "low", "medium", "high", "max"], - description: - "The reasoning level for the response. Higher levels provide more detailed analysis.", - }, - }, - required: ["peer_id", "query"], - }, - }, - { - name: "list_peers", - description: "Get all peers in the current workspace.", - inputSchema: { - type: "object", - properties: {}, - required: [], - }, - }, - { - name: "get_peer_card", - description: - "Get the peer card for a peer. The peer card contains compact biographical facts about the peer.", - inputSchema: { - type: "object", - properties: { - peer_id: { - type: "string", - description: "The ID of the observer peer.", - }, - target_peer_id: { - type: "string", - description: - "Optional target peer ID to get the card about. If not provided, returns the peer's own card.", - }, - }, - required: ["peer_id"], - }, - }, - { - name: "get_peer_context", - description: - "Get the peer context, which combines the representation and peer card for comprehensive peer information.", - inputSchema: { - type: "object", - properties: { - peer_id: { - type: "string", - description: "The ID of the observer peer.", - }, - target_peer_id: { - type: "string", - description: "Optional target peer ID to get context about.", - }, - session_id: { - type: "string", - description: "Optional session ID to scope the context.", - }, - search_query: { - type: "string", - description: "Optional semantic search query to filter conclusions.", - }, - max_conclusions: { - type: "integer", - description: "Maximum number of conclusions to include.", - }, - }, - required: ["peer_id"], - }, - }, - { - name: "get_representation", - description: - "Get the formatted representation for a peer, containing their conclusions and observations.", - inputSchema: { - type: "object", - properties: { - peer_id: { - type: "string", - description: "The ID of the observer peer.", - }, - target_peer_id: { - type: "string", - description: "Optional target peer ID to get representation about.", - }, - session_id: { - type: "string", - description: "Optional session ID to scope the representation.", - }, - search_query: { - type: "string", - description: "Optional semantic search query to filter conclusions.", - }, - max_conclusions: { - type: "integer", - description: "Maximum number of conclusions to include.", - }, - }, - required: ["peer_id"], - }, - }, - - // Conclusions operations - { - name: "list_conclusions", - description: - "List conclusions for a peer. Conclusions are facts and observations derived from conversations.", - inputSchema: { - type: "object", - properties: { - peer_id: { - type: "string", - description: "The ID of the observer peer.", - }, - target_peer_id: { - type: "string", - description: - "Optional target peer ID to get conclusions about. If not provided, returns conclusions about self.", - }, - }, - required: ["peer_id"], - }, - }, - { - name: "query_conclusions", - description: "Query conclusions using semantic search.", - inputSchema: { - type: "object", - properties: { - peer_id: { - type: "string", - description: "The ID of the observer peer.", - }, - query: { - type: "string", - description: "The semantic search query.", - }, - target_peer_id: { - type: "string", - description: "Optional target peer ID to search conclusions about.", - }, - top_k: { - type: "integer", - description: "Maximum number of results to return.", - }, - }, - required: ["peer_id", "query"], - }, - }, - { - name: "create_conclusions", - description: "Create conclusions manually for a peer.", - inputSchema: { - type: "object", - properties: { - peer_id: { - type: "string", - description: "The ID of the observer peer.", - }, - target_peer_id: { - type: "string", - description: "The target peer the conclusions are about.", - }, - conclusions: { - type: "array", - items: { type: "string" }, - description: "List of conclusion content strings to create.", - }, - session_id: { - type: "string", - description: - "Optional session ID to associate with conclusions. If not provided, conclusions will be global.", - }, - }, - required: ["peer_id", "target_peer_id", "conclusions"], - }, - }, - { - name: "delete_conclusion", - description: "Delete a specific conclusion.", - inputSchema: { - type: "object", - properties: { - peer_id: { - type: "string", - description: "The ID of the observer peer.", - }, - target_peer_id: { - type: "string", - description: "The target peer the conclusion is about.", - }, - conclusion_id: { - type: "string", - description: "The ID of the conclusion to delete.", - }, - }, - required: ["peer_id", "target_peer_id", "conclusion_id"], - }, - }, - - // System operations - { - name: "schedule_dream", - description: - "Schedule a dream (memory consolidation) for a peer. Dreams help consolidate and improve memory quality.", - inputSchema: { - type: "object", - properties: { - peer_id: { - type: "string", - description: "The ID of the observer peer.", - }, - target_peer_id: { - type: "string", - description: - "Optional target peer to dream about. If not provided, defaults to the observer peer (self-reflection).", - }, - session_id: { - type: "string", - description: - "Optional session ID to scope the dream to. If not provided, the dream will be global.", - }, - }, - required: ["peer_id"], - }, - }, - { - name: "get_queue_status", - description: - "Get the current queue status for the deriver (background processing system).", - inputSchema: { - type: "object", - properties: {}, - required: [], - }, - }, - - // Session operations - { - name: "create_session", - description: - "Create or get a session with the specified ID and optional configuration.", - inputSchema: { - type: "object", - properties: { - session_id: { - type: "string", - description: "Unique identifier for the session.", - }, - config: { - type: "object", - description: "Optional configuration dictionary for the session.", - }, - }, - required: ["session_id"], - }, - }, - { - name: "get_session_metadata", - description: "Get metadata for a specific session.", - inputSchema: { - type: "object", - properties: { - session_id: { - type: "string", - description: "The ID of the session to get metadata for.", - }, - }, - required: ["session_id"], - }, - }, - { - name: "set_session_metadata", - description: "Set metadata for a specific session.", - inputSchema: { - type: "object", - properties: { - session_id: { - type: "string", - description: "The ID of the session to set metadata for.", - }, - metadata: { - type: "object", - description: - "A dictionary of metadata to associate with the session.", - }, - }, - required: ["session_id", "metadata"], - }, - }, - { - name: "add_peers_to_session", - description: "Add peers to a session.", - inputSchema: { - type: "object", - properties: { - session_id: { - type: "string", - description: "The ID of the session to add peers to.", - }, - peer_ids: { - type: "array", - items: { type: "string" }, - description: "List of peer IDs to add to the session.", - }, - }, - required: ["session_id", "peer_ids"], - }, - }, - { - name: "remove_peers_from_session", - description: "Remove peers from a session.", - inputSchema: { - type: "object", - properties: { - session_id: { - type: "string", - description: "The ID of the session to remove peers from.", - }, - peer_ids: { - type: "array", - items: { type: "string" }, - description: "List of peer IDs to remove from the session.", - }, - }, - required: ["session_id", "peer_ids"], - }, - }, - { - name: "get_session_peers", - description: "Get all peer IDs in a session.", - inputSchema: { - type: "object", - properties: { - session_id: { - type: "string", - description: "The ID of the session to get peers from.", - }, - }, - required: ["session_id"], - }, - }, - { - name: "add_messages_to_session", - description: "Add messages to a session.", - inputSchema: { - type: "object", - properties: { - session_id: { - type: "string", - description: "The ID of the session to add messages to.", - }, - messages: { - type: "array", - items: { - type: "object", - properties: { - peer_id: { - type: "string", - description: "ID of the peer sending the message", - }, - content: { - type: "string", - description: "Message content", - }, - metadata: { - type: "object", - description: "Optional metadata dictionary", - }, - }, - required: ["peer_id", "content"], - }, - description: "List of message dictionaries.", - }, - }, - required: ["session_id", "messages"], - }, - }, - { - name: "get_session_messages", - description: "Get messages from a session with optional filtering.", - inputSchema: { - type: "object", - properties: { - session_id: { - type: "string", - description: "The ID of the session to get messages from.", - }, - filters: { - type: "object", - description: "Optional dictionary of filter criteria.", - }, - }, - required: ["session_id"], - }, - }, - { - name: "get_session_context", - description: "Get optimized context for a session within a token limit.", - inputSchema: { - type: "object", - properties: { - session_id: { - type: "string", - description: "The ID of the session to get context for.", - }, - summary: { - type: "boolean", - description: "Whether to include summary information.", - default: true, - }, - tokens: { - type: "integer", - description: "Maximum number of tokens to include in the context.", - }, - }, - required: ["session_id"], - }, - }, - { - name: "search_session_messages", - description: "Search for messages in a specific session.", - inputSchema: { - type: "object", - properties: { - session_id: { - type: "string", - description: "The ID of the session to search messages in.", - }, - query: { - type: "string", - description: "The search query to use.", - }, - }, - required: ["session_id", "query"], - }, - }, - { - name: "get_working_representation", - description: - "Get the current working representation of a peer in a session.", - inputSchema: { - type: "object", - properties: { - session_id: { - type: "string", - description: "The ID of the session.", - }, - peer_id: { - type: "string", - description: - "The ID of the peer to get the working representation of.", - }, - target_peer_id: { - type: "string", - description: - "Optional target peer ID to get the representation of what peer_id knows about target_peer_id.", - }, - }, - required: ["session_id", "peer_id"], - }, - }, - { - name: "list_sessions", - description: "Get all sessions in the current workspace.", - inputSchema: { - type: "object", - properties: {}, - required: [], - }, - }, -]; - -/** - * Execute a tool with validation and consistent response handling - */ -async function executeToolCall( - honcho: HonchoWorker, - toolName: string, - toolArguments: any, - requestId: string | number | null, -): Promise { - let result: any; - - switch (toolName) { - // Bespoke tools - case "start_conversation": - result = await honcho.startConversation(); - break; - - case "add_turn": { - const validation = validateArguments( - toolArguments, - ["session_id", "messages"], - requestId, - ); - if (validation) return validation; - - await honcho.addTurn(toolArguments.session_id, toolArguments.messages); - result = "Turn added successfully"; - break; - } - - case "get_personalization_insights": { - const validation = validateArguments( - toolArguments, - ["session_id", "query"], - requestId, - ); - if (validation) return validation; - - result = await honcho.getPersonalizationInsights( - toolArguments.session_id, - toolArguments.query, - toolArguments.reasoning_level, - ); - break; - } - - // Workspace operations - case "search_workspace": { - const validation = validateArguments(toolArguments, ["query"], requestId); - if (validation) return validation; - - result = await honcho.searchWorkspace(toolArguments.query); - break; - } - - case "get_workspace_metadata": - result = await honcho.getWorkspaceMetadata(); - break; - - case "set_workspace_metadata": { - const validation = validateArguments( - toolArguments, - ["metadata"], - requestId, - ); - if (validation) return validation; - - await honcho.setWorkspaceMetadata(toolArguments.metadata); - result = "Workspace metadata set successfully"; - break; - } - - // Peer operations - case "create_peer": { - const validation = validateArguments( - toolArguments, - ["peer_id"], - requestId, - ); - if (validation) return validation; - - result = await honcho.createPeer( - toolArguments.peer_id, - toolArguments.config, - ); - break; - } - - case "get_peer_metadata": { - const validation = validateArguments( - toolArguments, - ["peer_id"], - requestId, - ); - if (validation) return validation; - - result = await honcho.getPeerMetadata(toolArguments.peer_id); - break; - } - - case "set_peer_metadata": { - const validation = validateArguments( - toolArguments, - ["peer_id", "metadata"], - requestId, - ); - if (validation) return validation; - - await honcho.setPeerMetadata( - toolArguments.peer_id, - toolArguments.metadata, - ); - result = "Peer metadata set successfully"; - break; - } - - case "search_peer_messages": { - const validation = validateArguments( - toolArguments, - ["peer_id", "query"], - requestId, - ); - if (validation) return validation; - - result = await honcho.searchPeerMessages( - toolArguments.peer_id, - toolArguments.query, - ); - break; - } - - case "chat": { - const validation = validateArguments( - toolArguments, - ["peer_id", "query"], - requestId, - ); - if (validation) return validation; - - result = await honcho.chat( - toolArguments.peer_id, - toolArguments.query, - toolArguments.target_peer_id, - toolArguments.session_id, - toolArguments.reasoning_level, - ); - break; - } - - case "list_peers": - result = await honcho.listPeers(); - break; - - case "get_peer_card": { - const validation = validateArguments( - toolArguments, - ["peer_id"], - requestId, - ); - if (validation) return validation; - - result = await honcho.getPeerCard( - toolArguments.peer_id, - toolArguments.target_peer_id, - ); - break; - } - - case "get_peer_context": { - const validation = validateArguments( - toolArguments, - ["peer_id"], - requestId, - ); - if (validation) return validation; - - result = await honcho.getPeerContext( - toolArguments.peer_id, - toolArguments.target_peer_id, - toolArguments.session_id, - toolArguments.search_query, - toolArguments.max_conclusions, - ); - break; - } - - case "get_representation": { - const validation = validateArguments( - toolArguments, - ["peer_id"], - requestId, - ); - if (validation) return validation; - - result = await honcho.getRepresentation( - toolArguments.peer_id, - toolArguments.target_peer_id, - toolArguments.session_id, - toolArguments.search_query, - toolArguments.max_conclusions, - ); - break; - } - - // Conclusions operations - case "list_conclusions": { - const validation = validateArguments( - toolArguments, - ["peer_id"], - requestId, - ); - if (validation) return validation; - - result = await honcho.listConclusions( - toolArguments.peer_id, - toolArguments.target_peer_id, - ); - break; - } - - case "query_conclusions": { - const validation = validateArguments( - toolArguments, - ["peer_id", "query"], - requestId, - ); - if (validation) return validation; - - result = await honcho.queryConclusions( - toolArguments.peer_id, - toolArguments.query, - toolArguments.target_peer_id, - toolArguments.top_k, - ); - break; - } - - case "create_conclusions": { - const validation = validateArguments( - toolArguments, - ["peer_id", "target_peer_id", "conclusions"], - requestId, - ); - if (validation) return validation; - - const count = await honcho.createConclusions( - toolArguments.peer_id, - toolArguments.target_peer_id, - toolArguments.conclusions, - toolArguments.session_id, - ); - result = `Created ${count} conclusions successfully`; - break; - } - - case "delete_conclusion": { - const validation = validateArguments( - toolArguments, - ["peer_id", "target_peer_id", "conclusion_id"], - requestId, - ); - if (validation) return validation; - - await honcho.deleteConclusion( - toolArguments.peer_id, - toolArguments.target_peer_id, - toolArguments.conclusion_id, - ); - result = "Conclusion deleted successfully"; - break; - } - - // System operations - case "schedule_dream": { - const validation = validateArguments( - toolArguments, - ["peer_id"], - requestId, - ); - if (validation) return validation; - - result = await honcho.scheduleDream( - toolArguments.peer_id, - toolArguments.target_peer_id, - toolArguments.session_id, - ); - break; - } - - case "get_queue_status": - result = await honcho.getQueueStatus(); - break; - - // Session operations - case "create_session": { - const validation = validateArguments( - toolArguments, - ["session_id"], - requestId, - ); - if (validation) return validation; - - result = await honcho.createSession( - toolArguments.session_id, - toolArguments.config, - ); - break; - } - - case "get_session_metadata": { - const validation = validateArguments( - toolArguments, - ["session_id"], - requestId, - ); - if (validation) return validation; - - result = await honcho.getSessionMetadata(toolArguments.session_id); - break; - } - - case "set_session_metadata": { - const validation = validateArguments( - toolArguments, - ["session_id", "metadata"], - requestId, - ); - if (validation) return validation; - - await honcho.setSessionMetadata( - toolArguments.session_id, - toolArguments.metadata, - ); - result = "Session metadata set successfully"; - break; - } - - case "add_peers_to_session": { - const validation = validateArguments( - toolArguments, - ["session_id", "peer_ids"], - requestId, - ); - if (validation) return validation; - - await honcho.addPeersToSession( - toolArguments.session_id, - toolArguments.peer_ids, - ); - result = "Peers added to session successfully"; - break; - } - - case "remove_peers_from_session": { - const validation = validateArguments( - toolArguments, - ["session_id", "peer_ids"], - requestId, - ); - if (validation) return validation; - - await honcho.removePeersFromSession( - toolArguments.session_id, - toolArguments.peer_ids, - ); - result = "Peers removed from session successfully"; - break; - } - - case "get_session_peers": { - const validation = validateArguments( - toolArguments, - ["session_id"], - requestId, - ); - if (validation) return validation; - - result = await honcho.getSessionPeers(toolArguments.session_id); - break; - } - - case "add_messages_to_session": { - const validation = validateArguments( - toolArguments, - ["session_id", "messages"], - requestId, - ); - if (validation) return validation; - - await honcho.addMessagesToSession( - toolArguments.session_id, - toolArguments.messages, - ); - result = "Messages added to session successfully"; - break; - } - - case "get_session_messages": { - const validation = validateArguments( - toolArguments, - ["session_id"], - requestId, - ); - if (validation) return validation; - - result = await honcho.getSessionMessages( - toolArguments.session_id, - toolArguments.filters, - ); - break; - } - - case "get_session_context": { - const validation = validateArguments( - toolArguments, - ["session_id"], - requestId, - ); - if (validation) return validation; - - result = await honcho.getSessionContext( - toolArguments.session_id, - toolArguments.summary, - toolArguments.tokens, - ); - break; - } - - case "search_session_messages": { - const validation = validateArguments( - toolArguments, - ["session_id", "query"], - requestId, - ); - if (validation) return validation; - - result = await honcho.searchSessionMessages( - toolArguments.session_id, - toolArguments.query, - ); - break; - } - - case "get_working_representation": { - const validation = validateArguments( - toolArguments, - ["session_id", "peer_id"], - requestId, - ); - if (validation) return validation; - - result = await honcho.getWorkingRepresentation( - toolArguments.session_id, - toolArguments.peer_id, - toolArguments.target_peer_id, - ); - break; - } - - case "list_sessions": - result = await honcho.listSessions(); - break; - - default: - return createErrorResponse( - requestId, - -32601, - `Method not found: ${toolName}`, - ); - } - - const responseData = - typeof result === "string" ? result : JSON.stringify(result); - return new Response( - JSON.stringify( - createJsonRpcResponse(requestId, { - content: [ - { - type: "text", - text: responseData, - }, - ], - }), - ), - { - status: 200, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", - }, - }, - ); -} - -/** - * Main Cloudflare Worker export - */ -export default { - async fetch(request: Request): Promise { - // Handle CORS preflight requests - if (request.method === "OPTIONS") { - return new Response(null, { - status: 200, - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": - "Content-Type, Authorization, X-Honcho-User-Name, X-Honcho-Base-URL, X-Honcho-Workspace-ID, X-Honcho-Assistant-Name", - }, - }); - } - - // Only accept POST requests for JSON-RPC - if (request.method !== "POST") { - return createErrorResponse(null, -32600, "Invalid Request"); - } - - let requestData: JsonRpcRequest; - - try { - requestData = (await request.json()) as JsonRpcRequest; - } catch (error) { - return createErrorResponse(null, -32700, "Parse error"); - } - - // Validate JSON-RPC format - if (requestData.jsonrpc !== "2.0") { - return createErrorResponse( - requestData.id ?? null, - -32600, - "Invalid Request", - ); - } - - if (!requestData.method) { - return createErrorResponse( - requestData.id ?? null, - -32600, - "Invalid Request", - ); - } - - // Parse configuration - const config = parseConfig(request); - if (!config && requestData.method !== "initialize") { - return createErrorResponse( - requestData.id ?? null, - -32602, - "Missing or invalid API key", - ); - } - - const honcho = config ? new HonchoWorker(config) : null; - - try { - switch (requestData.method) { - case "initialize": - return new Response( - JSON.stringify( - createJsonRpcResponse(requestData.id ?? null, { - protocolVersion: "2024-11-05", - capabilities: { - tools: {}, - }, - serverInfo: { - name: "Honcho MCP Server", - version: "3.0.0", - }, - }), - ), - { - status: 200, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", - }, - }, - ); - - case "notifications/initialized": - // MCP initialized notification - no response needed - return new Response(null, { - status: 204, - headers: { - "Access-Control-Allow-Origin": "*", - }, - }); - - case "tools/list": - return new Response( - JSON.stringify( - createJsonRpcResponse(requestData.id ?? null, { - tools: tools, - }), - ), - { - status: 200, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", - }, - }, - ); - - case "tools/call": - if (!honcho) { - return createErrorResponse( - requestData.id ?? null, - -32602, - "Missing API key", - ); - } - - const toolName = requestData.params?.name; - const toolArguments = requestData.params?.arguments || {}; - - return await executeToolCall( - honcho, - toolName, - toolArguments, - requestData.id ?? null, - ); - - default: - return createErrorResponse( - requestData.id ?? null, - -32601, - `Method not found: ${requestData.method}`, - ); - } - } catch (error) { - console.error("Worker error:", error); - const errorMessage = - error instanceof Error ? error.message : "Internal server error"; - return createErrorResponse(requestData.id ?? null, -32603, errorMessage); - } - }, -}; diff --git a/mcp/wrangler.toml b/mcp/wrangler.toml index 219a261d..c83d240f 100644 --- a/mcp/wrangler.toml +++ b/mcp/wrangler.toml @@ -1,5 +1,5 @@ name = "honcho-mcp" -main = "worker.ts" +main = "src/index.ts" compatibility_date = "2024-12-09" compatibility_flags = ["nodejs_compat"] diff --git a/pyproject.toml b/pyproject.toml index 2afe05b0..0a2a49ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho" -version = "3.0.3" +version = "3.0.6" description = "Honcho Server" authors = [ {name = "Plastic Labs", email = "hello@plasticlabs.ai"}, @@ -9,7 +9,6 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "fastapi[standard]>=0.131.0", - "groq>=0.31.0", "python-dotenv>=1.0.0", "sqlalchemy>=2.0.30", "fastapi-pagination>=0.14.2", @@ -63,10 +62,12 @@ dev = [ [tool.uv.workspace] members = [ "sdks/python", + "honcho-cli", ] [tool.uv.sources] honcho-ai = { workspace = true } +honcho-cli = { workspace = true } [tool.ruff.lint] # from https://docs.astral.sh/ruff/linter/#rule-selection example @@ -95,6 +96,12 @@ asyncio_default_fixture_loop_scope = "session" addopts = "--strict-markers -n auto --ignore=tests/alembic" testpaths = ["tests"] pythonpath = ["src"] +markers = [ + "live_llm: calls live LLM provider APIs and requires --live-llm", + "requires_anthropic: requires LLM_ANTHROPIC_API_KEY", + "requires_openai: requires LLM_OPENAI_API_KEY", + "requires_gemini: requires LLM_GEMINI_API_KEY", +] filterwarnings = [ "ignore:Call to deprecated close\\. \\(Use aclose\\(\\) instead\\).*:DeprecationWarning", "ignore:websockets\\.legacy is deprecated; see .* for upgrade instructions:DeprecationWarning", diff --git a/sdks/python/CHANGELOG.md b/sdks/python/CHANGELOG.md index f2317e49..36576f05 100644 --- a/sdks/python/CHANGELOG.md +++ b/sdks/python/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2.1.1] - 2026-04-01 + +### Fixed + +- Broadened HTTP retry logic to cover `httpx.NetworkError` and `httpx.RemoteProtocolError` in addition to `httpx.TimeoutException` and `httpx.ConnectError`, improving resilience against transient network failures + +## [2.1.0] - 2026-03-25 + +### Added + +- `created_at` property on `Peer` and `Session` objects +- `is_active` property on `Session` objects +- `get_message(message_id)` method on `Session` (sync and async) to fetch a single message by ID +- `page`, `size`, and `reverse` pagination parameters on all list methods: `peers()`, `sessions()`, `messages()`, and `conclusions.list()` + +### Changed + +- **Breaking**: `peer()` and `session()` now always make a get-or-create API call. Previously, calling without metadata/configuration returned a lazy object with no API call. All Peer/Session objects now have `created_at` populated immediately. +- Response configuration models (`WorkspaceConfigurationResponse`, `SessionConfigurationResponse`) now tolerate unknown fields from newer servers for forward compatibility + +### Fixed + +- Sync and async `Session.get_metadata()`, `get_configuration()`, and `refresh()` now refresh cached `created_at` and `is_active` values along with metadata and configuration. +- `honcho.__version__` now derives from package metadata, with a `pyproject.toml` fallback in source checkouts, so it stays aligned with SDK releases. + +## [2.0.2] - 2026-03-10 + +### Changed + +- All input models now reject unknown fields via `extra="forbid"` Pydantic validation. Previously, misspelled or extraneous fields were silently ignored. Now a `ValidationError` is raised with the unrecognized field name. + ## [2.0.1] - 2026-02-09 ### Added diff --git a/sdks/python/README.md b/sdks/python/README.md index 6d391fdc..fa83ba0e 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -29,9 +29,6 @@ session.add_messages([ bob.message("Hi Alice, how are you?") ]) -# Wait for deriver to process all messages (only necessary if very recent messages are critical to query) -client.poll_deriver_status() - # Query conversation context response = alice.chat("What did Bob say to the user?") print(response) @@ -73,7 +70,7 @@ session.add_messages([ ]) # Get conversation context -context = session.get_context() +context = session.context() ``` ### Messages and Context @@ -82,7 +79,7 @@ Retrieve and use conversation history: ```python # Get messages from a session -messages = session.get_messages() +messages = session.messages() # Convert to OpenAI format for further prompting openai_messages = context.to_openai(assistant="assistant") @@ -93,11 +90,24 @@ anthropic_messages = context.to_anthropic(assistant="assistant") ### Async Support +The SDK provides async access via the `.aio` accessor on any instance: + ```python -from honcho import AsyncHoncho +from honcho import Honcho async def main(): - client = AsyncHoncho(api_key="your-api-key") + client = Honcho(api_key="your-api-key") + + # Async peer and session creation + peer = await client.aio.peer("user-123") + session = await client.aio.session("conversation-1") + + # Async chat + response = await peer.aio.chat("What does this user prefer?") + + # Async iteration + async for p in client.aio.peers(): + print(p.id) ``` ### Metadata Management @@ -119,7 +129,7 @@ response = alice.chat("Does Bob remember our discussion about the budget?", targ # Session-specific perspective response = alice.chat("What does Bob think about this project?", target=bob, - session_id=session.id) + session=session) ``` ## Configuration @@ -137,21 +147,12 @@ export HONCHO_WORKSPACE_ID="your-workspace" # Optional ```python client = Honcho( api_key="your-api-key", - environment="production", # or "local", "demo" + environment="production", # or "local" workspace_id="custom-workspace", base_url="https://api.honcho.dev" ) ``` -## Examples - -Check out the `examples/` directory for complete usage examples: - -- `example.py` - Comprehensive feature demonstration -- `chat.py` - Basic multi-peer chat -- `async_example.py` - Async/await usage -- `search.py` - Context search and retrieval - ## License Apache 2.0 - see [LICENSE](../../LICENSE) for details. @@ -159,5 +160,5 @@ Apache 2.0 - see [LICENSE](../../LICENSE) for details. ## Support - [Documentation](https://docs.honcho.dev) -- [GitHub Issues](https://github.com/plastic-labs/honcho-sdks/issues) +- [GitHub Issues](https://github.com/plastic-labs/honcho/issues) - [Discord Community](https://discord.gg/honcho) diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index dd389d96..07b532f8 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho-ai" -version = "2.0.1" +version = "2.1.1" description = "Official DX Optimized Python SDK for Honcho" dynamic = ["readme"] license = "Apache-2.0" @@ -29,6 +29,9 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] +[project.optional-dependencies] +cli = ["honcho-cli>=0.1.0"] + [project.urls] Homepage = "https://github.com/plastic-labs/honcho" Repository = "https://github.com/plastic-labs/honcho" diff --git a/sdks/python/src/honcho/__init__.py b/sdks/python/src/honcho/__init__.py index 35f42dce..239f35ef 100644 --- a/sdks/python/src/honcho/__init__.py +++ b/sdks/python/src/honcho/__init__.py @@ -37,6 +37,10 @@ Usage: print(p.id) """ +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +import re + from .aio import ConclusionScopeAio, HonchoAio, PeerAio, SessionAio from .api_types import MessageCreateParams from .base import PeerBase, SessionBase @@ -66,7 +70,23 @@ from .types import ( DialecticStreamResponse, ) -__version__ = "2.0.1" + +def _detect_version() -> str: + try: + return version("honcho-ai") + except PackageNotFoundError: + try: + pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml" + pyproject_text = pyproject_path.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', pyproject_text, re.MULTILINE) + if match: + return match.group(1) + except OSError: + pass + return "0.0.0" + + +__version__ = _detect_version() __author__ = "Plastic Labs" __email__ = "hello@plasticlabs.ai" diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py index aa851889..0edcc60f 100644 --- a/sdks/python/src/honcho/aio.py +++ b/sdks/python/src/honcho/aio.py @@ -167,42 +167,52 @@ class HonchoAio(AsyncMetadataConfigMixin): configuration: Optional configuration to set for this peer. Returns: - A Peer object + A Peer object with cached values from the API response. """ - if configuration is not None or metadata is not None: - await self._honcho._ensure_workspace_async() - body: dict[str, Any] = {"id": id} - if metadata is not None: - body["metadata"] = metadata - if configuration is not None: - body["configuration"] = configuration.model_dump(exclude_none=True) + await self._honcho._ensure_workspace_async() + body: dict[str, Any] = {"id": id} + if metadata is not None: + body["metadata"] = metadata + if configuration is not None: + body["configuration"] = configuration.model_dump(exclude_none=True) - data = await self._honcho._async_http_client.post( - routes.peers(self._honcho.workspace_id), body=body - ) - peer_data = PeerResponse.model_validate(data) - return Peer( - id, - self._honcho, - metadata=peer_data.metadata, - configuration=peer_data.configuration, - ) - - return Peer(id, self._honcho, metadata=metadata, configuration=configuration) + data = await self._honcho._async_http_client.post( + routes.peers(self._honcho.workspace_id), body=body + ) + peer_data = PeerResponse.model_validate(data) + return Peer( + id, + self._honcho, + metadata=peer_data.metadata, + configuration=peer_data.configuration, + created_at=peer_data.created_at, + ) async def peers( - self, filters: dict[str, object] | None = None + self, + filters: dict[str, object] | None = None, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> AsyncPage[PeerResponse, Peer]: """ Get all peers in the current workspace asynchronously. - Returns: - An AsyncPage of Peer objects + Args: + filters: Optional filter criteria. + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, reverses the default ordering. Default: False. """ await self._honcho._ensure_workspace_async() + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = await self._honcho._async_http_client.post( routes.peers_list(self._honcho.workspace_id), body={"filters": filters} if filters else None, + query=query, ) def transform(peer: PeerResponse) -> Peer: @@ -211,13 +221,17 @@ class HonchoAio(AsyncMetadataConfigMixin): self._honcho, metadata=peer.metadata, configuration=peer.configuration, + created_at=peer.created_at, ) - async def fetch_next(page: int) -> AsyncPage[PeerResponse, Peer]: + async def fetch_next(next_page: int) -> AsyncPage[PeerResponse, Peer]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = await self._honcho._async_http_client.post( routes.peers_list(self._honcho.workspace_id), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return AsyncPage(next_data, PeerResponse, transform, fetch_next) @@ -239,42 +253,55 @@ class HonchoAio(AsyncMetadataConfigMixin): configuration: Optional configuration to set for this session. Returns: - A Session object + A Session object with cached values from the API response. """ - if configuration is not None or metadata is not None: - await self._honcho._ensure_workspace_async() - body: dict[str, Any] = {"id": id} - if metadata is not None: - body["metadata"] = metadata - if configuration is not None: - body["configuration"] = configuration.model_dump(exclude_none=True) + await self._honcho._ensure_workspace_async() + body: dict[str, Any] = {"id": id} + if metadata is not None: + body["metadata"] = metadata + if configuration is not None: + body["configuration"] = configuration.model_dump(exclude_none=True) - data = await self._honcho._async_http_client.post( - routes.sessions(self._honcho.workspace_id), body=body - ) - session_data = SessionResponse.model_validate(data) - return Session( - id, - self._honcho, - metadata=session_data.metadata, - configuration=session_data.configuration, - ) - - return Session(id, self._honcho, metadata=metadata, configuration=configuration) + data = await self._honcho._async_http_client.post( + routes.sessions(self._honcho.workspace_id), body=body + ) + session_data = SessionResponse.model_validate(data) + return Session( + id, + self._honcho, + metadata=session_data.metadata, + configuration=SessionConfiguration.model_validate( + session_data.configuration.model_dump() + ), + created_at=session_data.created_at, + is_active=session_data.is_active, + ) async def sessions( - self, filters: dict[str, object] | None = None + self, + filters: dict[str, object] | None = None, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> AsyncPage[SessionResponse, Session]: """ Get all sessions in the current workspace asynchronously. - Returns: - An AsyncPage of Session objects + Args: + filters: Optional filter criteria. + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, reverses the default ordering. Default: False. """ await self._honcho._ensure_workspace_async() + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = await self._honcho._async_http_client.post( routes.sessions_list(self._honcho.workspace_id), body={"filters": filters} if filters else None, + query=query, ) def transform(session: SessionResponse) -> Session: @@ -283,13 +310,18 @@ class HonchoAio(AsyncMetadataConfigMixin): self._honcho, metadata=session.metadata, configuration=session.configuration, + created_at=session.created_at, + is_active=session.is_active, ) - async def fetch_next(page: int) -> AsyncPage[SessionResponse, Session]: + async def fetch_next(next_page: int) -> AsyncPage[SessionResponse, Session]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = await self._honcho._async_http_client.post( routes.sessions_list(self._honcho.workspace_id), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return AsyncPage(next_data, SessionResponse, transform, fetch_next) @@ -457,6 +489,21 @@ class PeerAio(AsyncMetadataConfigMixin): return {} return self._peer._configuration.model_dump(exclude_none=True) + def _apply_peer_response(self, peer: PeerResponse) -> None: + self._peer._metadata = peer.metadata or {} + self._peer._configuration = peer.configuration + self._peer._created_at = peer.created_at + + async def get_metadata(self) -> dict[str, object]: + """Get metadata from the server asynchronously.""" + await self._peer._honcho._ensure_workspace_async() + data = await self._get_async_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + peer = PeerResponse.model_validate(data) + self._apply_peer_response(peer) + return self._peer._metadata or {} + async def get_configuration(self) -> PeerConfig: # pyright: ignore[reportIncompatibleMethodOverride] """Get configuration from the server asynchronously.""" await self._peer._honcho._ensure_workspace_async() @@ -464,9 +511,17 @@ class PeerAio(AsyncMetadataConfigMixin): self._get_fetch_route(), body=self._get_fetch_body() ) peer = PeerResponse.model_validate(data) - self._peer._metadata = peer.metadata or {} - self._peer._configuration = peer.configuration - return self._peer._configuration + self._apply_peer_response(peer) + return self._peer._configuration or PeerConfig() + + async def refresh(self) -> None: + """Refresh cached metadata, configuration, and created_at asynchronously.""" + await self._peer._honcho._ensure_workspace_async() + data = await self._get_async_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + peer = PeerResponse.model_validate(data) + self._apply_peer_response(peer) async def set_configuration(self, configuration: PeerConfig) -> None: # pyright: ignore[reportIncompatibleMethodOverride] """Set configuration on the server asynchronously.""" @@ -544,23 +599,44 @@ class PeerAio(AsyncMetadataConfigMixin): return AsyncDialecticStreamResponse(stream_response()) async def sessions( - self, filters: dict[str, object] | None = None + self, + filters: dict[str, object] | None = None, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> AsyncPage[SessionResponse, Session]: """Get all sessions this peer is a member of asynchronously.""" await self._peer._honcho._ensure_workspace_async() + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = await self._peer._honcho._async_http_client.post( routes.peer_sessions_list(self._peer.workspace_id, self._peer.id), body={"filters": filters} if filters else None, + query=query, ) def transform(session: SessionResponse) -> Session: - return Session(session.id, self._peer._honcho) + return Session( + session.id, + self._peer._honcho, + metadata=session.metadata, + configuration=SessionConfiguration.model_validate( + session.configuration.model_dump() + ), + created_at=session.created_at, + is_active=session.is_active, + ) - async def fetch_next(page: int) -> AsyncPage[SessionResponse, Session]: + async def fetch_next(next_page: int) -> AsyncPage[SessionResponse, Session]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = await self._peer._honcho._async_http_client.post( routes.peer_sessions_list(self._peer.workspace_id, self._peer.id), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return AsyncPage(next_data, SessionResponse, transform, fetch_next) @@ -777,6 +853,24 @@ class SessionAio(AsyncMetadataConfigMixin): return {} return self._session._configuration.model_dump(exclude_none=True) + def _apply_session_response(self, session: SessionResponse) -> None: + self._session._metadata = session.metadata or {} + self._session._configuration = SessionConfiguration.model_validate( + session.configuration.model_dump() + ) + self._session._created_at = session.created_at + self._session._is_active = session.is_active + + async def get_metadata(self) -> dict[str, object]: + """Get metadata from the server asynchronously.""" + await self._session._honcho._ensure_workspace_async() + data = await self._get_async_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + session = SessionResponse.model_validate(data) + self._apply_session_response(session) + return self._get_metadata() + async def get_configuration(self) -> SessionConfiguration: # pyright: ignore[reportIncompatibleMethodOverride] """Get configuration from the server asynchronously.""" await self._session._honcho._ensure_workspace_async() @@ -784,9 +878,17 @@ class SessionAio(AsyncMetadataConfigMixin): self._get_fetch_route(), body=self._get_fetch_body() ) session = SessionResponse.model_validate(data) - self._session._metadata = session.metadata or {} - self._session._configuration = session.configuration - return self._session._configuration + self._apply_session_response(session) + return self._session._configuration or SessionConfiguration() + + async def refresh(self) -> None: + """Refresh cached metadata, configuration, and session status asynchronously.""" + await self._session._honcho._ensure_workspace_async() + data = await self._get_async_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + session = SessionResponse.model_validate(data) + self._apply_session_response(session) async def set_configuration(self, configuration: SessionConfiguration) -> None: # pyright: ignore[reportIncompatibleMethodOverride] """Set configuration on the server asynchronously.""" @@ -921,22 +1023,32 @@ class SessionAio(AsyncMetadataConfigMixin): self, *, filters: dict[str, object] | None = None, + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> AsyncPage[MessageResponse, Message]: """Get messages from this session asynchronously.""" await self._session._honcho._ensure_workspace_async() + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = await self._session._honcho._async_http_client.post( routes.messages_list(self._session.workspace_id, self._session.id), body={"filters": filters} if filters else None, + query=query, ) def transform(response: MessageResponse) -> Message: return Message.from_api_response(response) - async def fetch_next(page: int) -> AsyncPage[MessageResponse, Message]: + async def fetch_next(next_page: int) -> AsyncPage[MessageResponse, Message]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = await self._session._honcho._async_http_client.post( routes.messages_list(self._session.workspace_id, self._session.id), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return AsyncPage(next_data, MessageResponse, transform, fetch_next) @@ -966,6 +1078,8 @@ class SessionAio(AsyncMetadataConfigMixin): self._session._honcho, metadata=cloned.metadata, configuration=cloned.configuration, + created_at=cloned.created_at, + is_active=cloned.is_active, ) @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) @@ -1248,6 +1362,21 @@ class SessionAio(AsyncMetadataConfigMixin): ) return QueueStatusResponse.model_validate(data) + async def get_message(self, message_id: str) -> Message: + """Get a single message by ID from this session asynchronously. + + Args: + message_id: The ID of the message to retrieve + + Returns: + The Message object + """ + await self._session._honcho._ensure_workspace_async() + data = await self._session._honcho._async_http_client.get( + routes.message(self._session.workspace_id, self._session.id, message_id) + ) + return Message.from_api_response(MessageResponse.model_validate(data)) + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) async def update_message( self, @@ -1288,6 +1417,8 @@ class ConclusionScopeAio: page: int = 1, size: int = 50, session: str | SessionBase | None = None, + *, + reverse: bool = False, ) -> AsyncPage[ConclusionResponse, Conclusion]: """List conclusions in this scope asynchronously.""" await self._scope._honcho._ensure_workspace_async() @@ -1299,22 +1430,28 @@ class ConclusionScopeAio: if resolved_session_id: filters["session_id"] = resolved_session_id + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = await self._scope._honcho._async_http_client.post( routes.conclusions_list(self._scope.workspace_id), body={"filters": filters}, - query={"page": page, "size": size}, + query=query, ) def transform(response: ConclusionResponse) -> Conclusion: return Conclusion.from_api_response(response) async def fetch_next( - page: int, + next_page: int, ) -> AsyncPage[ConclusionResponse, Conclusion]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = await self._scope._honcho._async_http_client.post( routes.conclusions_list(self._scope.workspace_id), body={"filters": filters}, - query={"page": page, "size": size}, + query=next_query, ) return AsyncPage(next_data, ConclusionResponse, transform, fetch_next) diff --git a/sdks/python/src/honcho/api_types.py b/sdks/python/src/honcho/api_types.py index 47d8e4da..efc39713 100644 --- a/sdks/python/src/honcho/api_types.py +++ b/sdks/python/src/honcho/api_types.py @@ -18,6 +18,8 @@ from pydantic import BaseModel, ConfigDict, Field class ReasoningConfiguration(BaseModel): """Configuration for reasoning functionality.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + enabled: bool | None = None custom_instructions: str | None = None @@ -25,6 +27,8 @@ class ReasoningConfiguration(BaseModel): class PeerCardConfiguration(BaseModel): """Configuration for peer card functionality.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + use: bool | None = None create: bool | None = None @@ -32,6 +36,8 @@ class PeerCardConfiguration(BaseModel): class SummaryConfiguration(BaseModel): """Configuration for summary functionality.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + enabled: bool | None = None messages_per_short_summary: int | None = None messages_per_long_summary: int | None = None @@ -40,13 +46,15 @@ class SummaryConfiguration(BaseModel): class DreamConfiguration(BaseModel): """Configuration for dream functionality.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + enabled: bool | None = None class WorkspaceConfiguration(BaseModel): """Workspace-level configuration options.""" - model_config = ConfigDict(extra="allow") # pyright: ignore[reportUnannotatedClassAttribute] + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] reasoning: ReasoningConfiguration | None = None peer_card: PeerCardConfiguration | None = None @@ -60,9 +68,23 @@ class SessionConfiguration(WorkspaceConfiguration): pass +class WorkspaceConfigurationResponse(WorkspaceConfiguration): + """Workspace configuration for response parsing — tolerates unknown fields from newer servers.""" + + model_config = ConfigDict(extra="ignore") # pyright: ignore[reportUnannotatedClassAttribute] + + +class SessionConfigurationResponse(SessionConfiguration): + """Session configuration for response parsing — tolerates unknown fields from newer servers.""" + + model_config = ConfigDict(extra="ignore") # pyright: ignore[reportUnannotatedClassAttribute] + + class MessageConfiguration(BaseModel): """Message-level configuration options.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + reasoning: ReasoningConfiguration | None = None @@ -74,6 +96,8 @@ class MessageConfiguration(BaseModel): class PeerConfig(BaseModel): """Configuration for peer-level settings.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + observe_me: bool | None = None """Whether Honcho will use reasoning to form a representation of this peer.""" @@ -81,6 +105,8 @@ class PeerConfig(BaseModel): class SessionPeerConfig(BaseModel): """Configuration for a peer within a session.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + observe_others: bool | None = Field( None, description="Whether this peer should form a session-level theory-of-mind representation of other peers in the session", @@ -103,8 +129,8 @@ class WorkspaceResponse(BaseModel): id: str metadata: dict[str, Any] = Field(default_factory=dict) - configuration: WorkspaceConfiguration = Field( - default_factory=WorkspaceConfiguration + configuration: WorkspaceConfigurationResponse = Field( + default_factory=WorkspaceConfigurationResponse ) created_at: datetime.datetime @@ -112,6 +138,8 @@ class WorkspaceResponse(BaseModel): class WorkspaceCreateParams(BaseModel): """Parameters for creating a workspace.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + id: str = Field(min_length=1, max_length=100) metadata: dict[str, Any] = Field(default_factory=dict) configuration: WorkspaceConfiguration = Field( @@ -122,6 +150,8 @@ class WorkspaceCreateParams(BaseModel): class WorkspaceUpdateParams(BaseModel): """Parameters for updating a workspace.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + metadata: dict[str, Any] | None = None configuration: WorkspaceConfiguration | None = None @@ -129,6 +159,8 @@ class WorkspaceUpdateParams(BaseModel): class WorkspaceListParams(BaseModel): """Parameters for listing workspaces.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + filters: dict[str, Any] | None = None @@ -152,6 +184,8 @@ class PeerResponse(BaseModel): class PeerCreateParams(BaseModel): """Parameters for creating a peer.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + id: str = Field(min_length=1, max_length=100) metadata: dict[str, Any] | None = None configuration: PeerConfig | None = None @@ -160,6 +194,8 @@ class PeerCreateParams(BaseModel): class PeerUpdateParams(BaseModel): """Parameters for updating a peer.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + metadata: dict[str, Any] | None = None configuration: PeerConfig | None = None @@ -167,12 +203,16 @@ class PeerUpdateParams(BaseModel): class PeerListParams(BaseModel): """Parameters for listing peers.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + filters: dict[str, Any] | None = None class PeerRepresentationParams(BaseModel): """Parameters for getting peer representation.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + session_id: str | None = None target: str | None = None search_query: str | None = None @@ -217,13 +257,17 @@ class SessionResponse(BaseModel): is_active: bool workspace_id: str metadata: dict[str, Any] = Field(default_factory=dict) - configuration: SessionConfiguration = Field(default_factory=SessionConfiguration) + configuration: SessionConfigurationResponse = Field( + default_factory=SessionConfigurationResponse + ) created_at: datetime.datetime class SessionCreateParams(BaseModel): """Parameters for creating a session.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + id: str = Field(min_length=1, max_length=100) metadata: dict[str, Any] | None = None peers: dict[str, SessionPeerConfig] | None = None @@ -233,6 +277,8 @@ class SessionCreateParams(BaseModel): class SessionUpdateParams(BaseModel): """Parameters for updating a session.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + metadata: dict[str, Any] | None = None configuration: SessionConfiguration | None = None @@ -240,6 +286,8 @@ class SessionUpdateParams(BaseModel): class SessionListParams(BaseModel): """Parameters for listing sessions.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + filters: dict[str, Any] | None = None @@ -308,6 +356,8 @@ class MessageResponse(BaseModel): class MessageCreateParams(BaseModel): """Parameters for creating a message.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + content: str peer_id: str metadata: dict[str, Any] | None = None @@ -318,24 +368,32 @@ class MessageCreateParams(BaseModel): class MessageBatchCreateParams(BaseModel): """Parameters for batch message creation.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + messages: list[MessageCreateParams] = Field(min_length=1, max_length=100) class MessageUpdateParams(BaseModel): """Parameters for updating a message.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + metadata: dict[str, Any] | None = None class MessageListParams(BaseModel): """Parameters for listing messages.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + filters: dict[str, Any] | None = None class MessageSearchParams(BaseModel): """Parameters for searching messages.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + query: str filters: dict[str, Any] | None = None limit: int = Field(default=10, ge=1, le=100) @@ -362,6 +420,8 @@ class ConclusionResponse(BaseModel): class ConclusionCreateParams(BaseModel): """Parameters for creating a conclusion.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + content: str = Field(min_length=1, max_length=65535) observer_id: str observed_id: str @@ -371,18 +431,24 @@ class ConclusionCreateParams(BaseModel): class ConclusionBatchCreateParams(BaseModel): """Parameters for batch conclusion creation.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + conclusions: list[ConclusionCreateParams] = Field(min_length=1, max_length=100) class ConclusionListParams(BaseModel): """Parameters for listing conclusions.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + filters: dict[str, Any] | None = None class ConclusionQueryParams(BaseModel): """Parameters for querying conclusions.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + query: str top_k: int = Field(default=10, ge=1, le=100) distance: float | None = Field(default=None, ge=0.0, le=1.0) @@ -425,6 +491,8 @@ ReasoningLevel = Literal["minimal", "low", "medium", "high", "max"] class DialecticParams(BaseModel): """Parameters for dialectic chat.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + session_id: str | None = None target: str | None = None query: str = Field(min_length=1, max_length=10000) @@ -474,6 +542,8 @@ class PageResponse(BaseModel): class MessageUploadParams(BaseModel): """Parameters for file upload message creation.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + peer_id: str metadata: dict[str, Any] | None = None configuration: MessageConfiguration | None = None diff --git a/sdks/python/src/honcho/client.py b/sdks/python/src/honcho/client.py index a40f12ee..c34cc5f5 100644 --- a/sdks/python/src/honcho/client.py +++ b/sdks/python/src/honcho/client.py @@ -55,7 +55,7 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul recently fetched. Call get_configuration() for fresh data. """ - model_config = ConfigDict(extra="allow") # pyright: ignore + model_config = ConfigDict(extra="forbid") # pyright: ignore workspace_id: str = Field( ..., @@ -305,44 +305,62 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul """ Get or create a peer with the given ID. - Creates a Peer object that can be used to interact with the specified peer. - This method does not make an API call unless `configuration` or `metadata` is - provided. + Makes an API call to get or create the peer, then returns a Peer object + with cached values from the response. Args: - id: Unique identifier for the peer within the workspace. Should be a - stable identifier that can be used consistently across sessions. + id: Unique identifier for the peer within the workspace. metadata: Optional metadata dictionary to associate with this peer. - If set, will get/create peer immediately with metadata. configuration: Optional configuration to set for this peer. - If set, will get/create peer immediately with flags. Returns: - A Peer object that can be used to send messages, join sessions, and - query the peer's knowledge representations - - Raises: - ValidationError: If the peer ID is empty or invalid + A Peer object with cached metadata, configuration, and created_at. """ - return Peer(id, self, configuration=configuration, metadata=metadata) + self._ensure_workspace() + body: dict[str, Any] = {"id": id} + if metadata is not None: + body["metadata"] = metadata + if configuration is not None: + body["configuration"] = configuration.model_dump(exclude_none=True) + + data = self._http.post(routes.peers(self.workspace_id), body=body) + peer_data = PeerResponse.model_validate(data) + return Peer( + id, + self, + metadata=peer_data.metadata, + configuration=peer_data.configuration, + created_at=peer_data.created_at, + ) def peers( - self, filters: dict[str, object] | None = None + self, + filters: dict[str, object] | None = None, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> SyncPage[PeerResponse, Peer]: """ Get all peers in the current workspace. - Makes an API call to retrieve all peers that have been created or used - within the current workspace. Returns a paginated result that transforms - inner client Peer objects to SDK Peer objects as they are consumed. + Args: + filters: Optional filter criteria. + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, reverses the default ordering. Default: False. Returns: A SyncPage of Peer objects representing all peers in the workspace """ self._ensure_workspace() + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = self._http.post( routes.peers_list(self.workspace_id), body={"filters": filters} if filters else None, + query=query, ) def transform(peer: PeerResponse) -> Peer: @@ -351,13 +369,17 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul self, metadata=peer.metadata, configuration=peer.configuration, + created_at=peer.created_at, ) - def fetch_next(page: int) -> SyncPage[PeerResponse, Peer]: + def fetch_next(next_page: int) -> SyncPage[PeerResponse, Peer]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = self._http.post( routes.peers_list(self.workspace_id), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return SyncPage(next_data, PeerResponse, transform, fetch_next) @@ -382,45 +404,65 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul """ Get or create a session with the given ID. - Creates a Session object that can be used to manage conversations between - multiple peers. This method does not make an API call unless `configuration` or - `metadata` is provided. + Makes an API call to get or create the session, then returns a Session object + with cached values from the response. Args: - id: Unique identifier for the session within the workspace. Should be a - stable identifier that can be used consistently to reference the - same conversation + id: Unique identifier for the session within the workspace. metadata: Optional metadata dictionary to associate with this session. - If set, will get/create session immediately with metadata. configuration: Optional configuration to set for this session. - If set, will get/create session immediately with flags. Returns: - A Session object that can be used to add peers, send messages, and - manage conversation context - - Raises: - ValidationError: If the session ID is empty or invalid + A Session object with cached metadata, configuration, created_at, and is_active. """ - return Session(id, self, configuration=configuration, metadata=metadata) + self._ensure_workspace() + body: dict[str, Any] = {"id": id} + if metadata is not None: + body["metadata"] = metadata + if configuration is not None: + body["configuration"] = configuration.model_dump(exclude_none=True) + + data = self._http.post(routes.sessions(self.workspace_id), body=body) + session_data = SessionResponse.model_validate(data) + return Session( + id, + self, + metadata=session_data.metadata, + configuration=SessionConfiguration.model_validate( + session_data.configuration.model_dump() + ), + created_at=session_data.created_at, + is_active=session_data.is_active, + ) def sessions( - self, filters: dict[str, object] | None = None + self, + filters: dict[str, object] | None = None, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> SyncPage[SessionResponse, Session]: """ Get all sessions in the current workspace. - Makes an API call to retrieve all sessions that have been created within - the current workspace. + Args: + filters: Optional filter criteria. + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, reverses the default ordering. Default: False. Returns: A SyncPage of Session objects representing all sessions in the workspace. - Returns an empty page if no sessions exist """ self._ensure_workspace() + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = self._http.post( routes.sessions_list(self.workspace_id), body={"filters": filters} if filters else None, + query=query, ) def transform(session: SessionResponse) -> Session: @@ -429,13 +471,18 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul self, metadata=session.metadata, configuration=session.configuration, + created_at=session.created_at, + is_active=session.is_active, ) - def fetch_next(page: int) -> SyncPage[SessionResponse, Session]: + def fetch_next(next_page: int) -> SyncPage[SessionResponse, Session]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = self._http.post( routes.sessions_list(self.workspace_id), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return SyncPage(next_data, SessionResponse, transform, fetch_next) diff --git a/sdks/python/src/honcho/conclusions.py b/sdks/python/src/honcho/conclusions.py index 56e8f4c6..e76b1900 100644 --- a/sdks/python/src/honcho/conclusions.py +++ b/sdks/python/src/honcho/conclusions.py @@ -168,6 +168,8 @@ class ConclusionScope: page: int = 1, size: int = 50, session: str | SessionBase | None = None, + *, + reverse: bool = False, ) -> SyncPage[ConclusionResponse, Conclusion]: """ List conclusions in this scope. @@ -176,6 +178,7 @@ class ConclusionScope: page: Page number (1-indexed) size: Number of results per page session: Optional session (ID string or Session object) to filter by + reverse: If True, reverses the default ordering. Default: False. Returns: Paginated response containing Conclusion objects @@ -189,22 +192,28 @@ class ConclusionScope: if resolved_session_id: filters["session_id"] = resolved_session_id + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = self._honcho._http.post( routes.conclusions_list(self.workspace_id), body={"filters": filters}, - query={"page": page, "size": size}, + query=query, ) def transform(response: ConclusionResponse) -> Conclusion: return Conclusion.from_api_response(response) def fetch_next( - page: int, + next_page: int, ) -> SyncPage[ConclusionResponse, Conclusion]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = self._honcho._http.post( routes.conclusions_list(self.workspace_id), body={"filters": filters}, - query={"page": page, "size": size}, + query=next_query, ) return SyncPage(next_data, ConclusionResponse, transform, fetch_next) diff --git a/sdks/python/src/honcho/http/async_client.py b/sdks/python/src/honcho/http/async_client.py index 0db853b8..14a1df8c 100644 --- a/sdks/python/src/honcho/http/async_client.py +++ b/sdks/python/src/honcho/http/async_client.py @@ -131,17 +131,15 @@ class AsyncHonchoHTTPClient: raise error - except httpx.TimeoutException as e: - error = TimeoutError(f"Request timed out after {request_timeout}s") - if attempt < self.max_retries: - last_error = error - await asyncio.sleep(self._get_retry_delay(attempt)) - attempt += 1 - continue - raise error from e - - except httpx.ConnectError as e: - error = ConnectionError(f"Connection failed: {e}") + except ( + httpx.TimeoutException, + httpx.NetworkError, + httpx.RemoteProtocolError, + ) as e: + if isinstance(e, httpx.TimeoutException): + error = TimeoutError(f"Request timed out after {request_timeout}s") + else: + error = ConnectionError(f"Connection error: {e}") if attempt < self.max_retries: last_error = error await asyncio.sleep(self._get_retry_delay(attempt)) diff --git a/sdks/python/src/honcho/http/client.py b/sdks/python/src/honcho/http/client.py index dd406953..f3cc2fc9 100644 --- a/sdks/python/src/honcho/http/client.py +++ b/sdks/python/src/honcho/http/client.py @@ -131,17 +131,15 @@ class HonchoHTTPClient: raise error - except httpx.TimeoutException as e: - error = TimeoutError(f"Request timed out after {request_timeout}s") - if attempt < self.max_retries: - last_error = error - time.sleep(self._get_retry_delay(attempt)) - attempt += 1 - continue - raise error from e - - except httpx.ConnectError as e: - error = ConnectionError(f"Connection failed: {e}") + except ( + httpx.TimeoutException, + httpx.NetworkError, + httpx.RemoteProtocolError, + ) as e: + if isinstance(e, httpx.TimeoutException): + error = TimeoutError(f"Request timed out after {request_timeout}s") + else: + error = ConnectionError(f"Connection error: {e}") if attempt < self.max_retries: last_error = error time.sleep(self._get_retry_delay(attempt)) diff --git a/sdks/python/src/honcho/peer.py b/sdks/python/src/honcho/peer.py index 5984737b..fc65a420 100644 --- a/sdks/python/src/honcho/peer.py +++ b/sdks/python/src/honcho/peer.py @@ -19,6 +19,7 @@ from .api_types import ( PeerContextResponse, PeerResponse, RepresentationResponse, + SessionConfiguration, SessionResponse, ) from .base import PeerBase, SessionBase @@ -57,6 +58,7 @@ class Peer(PeerBase, MetadataConfigMixin): _metadata: dict[str, object] | None = PrivateAttr(default=None) _configuration: PeerConfig | None = PrivateAttr(default=None) + _created_at: datetime.datetime | None = PrivateAttr(default=None) _honcho: "Honcho" = PrivateAttr() @property @@ -69,6 +71,11 @@ class Peer(PeerBase, MetadataConfigMixin): """Cached configuration for this peer. May be stale. Use get_configuration() for fresh data.""" return self._configuration + @property + def created_at(self) -> datetime.datetime | None: + """Timestamp when this peer was created. Only available if fetched from the API.""" + return self._created_at + # MetadataConfigMixin implementation def _get_http_client(self): self._honcho._ensure_workspace() @@ -90,6 +97,21 @@ class Peer(PeerBase, MetadataConfigMixin): # Return configuration as dict for mixin compatibility return peer.metadata or {}, peer.configuration.model_dump(exclude_none=True) + def _apply_peer_response(self, peer: PeerResponse) -> None: + self._metadata = peer.metadata or {} + self._configuration = peer.configuration + self._created_at = peer.created_at + + def get_metadata(self) -> dict[str, object]: + """Get metadata from the server and update the cache.""" + self._honcho._ensure_workspace() + data = self._get_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + peer = PeerResponse.model_validate(data) + self._apply_peer_response(peer) + return self._metadata or {} + def get_configuration(self) -> PeerConfig: # pyright: ignore[reportIncompatibleMethodOverride] """ Get configuration from the server and update the cache. @@ -102,9 +124,17 @@ class Peer(PeerBase, MetadataConfigMixin): self._get_fetch_route(), body=self._get_fetch_body() ) peer = PeerResponse.model_validate(data) - self._metadata = peer.metadata or {} - self._configuration = peer.configuration - return self._configuration + self._apply_peer_response(peer) + return self._configuration or PeerConfig() + + def refresh(self) -> None: + """Refresh cached metadata, configuration, and created_at from the server.""" + self._honcho._ensure_workspace() + data = self._get_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + peer = PeerResponse.model_validate(data) + self._apply_peer_response(peer) @validate_call def set_configuration( # pyright: ignore[reportIncompatibleMethodOverride] @@ -163,6 +193,10 @@ class Peer(PeerBase, MetadataConfigMixin): None, description="Optional configuration to set for this peer. If set, will get/create peer immediately with flags.", ), + created_at: datetime.datetime | None = Field( + None, + description="Timestamp when this peer was created.", + ), ) -> None: """ Initialize a new Peer. @@ -184,21 +218,8 @@ class Peer(PeerBase, MetadataConfigMixin): ) self._honcho = honcho self._metadata = metadata - self._configuration = configuration - - if configuration is not None or metadata is not None: - self._honcho._ensure_workspace() - body: dict[str, Any] = {"id": peer_id} - if metadata is not None: - body["metadata"] = metadata - if configuration is not None: - body["configuration"] = configuration.model_dump(exclude_none=True) - - data = honcho._http.post(routes.peers(honcho.workspace_id), body=body) - peer_data = PeerResponse.model_validate(data) - # Update cached values with API response - self._metadata = peer_data.metadata - self._configuration = peer_data.configuration # pyright: ignore[reportIncompatibleVariableOverride] + self._configuration = configuration # pyright: ignore[reportIncompatibleVariableOverride] + self._created_at = created_at @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def chat( @@ -309,35 +330,58 @@ class Peer(PeerBase, MetadataConfigMixin): return DialecticStreamResponse(stream_response()) def sessions( - self, filters: dict[str, object] | None = None + self, + filters: dict[str, object] | None = None, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> SyncPage[SessionResponse, "Session"]: """ Get all sessions this peer is a member of. - Makes an API call to retrieve all sessions where this peer is an active participant. - Sessions are created when peers are added to them or send messages to them. + Args: + filters: Optional filter criteria. + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, reverses the default ordering. Default: False. Returns: - A paginated list of Session objects this peer belongs to. Returns an empty - list if the peer is not a member of any sessions + A paginated list of Session objects this peer belongs to. """ self._honcho._ensure_workspace() # Import here to avoid circular import (session.py imports Peer) from .session import Session + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = self._honcho._http.post( routes.peer_sessions_list(self.workspace_id, self.id), body={"filters": filters} if filters else None, + query=query, ) def transform(session: SessionResponse) -> Session: - return Session(session.id, self._honcho) + return Session( + session.id, + self._honcho, + metadata=session.metadata, + configuration=SessionConfiguration.model_validate( + session.configuration.model_dump() + ), + created_at=session.created_at, + is_active=session.is_active, + ) - def fetch_next(page: int) -> SyncPage[SessionResponse, Session]: + def fetch_next(next_page: int) -> SyncPage[SessionResponse, Session]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = self._honcho._http.post( routes.peer_sessions_list(self.workspace_id, self.id), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return SyncPage(next_data, SessionResponse, transform, fetch_next) diff --git a/sdks/python/src/honcho/session.py b/sdks/python/src/honcho/session.py index 8414cc60..8a91d788 100644 --- a/sdks/python/src/honcho/session.py +++ b/sdks/python/src/honcho/session.py @@ -62,6 +62,8 @@ class Session(SessionBase, MetadataConfigMixin): _metadata: dict[str, object] | None = PrivateAttr(default=None) _configuration: SessionConfiguration | None = PrivateAttr(default=None) + _created_at: datetime | None = PrivateAttr(default=None) + _is_active: bool | None = PrivateAttr(default=None) _honcho: "Honcho" = PrivateAttr() @property @@ -74,6 +76,16 @@ class Session(SessionBase, MetadataConfigMixin): """Cached configuration for this session. May be stale. Use get_configuration() for fresh data.""" return self._configuration + @property + def created_at(self) -> datetime | None: + """Timestamp when this session was created. Only available if fetched from the API.""" + return self._created_at + + @property + def is_active(self) -> bool | None: + """Whether this session is active. Only available if fetched from the API.""" + return self._is_active + # MetadataConfigMixin implementation def _get_http_client(self): self._honcho._ensure_workspace() @@ -97,6 +109,30 @@ class Session(SessionBase, MetadataConfigMixin): exclude_none=True ) + def _apply_session_response(self, session: SessionResponse) -> None: + self._metadata = session.metadata or {} + self._configuration = SessionConfiguration.model_validate( + session.configuration.model_dump() + ) + self._created_at = session.created_at + self._is_active = session.is_active + + def get_metadata(self) -> dict[str, object]: + """ + Get metadata from the server and update the cache. + + Returns: + A dictionary containing the metadata. Returns an empty dictionary + if no metadata is set. + """ + self._honcho._ensure_workspace() + data = self._get_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + session = SessionResponse.model_validate(data) + self._apply_session_response(session) + return self._metadata or {} + def get_configuration(self) -> SessionConfiguration: # pyright: ignore[reportIncompatibleMethodOverride] """ Get configuration from the server and update the cache. @@ -109,9 +145,19 @@ class Session(SessionBase, MetadataConfigMixin): self._get_fetch_route(), body=self._get_fetch_body() ) session = SessionResponse.model_validate(data) - self._metadata = session.metadata or {} - self._configuration = session.configuration - return self._configuration + self._apply_session_response(session) + return self._configuration or SessionConfiguration() + + def refresh(self) -> None: + """ + Refresh cached metadata, configuration, and session status from the server. + """ + self._honcho._ensure_workspace() + data = self._get_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + session = SessionResponse.model_validate(data) + self._apply_session_response(session) @validate_call def set_configuration( # pyright: ignore[reportIncompatibleMethodOverride] @@ -171,6 +217,14 @@ class Session(SessionBase, MetadataConfigMixin): None, description="Optional configuration to set for this session. If set, will get/create session immediately with flags.", ), + created_at: datetime | None = Field( + None, + description="Timestamp when this session was created.", + ), + is_active: bool | None = Field( + None, + description="Whether this session is active.", + ), ) -> None: """ Initialize a new Session. @@ -192,21 +246,9 @@ class Session(SessionBase, MetadataConfigMixin): ) self._honcho = honcho self._metadata = metadata - self._configuration = configuration - - if configuration is not None or metadata is not None: - self._honcho._ensure_workspace() - body: dict[str, Any] = {"id": session_id} - if metadata is not None: - body["metadata"] = metadata - if configuration is not None: - body["configuration"] = configuration.model_dump(exclude_none=True) - - data = honcho._http.post(routes.sessions(honcho.workspace_id), body=body) - session_data = SessionResponse.model_validate(data) - # Update cached values with API response - self._metadata = session_data.metadata - self._configuration = session_data.configuration # pyright: ignore[reportIncompatibleVariableOverride] + self._configuration = configuration # pyright: ignore[reportIncompatibleVariableOverride] + self._created_at = created_at + self._is_active = is_active def add_peers( self, @@ -405,38 +447,43 @@ class Session(SessionBase, MetadataConfigMixin): filters: dict[str, object] | None = Field( None, description="Dictionary of filter criteria" ), + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> SyncPage[MessageResponse, Message]: """ Get messages from this session with optional filtering. - Makes an API call to retrieve messages from this session. Results can be - filtered based on various criteria. - Args: - filters: Dictionary of filter criteria. Supported filters include: - - peer_id: Filter messages by the peer who created them - - metadata: Filter messages by metadata key-value pairs - - timestamp_start: Filter messages after a specific timestamp - - timestamp_end: Filter messages before a specific timestamp + filters: Dictionary of filter criteria. + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, returns messages in reverse chronological order. Default: False. Returns: - A list of Message objects matching the specified criteria, ordered by - creation time (most recent first) + A paginated result of Message objects. """ self._honcho._ensure_workspace() + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = self._honcho._http.post( routes.messages_list(self.workspace_id, self.id), body={"filters": filters} if filters else None, + query=query, ) def transform(response: MessageResponse) -> Message: return Message.from_api_response(response) - def fetch_next(page: int) -> SyncPage[MessageResponse, Message]: + def fetch_next(next_page: int) -> SyncPage[MessageResponse, Message]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = self._honcho._http.post( routes.messages_list(self.workspace_id, self.id), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return SyncPage(next_data, MessageResponse, transform, fetch_next) @@ -503,6 +550,8 @@ class Session(SessionBase, MetadataConfigMixin): self._honcho, metadata=cloned.metadata, configuration=cloned.configuration, + created_at=cloned.created_at, + is_active=cloned.is_active, ) @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) @@ -919,6 +968,21 @@ class Session(SessionBase, MetadataConfigMixin): ) return QueueStatusResponse.model_validate(data) + def get_message(self, message_id: str) -> Message: + """Get a single message by ID from this session. + + Args: + message_id: The ID of the message to retrieve + + Returns: + The Message object + """ + self._honcho._ensure_workspace() + data = self._honcho._http.get( + routes.message(self.workspace_id, self.id, message_id) + ) + return Message.from_api_response(MessageResponse.model_validate(data)) + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def update_message( self, diff --git a/sdks/typescript/CHANGELOG.md b/sdks/typescript/CHANGELOG.md index 87cfa8bf..008d2f45 100644 --- a/sdks/typescript/CHANGELOG.md +++ b/sdks/typescript/CHANGELOG.md @@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2.1.1] - 2026-04-01 + +### Fixed + +- Broadened fetch error retry logic to catch all `TypeError` network failures (connection resets, DNS errors, etc.) instead of only those with `'fetch'` in the message, improving resilience across runtimes (Node, Bun, browsers) + +## [2.1.0] - 2026-03-25 + +### Added + +- `createdAt` property on `Peer` and `Session` wrapper objects +- `isActive` property on `Session` wrapper objects +- `getMessage(messageId)` method on `Session` to fetch a single message by ID +- `Peer.representation()`, `Session.representation()`, and `Session.context()` now accept `Message` objects for `searchQuery` (extracts `.content` automatically) +- `page`, `size`, and `reverse` pagination controls on all list methods: `peers()`, `sessions()`, `messages()`, `workspaces()`, and `conclusions.list()` + +### Changed + +- **Breaking**: `searchQuery` removed from top-level `context()` options. Use `representationOptions.searchQuery` instead — this eliminates the duplicate parameter and is consistent with the API structure. +- List methods (`peers()`, `sessions()`, `messages()`, `workspaces()`) support both the new options object and the legacy raw-filter form: `peers({ filters, page, size, reverse })` and `peers(filters)`. +- Representation search options now accept strings and content-like objects (including `Message` instances) while rejecting whitespace-only or invalid runtime inputs. +- **Breaking**: `peer()` and `session()` now always make a get-or-create API call. Previously, calling without metadata/configuration returned a lazy object with no API call. All Peer/Session objects now have `createdAt` populated immediately. +- Response configuration models (`WorkspaceConfigurationResponse`, `SessionConfigurationResponse`) now tolerate unknown fields from newer servers for forward compatibility +- Reusable `PeerIdObjectSchema` and `SessionIdObjectSchema` helpers for union validation +- Moved `@types/node` from `dependencies` to `devDependencies` + +### Fixed + +- `uploadFile()` now rejects unsupported top-level binary/object inputs and only validates inputs the serializer can actually upload. +- `uploadFile()` now serializes message configuration using API field names, matching `addMessages()`. +- Session fetch methods now refresh cached `createdAt` and `isActive` values alongside metadata and configuration. + +## [2.0.2] - 2026-03-10 + +### Changed + +- **Breaking**: Client constructor now rejects unknown options via `.strict()` Zod validation. Previously, misspelled options (e.g., `baseUrl` instead of `baseURL`) were silently ignored, causing the SDK to fall back to defaults. Now a `ZodError` is thrown with the unrecognized key name. + ## [2.0.1] - 2026-02-09 ### Added diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index af79dd4f..ad8b8069 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -1,6 +1,6 @@ # Honcho TypeScript SDK -A high-level, ergonomic TypeScript SDK for the Honcho conversational memory platform. This library wraps [honcho-node-core](../honcho-node-core) to provide a user-friendly, Pythonic API for managing peers, sessions, and conversational context. +A high-level, ergonomic TypeScript SDK for the Honcho conversational memory platform. Provides a user-friendly API for managing peers, sessions, and conversational context. ## Installation @@ -15,16 +15,16 @@ import { Honcho } from "@honcho-ai/sdk"; const honcho = new Honcho({ apiKey: process.env.HONCHO_API_KEY, - baseUrl: "http://localhost:8000", + baseURL: "http://localhost:8000", workspaceId: "test", }); -const assistant = honcho.peer("bob"); -const alice = honcho.peer("alice"); +const assistant = await honcho.peer("bob"); +const alice = await honcho.peer("alice"); -await honcho.getPeers(); +await honcho.peers(); -const session = honcho.session("session_1"); +const session = await honcho.session("session_1"); await session.addPeers([alice, assistant]); await session.addMessages([ diff --git a/sdks/typescript/__tests__/client.test.ts b/sdks/typescript/__tests__/client.test.ts index 1af3d7df..715d55e4 100644 --- a/sdks/typescript/__tests__/client.test.ts +++ b/sdks/typescript/__tests__/client.test.ts @@ -143,6 +143,27 @@ describe('Honcho Client', () => { await testClient.deleteWorkspace(testClient.workspaceId) } }) + + test('workspaces with options.filters narrows results', async () => { + const uniqueValue = `filter-options-${Date.now()}` + const testClient = new Honcho({ + baseURL: TEST_CONFIG.baseURL, + apiKey: TEST_CONFIG.apiKey, + workspaceId: generateWorkspaceId('filter-options'), + }) + + try { + await testClient.setMetadata({ filterKey: uniqueValue }) + + const page = await client.workspaces({ + filters: { metadata: { filterKey: uniqueValue } }, + }) + + expect(page.items).toContain(testClient.workspaceId) + } finally { + await testClient.deleteWorkspace(testClient.workspaceId) + } + }) }) // =========================================================================== @@ -172,11 +193,12 @@ describe('Honcho Client', () => { // =========================================================================== describe('Peer access', () => { - test('peer() returns Peer instance without API call', async () => { + test('peer() returns Peer instance with cached data', async () => { const peer = await client.peer('lazy-peer') expect(peer.id).toBe('lazy-peer') expect(peer.workspaceId).toBe(client.workspaceId) + expect(peer.createdAt).toBeDefined() }) test('peer() with metadata makes API call', async () => { @@ -205,11 +227,13 @@ describe('Honcho Client', () => { }) describe('Session access', () => { - test('session() returns Session instance without API call', async () => { + test('session() returns Session instance with cached data', async () => { const session = await client.session('lazy-session', { metadata: {} }) expect(session.id).toBe('lazy-session') expect(session.workspaceId).toBe(client.workspaceId) + expect(session.createdAt).toBeDefined() + expect(session.isActive).toBe(true) }) test('session() with metadata makes API call', async () => { diff --git a/sdks/typescript/__tests__/helpers.ts b/sdks/typescript/__tests__/helpers.ts index ed66078a..83cefe24 100644 --- a/sdks/typescript/__tests__/helpers.ts +++ b/sdks/typescript/__tests__/helpers.ts @@ -14,6 +14,11 @@ import type { PageResponse, } from '../src/types/api' +function expectValidDateString(value: string): void { + const parsedDate = new Date(value) + expect(Number.isNaN(parsedDate.getTime())).toBe(false) +} + // ============================================================================= // Response Shape Assertions // ============================================================================= @@ -28,8 +33,7 @@ export function assertWorkspaceShape(workspace: WorkspaceResponse): void { expect(typeof workspace.metadata).toBe('object') expect(typeof workspace.configuration).toBe('object') expect(typeof workspace.created_at).toBe('string') - // Validate ISO 8601 date format - expect(() => new Date(workspace.created_at)).not.toThrow() + expectValidDateString(workspace.created_at) } /** @@ -43,7 +47,7 @@ export function assertPeerShape(peer: PeerResponse): void { expect(typeof peer.metadata).toBe('object') expect(typeof peer.configuration).toBe('object') expect(typeof peer.created_at).toBe('string') - expect(() => new Date(peer.created_at)).not.toThrow() + expectValidDateString(peer.created_at) } /** @@ -58,7 +62,7 @@ export function assertSessionShape(session: SessionResponse): void { expect(typeof session.metadata).toBe('object') expect(typeof session.configuration).toBe('object') expect(typeof session.created_at).toBe('string') - expect(() => new Date(session.created_at)).not.toThrow() + expectValidDateString(session.created_at) } /** @@ -76,7 +80,7 @@ export function assertMessageShape(message: Message): void { expect(typeof message.createdAt).toBe('string') expect(typeof message.tokenCount).toBe('number') expect(message.tokenCount).toBeGreaterThanOrEqual(0) - expect(() => new Date(message.createdAt)).not.toThrow() + expectValidDateString(message.createdAt) } /** @@ -91,7 +95,7 @@ export function assertConclusionShape(conclusion: ConclusionResponse): void { expect(typeof conclusion.observed_id).toBe('string') expect(typeof conclusion.session_id).toBe('string') expect(typeof conclusion.created_at).toBe('string') - expect(() => new Date(conclusion.created_at)).not.toThrow() + expectValidDateString(conclusion.created_at) } /** diff --git a/sdks/typescript/__tests__/messages.test.ts b/sdks/typescript/__tests__/messages.test.ts index 210c90c4..acb865e3 100644 --- a/sdks/typescript/__tests__/messages.test.ts +++ b/sdks/typescript/__tests__/messages.test.ts @@ -223,7 +223,7 @@ describe('Messages', () => { bob.message('From Bob'), ]) - const page = await session.messages({ peer_id: alice.id }) + const page = await session.messages({ filters: { peer_id: alice.id } }) expect(page.items.length).toBe(1) expect(page.items[0].peerId).toBe(alice.id) @@ -238,11 +238,27 @@ describe('Messages', () => { peer.message('Untagged'), ]) - const page = await session.messages({ metadata: { category: 'important' } }) + const page = await session.messages({ filters: { metadata: { category: 'important' } } }) expect(page.items.length).toBe(1) expect(page.items[0].metadata.category).toBe('important') }) + + test('accepts legacy raw filter objects', async () => { + const session = await client.session('legacy-filter-msg-session', { metadata: {} }) + const alice = await client.peer('legacy-filter-alice') + const bob = await client.peer('legacy-filter-bob') + await session.addPeers([alice.id, bob.id]) + await session.addMessages([ + alice.message('Legacy Alice'), + bob.message('Legacy Bob'), + ]) + + const page = await session.messages({ peer_id: alice.id }) + + expect(page.items.length).toBe(1) + expect(page.items[0].peerId).toBe(alice.id) + }) }) // =========================================================================== @@ -382,8 +398,8 @@ describe('Messages', () => { expect(typeof message.tokenCount).toBe('number') // Validate date format - expect(() => new Date(message.createdAt)).not.toThrow() const date = new Date(message.createdAt) + expect(Number.isNaN(date.getTime())).toBe(false) expect(date.getTime()).toBeGreaterThan(0) }) @@ -403,4 +419,109 @@ describe('Messages', () => { expect(uniqueIds.size).toBe(ids.length) }) }) + + // =========================================================================== + // Get Single Message (GET /messages/:id) + // =========================================================================== + + describe('GET /messages/:id (getMessage)', () => { + test('getMessage returns message by ID', async () => { + const session = await client.session('get-msg-session', { metadata: {} }) + const peer = await client.peer('get-msg-peer') + await session.addPeers([peer.id]) + + const [created] = await session.addMessages(peer.message('Retrievable')) + const fetched = await session.getMessage(created.id) + + expect(fetched.id).toBe(created.id) + expect(fetched.content).toBe('Retrievable') + expect(fetched.peerId).toBe(peer.id) + expect(fetched.sessionId).toBe(session.id) + }) + + test('getMessage preserves metadata', async () => { + const session = await client.session('get-msg-meta-session', { + metadata: {}, + }) + const peer = await client.peer('get-msg-meta-peer') + await session.addPeers([peer.id]) + + const [created] = await session.addMessages( + peer.message('With metadata', { metadata: { key: 'value' } }) + ) + const fetched = await session.getMessage(created.id) + + expect(fetched.metadata).toEqual({ key: 'value' }) + }) + }) + + // =========================================================================== + // Pagination Controls + // =========================================================================== + + describe('Pagination controls', () => { + test('messages with custom page size', async () => { + const session = await client.session('page-size-session', { metadata: {} }) + const peer = await client.peer('page-size-peer') + await session.addPeers([peer.id]) + + // Create 3 messages + await session.addMessages([ + peer.message('One'), + peer.message('Two'), + peer.message('Three'), + ]) + + // Fetch with size=2 + const page = await session.messages({ size: 2 }) + const items = await collectAll(page) + + // Should get all 3 via auto-pagination, but first page had only 2 + expect(items.length).toBe(3) + }) + + test('messages with explicit page number', async () => { + const session = await client.session('page-num-session', { metadata: {} }) + const peer = await client.peer('page-num-peer') + await session.addPeers([peer.id]) + + await session.addMessages([ + peer.message('A'), + peer.message('B'), + peer.message('C'), + ]) + + // Fetch page 1 with size 2 + const page1 = await session.messages({ page: 1, size: 2 }) + expect(page1.items.length).toBe(2) + + // Fetch page 2 + const page2 = await session.messages({ page: 2, size: 2 }) + expect(page2.items.length).toBe(1) + }) + + test('messages with reverse ordering', async () => { + const session = await client.session('reverse-session', { metadata: {} }) + const peer = await client.peer('reverse-peer') + await session.addPeers([peer.id]) + + await session.addMessages([ + peer.message('First'), + peer.message('Second'), + peer.message('Third'), + ]) + + const normal = await session.messages() + const reversed = await session.messages({ reverse: true }) + + const normalItems = await collectAll(normal) + const reversedItems = await collectAll(reversed) + + expect(normalItems.length).toBe(reversedItems.length) + // Reversed order should have different first element + expect(normalItems[0].content).not.toBe( + reversedItems[0].content + ) + }) + }) }) diff --git a/sdks/typescript/__tests__/peer.test.ts b/sdks/typescript/__tests__/peer.test.ts index 77953168..d8a3750c 100644 --- a/sdks/typescript/__tests__/peer.test.ts +++ b/sdks/typescript/__tests__/peer.test.ts @@ -52,6 +52,18 @@ describe('Peer', () => { expect(peer).toBeInstanceOf(Peer) expect(peer.id).toBe('simple-peer') expect(peer.workspaceId).toBe(client.workspaceId) + expect(peer.createdAt).toBeDefined() + }) + + test('peer created with metadata exposes createdAt', async () => { + const peer = await client.peer('peer-created-at', { + metadata: { test: true }, + }) + + expect(peer.createdAt).toBeDefined() + expect(typeof peer.createdAt).toBe('string') + const createdAt = new Date(peer.createdAt!) + expect(Number.isNaN(createdAt.getTime())).toBe(false) }) test('creates peer with metadata', async () => { @@ -116,12 +128,24 @@ describe('Peer', () => { metadata: { uniqueTag }, }) - const page = await client.peers({ metadata: { uniqueTag } }) + const page = await client.peers({ filters: { metadata: { uniqueTag } } }) expect(page.items.length).toBe(1) expect(page.items[0].id).toBe('filtered-peer') }) + test('peers accept legacy raw filter objects', async () => { + const uniqueTag = `legacy-tag-${Date.now()}` + await client.peer(`legacy-filtered-peer-${Date.now()}`, { + metadata: { uniqueTag }, + }) + + const page = await client.peers({ metadata: { uniqueTag } }) + + expect(page.items.length).toBe(1) + expect(page.items[0].metadata.uniqueTag).toBe(uniqueTag) + }) + test('Page is async iterable', async () => { await client.peer('iter-peer-1', { metadata: {} }) await client.peer('iter-peer-2', { metadata: {} }) @@ -213,10 +237,24 @@ describe('Peer', () => { }) await session.addPeers([peer.id]) - const sessions = await peer.sessions({ metadata: { category: 'special' } }) + const sessions = await peer.sessions({ filters: { metadata: { category: 'special' } } }) expect(sessions.items.length).toBeGreaterThanOrEqual(1) }) + + test('sessions accept legacy raw filter objects', async () => { + const category = `legacy-category-${Date.now()}` + const peer = await client.peer(`legacy-filter-sessions-peer-${Date.now()}`) + const session = await client.session(`legacy-filterable-session-${Date.now()}`, { + metadata: { category }, + }) + await session.addPeers([peer.id]) + + const sessions = await peer.sessions({ metadata: { category } }) + + expect(sessions.items.length).toBeGreaterThanOrEqual(1) + expect(sessions.items[0].metadata.category).toBe(category) + }) }) // =========================================================================== @@ -637,4 +675,32 @@ describe('Peer', () => { expect(str).toBe("Peer(id='tostring-peer')") }) }) + + // =========================================================================== + // Pagination Controls + // =========================================================================== + + describe('Pagination controls', () => { + test('peers with custom page size', async () => { + // Create peers with unique metadata + await client.peer('page-peer-1', { metadata: { group: 'page-test' } }) + await client.peer('page-peer-2', { metadata: { group: 'page-test' } }) + await client.peer('page-peer-3', { metadata: { group: 'page-test' } }) + + const page = await client.peers({ + filters: { metadata: { group: 'page-test' } }, + size: 2, + }) + expect(page.items.length).toBe(2) + }) + + test('peers with explicit page number', async () => { + const page2 = await client.peers({ + filters: { metadata: { group: 'page-test' } }, + page: 2, + size: 2, + }) + expect(page2.items.length).toBe(1) + }) + }) }) diff --git a/sdks/typescript/__tests__/session.test.ts b/sdks/typescript/__tests__/session.test.ts index 5562785f..b9f4cdeb 100644 --- a/sdks/typescript/__tests__/session.test.ts +++ b/sdks/typescript/__tests__/session.test.ts @@ -54,6 +54,20 @@ describe('Session', () => { expect(session).toBeInstanceOf(Session) expect(session.id).toBe('simple-session') expect(session.workspaceId).toBe(client.workspaceId) + expect(session.createdAt).toBeDefined() + expect(session.isActive).toBe(true) + }) + + test('session created with metadata exposes createdAt and isActive', async () => { + const session = await client.session('session-timestamps', { + metadata: { test: true }, + }) + + expect(session.createdAt).toBeDefined() + expect(typeof session.createdAt).toBe('string') + const createdAt = new Date(session.createdAt!) + expect(Number.isNaN(createdAt.getTime())).toBe(false) + expect(session.isActive).toBe(true) }) test('creates session with metadata', async () => { @@ -104,11 +118,23 @@ describe('Session', () => { const tag = `tag-${Date.now()}` await client.session('filtered-session', { metadata: { tag } }) - const page = await client.sessions({ metadata: { tag } }) + const page = await client.sessions({ filters: { metadata: { tag } } }) expect(page.items.length).toBe(1) expect(page.items[0].id).toBe('filtered-session') }) + + test('sessions accept legacy raw filter objects', async () => { + const tag = `legacy-tag-${Date.now()}` + await client.session(`legacy-filtered-session-${Date.now()}`, { + metadata: { tag }, + }) + + const page = await client.sessions({ metadata: { tag } }) + + expect(page.items.length).toBe(1) + expect(page.items[0].metadata.tag).toBe(tag) + }) }) // =========================================================================== @@ -446,7 +472,7 @@ describe('Session', () => { peer.message('Not this one'), ]) - const page = await session.messages({ metadata: { tag: 'special' } }) + const page = await session.messages({ filters: { metadata: { tag: 'special' } } }) expect(page.items.length).toBe(1) expect(page.items[0].metadata.tag).toBe('special') @@ -610,4 +636,34 @@ describe('Session', () => { expect(str).toBe("Session(id='tostring-session')") }) }) + + // =========================================================================== + // Pagination Controls + // =========================================================================== + + describe('Pagination controls', () => { + test('sessions with custom page size', async () => { + // Create 3 sessions + await client.session('page-test-1', { metadata: { group: 'page-test' } }) + await client.session('page-test-2', { metadata: { group: 'page-test' } }) + await client.session('page-test-3', { metadata: { group: 'page-test' } }) + + const page = await client.sessions({ + filters: { metadata: { group: 'page-test' } }, + size: 2, + }) + // First page should have 2 items + expect(page.items.length).toBe(2) + }) + + test('sessions with explicit page number', async () => { + const page2 = await client.sessions({ + filters: { metadata: { group: 'page-test' } }, + page: 2, + size: 2, + }) + // Page 2 should have the remaining item + expect(page2.items.length).toBe(1) + }) + }) }) diff --git a/sdks/typescript/__tests__/session.unit.test.ts b/sdks/typescript/__tests__/session.unit.test.ts new file mode 100644 index 00000000..71a440e6 --- /dev/null +++ b/sdks/typescript/__tests__/session.unit.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from 'bun:test' +import { ZodError } from 'zod' +import type { HonchoHTTPClient } from '../src/http/client' +import { Session } from '../src/session' +import type { SessionContextResponse, SessionResponse } from '../src/types/api' + +function createSessionResponse( + overrides: Partial = {} +): SessionResponse { + return { + id: 'session-1', + workspace_id: 'workspace-1', + is_active: true, + metadata: {}, + configuration: {}, + created_at: '2024-01-01T00:00:00Z', + ...overrides, + } +} + +function createSessionContextResponse(): SessionContextResponse { + return { + id: 'session-1', + messages: [], + summary: null, + peer_representation: null, + peer_card: null, + } +} + +describe('Session unit behavior', () => { + test('getMetadata populates createdAt and isActive from fetched session data', async () => { + const http = { + post: async () => + createSessionResponse({ + metadata: { topic: 'testing' }, + created_at: '2024-02-01T12:00:00Z', + is_active: true, + }), + } as unknown as HonchoHTTPClient + + const session = new Session('session-1', 'workspace-1', http) + + expect(session.createdAt).toBeUndefined() + expect(session.isActive).toBeUndefined() + + const metadata = await session.getMetadata() + + expect(metadata).toEqual({ topic: 'testing' }) + expect(session.createdAt).toBe('2024-02-01T12:00:00Z') + expect(session.isActive).toBe(true) + }) + + test('refresh updates cached createdAt and isActive from the latest fetch', async () => { + const responses = [ + createSessionResponse({ + metadata: { version: 1 }, + configuration: { reasoning: { enabled: true } }, + created_at: '2024-01-01T00:00:00Z', + is_active: true, + }), + createSessionResponse({ + metadata: { version: 2 }, + configuration: { reasoning: { enabled: false } }, + created_at: '2024-01-02T00:00:00Z', + is_active: false, + }), + ] + + const http = { + post: async () => responses.shift() ?? createSessionResponse(), + } as unknown as HonchoHTTPClient + + const session = new Session('session-1', 'workspace-1', http) + + await session.getMetadata() + await session.refresh() + + expect(session.metadata).toEqual({ version: 2 }) + expect(session.configuration).toMatchObject({ + reasoning: { enabled: false }, + }) + expect(session.createdAt).toBe('2024-01-02T00:00:00Z') + expect(session.isActive).toBe(false) + }) + + test('uploadFile converts configuration to API field names', async () => { + let capturedFormData: FormData | undefined + + const http = { + upload: async (_path: string, formData: FormData) => { + capturedFormData = formData + return [] + }, + } as unknown as HonchoHTTPClient + + const session = new Session('session-1', 'workspace-1', http) + + await session.uploadFile(new Blob(['hello'], { type: 'text/plain' }), 'peer-1', { + configuration: { + reasoning: { + enabled: true, + customInstructions: 'Keep headings intact', + }, + }, + }) + + expect(capturedFormData).toBeDefined() + + const rawConfiguration = capturedFormData?.get('configuration') + expect(typeof rawConfiguration).toBe('string') + expect(JSON.parse(rawConfiguration as string)).toEqual({ + reasoning: { + enabled: true, + custom_instructions: 'Keep headings intact', + }, + }) + }) + + test('context rejects invalid content-like searchQuery inputs', async () => { + const http = { + get: async () => createSessionContextResponse(), + } as unknown as HonchoHTTPClient + + const session = new Session('session-1', 'workspace-1', http) + + await expect( + session.context({ + peerTarget: 'peer-1', + representationOptions: { + searchQuery: { foo: 'bar' } as never, + }, + }) + ).rejects.toBeInstanceOf(ZodError) + }) + + test('representation rejects whitespace-only content-like searchQuery inputs', async () => { + const http = { + post: async () => ({ representation: 'ok' }), + } as unknown as HonchoHTTPClient + + const session = new Session('session-1', 'workspace-1', http) + + await expect( + session.representation('peer-1', { + searchQuery: { content: ' ' } as never, + }) + ).rejects.toBeInstanceOf(ZodError) + }) +}) diff --git a/sdks/typescript/__tests__/validation.test.ts b/sdks/typescript/__tests__/validation.test.ts new file mode 100644 index 00000000..42d03cf7 --- /dev/null +++ b/sdks/typescript/__tests__/validation.test.ts @@ -0,0 +1,406 @@ +/** + * Validation Schema Tests + * + * Unit tests for Zod validation schemas. No server required. + */ + +import { describe, test, expect } from 'bun:test' +import { ZodError } from 'zod' +import { + ChatQuerySchema, + ContextParamsSchema, + HonchoConfigSchema, + MessageInputSchema, + FileUploadSchema, + RepresentationOptionsSchema, +} from '../src/validation' + +// ============================================================================= +// ChatQuerySchema +// ============================================================================= + +describe('ChatQuerySchema', () => { + // --- Valid inputs --- + + test('minimal valid query', () => { + const result = ChatQuerySchema.parse({ query: 'hello' }) + expect(result.query).toBe('hello') + expect(result.target).toBeUndefined() + expect(result.session).toBeUndefined() + expect(result.reasoningLevel).toBeUndefined() + }) + + test('query with all optional fields', () => { + const result = ChatQuerySchema.parse({ + query: 'hello', + target: 'peer-1', + session: 'session-1', + reasoningLevel: 'high', + }) + expect(result.query).toBe('hello') + expect(result.target).toBe('peer-1') + expect(result.session).toBe('session-1') + expect(result.reasoningLevel).toBe('high') + }) + + test('target as object with id is transformed to string', () => { + const result = ChatQuerySchema.parse({ + query: 'hello', + target: { id: 'peer-1' }, + }) + expect(result.target).toBe('peer-1') + }) + + test('session as object with id is transformed to string', () => { + const result = ChatQuerySchema.parse({ + query: 'hello', + session: { id: 'session-1' }, + }) + expect(result.session).toBe('session-1') + }) + + test.each(['minimal', 'low', 'medium', 'high', 'max'] as const)( + 'reasoning level "%s" is valid', + (level) => { + const result = ChatQuerySchema.parse({ query: 'hello', reasoningLevel: level }) + expect(result.reasoningLevel).toBe(level) + } + ) + + // --- Missing required fields --- + + test('missing query throws', () => { + expect(() => ChatQuerySchema.parse({})).toThrow(ZodError) + }) + + test('empty query throws', () => { + expect(() => ChatQuerySchema.parse({ query: '' })).toThrow(ZodError) + }) + + test('whitespace-only query throws', () => { + expect(() => ChatQuerySchema.parse({ query: ' ' })).toThrow(ZodError) + }) + + // --- Invalid field values --- + + test('invalid reasoning level throws', () => { + expect(() => + ChatQuerySchema.parse({ query: 'hello', reasoningLevel: 'ultra' }) + ).toThrow(ZodError) + }) + + test('empty target string throws', () => { + expect(() => + ChatQuerySchema.parse({ query: 'hello', target: '' }) + ).toThrow(ZodError) + }) + + test('empty session string throws', () => { + expect(() => + ChatQuerySchema.parse({ query: 'hello', session: '' }) + ).toThrow(ZodError) + }) + + test('target with special characters throws', () => { + expect(() => + ChatQuerySchema.parse({ query: 'hello', target: 'peer with spaces' }) + ).toThrow(ZodError) + }) + + test('session with special characters throws', () => { + expect(() => + ChatQuerySchema.parse({ query: 'hello', session: 'session/bad' }) + ).toThrow(ZodError) + }) + + // --- Strict mode: unknown fields --- + + test('unknown field throws (strict)', () => { + expect(() => + ChatQuerySchema.parse({ query: 'hello', typo: 'oops' }) + ).toThrow(ZodError) + }) + + test('misspelled field throws (strict)', () => { + expect(() => + ChatQuerySchema.parse({ query: 'hello', reasoning_level: 'high' }) + ).toThrow(ZodError) + }) + + test('extra field alongside valid fields throws (strict)', () => { + expect(() => + ChatQuerySchema.parse({ + query: 'hello', + target: 'peer-1', + session: 'session-1', + reasoningLevel: 'low', + extra: true, + }) + ).toThrow(ZodError) + }) +}) + +// ============================================================================= +// HonchoConfigSchema (strict validation) +// ============================================================================= + +describe('HonchoConfigSchema (strict)', () => { + test('baseUrl (wrong casing) throws', () => { + expect(() => + HonchoConfigSchema.parse({ + baseUrl: 'http://localhost:8000', + workspaceId: 'test', + }) + ).toThrow(ZodError) + }) + + test('baseURL (correct casing) passes', () => { + const result = HonchoConfigSchema.parse({ + baseURL: 'http://localhost:8000', + workspaceId: 'test', + }) + expect(result.baseURL).toBe('http://localhost:8000') + }) + + test('unknown option throws', () => { + expect(() => + HonchoConfigSchema.parse({ + workspaceId: 'test', + retries: 3, + }) + ).toThrow(ZodError) + }) +}) + +// ============================================================================= +// MessageInputSchema (strict validation) +// ============================================================================= + +describe('MessageInputSchema (strict)', () => { + test('valid message input', () => { + const result = MessageInputSchema.parse({ + peerId: 'peer-1', + content: 'hello', + metadata: { key: 'value' }, + }) + expect(result.peerId).toBe('peer-1') + expect(result.content).toBe('hello') + }) + + test('snake_case peerId alias throws (strict)', () => { + expect(() => + MessageInputSchema.parse({ + peer_id: 'peer-1', + content: 'hello', + }) + ).toThrow(ZodError) + }) + + test('unknown field throws (strict)', () => { + expect(() => + MessageInputSchema.parse({ + peerId: 'peer-1', + content: 'hello', + role: 'user', + }) + ).toThrow(ZodError) + }) +}) + +// ============================================================================= +// ContextParamsSchema (searchQuery in representationOptions) +// ============================================================================= + +describe('ContextParamsSchema', () => { + test('top-level searchQuery throws (strict)', () => { + expect(() => + ContextParamsSchema.parse({ + peerTarget: 'peer-1', + searchQuery: 'hello', + }) + ).toThrow(ZodError) + }) + + test('searchQuery inside representationOptions passes', () => { + const result = ContextParamsSchema.parse({ + peerTarget: 'peer-1', + representationOptions: { searchQuery: 'hello' }, + }) + expect(result.representationOptions?.searchQuery).toBe('hello') + }) + + test('representationOptions.searchQuery requires peerTarget', () => { + expect(() => + ContextParamsSchema.parse({ + representationOptions: { searchQuery: 'hello' }, + }) + ).toThrow(ZodError) + }) + + test('minimal valid context params', () => { + const result = ContextParamsSchema.parse({}) + expect(result.summary).toBeUndefined() + expect(result.peerTarget).toBeUndefined() + }) + + test('all valid options', () => { + const result = ContextParamsSchema.parse({ + summary: true, + tokens: 1000, + peerTarget: 'peer-1', + peerPerspective: 'peer-2', + limitToSession: true, + representationOptions: { + searchQuery: 'preferences', + searchTopK: 10, + searchMaxDistance: 0.5, + includeMostFrequent: true, + maxConclusions: 25, + }, + }) + expect(result.summary).toBe(true) + expect(result.peerTarget).toBe('peer-1') + expect(result.representationOptions?.searchQuery).toBe('preferences') + expect(result.representationOptions?.searchTopK).toBe(10) + }) + + test('peerPerspective without peerTarget throws', () => { + expect(() => + ContextParamsSchema.parse({ peerPerspective: 'peer-2' }) + ).toThrow(ZodError) + }) +}) + +// ============================================================================= +// RepresentationOptionsSchema +// ============================================================================= + +describe('RepresentationOptionsSchema', () => { + test('string searchQuery passes', () => { + const result = RepresentationOptionsSchema.parse({ searchQuery: 'hello' }) + expect(result.searchQuery).toBe('hello') + }) + + test('content-like object searchQuery passes', () => { + const result = RepresentationOptionsSchema.parse({ + searchQuery: { + id: 'msg-1', + content: 'hello world', + created_at: '2024-01-01T00:00:00Z', + peer_id: 'peer-1', + session_id: 'session-1', + token_count: 2, + workspace_id: 'ws-1', + metadata: {}, + }, + }) + expect(result.searchQuery).toBeDefined() + }) + + test('whitespace-only string searchQuery throws', () => { + expect(() => + RepresentationOptionsSchema.parse({ searchQuery: ' ' }) + ).toThrow(ZodError) + }) + + test('whitespace-only object content searchQuery throws', () => { + expect(() => + RepresentationOptionsSchema.parse({ + searchQuery: { content: ' ' }, + }) + ).toThrow(ZodError) + }) + + test('invalid object searchQuery throws', () => { + expect(() => + RepresentationOptionsSchema.parse({ + searchQuery: { foo: 'bar' }, + }) + ).toThrow(ZodError) + }) + + test('numeric searchQuery throws', () => { + expect(() => + RepresentationOptionsSchema.parse({ + searchQuery: 42, + }) + ).toThrow(ZodError) + }) + + test('unknown field throws (strict)', () => { + expect(() => + RepresentationOptionsSchema.parse({ + searchQuery: 'hello', + typo: true, + }) + ).toThrow(ZodError) + }) +}) + +// ============================================================================= +// FileUploadSchema +// ============================================================================= + +describe('FileUploadSchema', () => { + test('Blob upload passes', () => { + const result = FileUploadSchema.parse({ + file: new Blob(['hello'], { type: 'text/plain' }), + peer: 'peer-1', + }) + + expect(result.file).toBeInstanceOf(Blob) + }) + + test('custom upload object passes', () => { + const result = FileUploadSchema.parse({ + file: { + filename: 'test.txt', + content: new TextEncoder().encode('hello'), + content_type: 'text/plain', + }, + peer: 'peer-1', + }) + + expect(result.file).toBeDefined() + }) + + test('custom upload object accepts Buffer content', () => { + const result = FileUploadSchema.parse({ + file: { + filename: 'test.txt', + content: Buffer.from('hello'), + content_type: 'text/plain', + }, + peer: 'peer-1', + }) + + expect(result.file).toBeDefined() + }) + + test('top-level Uint8Array throws', () => { + expect(() => + FileUploadSchema.parse({ + file: new Uint8Array([1, 2, 3]), + peer: 'peer-1', + }) + ).toThrow(ZodError) + }) + + test('top-level Buffer throws', () => { + expect(() => + FileUploadSchema.parse({ + file: Buffer.from('hello'), + peer: 'peer-1', + }) + ).toThrow(ZodError) + }) + + test('arbitrary object throws', () => { + expect(() => + FileUploadSchema.parse({ + file: { filename: 'test.txt' }, + peer: 'peer-1', + }) + ).toThrow(ZodError) + }) +}) diff --git a/sdks/typescript/bun.lock b/sdks/typescript/bun.lock index c67af981..f7c1641a 100644 --- a/sdks/typescript/bun.lock +++ b/sdks/typescript/bun.lock @@ -5,12 +5,12 @@ "": { "name": "@honcho-ai/sdk", "dependencies": { - "@types/node": "^24.0.1", "zod": "4.0.0", }, "devDependencies": { "@biomejs/biome": "^2.1.2", "@types/bun": "latest", + "@types/node": "^24.0.1", "typescript": "^5.0.0", }, }, diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 618a0acf..136b716a 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@honcho-ai/sdk", - "version": "2.0.1", + "version": "2.1.1", "description": "Official DX Optimized TypeScript SDK for Honcho", "author": "Plastic Labs ", "license": "Apache-2.0", @@ -18,12 +18,12 @@ "test": "echo 'Error: Tests must be run from the monorepo root via pytest. See tests/README.md' && exit 1" }, "dependencies": { - "@types/node": "^24.0.1", "zod": "4.0.0" }, "devDependencies": { "@biomejs/biome": "^2.1.2", "@types/bun": "latest", + "@types/node": "^24.0.1", "typescript": "^5.0.0" } } diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 53801d9d..223f3833 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -21,6 +21,7 @@ import { type HonchoConfig, HonchoConfigSchema, LimitSchema, + normalizeListOptions, type PeerConfig, PeerConfigSchema, PeerIdSchema, @@ -279,13 +280,18 @@ export class Honcho { filters?: Record page?: number size?: number + reverse?: boolean } ): Promise> { return this._http.post>( `/${API_VERSION}/workspaces/${workspaceId}/peers/list`, { body: { filters: params?.filters }, - query: { page: params?.page, size: params?.size }, + query: { + page: params?.page, + size: params?.size, + reverse: params?.reverse ? 'true' : undefined, + }, } ) } @@ -310,13 +316,18 @@ export class Honcho { filters?: Record page?: number size?: number + reverse?: boolean } ): Promise> { return this._http.post>( `/${API_VERSION}/workspaces/${workspaceId}/sessions/list`, { body: { filters: params?.filters }, - query: { page: params?.page, size: params?.size }, + query: { + page: params?.page, + size: params?.size, + reverse: params?.reverse ? 'true' : undefined, + }, } ) } @@ -381,29 +392,19 @@ export class Honcho { ? PeerConfigSchema.parse(options.configuration) : undefined - if (validatedConfiguration || validatedMetadata) { - const peerData = await this._getOrCreatePeer(this.workspaceId, { - id: validatedId, - configuration: peerConfigToApi(validatedConfiguration), - metadata: validatedMetadata, - }) - return new Peer( - validatedId, - this.workspaceId, - this._http, - peerData.metadata ?? undefined, - peerConfigFromApi(peerData.configuration) ?? undefined, - () => this._ensureWorkspace() - ) - } - + const peerData = await this._getOrCreatePeer(this.workspaceId, { + id: validatedId, + configuration: peerConfigToApi(validatedConfiguration), + metadata: validatedMetadata, + }) return new Peer( validatedId, this.workspaceId, this._http, - undefined, - undefined, - () => this._ensureWorkspace() + peerData.metadata ?? undefined, + peerConfigFromApi(peerData.configuration) ?? undefined, + () => this._ensureWorkspace(), + peerData.created_at ) } @@ -413,14 +414,37 @@ export class Honcho { * Makes an API call to retrieve all peers that have been created or used * within the current workspace. Returns a paginated result. * - * @param filters - Optional filter criteria for peers. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). + * @param options - Either a legacy raw filter object or an options object with + * `filters`, `page`, `size`, and `reverse`. See + * [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). * @returns Promise resolving to a Page of Peer objects representing all peers in the workspace */ - async peers(filters?: Filters): Promise> { + async peers( + options?: + | Filters + | { + filters?: Filters + page?: number + size?: number + reverse?: boolean + } + ): Promise> { await this._ensureWorkspace() - const validatedFilter = filters ? FilterSchema.parse(filters) : undefined + const normalizedOptions = normalizeListOptions(options, [ + 'filters', + 'page', + 'size', + 'reverse', + ]) + const validatedFilter = normalizedOptions.filters + ? FilterSchema.parse(normalizedOptions.filters) + : undefined + const reverse = normalizedOptions.reverse const peersPage = await this._listPeers(this.workspaceId, { filters: validatedFilter, + page: normalizedOptions.page, + size: normalizedOptions.size, + reverse, }) const fetchNextPage = async ( @@ -431,6 +455,7 @@ export class Honcho { filters: validatedFilter, page, size, + reverse, }) } @@ -443,7 +468,8 @@ export class Honcho { this._http, peer.metadata ?? undefined, peerConfigFromApi(peer.configuration) ?? undefined, - () => this._ensureWorkspace() + () => this._ensureWorkspace(), + peer.created_at ), fetchNextPage ) @@ -486,29 +512,20 @@ export class Honcho { ? SessionConfigSchema.parse(options.configuration) : undefined - if (validatedConfiguration || validatedMetadata) { - const sessionData = await this._getOrCreateSession(this.workspaceId, { - id: validatedId, - configuration: validatedConfiguration, - metadata: validatedMetadata, - }) - return new Session( - validatedId, - this.workspaceId, - this._http, - sessionData.metadata ?? undefined, - sessionConfigFromApi(sessionData.configuration) ?? undefined, - () => this._ensureWorkspace() - ) - } - + const sessionData = await this._getOrCreateSession(this.workspaceId, { + id: validatedId, + configuration: validatedConfiguration, + metadata: validatedMetadata, + }) return new Session( validatedId, this.workspaceId, this._http, - undefined, - undefined, - () => this._ensureWorkspace() + sessionData.metadata ?? undefined, + sessionConfigFromApi(sessionData.configuration) ?? undefined, + () => this._ensureWorkspace(), + sessionData.created_at, + sessionData.is_active ) } @@ -518,15 +535,38 @@ export class Honcho { * Makes an API call to retrieve all sessions that have been created within * the current workspace. * - * @param filters - Optional filter criteria for sessions. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). + * @param options - Either a legacy raw filter object or an options object with + * `filters`, `page`, `size`, and `reverse`. See + * [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). * @returns Promise resolving to a Page of Session objects representing all sessions * in the workspace. Returns an empty page if no sessions exist */ - async sessions(filters?: Filters): Promise> { + async sessions( + options?: + | Filters + | { + filters?: Filters + page?: number + size?: number + reverse?: boolean + } + ): Promise> { await this._ensureWorkspace() - const validatedFilter = filters ? FilterSchema.parse(filters) : undefined + const normalizedOptions = normalizeListOptions(options, [ + 'filters', + 'page', + 'size', + 'reverse', + ]) + const validatedFilter = normalizedOptions.filters + ? FilterSchema.parse(normalizedOptions.filters) + : undefined + const reverse = normalizedOptions.reverse const sessionsPage = await this._listSessions(this.workspaceId, { filters: validatedFilter, + page: normalizedOptions.page, + size: normalizedOptions.size, + reverse, }) const fetchNextPage = async ( @@ -537,6 +577,7 @@ export class Honcho { filters: validatedFilter, page, size, + reverse, }) } @@ -549,7 +590,9 @@ export class Honcho { this._http, session.metadata ?? undefined, sessionConfigFromApi(session.configuration) ?? undefined, - () => this._ensureWorkspace() + () => this._ensureWorkspace(), + session.created_at, + session.is_active ), fetchNextPage ) @@ -647,16 +690,33 @@ export class Honcho { * Makes an API call to retrieve all workspace IDs that the authenticated * user has access to. * - * @param filters - Optional filter criteria for workspaces. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). + * @param options - Either a legacy raw filter object or an options object with + * `filters`, `page`, and `size`. See + * [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). * @returns Promise resolving to a Page of workspace ID strings. Returns an empty * page if no workspaces are accessible or none exist */ async workspaces( - filters?: Filters + options?: + | Filters + | { + filters?: Filters + page?: number + size?: number + } ): Promise> { - const validatedFilter = filters ? FilterSchema.parse(filters) : undefined + const normalizedOptions = normalizeListOptions(options, [ + 'filters', + 'page', + 'size', + ]) + const validatedFilter = normalizedOptions.filters + ? FilterSchema.parse(normalizedOptions.filters) + : undefined const workspacesPage = await this._listWorkspaces({ filters: validatedFilter, + page: normalizedOptions.page, + size: normalizedOptions.size, }) const fetchNextPage = async ( diff --git a/sdks/typescript/src/conclusions.ts b/sdks/typescript/src/conclusions.ts index 28cd06e9..7c854b57 100644 --- a/sdks/typescript/src/conclusions.ts +++ b/sdks/typescript/src/conclusions.ts @@ -8,6 +8,7 @@ import type { RepresentationOptions, RepresentationResponse, } from './types/api' +import { normalizeSearchQuery, RepresentationOptionsSchema } from './validation' /** * Parameters for creating a conclusion. @@ -101,13 +102,18 @@ export class ConclusionScope { filters?: Record page?: number size?: number + reverse?: boolean }): Promise> { await this._ensureWorkspace() return this._http.post>( `/${API_VERSION}/workspaces/${this.workspaceId}/conclusions/list`, { body: { filters: params.filters }, - query: { page: params.page, size: params.size }, + query: { + page: params.page, + size: params.size, + reverse: params.reverse ? 'true' : undefined, + }, } ) } @@ -182,6 +188,7 @@ export class ConclusionScope { page?: number size?: number session?: string | Session + reverse?: boolean }): Promise> { const resolvedSessionId = options?.session ? typeof options.session === 'string' @@ -195,18 +202,20 @@ export class ConclusionScope { if (resolvedSessionId) { filters.session_id = resolvedSessionId } + const reverse = options?.reverse const response = await this._list({ filters, page: options?.page ?? 1, size: options?.size ?? 50, + reverse, }) const fetchNextPage = async ( page: number, size: number ): Promise> => { - return this._list({ filters, page, size }) + return this._list({ filters, page, size, reverse }) } return new Page( @@ -277,13 +286,22 @@ export class ConclusionScope { * Get the computed representation for this scope. */ async representation(options?: RepresentationOptions): Promise { + const searchQuery = normalizeSearchQuery(options?.searchQuery) + const validatedOptions = RepresentationOptionsSchema.parse({ + searchQuery, + searchTopK: options?.searchTopK, + searchMaxDistance: options?.searchMaxDistance, + includeMostFrequent: options?.includeMostFrequent, + maxConclusions: options?.maxConclusions, + }) + const response = await this._getRepresentation(this.observer, { target: this.observed, - search_query: options?.searchQuery, - search_top_k: options?.searchTopK, - search_max_distance: options?.searchMaxDistance, - include_most_frequent: options?.includeMostFrequent, - max_conclusions: options?.maxConclusions, + search_query: searchQuery, + search_top_k: validatedOptions.searchTopK, + search_max_distance: validatedOptions.searchMaxDistance, + include_most_frequent: validatedOptions.includeMostFrequent, + max_conclusions: validatedOptions.maxConclusions, }) return response.representation } diff --git a/sdks/typescript/src/http/client.ts b/sdks/typescript/src/http/client.ts index a3ff2c1f..e22480d2 100644 --- a/sdks/typescript/src/http/client.ts +++ b/sdks/typescript/src/http/client.ts @@ -134,18 +134,6 @@ export class HonchoHTTPClient { throw error } - // Handle fetch errors (network issues) - if (error instanceof TypeError && error.message.includes('fetch')) { - const connError = new ConnectionError(error.message) - if (attempt < this.maxRetries) { - lastError = connError - await this.sleep(this.getRetryDelay(attempt)) - attempt++ - continue - } - throw connError - } - throw error } } @@ -342,6 +330,14 @@ export class HonchoHTTPClient { if (error instanceof DOMException && error.name === 'AbortError') { throw new TimeoutError(`Request timed out after ${timeout}ms`) } + // fetch() throws TypeError for network-level failures (e.g. "fetch + // failed", connection reset, DNS errors). Convert these to + // ConnectionError so the retry loop can handle them. This mapping + // lives here rather than in the outer catch so that TypeErrors from + // other sources (e.g. JSON.stringify serialization) propagate as-is. + if (error instanceof TypeError) { + throw new ConnectionError(error.message) + } throw error } finally { clearTimeout(timeoutId) diff --git a/sdks/typescript/src/peer.ts b/sdks/typescript/src/peer.ts index 65567ad5..bc129616 100644 --- a/sdks/typescript/src/peer.ts +++ b/sdks/typescript/src/peer.ts @@ -28,6 +28,8 @@ import { MessageConfigurationSchema, MessageContentSchema, MessageMetadataSchema, + normalizeListOptions, + normalizeSearchQuery, PeerCardContentSchema, type PeerConfig, PeerConfigSchema, @@ -35,7 +37,9 @@ import { PeerMetadataSchema, peerConfigFromApi, peerConfigToApi, + RepresentationOptionsSchema, SearchQuerySchema, + sessionConfigFromApi, } from './validation' /** @@ -120,6 +124,7 @@ export class Peer { * Private cached configuration for this peer. */ private _configuration?: PeerConfig + private _createdAt?: string /** * Cached metadata for this peer. May be stale if the peer @@ -143,6 +148,13 @@ export class Peer { return this._configuration } + /** + * Timestamp when this peer was created. Only available if fetched from the API. + */ + get createdAt(): string | undefined { + return this._createdAt + } + /** * Initialize a new Peer. **Do not call this directly, use the client.peer() method instead.** * @@ -158,7 +170,8 @@ export class Peer { http: HonchoHTTPClient, metadata?: Record, configuration?: PeerConfig, - ensureWorkspace: () => Promise = async () => undefined + ensureWorkspace: () => Promise = async () => undefined, + createdAt?: string ) { this.id = id this.workspaceId = workspaceId @@ -166,6 +179,7 @@ export class Peer { this._metadata = metadata this._configuration = configuration this._ensureWorkspace = ensureWorkspace + this._createdAt = createdAt } // =========================================================================== @@ -199,13 +213,18 @@ export class Peer { filters?: Record page?: number size?: number + reverse?: boolean }): Promise> { await this._ensureWorkspace() return this._http.post>( `/${API_VERSION}/workspaces/${this.workspaceId}/peers/${this.id}/sessions`, { body: { filters: params?.filters }, - query: { page: params?.page, size: params?.size }, + query: { + page: params?.page, + size: params?.size, + reverse: params?.reverse ? 'true' : undefined, + }, } ) } @@ -459,19 +478,49 @@ export class Peer { * Makes an API call to retrieve all sessions where this peer is an active participant. * Sessions are created when peers are added to them or send messages to them. * - * @param filters - Optional filter criteria for sessions. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). + * @param options - Either a legacy raw filter object or an options object with + * `filters`, `page`, `size`, and `reverse`. See + * [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). * @returns Promise resolving to a paginated list of Session objects this peer belongs to. * Returns an empty list if the peer is not a member of any sessions */ - async sessions(filters?: Filters): Promise> { - const validatedFilter = filters ? FilterSchema.parse(filters) : undefined - const sessionsPage = await this._listSessions({ filters: validatedFilter }) + async sessions( + options?: + | Filters + | { + filters?: Filters + page?: number + size?: number + reverse?: boolean + } + ): Promise> { + const normalizedOptions = normalizeListOptions(options, [ + 'filters', + 'page', + 'size', + 'reverse', + ]) + const validatedFilter = normalizedOptions.filters + ? FilterSchema.parse(normalizedOptions.filters) + : undefined + const reverse = normalizedOptions.reverse + const sessionsPage = await this._listSessions({ + filters: validatedFilter, + page: normalizedOptions.page, + size: normalizedOptions.size, + reverse, + }) const fetchNextPage = async ( page: number, size: number ): Promise> => { - return this._listSessions({ filters: validatedFilter, page, size }) + return this._listSessions({ + filters: validatedFilter, + page, + size, + reverse, + }) } return new Page( @@ -482,7 +531,10 @@ export class Peer { this.workspaceId, this._http, session.metadata ?? undefined, - session.configuration ?? undefined + sessionConfigFromApi(session.configuration) ?? undefined, + () => this._ensureWorkspace(), + session.created_at, + session.is_active ), fetchNextPage ) @@ -559,6 +611,7 @@ export class Peer { async getMetadata(): Promise> { const peer = await this._getOrCreate({ id: this.id }) this._metadata = peer.metadata || {} + this._createdAt = peer.created_at return this._metadata } @@ -590,6 +643,7 @@ export class Peer { async getConfiguration(): Promise { const peer = await this._getOrCreate({ id: this.id }) this._configuration = peerConfigFromApi(peer.configuration) || {} + this._createdAt = peer.created_at return this._configuration } @@ -621,6 +675,7 @@ export class Peer { const peer = await this._getOrCreate({ id: this.id }) this._metadata = peer.metadata || {} this._configuration = peerConfigFromApi(peer.configuration) || {} + this._createdAt = peer.created_at } /** @@ -741,17 +796,18 @@ export class Peer { async representation(options?: { session?: string | Session target?: string | Peer - searchQuery?: string + searchQuery?: string | Message searchTopK?: number searchMaxDistance?: number includeMostFrequent?: boolean maxConclusions?: number }): Promise { + const searchQuery = normalizeSearchQuery(options?.searchQuery) const getRepresentationParams = PeerGetRepresentationParamsSchema.parse({ session: options?.session, target: options?.target, options: { - searchQuery: options?.searchQuery, + searchQuery, searchTopK: options?.searchTopK, searchMaxDistance: options?.searchMaxDistance, includeMostFrequent: options?.includeMostFrequent, @@ -772,7 +828,7 @@ export class Peer { const response = await this._getRepresentation({ session_id: sessionId, target: targetId, - search_query: getRepresentationParams.options?.searchQuery, + search_query: searchQuery, search_top_k: getRepresentationParams.options?.searchTopK, search_max_distance: getRepresentationParams.options?.searchMaxDistance, include_most_frequent: @@ -827,14 +883,25 @@ export class Peer { ? options.target : options.target.id : undefined + const searchQuery = + options?.searchQuery === undefined + ? undefined + : SearchQuerySchema.parse(options.searchQuery) + const validatedOptions = RepresentationOptionsSchema.parse({ + searchQuery, + searchTopK: options?.searchTopK, + searchMaxDistance: options?.searchMaxDistance, + includeMostFrequent: options?.includeMostFrequent, + maxConclusions: options?.maxConclusions, + }) const response = await this._getContext({ target: targetId, - search_query: options?.searchQuery, - search_top_k: options?.searchTopK, - search_max_distance: options?.searchMaxDistance, - include_most_frequent: options?.includeMostFrequent, - max_conclusions: options?.maxConclusions, + search_query: searchQuery, + search_top_k: validatedOptions.searchTopK, + search_max_distance: validatedOptions.searchMaxDistance, + include_most_frequent: validatedOptions.includeMostFrequent, + max_conclusions: validatedOptions.maxConclusions, }) return PeerContext.fromApiResponse(response) diff --git a/sdks/typescript/src/session.ts b/sdks/typescript/src/session.ts index 6d257f71..41fb2bba 100644 --- a/sdks/typescript/src/session.ts +++ b/sdks/typescript/src/session.ts @@ -28,10 +28,14 @@ import { type MessageAddition, MessageAdditionToApiSchema, MessageMetadataSchema, + messageConfigToApi, + normalizeListOptions, + normalizeSearchQuery, type PeerAddition, PeerAdditionToApiSchema, type PeerRemoval, PeerRemovalSchema, + peerConfigFromApi, type QueueStatusOptions, SearchQuerySchema, type SessionConfig, @@ -79,6 +83,8 @@ export class Session { private _http: HonchoHTTPClient private _metadata?: Record private _configuration?: SessionConfig + private _createdAt?: string + private _isActive?: boolean private _ensureWorkspace: () => Promise /** @@ -103,6 +109,20 @@ export class Session { return this._configuration } + /** + * Timestamp when this session was created. Only available if fetched from the API. + */ + get createdAt(): string | undefined { + return this._createdAt + } + + /** + * Whether this session is active. Only available if fetched from the API. + */ + get isActive(): boolean | undefined { + return this._isActive + } + /** * Initialize a new Session. **Do not call this directly, use the client.session() method instead.** * @@ -118,7 +138,9 @@ export class Session { http: HonchoHTTPClient, metadata?: Record, configuration?: SessionConfig, - ensureWorkspace: () => Promise = async () => undefined + ensureWorkspace: () => Promise = async () => undefined, + createdAt?: string, + isActive?: boolean ) { this.id = id this.workspaceId = workspaceId @@ -126,6 +148,15 @@ export class Session { this._metadata = metadata this._configuration = configuration this._ensureWorkspace = ensureWorkspace + this._createdAt = createdAt + this._isActive = isActive + } + + private _applySessionResponse(session: SessionResponse): void { + this._metadata = session.metadata || {} + this._configuration = sessionConfigFromApi(session.configuration) || {} + this._createdAt = session.created_at + this._isActive = session.is_active } // =========================================================================== @@ -305,13 +336,18 @@ export class Session { filters?: Record page?: number size?: number + reverse?: boolean }): Promise> { await this._ensureWorkspace() return this._http.post>( `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/messages/list`, { body: { filters: params?.filters }, - query: { page: params?.page, size: params?.size }, + query: { + page: params?.page, + size: params?.size, + reverse: params?.reverse ? 'true' : undefined, + }, } ) } @@ -358,6 +394,13 @@ export class Session { ) } + private async _getMessage(messageId: string): Promise { + await this._ensureWorkspace() + return this._http.get( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/messages/${messageId}` + ) + } + private async _updateMessage( messageId: string, params: { metadata: Record } @@ -450,9 +493,10 @@ export class Session { peer.id, this.workspaceId, this._http, - undefined, - undefined, - () => this._ensureWorkspace() + peer.metadata ?? undefined, + peerConfigFromApi(peer.configuration) ?? undefined, + () => this._ensureWorkspace(), + peer.created_at ) ) } @@ -539,19 +583,48 @@ export class Session { * * Makes an API call to retrieve messages in the session, with optional filtering. * - * @param filters - Optional filter criteria for messages. See + * @param options - Either a legacy raw filter object or an options object with + * `filters`, `page`, `size`, and `reverse`. See * [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). * @returns Promise resolving to a paginated Page of Message objects */ - async messages(filters?: Filters): Promise> { - const validatedFilter = filters ? FilterSchema.parse(filters) : undefined - const messagesPage = await this._listMessages({ filters: validatedFilter }) + async messages( + options?: + | Filters + | { + filters?: Filters + page?: number + size?: number + reverse?: boolean + } + ): Promise> { + const normalizedOptions = normalizeListOptions(options, [ + 'filters', + 'page', + 'size', + 'reverse', + ]) + const validatedFilter = normalizedOptions.filters + ? FilterSchema.parse(normalizedOptions.filters) + : undefined + const reverse = normalizedOptions.reverse + const messagesPage = await this._listMessages({ + filters: validatedFilter, + page: normalizedOptions.page, + size: normalizedOptions.size, + reverse, + }) const fetchNextPage = async ( page: number, size: number ): Promise> => { - return this._listMessages({ filters: validatedFilter, page, size }) + return this._listMessages({ + filters: validatedFilter, + page, + size, + reverse, + }) } return new Page(messagesPage, Message.fromApiResponse, fetchNextPage) @@ -568,8 +641,8 @@ export class Session { */ async getMetadata(): Promise> { const session = await this._getOrCreate({ id: this.id }) - this._metadata = session.metadata || {} - return this._metadata + this._applySessionResponse(session) + return this._metadata ?? {} } /** @@ -599,8 +672,8 @@ export class Session { */ async getConfiguration(): Promise { const session = await this._getOrCreate({ id: this.id }) - this._configuration = sessionConfigFromApi(session.configuration) || {} - return this._configuration + this._applySessionResponse(session) + return this._configuration ?? {} } /** @@ -627,8 +700,7 @@ export class Session { */ async refresh(): Promise { const session = await this._getOrCreate({ id: this.id }) - this._metadata = session.metadata || {} - this._configuration = sessionConfigFromApi(session.configuration) || {} + this._applySessionResponse(session) } /** @@ -662,7 +734,9 @@ export class Session { this._http, clonedSessionData.metadata ?? undefined, sessionConfigFromApi(clonedSessionData.configuration) ?? undefined, - () => this._ensureWorkspace() + () => this._ensureWorkspace(), + clonedSessionData.created_at, + clonedSessionData.is_active ) } @@ -677,10 +751,9 @@ export class Session { * @param options.summary - Whether to include a summary of earlier messages * @param options.tokens - Target token count for the context window * @param options.peerTarget - The peer to get representation for - * @param options.lastUserMessage - Message text (string) or Message object whose content will be used for semantic search * @param options.peerPerspective - The peer whose perspective to use for representation * @param options.limitToSession - Whether to limit representation to this session only - * @param options.representationOptions - Options for representation retrieval + * @param options.representationOptions - Options for representation retrieval (searchQuery, searchTopK, etc.) * @returns Promise resolving to a SessionContext with messages, summary, and representation * * @example @@ -699,7 +772,6 @@ export class Session { summary?: boolean tokens?: number peerTarget?: string | Peer - searchQuery?: string | Message peerPerspective?: string | Peer limitToSession?: boolean representationOptions?: RepresentationOptions @@ -713,30 +785,29 @@ export class Session { typeof opts.peerPerspective === 'object' ? opts.peerPerspective.id : opts.peerPerspective - const searchQueryText = - typeof opts.searchQuery === 'string' - ? opts.searchQuery - : opts.searchQuery?.content + + const searchQuery = normalizeSearchQuery( + opts.representationOptions?.searchQuery + ) const contextParams = ContextParamsSchema.parse({ summary: opts.summary, tokens: opts.tokens, peerTarget: peerTargetId, - searchQuery: searchQueryText, peerPerspective: peerPerspectiveId, limitToSession: opts.limitToSession, - representationOptions: opts.representationOptions, + representationOptions: opts.representationOptions + ? { + ...opts.representationOptions, + searchQuery, + } + : undefined, }) - const searchQueryParsed = - typeof contextParams.searchQuery === 'string' - ? contextParams.searchQuery - : contextParams.searchQuery?.content - const context = await this._getContext({ tokens: contextParams.tokens, summary: contextParams.summary, - search_query: searchQueryParsed, + search_query: searchQuery, peer_target: contextParams.peerTarget, peer_perspective: contextParams.peerPerspective, limit_to_session: contextParams.limitToSession, @@ -895,14 +966,15 @@ export class Session { }) const formData = new FormData() + const uploadFile = uploadParams.file - if (file instanceof File || file instanceof Blob) { - formData.append('file', file) + if (uploadFile instanceof Blob) { + formData.append('file', uploadFile) } else { // Convert to Uint8Array for Blob compatibility - const content = new Uint8Array(file.content) - const blob = new Blob([content], { type: file.content_type }) - formData.append('file', blob, file.filename) + const content = new Uint8Array(uploadFile.content) + const blob = new Blob([content], { type: uploadFile.content_type }) + formData.append('file', blob, uploadFile.filename) } formData.append('peer_id', resolvedPeerId) @@ -913,10 +985,8 @@ export class Session { uploadParams.configuration !== undefined && uploadParams.configuration !== null ) { - formData.append( - 'configuration', - JSON.stringify(uploadParams.configuration) - ) + const apiConfiguration = messageConfigToApi(uploadParams.configuration) + formData.append('configuration', JSON.stringify(apiConfiguration)) } if ( uploadParams.createdAt !== undefined && @@ -949,18 +1019,19 @@ export class Session { peer: string | Peer, options?: { target?: string | Peer - searchQuery?: string + searchQuery?: string | Message searchTopK?: number searchMaxDistance?: number includeMostFrequent?: boolean maxConclusions?: number } ): Promise { + const searchQuery = normalizeSearchQuery(options?.searchQuery) const getRepresentationParams = GetRepresentationParamsSchema.parse({ peer, target: options?.target, options: { - searchQuery: options?.searchQuery, + searchQuery, searchTopK: options?.searchTopK, searchMaxDistance: options?.searchMaxDistance, includeMostFrequent: options?.includeMostFrequent, @@ -980,7 +1051,7 @@ export class Session { const response = await this._getRepresentation(peerId, { session_id: this.id, target: targetId, - search_query: getRepresentationParams.options?.searchQuery, + search_query: searchQuery, search_top_k: getRepresentationParams.options?.searchTopK, search_max_distance: getRepresentationParams.options?.searchMaxDistance, include_most_frequent: @@ -990,6 +1061,17 @@ export class Session { return response.representation } + /** + * Get a single message by ID from this session. + * + * @param messageId - The ID of the message to retrieve + * @returns Promise resolving to the Message object + */ + async getMessage(messageId: string): Promise { + const response = await this._getMessage(messageId) + return Message.fromApiResponse(response) + } + /** * Update the metadata of a message in this session. * diff --git a/sdks/typescript/src/types/api.ts b/sdks/typescript/src/types/api.ts index 9c71d558..1622c99c 100644 --- a/sdks/typescript/src/types/api.ts +++ b/sdks/typescript/src/types/api.ts @@ -285,8 +285,9 @@ export interface RepresentationResponse { export interface RepresentationOptions { /** * Semantic search query to filter relevant conclusions. + * Accepts a string or any object with a `content` property (e.g., a Message). */ - searchQuery?: string + searchQuery?: string | { content: string } /** * Number of semantically relevant conclusions to return. diff --git a/sdks/typescript/src/validation.ts b/sdks/typescript/src/validation.ts index 829699ca..34aa2caf 100644 --- a/sdks/typescript/src/validation.ts +++ b/sdks/typescript/src/validation.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import type { MessageResponse } from './types/api' /** * Validation schemas for the Honcho TypeScript SDK. @@ -23,23 +22,28 @@ export const WorkspaceIdSchema = z /** * Schema for Honcho client configuration options. */ -export const HonchoConfigSchema = z.object({ - apiKey: z.string().optional(), - environment: z.enum(['local', 'production']).optional(), - baseURL: z.url('Base URL must be a valid URL').optional(), - workspaceId: WorkspaceIdSchema.optional(), - timeout: z.number().positive('Timeout must be a positive number').optional(), - maxRetries: z - .number() - .int() - .min(0, 'Max retries must be a non-negative integer') - .max(3, 'Max retries must be at most 3') - .optional(), - defaultHeaders: z.record(z.string(), z.string()).optional(), - defaultQuery: z - .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) - .optional(), -}) +export const HonchoConfigSchema = z + .object({ + apiKey: z.string().optional(), + environment: z.enum(['local', 'production']).optional(), + baseURL: z.url('Base URL must be a valid URL').optional(), + workspaceId: WorkspaceIdSchema.optional(), + timeout: z + .number() + .positive('Timeout must be a positive number') + .optional(), + maxRetries: z + .number() + .int() + .min(0, 'Max retries must be a non-negative integer') + .max(3, 'Max retries must be at most 3') + .optional(), + defaultHeaders: z.record(z.string(), z.string()).optional(), + defaultQuery: z + .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) + .optional(), + }) + .strict() /** * Schema for peer metadata. @@ -49,9 +53,11 @@ export const PeerMetadataSchema = z.record(z.string(), z.unknown()) /** * Schema for peer configuration. */ -export const PeerConfigSchema = z.object({ - observeMe: z.boolean().nullable().optional(), -}) +export const PeerConfigSchema = z + .object({ + observeMe: z.boolean().nullable().optional(), + }) + .strict() /** * Schema for peer ID validation. @@ -65,6 +71,11 @@ export const PeerIdSchema = z ) .max(100, 'Peer ID can be at most 100 characters') +/** + * Strict helper: peer ID as object. + */ +const PeerIdObjectSchema = z.object({ id: PeerIdSchema }) + /** * Schema for session metadata. */ @@ -78,48 +89,58 @@ export const SessionMetadataSchema = z.record(z.string(), z.unknown()) * Schema for reasoning configuration. * Used in workspace, session, and message configuration. */ -export const ReasoningConfigSchema = z.object({ - enabled: z.boolean().nullable().optional(), - customInstructions: z.string().nullable().optional(), -}) +export const ReasoningConfigSchema = z + .object({ + enabled: z.boolean().nullable().optional(), + customInstructions: z.string().nullable().optional(), + }) + .strict() /** * Schema for peer card configuration. * Used in workspace and session configuration. */ -export const PeerCardConfigSchema = z.object({ - use: z.boolean().nullable().optional(), - create: z.boolean().nullable().optional(), -}) +export const PeerCardConfigSchema = z + .object({ + use: z.boolean().nullable().optional(), + create: z.boolean().nullable().optional(), + }) + .strict() /** * Schema for summary configuration. * Used in workspace and session configuration. */ -export const SummaryConfigSchema = z.object({ - enabled: z.boolean().nullable().optional(), - messagesPerShortSummary: z.number().int().min(10).nullable().optional(), - messagesPerLongSummary: z.number().int().min(20).nullable().optional(), -}) +export const SummaryConfigSchema = z + .object({ + enabled: z.boolean().nullable().optional(), + messagesPerShortSummary: z.number().int().min(10).nullable().optional(), + messagesPerLongSummary: z.number().int().min(20).nullable().optional(), + }) + .strict() /** * Schema for dream configuration. * Used in workspace and session configuration. */ -export const DreamConfigSchema = z.object({ - enabled: z.boolean().nullable().optional(), -}) +export const DreamConfigSchema = z + .object({ + enabled: z.boolean().nullable().optional(), + }) + .strict() /** * Schema for session configuration. * Includes reasoning, peer card, summary, and dream settings. */ -export const SessionConfigSchema = z.object({ - reasoning: ReasoningConfigSchema.nullable().optional(), - peerCard: PeerCardConfigSchema.nullable().optional(), - summary: SummaryConfigSchema.nullable().optional(), - dream: DreamConfigSchema.nullable().optional(), -}) +export const SessionConfigSchema = z + .object({ + reasoning: ReasoningConfigSchema.nullable().optional(), + peerCard: PeerCardConfigSchema.nullable().optional(), + summary: SummaryConfigSchema.nullable().optional(), + dream: DreamConfigSchema.nullable().optional(), + }) + .strict() /** * Schema for session ID validation. @@ -133,13 +154,20 @@ export const SessionIdSchema = z ) .max(100, 'Session ID can be at most 100 characters') +/** + * Strict helper: session ID as object. + */ +const SessionIdObjectSchema = z.object({ id: SessionIdSchema }) + /** * Schema for session peer configuration. */ -export const SessionPeerConfigSchema = z.object({ - observeMe: z.boolean().nullable().optional(), - observeOthers: z.boolean().nullable().optional(), -}) +export const SessionPeerConfigSchema = z + .object({ + observeMe: z.boolean().nullable().optional(), + observeOthers: z.boolean().nullable().optional(), + }) + .strict() /** * Schema for message content. @@ -166,19 +194,22 @@ export const MessageConfigurationSchema = z .object({ reasoning: ReasoningConfigSchema.nullable().optional(), }) + .strict() .nullable() .optional() /** * Schema for message input. */ -export const MessageInputSchema = z.object({ - peerId: PeerIdSchema, - content: MessageContentSchema, - metadata: MessageMetadataSchema, - configuration: MessageConfigurationSchema, - createdAt: z.string().nullable().optional(), -}) +export const MessageInputSchema = z + .object({ + peerId: PeerIdSchema, + content: MessageContentSchema, + metadata: MessageMetadataSchema, + configuration: MessageConfigurationSchema, + createdAt: z.string().nullable().optional(), + }) + .strict() /** * Schema for search query validation. @@ -191,78 +222,125 @@ export const SearchQuerySchema = z 'Search query cannot be only whitespace' ) +/** + * Schema for content-like search query objects. + * Accepts SDK Message instances and other objects with a valid content field. + */ +export const SearchQueryObjectSchema = z + .object({ + content: SearchQuerySchema, + }) + .passthrough() + +/** + * Schema for search query inputs that can be normalized to a string. + */ +export const SearchQueryLikeSchema = z.union([ + SearchQuerySchema, + SearchQueryObjectSchema, +]) + +/** + * Normalize a supported search query input to plain text. + */ +export function normalizeSearchQuery(searchQuery: unknown): string | undefined { + if (searchQuery === undefined) { + return undefined + } + + const validatedSearchQuery = SearchQueryLikeSchema.parse(searchQuery) + return typeof validatedSearchQuery === 'string' + ? validatedSearchQuery + : validatedSearchQuery.content +} + /** * Schema for filter objects. */ export const FilterSchema = z.record(z.string(), z.unknown()).optional() /** - * Schema for chat query parameters. + * Normalize list-method input so legacy raw filters and the new options object + * shape are both accepted. + * + * Discriminates on the `filters` key: if the input has a `filters` property or + * any of the pagination-only keys (`page`, `size`, `reverse`) it is treated as + * the new options object. Otherwise it is treated as a legacy raw filter. */ -export const ChatQuerySchema = z.object({ - query: SearchQuerySchema, - target: z - .union([PeerIdSchema, z.object({ id: PeerIdSchema })]) - .optional() - .transform((val) => - val ? (typeof val === 'string' ? val : val.id) : undefined - ), - session: z - .union([SessionIdSchema, z.object({ id: SessionIdSchema })]) - .optional() - .transform((val) => - val ? (typeof val === 'string' ? val : val.id) : undefined - ), - reasoningLevel: z - .enum(['minimal', 'low', 'medium', 'high', 'max']) - .optional(), -}) +export function normalizeListOptions( + input: Filters | T | undefined, + optionKeys: string[] +): T { + if (input === undefined) { + return {} as T + } + + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + return { filters: input as Filters } as T + } + + // Pagination-only keys can never appear in a raw filter object + const paginationKeys = optionKeys.filter((k) => k !== 'filters') + const hasFiltersKey = 'filters' in input + const hasPaginationKey = paginationKeys.some((key) => key in input) + + if (hasFiltersKey || hasPaginationKey) { + return input as T + } + + return { filters: input as Filters } as T +} /** - * Schema for validating Message API responses (snake_case). + * Schema for chat query parameters. */ -const MessageResponseSchema: z.ZodType = z.object({ - id: z.string(), - content: z.string(), - created_at: z.string(), - peer_id: PeerIdSchema, - session_id: SessionIdSchema, - token_count: z.number(), - workspace_id: WorkspaceIdSchema, - metadata: z.record(z.string(), z.unknown()), -}) as z.ZodType +export const ChatQuerySchema = z + .object({ + query: SearchQuerySchema, + target: z + .union([PeerIdSchema, PeerIdObjectSchema]) + .optional() + .transform((val) => + val ? (typeof val === 'string' ? val : val.id) : undefined + ), + session: z + .union([SessionIdSchema, SessionIdObjectSchema]) + .optional() + .transform((val) => + val ? (typeof val === 'string' ? val : val.id) : undefined + ), + reasoningLevel: z + .enum(['minimal', 'low', 'medium', 'high', 'max']) + .optional(), + }) + .strict() /** * Schema for representation options. */ -export const RepresentationOptionsSchema = z.object({ - searchQuery: z - .string() - .min(1, 'searchQuery must be a non-empty string') - .refine( - (query: string) => query.trim().length > 0, - 'searchQuery cannot be only whitespace' - ) - .optional(), - searchTopK: z - .number() - .int() - .min(1, 'searchTopK must be at least 1') - .max(100, 'searchTopK must be at most 100') - .optional(), - searchMaxDistance: z - .number() - .min(0.0, 'searchMaxDistance must be at least 0.0') - .max(1.0, 'searchMaxDistance must be at most 1.0') - .optional(), - includeMostFrequent: z.boolean().optional(), - maxConclusions: z - .number() - .int() - .min(1, 'maxConclusions must be at least 1') - .max(100, 'maxConclusions must be at most 100') - .optional(), -}) +export const RepresentationOptionsSchema = z + .object({ + searchQuery: SearchQueryLikeSchema.optional(), + searchTopK: z + .number() + .int() + .min(1, 'searchTopK must be at least 1') + .max(100, 'searchTopK must be at most 100') + .optional(), + searchMaxDistance: z + .number() + .min(0.0, 'searchMaxDistance must be at least 0.0') + .max(1.0, 'searchMaxDistance must be at most 1.0') + .optional(), + includeMostFrequent: z.boolean().optional(), + maxConclusions: z + .number() + .int() + .min(1, 'maxConclusions must be at least 1') + .max(100, 'maxConclusions must be at most 100') + .optional(), + }) + .strict() /** * Schema for context retrieval parameters. @@ -271,23 +349,18 @@ export const ContextParamsSchema = z .object({ summary: z.boolean().optional(), tokens: z.int('Token limit must be an integer').optional(), - searchQuery: z - .union([ - z.string().min(1, 'Search query must be a non-empty string'), - MessageResponseSchema, - ]) - .optional(), peerTarget: PeerIdSchema.optional(), peerPerspective: PeerIdSchema.optional(), limitToSession: z.boolean().optional(), representationOptions: RepresentationOptionsSchema.optional(), }) + .strict() .superRefine((data, ctx) => { - if (data.searchQuery && !data.peerTarget) { + if (data.representationOptions?.searchQuery && !data.peerTarget) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'peerTarget is required when searchQuery is provided', - path: ['searchQuery'], + path: ['representationOptions', 'searchQuery'], }) } @@ -303,74 +376,72 @@ export const ContextParamsSchema = z /** * Schema for deriver status options. */ -export const QueueStatusOptionsSchema = z.object({ - observer: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]).optional(), - sender: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]).optional(), - session: z - .union([SessionIdSchema, z.object({ id: SessionIdSchema })]) - .optional(), - timeout: z.number().positive('Timeout must be a positive number').optional(), -}) +export const QueueStatusOptionsSchema = z + .object({ + observer: z.union([PeerIdSchema, PeerIdObjectSchema]).optional(), + sender: z.union([PeerIdSchema, PeerIdObjectSchema]).optional(), + session: z.union([SessionIdSchema, SessionIdObjectSchema]).optional(), + timeout: z + .number() + .positive('Timeout must be a positive number') + .optional(), + }) + .strict() /** * Schema for file upload parameters. - * Supports File objects (browser), Buffer, Uint8Array, and custom uploadable objects. + * Supports Blob/File objects and custom uploadable objects with binary content. */ -export const FileUploadSchema = z.object({ - file: z.union([ - // Browser File object - z.instanceof(File), - // Node.js Buffer - z.instanceof(Buffer), - // Uint8Array - z.instanceof(Uint8Array), - // Custom uploadable object with filename, content, and content_type - z.object({ - filename: z.string().min(1, 'Filename must be a non-empty string'), - content: z.union([z.instanceof(Buffer), z.instanceof(Uint8Array)]), - content_type: z - .string() - .min(1, 'Content type must be a non-empty string'), - }), - // Fallback for any other uploadable type - z - .any() - .refine( - (val) => val !== null && val !== undefined, - 'File must not be null or undefined' - ), - ]), - peer: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]), - metadata: MessageMetadataSchema, - configuration: z.record(z.string(), z.unknown()).optional(), - createdAt: z.string().nullable().optional(), -}) +export const FileUploadSchema = z + .object({ + file: z.union([ + // Browser/File API objects + z.instanceof(Blob), + // Custom uploadable object with filename, content, and content_type + z + .object({ + filename: z.string().min(1, 'Filename must be a non-empty string'), + content: z.instanceof(Uint8Array), + content_type: z + .string() + .min(1, 'Content type must be a non-empty string'), + }) + .strict(), + ]), + peer: z.union([PeerIdSchema, PeerIdObjectSchema]), + metadata: MessageMetadataSchema, + configuration: MessageConfigurationSchema, + createdAt: z.string().nullable().optional(), + }) + .strict() /** * Schema for get representation parameters. */ -export const GetRepresentationParamsSchema = z.object({ - peer: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]), - target: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]).optional(), - options: RepresentationOptionsSchema.optional(), -}) +export const GetRepresentationParamsSchema = z + .object({ + peer: z.union([PeerIdSchema, PeerIdObjectSchema]), + target: z.union([PeerIdSchema, PeerIdObjectSchema]).optional(), + options: RepresentationOptionsSchema.optional(), + }) + .strict() /** * Schema for peer get representation parameters. */ -export const PeerGetRepresentationParamsSchema = z.object({ - session: z - .union([SessionIdSchema, z.object({ id: SessionIdSchema })]) - .optional(), - target: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]).optional(), - options: RepresentationOptionsSchema.optional(), -}) +export const PeerGetRepresentationParamsSchema = z + .object({ + session: z.union([SessionIdSchema, SessionIdObjectSchema]).optional(), + target: z.union([PeerIdSchema, PeerIdObjectSchema]).optional(), + options: RepresentationOptionsSchema.optional(), + }) + .strict() /** * Schema for peer card target parameter. */ export const CardTargetSchema = z - .union([PeerIdSchema, z.object({ id: PeerIdSchema })]) + .union([PeerIdSchema, PeerIdObjectSchema]) .optional() .transform((val) => val ? (typeof val === 'string' ? val : val.id) : undefined @@ -386,19 +457,19 @@ export const PeerCardContentSchema = z.array(z.string()) */ export const PeerAdditionSchema = z.union([ PeerIdSchema, - z.object({ id: PeerIdSchema }), + PeerIdObjectSchema, z.array( z.union([ PeerIdSchema, - z.object({ id: PeerIdSchema }), + PeerIdObjectSchema, z.tuple([ - z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]), + z.union([PeerIdSchema, PeerIdObjectSchema]), SessionPeerConfigSchema, ]), ]) ), z.tuple([ - z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]), + z.union([PeerIdSchema, PeerIdObjectSchema]), SessionPeerConfigSchema, ]), ]) @@ -798,8 +869,8 @@ export const PeerAdditionToApiSchema = PeerAdditionSchema.transform( */ export const PeerRemovalSchema = z.union([ PeerIdSchema, - z.object({ id: PeerIdSchema }), - z.array(z.union([PeerIdSchema, z.object({ id: PeerIdSchema })])), + PeerIdObjectSchema, + z.array(z.union([PeerIdSchema, PeerIdObjectSchema])), ]) /** @@ -835,12 +906,14 @@ export const WorkspaceMetadataSchema = z.record(z.string(), z.unknown()) * Schema for workspace configuration. * Includes reasoning, peer card, summary, and dream settings. */ -export const WorkspaceConfigSchema = z.object({ - reasoning: ReasoningConfigSchema.nullable().optional(), - peerCard: PeerCardConfigSchema.nullable().optional(), - summary: SummaryConfigSchema.nullable().optional(), - dream: DreamConfigSchema.nullable().optional(), -}) +export const WorkspaceConfigSchema = z + .object({ + reasoning: ReasoningConfigSchema.nullable().optional(), + peerCard: PeerCardConfigSchema.nullable().optional(), + summary: SummaryConfigSchema.nullable().optional(), + dream: DreamConfigSchema.nullable().optional(), + }) + .strict() /** * Schema for limit. @@ -854,21 +927,23 @@ export const LimitSchema = z /** * Schema for conclusion query parameters. */ -export const ConclusionQueryParamsSchema = z.object({ - query: SearchQuerySchema, - top_k: z - .number() - .int() - .min(1, 'top_k must be at least 1') - .max(100, 'top_k must be at most 100') - .optional(), - distance: z - .number() - .min(0.0, 'distance must be at least 0.0') - .max(1.0, 'distance must be at most 1.0') - .optional(), - filters: FilterSchema, -}) +export const ConclusionQueryParamsSchema = z + .object({ + query: SearchQuerySchema, + top_k: z + .number() + .int() + .min(1, 'top_k must be at least 1') + .max(100, 'top_k must be at most 100') + .optional(), + distance: z + .number() + .min(0.0, 'distance must be at least 0.0') + .max(1.0, 'distance must be at most 1.0') + .optional(), + filters: FilterSchema, + }) + .strict() /** * Type exports for use throughout the SDK. @@ -883,6 +958,7 @@ export type MessageInput = z.infer export type Filters = z.infer export type ChatQuery = z.infer export type ContextParams = z.infer +export type SearchQueryLike = z.infer export type QueueStatusOptions = z.infer export type FileUpload = z.infer export type GetRepresentationParams = z.infer< diff --git a/src/config.py b/src/config.py index f01c451b..cae0c5de 100644 --- a/src/config.py +++ b/src/config.py @@ -1,10 +1,11 @@ import logging +import os from pathlib import Path -from typing import Annotated, Any, ClassVar, Literal, Protocol +from typing import Annotated, Any, ClassVar, Literal, cast import tomllib from dotenv import load_dotenv -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import AliasChoices, BaseModel, Field, field_validator, model_validator from pydantic.fields import FieldInfo from pydantic_settings import ( BaseSettings, @@ -14,17 +15,27 @@ from pydantic_settings import ( SettingsConfigDict, ) -from src.utils.types import SupportedProviders - # Load .env file for local development. # Make sure this is called before AppSettings is instantiated if you rely on .env for AppSettings construction. -load_dotenv(override=True) +if not os.getenv("PYTHON_DOTENV_DISABLED"): + load_dotenv(override=True) logger = logging.getLogger(__name__) +ModelTransport = Literal["anthropic", "openai", "gemini"] +EmbeddingTransport = Literal["openai", "gemini"] + + +def _default_embedding_model_for_transport(transport: EmbeddingTransport) -> str: + if transport == "gemini": + return "gemini-embedding-001" + return "text-embedding-3-small" + def load_toml_config(config_path: str = "config.toml") -> dict[str, Any]: """Load configuration from TOML file if it exists.""" + if config_path == "config.toml" and os.getenv("HONCHO_CONFIG_TOML_DISABLED"): + return {} config_file = Path(config_path) if config_file.exists(): try: @@ -40,13 +51,463 @@ def load_toml_config(config_path: str = "config.toml") -> dict[str, Any]: TOML_CONFIG = load_toml_config() -class LLMComponentSettings(Protocol): - """Protocol for settings classes that use LLM providers with backup support.""" +ThinkingEffortLevel = Literal[ + "none", "minimal", "low", "medium", "high", "xhigh", "max" +] - PROVIDER: SupportedProviders - MODEL: str - BACKUP_PROVIDER: SupportedProviders | None - BACKUP_MODEL: str | None + +class ModelOverrideSettings(BaseModel): + """Advanced module-level transport overrides.""" + + api_key: str | None = None + api_key_env: str | None = None + base_url: str | None = None + + provider_params: dict[str, Any] = Field(default_factory=dict) + + +class PromptCachePolicy(BaseModel): + """Per-call prompt-caching configuration. + + Lives in config.py (not src/llm/caching.py) so ModelConfig can reference + it as a field without a circular import. src/llm/caching.py re-exports + this class for existing import paths. + """ + + mode: Literal["none", "prefix", "gemini_cached_content"] = "none" + ttl_seconds: int | None = None + key_version: str = "v1" + + +def _normalize_model_transport(data: Any) -> Any: + """Normalize 'provider/model' shorthand into separate transport + model fields.""" + if not isinstance(data, dict): + return data + raw_data = cast(dict[Any, Any], data) + update: dict[str, Any] = {str(key): value for key, value in raw_data.items()} + model_value = update.get("model") + transport_value = update.get("transport") + if isinstance(model_value, str) and "/" in model_value and transport_value is None: + prefix, bare_model = model_value.split("/", 1) + if prefix in {"anthropic", "openai", "gemini"}: + update["transport"] = prefix + update["model"] = bare_model + return update + + +def _validate_thinking_constraints( + transport: ModelTransport, thinking_budget_tokens: int | None +) -> None: + """Enforce transport-specific thinking_budget_tokens rules. + + Anthropic requires a minimum of 1024 tokens when thinking is enabled. + Gemini/OpenAI accept any non-negative value (including 0 to disable). + """ + if ( + transport == "anthropic" + and thinking_budget_tokens is not None + and 0 < thinking_budget_tokens < 1024 + ): + raise ValueError("thinking_budget_tokens must be >= 1024 for Anthropic models") + + +class FallbackModelSettings(BaseModel): + """Independent fallback model configuration. No inheritance from primary.""" + + model: str + transport: ModelTransport + + temperature: float | None = None + top_p: float | None = None + top_k: int | None = None + frequency_penalty: float | None = None + presence_penalty: float | None = None + seed: int | None = None + + thinking_effort: ThinkingEffortLevel | None = Field( + default=None, + validation_alias=AliasChoices("thinking_effort", "reasoning_effort"), + ) + thinking_budget_tokens: int | None = None + + max_output_tokens: int | None = None + stop_sequences: list[str] | None = None + + cache_policy: PromptCachePolicy | None = None + + overrides: ModelOverrideSettings = Field(default_factory=ModelOverrideSettings) + + @model_validator(mode="before") + @classmethod + def _normalize_legacy_model_format(cls, data: Any) -> Any: + return _normalize_model_transport(data) + + @property + def reasoning_effort(self) -> ThinkingEffortLevel | None: + return self.thinking_effort + + @model_validator(mode="after") + def _validate_runtime_shape(self) -> "FallbackModelSettings": + _validate_thinking_constraints(self.transport, self.thinking_budget_tokens) + return self + + +class ConfiguredModelSettings(BaseModel): + """Operator-configurable persisted model settings.""" + + model: str + transport: ModelTransport + + fallback: FallbackModelSettings | None = None + + temperature: float | None = None + top_p: float | None = None + top_k: int | None = None + frequency_penalty: float | None = None + presence_penalty: float | None = None + seed: int | None = None + + thinking_effort: ThinkingEffortLevel | None = Field( + default=None, + validation_alias=AliasChoices("thinking_effort", "reasoning_effort"), + ) + thinking_budget_tokens: int | None = None + + max_output_tokens: int | None = None + stop_sequences: list[str] | None = None + + cache_policy: PromptCachePolicy | None = None + + overrides: ModelOverrideSettings = Field(default_factory=ModelOverrideSettings) + + @model_validator(mode="before") + @classmethod + def _normalize_legacy_model_format(cls, data: Any) -> Any: + return _normalize_model_transport(data) + + @property + def reasoning_effort(self) -> ThinkingEffortLevel | None: + """Backward-compatible alias for the generic thinking effort field.""" + return self.thinking_effort + + @model_validator(mode="after") + def _validate_runtime_shape(self) -> "ConfiguredModelSettings": + _validate_thinking_constraints(self.transport, self.thinking_budget_tokens) + return self + + +class ResolvedFallbackConfig(BaseModel): + """Runtime-resolved fallback config with credentials already resolved.""" + + model: str + transport: ModelTransport + + api_key: str | None = None + base_url: str | None = None + + temperature: float | None = None + top_p: float | None = None + top_k: int | None = None + frequency_penalty: float | None = None + presence_penalty: float | None = None + seed: int | None = None + + thinking_effort: ThinkingEffortLevel | None = Field( + default=None, + validation_alias=AliasChoices("thinking_effort", "reasoning_effort"), + ) + thinking_budget_tokens: int | None = None + provider_params: dict[str, Any] = Field(default_factory=dict) + + max_output_tokens: int | None = None + stop_sequences: list[str] | None = None + + cache_policy: PromptCachePolicy | None = None + + @property + def reasoning_effort(self) -> ThinkingEffortLevel | None: + return self.thinking_effort + + +class ModelConfig(BaseModel): + """Reusable model configuration for any non-embedding LLM caller.""" + + model: str + transport: ModelTransport + + fallback: ResolvedFallbackConfig | None = None + + api_key: str | None = None + base_url: str | None = None + + temperature: float | None = None + top_p: float | None = None + top_k: int | None = None + frequency_penalty: float | None = None + presence_penalty: float | None = None + seed: int | None = None + + thinking_effort: ThinkingEffortLevel | None = Field( + default=None, + validation_alias=AliasChoices("thinking_effort", "reasoning_effort"), + ) + thinking_budget_tokens: int | None = None + provider_params: dict[str, Any] = Field(default_factory=dict) + + max_output_tokens: int | None = None + stop_sequences: list[str] | None = None + + cache_policy: PromptCachePolicy | None = None + + @model_validator(mode="before") + @classmethod + def _normalize_legacy_model_format(cls, data: Any) -> Any: + return _normalize_model_transport(data) + + @property + def reasoning_effort(self) -> ThinkingEffortLevel | None: + """Backward-compatible alias for the generic thinking effort field.""" + return self.thinking_effort + + @model_validator(mode="after") + def _validate_thinking_constraints_on_self(self) -> "ModelConfig": + _validate_thinking_constraints(self.transport, self.thinking_budget_tokens) + return self + + def for_model( + self, + model_override: str, + *, + transport_override: ModelTransport | None = None, + ) -> "ModelConfig": + return self.model_copy( + update={ + "model": model_override, + "transport": transport_override or self.transport, + } + ) + + +class ConfiguredEmbeddingModelSettings(BaseModel): + """Operator-configurable persisted embedding settings.""" + + model: str = "text-embedding-3-small" + transport: EmbeddingTransport = "openai" + overrides: ModelOverrideSettings = Field(default_factory=ModelOverrideSettings) + + @model_validator(mode="before") + @classmethod + def _normalize_legacy_model_format(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + + raw_data = cast(dict[Any, Any], data) + update: dict[str, Any] = {str(key): value for key, value in raw_data.items()} + model_value = update.get("model") + transport_value = update.get("transport") + if ( + isinstance(model_value, str) + and "/" in model_value + and transport_value is None + ): + prefix, bare_model = model_value.split("/", 1) + if prefix in {"openai", "gemini"}: + update["transport"] = prefix + update["model"] = bare_model + return update + + @model_validator(mode="after") + def _default_model_for_transport(self) -> "ConfiguredEmbeddingModelSettings": + if "model" not in self.model_fields_set: + self.model = _default_embedding_model_for_transport(self.transport) + return self + + +class EmbeddingModelConfig(BaseModel): + """Runtime embedding configuration with resolved credentials.""" + + model: str = "text-embedding-3-small" + transport: EmbeddingTransport = "openai" + api_key: str | None = None + base_url: str | None = None + + @model_validator(mode="before") + @classmethod + def _normalize_legacy_model_format(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + + raw_data = cast(dict[Any, Any], data) + update: dict[str, Any] = {str(key): value for key, value in raw_data.items()} + model_value = update.get("model") + transport_value = update.get("transport") + if ( + isinstance(model_value, str) + and "/" in model_value + and transport_value is None + ): + prefix, bare_model = model_value.split("/", 1) + if prefix in {"openai", "gemini"}: + update["transport"] = prefix + update["model"] = bare_model + return update + + @model_validator(mode="after") + def _default_model_for_transport(self) -> "EmbeddingModelConfig": + if "model" not in self.model_fields_set: + self.model = _default_embedding_model_for_transport(self.transport) + return self + + +def _resolve_secret(value: str | None, env_name: str | None) -> str | None: + if value is not None: + return value + if env_name is None: + return None + return os.getenv(env_name) + + +def _resolve_fallback_config( + fallback: FallbackModelSettings, +) -> ResolvedFallbackConfig: + """Resolve a FallbackModelSettings into a runtime ResolvedFallbackConfig.""" + return ResolvedFallbackConfig( + model=fallback.model, + transport=fallback.transport, + api_key=_resolve_secret( + fallback.overrides.api_key, + fallback.overrides.api_key_env, + ), + base_url=fallback.overrides.base_url, + temperature=fallback.temperature, + top_p=fallback.top_p, + top_k=fallback.top_k, + frequency_penalty=fallback.frequency_penalty, + presence_penalty=fallback.presence_penalty, + seed=fallback.seed, + thinking_effort=fallback.thinking_effort, + thinking_budget_tokens=fallback.thinking_budget_tokens, + provider_params=fallback.overrides.provider_params, + max_output_tokens=fallback.max_output_tokens, + stop_sequences=fallback.stop_sequences, + cache_policy=fallback.cache_policy, + ) + + +def resolve_model_config(configured: ConfiguredModelSettings) -> ModelConfig: + """Resolve persisted model settings into the runtime ModelConfig.""" + + resolved_fallback = ( + _resolve_fallback_config(configured.fallback) + if configured.fallback is not None + else None + ) + + return ModelConfig( + model=configured.model, + transport=configured.transport, + fallback=resolved_fallback, + api_key=_resolve_secret( + configured.overrides.api_key, + configured.overrides.api_key_env, + ), + base_url=configured.overrides.base_url, + temperature=configured.temperature, + top_p=configured.top_p, + top_k=configured.top_k, + frequency_penalty=configured.frequency_penalty, + presence_penalty=configured.presence_penalty, + seed=configured.seed, + thinking_effort=configured.thinking_effort, + thinking_budget_tokens=configured.thinking_budget_tokens, + provider_params=configured.overrides.provider_params, + max_output_tokens=configured.max_output_tokens, + stop_sequences=configured.stop_sequences, + cache_policy=configured.cache_policy, + ) + + +def _default_embedding_api_key(transport: EmbeddingTransport) -> str | None: + """Fall back to the global LLM API key for the matching transport.""" + if transport == "openai": + return settings.LLM.OPENAI_API_KEY + if transport == "gemini": + return settings.LLM.GEMINI_API_KEY + + +def resolve_embedding_model_config( + configured: ConfiguredEmbeddingModelSettings, +) -> EmbeddingModelConfig: + """Resolve persisted embedding settings into the runtime config.""" + + api_key = _resolve_secret( + configured.overrides.api_key, + configured.overrides.api_key_env, + ) + if api_key is None: + api_key = _default_embedding_api_key(configured.transport) + + return EmbeddingModelConfig( + model=configured.model, + transport=configured.transport, + api_key=api_key, + base_url=configured.overrides.base_url, + ) + + +_TRANSPORT_SPECIFIC_THINKING_KEYS: frozenset[str] = frozenset( + {"thinking_budget_tokens", "thinking_effort"} +) + + +def _fill_defaults_for_nested_field( + data: dict[str, Any], + field_name: str, + default_factory: Any, +) -> dict[str, Any]: + """Fill missing keys in a partial nested dict from the field's defaults. + + When Pydantic's env_nested_delimiter splits an env var like + ``DERIVER_MODEL_CONFIG__THINKING_BUDGET_TOKENS=2048`` it produces + ``{"MODEL_CONFIG": {"THINKING_BUDGET_TOKENS": 2048}}``. Without merging + that partial dict would fail validation because required keys like + ``model`` and ``transport`` are missing. This helper fills them from + the field's ``default_factory`` so partial overrides work. + + If the env override switches ``transport`` to a value that differs from + the default's, transport-specific thinking params + (``thinking_budget_tokens``, ``thinking_effort``) are dropped from the + default before merging. This prevents e.g. a Gemini default's + ``thinking_budget_tokens=1024`` from leaking into an OpenAI override, + which would then be rejected by the OpenAI backend (OpenAI uses + ``reasoning.effort``, not a token budget). Explicit thinking params in + the env override are preserved. + """ + raw: Any = data.get(field_name) or data.get(field_name.lower()) + if not isinstance(raw, dict): + return data + + default_obj = default_factory() + if isinstance(default_obj, BaseModel): + default_dict: dict[str, Any] = default_obj.model_dump(by_alias=True) + else: + default_dict = dict(default_obj) + + raw_dict = cast(dict[str, Any], raw) + raw_lower = {k.lower(): v for k, v in raw_dict.items()} + default_lower = {k.lower(): v for k, v in default_dict.items()} + override_transport = raw_lower.get("transport") + default_transport = default_lower.get("transport") + if override_transport is not None and override_transport != default_transport: + for k in list(default_dict.keys()): + if k.lower() in _TRANSPORT_SPECIFIC_THINKING_KEYS: + del default_dict[k] + + merged: dict[str, Any] = {**default_dict, **raw_dict} + # Preserve the key casing used in data + key = field_name if field_name in data else field_name.lower() + data[key] = merged + return data class TomlConfigSettingsSource(PydanticBaseSettingsSource): @@ -61,6 +522,7 @@ class TomlConfigSettingsSource(PydanticBaseSettingsSource): "SENTRY": "sentry", "CACHE": "cache", "LLM": "llm", + "EMBEDDING": "embedding", "DERIVER": "deriver", "PEER_CARD": "peer_card", "DIALECTIC": "dialectic", @@ -132,26 +594,6 @@ class HonchoSettings(BaseSettings): ) -class BackupLLMSettingsMixin: - """Mixin class for settings that support backup LLM provider configuration. - - Provides backup provider and model fields along with validation to ensure - both fields are set together or both are None. - """ - - BACKUP_PROVIDER: SupportedProviders | None = None - BACKUP_MODEL: str | None = None - - @model_validator(mode="after") - def _validate_backup_configuration(self): - """Ensure both backup fields are set together or both are None.""" - if (self.BACKUP_PROVIDER is None) != (self.BACKUP_MODEL is None): - raise ValueError( - "BACKUP_PROVIDER and BACKUP_MODEL must both be set or both be None" - ) - return self - - class DBSettings(HonchoSettings): model_config = SettingsConfigDict(env_prefix="DB_", extra="ignore") # pyright: ignore @@ -204,16 +646,7 @@ class LLMSettings(HonchoSettings): # API Keys for LLM providers ANTHROPIC_API_KEY: str | None = None OPENAI_API_KEY: str | None = None - OPENAI_COMPATIBLE_API_KEY: str | None = None GEMINI_API_KEY: str | None = None - GROQ_API_KEY: str | None = None - OPENAI_COMPATIBLE_BASE_URL: str | None = None - - # Separate vLLM endpoint (for local models) - VLLM_API_KEY: str | None = None - VLLM_BASE_URL: str | None = None - - EMBEDDING_PROVIDER: Literal["openai", "gemini", "openrouter"] = "openai" # General LLM settings DEFAULT_MAX_TOKENS: Annotated[int, Field(default=1000, gt=0, le=100_000)] = 2500 @@ -232,8 +665,41 @@ class LLMSettings(HonchoSettings): ) -class DeriverSettings(BackupLLMSettingsMixin, HonchoSettings): - model_config = SettingsConfigDict(env_prefix="DERIVER_", extra="ignore") # pyright: ignore +class EmbeddingSettings(HonchoSettings): + model_config = SettingsConfigDict( # pyright: ignore + env_prefix="EMBEDDING_", env_nested_delimiter="__", extra="ignore" + ) + + @staticmethod + def _MODEL_CONFIG_DEFAULT() -> ConfiguredEmbeddingModelSettings: + return ConfiguredEmbeddingModelSettings( + transport="openai", + model="text-embedding-3-small", + ) + + MODEL_CONFIG: ConfiguredEmbeddingModelSettings = Field( + default_factory=_MODEL_CONFIG_DEFAULT + ) + VECTOR_DIMENSIONS: Annotated[int, Field(default=1536, gt=0)] = 1536 + MAX_INPUT_TOKENS: Annotated[int, Field(default=8192, gt=0)] = 8192 + MAX_TOKENS_PER_REQUEST: Annotated[int, Field(default=300_000, gt=0)] = 300_000 + + @model_validator(mode="before") + @classmethod + def _merge_model_config_defaults(cls, data: Any) -> Any: + if isinstance(data, dict): + _fill_defaults_for_nested_field( + cast(dict[str, Any], data), + "MODEL_CONFIG", + cls._MODEL_CONFIG_DEFAULT, + ) + return data # pyright: ignore[reportUnknownVariableType] + + +class DeriverSettings(HonchoSettings): + model_config = SettingsConfigDict( # pyright: ignore + env_prefix="DERIVER_", env_nested_delimiter="__", extra="ignore" + ) ENABLED: bool = True @@ -248,16 +714,21 @@ class DeriverSettings(BackupLLMSettingsMixin, HonchoSettings): int, Field(default=30 * 24 * 3600, gt=0) ] = 30 * 24 * 3600 # 30 days default - PROVIDER: SupportedProviders = "google" - MODEL: str = "gemini-2.5-flash-lite" - TEMPERATURE: float | None = None + @staticmethod + def _MODEL_CONFIG_DEFAULT() -> ConfiguredModelSettings: + # Minimal default: transport + model only. Any other knobs would merge + # into operator-supplied env / config.toml overrides via + # _fill_defaults_for_nested_field and clobber intent. + return ConfiguredModelSettings( + transport="openai", + model="gpt-5.4-mini", + ) + + MODEL_CONFIG: ConfiguredModelSettings = Field(default_factory=_MODEL_CONFIG_DEFAULT) # Whether to deduplicate documents when creating them DEDUPLICATE: bool = True - MAX_OUTPUT_TOKENS: Annotated[int, Field(default=4096, gt=0, le=100_000)] = 4096 - THINKING_BUDGET_TOKENS: Annotated[int, Field(default=1024, gt=0, le=5000)] = 1024 - LOG_OBSERVATIONS: bool = False MAX_INPUT_TOKENS: Annotated[int, Field(default=23000, gt=0, le=23000)] = 23000 @@ -276,6 +747,17 @@ class DeriverSettings(BackupLLMSettingsMixin, HonchoSettings): # When enabled, bypasses the batch token threshold and processes work immediately FLUSH_ENABLED: bool = False + @model_validator(mode="before") + @classmethod + def _merge_model_config_defaults(cls, data: Any) -> Any: + if isinstance(data, dict): + _fill_defaults_for_nested_field( + cast(dict[str, Any], data), + "MODEL_CONFIG", + cls._MODEL_CONFIG_DEFAULT, + ) + return data # pyright: ignore[reportUnknownVariableType] + @model_validator(mode="after") def validate_batch_tokens_vs_context_limit(self): if self.REPRESENTATION_BATCH_MAX_TOKENS > self.MAX_INPUT_TOKENS: @@ -307,14 +789,9 @@ class DialecticLevelSettings(BaseModel): model_config = SettingsConfigDict(populate_by_name=True) # pyright: ignore - PROVIDER: Annotated[SupportedProviders, Field(validation_alias="provider")] - MODEL: Annotated[str, Field(validation_alias="model")] - BACKUP_PROVIDER: Annotated[ - SupportedProviders | None, Field(validation_alias="backup_provider") - ] = None - BACKUP_MODEL: Annotated[str | None, Field(validation_alias="backup_model")] = None - THINKING_BUDGET_TOKENS: Annotated[ - int, Field(ge=0, le=100_000, validation_alias="thinking_budget_tokens") + MODEL_CONFIG: Annotated[ + ConfiguredModelSettings, + Field(validation_alias="model_config"), ] MAX_TOOL_ITERATIONS: Annotated[ int, Field(ge=0, le=50, validation_alias="max_tool_iterations") @@ -326,72 +803,69 @@ class DialecticLevelSettings(BaseModel): None # None/auto lets model decide, "any"/"required" forces tool use ) - @model_validator(mode="after") - def _validate_backup_configuration(self) -> "DialecticLevelSettings": - """Ensure both backup fields are set together or both are None.""" - if (self.BACKUP_PROVIDER is None) != (self.BACKUP_MODEL is None): - raise ValueError( - "BACKUP_PROVIDER and BACKUP_MODEL must both be set or both be None" - ) - return self - @model_validator(mode="after") def _validate_anthropic_thinking_budget(self) -> "DialecticLevelSettings": """Ensure Anthropic thinking budget is >= 1024 when enabled.""" if ( - self.PROVIDER == "anthropic" - and self.THINKING_BUDGET_TOKENS > 0 - and self.THINKING_BUDGET_TOKENS < 1024 + self.MODEL_CONFIG.transport == "anthropic" + and self.MODEL_CONFIG.thinking_budget_tokens is not None + and self.MODEL_CONFIG.thinking_budget_tokens > 0 + and self.MODEL_CONFIG.thinking_budget_tokens < 1024 ): raise ValueError( - f"THINKING_BUDGET_TOKENS must be >= 1024 for Anthropic provider when enabled (got {self.THINKING_BUDGET_TOKENS})" + "MODEL_CONFIG.thinking_budget_tokens must be >= 1024 for " + + "Anthropic models when enabled " + + f"(got {self.MODEL_CONFIG.thinking_budget_tokens})" ) return self +def _default_dialectic_levels() -> dict[ReasoningLevel, DialecticLevelSettings]: + # Minimal defaults per level: transport + model only. Non-MODEL_CONFIG + # level tuning (MAX_TOOL_ITERATIONS, MAX_OUTPUT_TOKENS, TOOL_CHOICE) + # stays here because it's the per-level behavior, not a model knob — + # operators still override any of it via + # DIALECTIC_LEVELS____MODEL_CONFIG__* without conflict. + def _default_model_config() -> ConfiguredModelSettings: + return ConfiguredModelSettings( + transport="openai", + model="gpt-5.4-mini", + ) + + return { + "minimal": DialecticLevelSettings( + MODEL_CONFIG=_default_model_config(), + MAX_TOOL_ITERATIONS=1, + MAX_OUTPUT_TOKENS=250, + TOOL_CHOICE="any", + ), + "low": DialecticLevelSettings( + MODEL_CONFIG=_default_model_config(), + MAX_TOOL_ITERATIONS=5, + TOOL_CHOICE="any", + ), + "medium": DialecticLevelSettings( + MODEL_CONFIG=_default_model_config(), + MAX_TOOL_ITERATIONS=2, + ), + "high": DialecticLevelSettings( + MODEL_CONFIG=_default_model_config(), + MAX_TOOL_ITERATIONS=4, + ), + "max": DialecticLevelSettings( + MODEL_CONFIG=_default_model_config(), + MAX_TOOL_ITERATIONS=10, + ), + } + + class DialecticSettings(HonchoSettings): model_config = SettingsConfigDict( # pyright: ignore env_prefix="DIALECTIC_", env_nested_delimiter="__", extra="ignore" ) - # Per-level settings for provider, model, thinking budget, and tool iterations - # TODO: Fill in appropriate values for each reasoning level LEVELS: dict[ReasoningLevel, DialecticLevelSettings] = Field( - default_factory=lambda: { - "minimal": DialecticLevelSettings( - PROVIDER="google", - MODEL="gemini-2.5-flash-lite", - THINKING_BUDGET_TOKENS=0, - MAX_TOOL_ITERATIONS=1, - MAX_OUTPUT_TOKENS=250, - TOOL_CHOICE="any", - ), - "low": DialecticLevelSettings( - PROVIDER="google", - MODEL="gemini-2.5-flash-lite", - THINKING_BUDGET_TOKENS=0, - MAX_TOOL_ITERATIONS=5, - TOOL_CHOICE="any", - ), - "medium": DialecticLevelSettings( - PROVIDER="anthropic", - MODEL="claude-haiku-4-5", - THINKING_BUDGET_TOKENS=1024, - MAX_TOOL_ITERATIONS=2, - ), - "high": DialecticLevelSettings( - PROVIDER="anthropic", - MODEL="claude-haiku-4-5", - THINKING_BUDGET_TOKENS=1024, - MAX_TOOL_ITERATIONS=4, - ), - "max": DialecticLevelSettings( - PROVIDER="anthropic", - MODEL="claude-haiku-4-5", - THINKING_BUDGET_TOKENS=2048, - MAX_TOOL_ITERATIONS=10, - ), - } + default_factory=_default_dialectic_levels ) MAX_OUTPUT_TOKENS: Annotated[int, Field(default=8192, gt=0, le=100_000)] = 8192 @@ -406,13 +880,68 @@ class DialecticSettings(HonchoSettings): int, Field(default=4_096, ge=0, le=16_384) ] = 4_096 + @model_validator(mode="before") + @classmethod + def _merge_level_defaults(cls, data: Any) -> Any: + """Merge partial level overrides with built-in defaults.""" + if not isinstance(data, dict): + return data + typed_data = cast(dict[str, Any], data) + levels_raw: dict[str, Any] | None = typed_data.get("LEVELS") or typed_data.get( + "levels" + ) + if not isinstance(levels_raw, dict): + return data # pyright: ignore[reportUnknownVariableType] + defaults = _default_dialectic_levels() + for level_name_key, level_override_val in levels_raw.items(): + level_name = str(level_name_key) + if not isinstance(level_override_val, dict): + continue + level_override = cast(dict[str, Any], level_override_val) + if level_name in defaults: + base: dict[str, Any] = defaults[level_name].model_dump(by_alias=True) + # Recursively merge nested MODEL_CONFIG / model_config too. + # model_dump() always produces the Python field name + # ("MODEL_CONFIG"), but TOML overrides arrive as lowercase + # ("model_config"). Check both casings in the override and + # resolve the base value from whichever casing is present. + for mc_key in ("MODEL_CONFIG", "model_config"): + if mc_key in level_override and isinstance( + level_override[mc_key], dict + ): + base_mc: dict[str, Any] = dict( + base.get("MODEL_CONFIG") or base.get("model_config") or {} + ) + override_mc = cast(dict[str, Any], level_override[mc_key]) + override_lower = {k.lower(): v for k, v in override_mc.items()} + base_lower = {k.lower(): v for k, v in base_mc.items()} + override_transport = override_lower.get("transport") + base_transport = base_lower.get("transport") + if ( + override_transport is not None + and override_transport != base_transport + ): + for k in list(base_mc.keys()): + if k.lower() in _TRANSPORT_SPECIFIC_THINKING_KEYS: + del base_mc[k] + level_override[mc_key] = {**base_mc, **override_mc} + levels_raw[level_name] = {**base, **level_override} + return data # pyright: ignore[reportUnknownVariableType] + @model_validator(mode="after") def _validate_token_budgets(self) -> "DialecticSettings": """Ensure the output token limit exceeds all thinking budgets.""" for level, level_settings in self.LEVELS.items(): - if self.MAX_OUTPUT_TOKENS <= level_settings.THINKING_BUDGET_TOKENS: + thinking_budget = level_settings.MODEL_CONFIG.thinking_budget_tokens or 0 + effective_max = ( + level_settings.MAX_OUTPUT_TOKENS + if level_settings.MAX_OUTPUT_TOKENS is not None + else self.MAX_OUTPUT_TOKENS + ) + if thinking_budget > 0 and thinking_budget >= effective_max: raise ValueError( - f"MAX_OUTPUT_TOKENS must be greater than THINKING_BUDGET_TOKENS for level '{level}'" + "MAX_OUTPUT_TOKENS must be greater than MODEL_CONFIG." + + f"thinking_budget_tokens for level '{level}'" ) return self @@ -425,21 +954,40 @@ class DialecticSettings(HonchoSettings): return self -class SummarySettings(BackupLLMSettingsMixin, HonchoSettings): - model_config = SettingsConfigDict(env_prefix="SUMMARY_", extra="ignore") # pyright: ignore +class SummarySettings(HonchoSettings): + model_config = SettingsConfigDict( # pyright: ignore + env_prefix="SUMMARY_", env_nested_delimiter="__", extra="ignore" + ) ENABLED: bool = True MESSAGES_PER_SHORT_SUMMARY: Annotated[int, Field(default=20, gt=0, le=100)] = 20 MESSAGES_PER_LONG_SUMMARY: Annotated[int, Field(default=60, gt=0, le=500)] = 60 - PROVIDER: SupportedProviders = "google" - MODEL: str = "gemini-2.5-flash" + @staticmethod + def _MODEL_CONFIG_DEFAULT() -> ConfiguredModelSettings: + # Minimal default; extra knobs would merge into env/TOML overrides. + return ConfiguredModelSettings( + transport="openai", + model="gpt-5.4-mini", + ) + + MODEL_CONFIG: ConfiguredModelSettings = Field(default_factory=_MODEL_CONFIG_DEFAULT) + + @model_validator(mode="before") + @classmethod + def _merge_model_config_defaults(cls, data: Any) -> Any: + if isinstance(data, dict): + _fill_defaults_for_nested_field( + cast(dict[str, Any], data), + "MODEL_CONFIG", + cls._MODEL_CONFIG_DEFAULT, + ) + return data # pyright: ignore[reportUnknownVariableType] + MAX_TOKENS_SHORT: Annotated[int, Field(default=1000, gt=0, le=10_000)] = 1000 MAX_TOKENS_LONG: Annotated[int, Field(default=4000, gt=0, le=20_000)] = 4000 - THINKING_BUDGET_TOKENS: Annotated[int, Field(default=512, gt=0, le=2000)] = 512 - class WebhookSettings(HonchoSettings): model_config = SettingsConfigDict(env_prefix="WEBHOOK_", extra="ignore") # pyright: ignore @@ -528,7 +1076,7 @@ class SurprisalSettings(BaseModel): INCLUDE_LEVELS: list[str] = ["explicit", "deductive"] -class DreamSettings(BackupLLMSettingsMixin, HonchoSettings): +class DreamSettings(HonchoSettings): model_config = SettingsConfigDict( # pyright: ignore env_prefix="DREAM_", env_nested_delimiter="__", extra="ignore" ) @@ -539,11 +1087,6 @@ class DreamSettings(BackupLLMSettingsMixin, HonchoSettings): MIN_HOURS_BETWEEN_DREAMS: Annotated[int, Field(default=8, gt=0, le=72)] = 8 ENABLED_TYPES: list[str] = ["omni"] - PROVIDER: SupportedProviders = "anthropic" - MODEL: str = "claude-sonnet-4-20250514" - MAX_OUTPUT_TOKENS: Annotated[int, Field(default=16_384, gt=0, le=64_000)] = 16_384 - THINKING_BUDGET_TOKENS: Annotated[int, Field(default=8192, gt=0, le=32_000)] = 8192 - # Agent iteration limit - increased for extended reasoning workflow MAX_TOOL_ITERATIONS: Annotated[int, Field(default=20, gt=0, le=50)] = 20 @@ -552,23 +1095,66 @@ class DreamSettings(BackupLLMSettingsMixin, HonchoSettings): 16_384 ) - ## NOTE: specialist models use the same provider as the main model + @staticmethod + def _DEDUCTION_MODEL_CONFIG_DEFAULT() -> ConfiguredModelSettings: + # Minimal default; extra knobs would merge into env/TOML overrides. + return ConfiguredModelSettings( + transport="openai", + model="gpt-5.4-mini", + ) - # Deduction Specialist: handles logical inference - DEDUCTION_MODEL: str = "claude-haiku-4-5" - # Induction Specialist: identifies patterns across observations - INDUCTION_MODEL: str = "claude-haiku-4-5" + DEDUCTION_MODEL_CONFIG: ConfiguredModelSettings = Field( + default_factory=_DEDUCTION_MODEL_CONFIG_DEFAULT + ) + + @staticmethod + def _INDUCTION_MODEL_CONFIG_DEFAULT() -> ConfiguredModelSettings: + # Minimal default; extra knobs would merge into env/TOML overrides. + return ConfiguredModelSettings( + transport="openai", + model="gpt-5.4-mini", + ) + + INDUCTION_MODEL_CONFIG: ConfiguredModelSettings = Field( + default_factory=_INDUCTION_MODEL_CONFIG_DEFAULT + ) # Surprisal-based sampling subsystem SURPRISAL: SurprisalSettings = Field(default_factory=SurprisalSettings) - @model_validator(mode="after") - def _validate_token_budgets(self) -> "DreamSettings": - """Ensure the output token limit exceeds the thinking budget.""" - if self.MAX_OUTPUT_TOKENS <= self.THINKING_BUDGET_TOKENS: - raise ValueError( - "MAX_OUTPUT_TOKENS must be greater than THINKING_BUDGET_TOKENS" + @model_validator(mode="before") + @classmethod + def _merge_model_config_defaults(cls, data: Any) -> Any: + if isinstance(data, dict): + typed_data = cast(dict[str, Any], data) + _fill_defaults_for_nested_field( + typed_data, + "DEDUCTION_MODEL_CONFIG", + cls._DEDUCTION_MODEL_CONFIG_DEFAULT, ) + _fill_defaults_for_nested_field( + typed_data, + "INDUCTION_MODEL_CONFIG", + cls._INDUCTION_MODEL_CONFIG_DEFAULT, + ) + return data # pyright: ignore[reportUnknownVariableType] + + @model_validator(mode="after") + def _validate_specialist_token_budgets(self) -> "DreamSettings": + """Ensure thinking_budget_tokens < max_output_tokens for each specialist.""" + for name, cfg in ( + ("DEDUCTION_MODEL_CONFIG", self.DEDUCTION_MODEL_CONFIG), + ("INDUCTION_MODEL_CONFIG", self.INDUCTION_MODEL_CONFIG), + ): + if ( + cfg.max_output_tokens is not None + and cfg.thinking_budget_tokens is not None + and cfg.max_output_tokens <= cfg.thinking_budget_tokens + ): + raise ValueError( + f"dream.{name}.max_output_tokens must be greater than " + + f"dream.{name}.thinking_budget_tokens" + ) return self @@ -633,10 +1219,6 @@ class AppSettings(HonchoSettings): MAX_MESSAGE_SIZE: Annotated[int, Field(default=25_000, gt=0)] = 25_000 EMBED_MESSAGES: bool = True - MAX_EMBEDDING_TOKENS: Annotated[int, Field(default=8192, gt=0)] = 8192 - MAX_EMBEDDING_TOKENS_PER_REQUEST: Annotated[int, Field(default=300_000, gt=0)] = ( - 300_000 - ) LANGFUSE_HOST: str | None = None LANGFUSE_PUBLIC_KEY: str | None = None @@ -651,6 +1233,7 @@ class AppSettings(HonchoSettings): AUTH: AuthSettings = Field(default_factory=AuthSettings) SENTRY: SentrySettings = Field(default_factory=SentrySettings) LLM: LLMSettings = Field(default_factory=LLMSettings) + EMBEDDING: EmbeddingSettings = Field(default_factory=EmbeddingSettings) DERIVER: DeriverSettings = Field(default_factory=DeriverSettings) DIALECTIC: DialecticSettings = Field(default_factory=DialecticSettings) PEER_CARD: PeerCardSettings = Field(default_factory=PeerCardSettings) @@ -676,11 +1259,25 @@ class AppSettings(HonchoSettings): self.CACHE.NAMESPACE = self.NAMESPACE if "NAMESPACE" not in self.VECTOR_STORE.model_fields_set: self.VECTOR_STORE.NAMESPACE = self.NAMESPACE + if "DIMENSIONS" not in self.VECTOR_STORE.model_fields_set: + self.VECTOR_STORE.DIMENSIONS = self.EMBEDDING.VECTOR_DIMENSIONS + elif self.VECTOR_STORE.DIMENSIONS != self.EMBEDDING.VECTOR_DIMENSIONS: + raise ValueError( + "VECTOR_STORE.DIMENSIONS must match EMBEDDING.VECTOR_DIMENSIONS" + ) if "NAMESPACE" not in self.TELEMETRY.model_fields_set: self.TELEMETRY.NAMESPACE = self.NAMESPACE if "NAMESPACE" not in self.METRICS.model_fields_set: self.METRICS.NAMESPACE = self.NAMESPACE + if self.EMBEDDING.VECTOR_DIMENSIONS != 1536 and ( + self.VECTOR_STORE.TYPE == "pgvector" or not self.VECTOR_STORE.MIGRATED + ): + raise ValueError( + "EMBEDDING.VECTOR_DIMENSIONS must remain 1536 while pgvector is " + + "active or vector-store migration is incomplete" + ) + return self diff --git a/src/crud/__init__.py b/src/crud/__init__.py index fdff810d..ff8f87ac 100644 --- a/src/crud/__init__.py +++ b/src/crud/__init__.py @@ -9,6 +9,7 @@ from .document import ( create_observations, delete_document, delete_document_by_id, + fetch_documents_by_ids, get_all_documents, get_child_observations, get_documents_by_ids, @@ -16,6 +17,7 @@ from .document import ( query_documents, query_documents_most_derived, query_documents_recent, + query_external_vector_document_ids, ) from .message import ( create_messages, @@ -82,6 +84,7 @@ __all__ = [ # Document "create_documents", "create_observations", + "fetch_documents_by_ids", "get_all_documents", "get_child_observations", "get_documents_by_ids", @@ -89,6 +92,7 @@ __all__ = [ "query_documents", "query_documents_most_derived", "query_documents_recent", + "query_external_vector_document_ids", "delete_document", "delete_document_by_id", # Message diff --git a/src/crud/document.py b/src/crud/document.py index 4e5d8751..ba652eea 100644 --- a/src/crud/document.py +++ b/src/crud/document.py @@ -15,14 +15,18 @@ from src.config import settings from src.crud.collection import get_or_create_collection from src.crud.peer import get_peer from src.crud.session import get_session +from src.dependencies import tracked_db from src.embedding_client import embedding_client -from src.exceptions import ResourceNotFoundException, ValidationException +from src.exceptions import ( + ResourceNotFoundException, + ValidationException, + VectorStoreError, +) from src.utils.filter import apply_filter from src.vector_store import ( VectorRecord, VectorStore, get_external_vector_store, - upsert_with_retry, ) logger = getLogger(__name__) @@ -190,9 +194,127 @@ async def query_documents_most_derived( return result.scalars().all() -async def query_documents( +def _uses_pgvector() -> bool: + """Check whether queries should go through pgvector (DB-only) path.""" + return ( + settings.VECTOR_STORE.TYPE == "pgvector" or not settings.VECTOR_STORE.MIGRATED + ) + + +async def query_external_vector_document_ids( + workspace_name: str, + observer: str, + observed: str, + embedding: list[float], + top_k: int = 5, + max_distance: float | None = None, + filters: dict[str, Any] | None = None, +) -> list[str] | None: + """Query external vector store for document IDs sorted by similarity. + + No DB session needed — safe to call outside a tracked_db scope. + + Returns: + Ordered list of document IDs on the external-store path, + empty list when the external store has no results, + or None when the pgvector (DB-only) path should be used instead. + """ + if _uses_pgvector(): + return None + + external_vector_store = get_external_vector_store() + if external_vector_store is None: + return [] + + namespace = external_vector_store.get_vector_namespace( + "document", workspace_name, observer, observed + ) + + vector_filters: dict[str, Any] = {} + if filters: + for key in ["level", "session_name"]: + if key in filters: + vector_filters[key] = filters[key] + + vector_results = await external_vector_store.query( + namespace, + embedding, + top_k=top_k, + max_distance=max_distance, + filters=vector_filters if vector_filters else None, + ) + + if not vector_results: + return [] + + return [result.id for result in vector_results] + + +async def fetch_documents_by_ids( db: AsyncSession, workspace_name: str, + observer: str, + observed: str, + document_ids: list[str], + filters: dict[str, Any] | None = None, +) -> list[models.Document]: + """Fetch documents by IDs, preserving input order. DB-only operation.""" + if not document_ids: + return [] + + stmt = ( + select(models.Document) + .where(models.Document.workspace_name == workspace_name) + .where(models.Document.observer == observer) + .where(models.Document.observed == observed) + .where(models.Document.deleted_at.is_(None)) + .where(models.Document.id.in_(document_ids)) + ) + stmt = apply_filter(stmt, models.Document, filters) + + result = await db.execute(stmt) + documents = {doc.id: doc for doc in result.scalars().all()} + + return [documents[doc_id] for doc_id in document_ids if doc_id in documents] + + +async def _query_documents_pgvector( + db: AsyncSession, + workspace_name: str, + observer: str, + observed: str, + embedding: list[float], + filters: dict[str, Any] | None, + max_distance: float | None, + top_k: int, +) -> list[models.Document]: + """pgvector similarity search — pure DB operation.""" + stmt = ( + select(models.Document) + .where(models.Document.workspace_name == workspace_name) + .where(models.Document.observer == observer) + .where(models.Document.observed == observed) + .where(models.Document.embedding.isnot(None)) + .where(models.Document.deleted_at.is_(None)) + ) + + if max_distance is not None: + stmt = stmt.where( + models.Document.embedding.cosine_distance(embedding) <= max_distance + ) + + stmt = apply_filter(stmt, models.Document, filters) + stmt = stmt.order_by(models.Document.embedding.cosine_distance(embedding)).limit( + top_k + ) + + result = await db.execute(stmt) + return list(result.scalars().all()) + + +async def query_documents( + db: AsyncSession | None, + workspace_name: str, query: str, *, observer: str, @@ -205,8 +327,12 @@ async def query_documents( """ Query documents using semantic similarity. + When *db* is provided the caller owns the session lifetime. When *db* is + ``None`` the function opens (and closes) its own short-lived session so that + no DB connection is held during external vector-store calls. + Args: - db: Database session + db: Database session, or None to let the function manage its own workspace_name: Name of the workspace query: Search query text observer: Name of the observing peer @@ -225,92 +351,73 @@ async def query_documents( embedding = await embedding_client.embed(query) except ValueError as e: raise ValidationException( - f"Query exceeds maximum token limit of {settings.MAX_EMBEDDING_TOKENS}." + "Query exceeds maximum token limit of " + + f"{settings.EMBEDDING.MAX_INPUT_TOKENS}." ) from e - # Query Postgres directly when using pgvector OR during migration (not yet migrated) - # This ensures we use pgvector as source of truth until migration is complete - if settings.VECTOR_STORE.TYPE == "pgvector" or not settings.VECTOR_STORE.MIGRATED: - stmt = ( - select(models.Document) - .where(models.Document.workspace_name == workspace_name) - .where(models.Document.observer == observer) - .where(models.Document.observed == observed) - .where(models.Document.embedding.isnot(None)) - .where(models.Document.deleted_at.is_(None)) - ) - - if max_distance is not None: - stmt = stmt.where( - models.Document.embedding.cosine_distance(embedding) <= max_distance + if _uses_pgvector(): + # pgvector path — pure DB, open a short session if none provided + if db is not None: + return await _query_documents_pgvector( + db, + workspace_name, + observer, + observed, + embedding, + filters, + max_distance, + top_k, ) + async with tracked_db("query_documents.pgvector") as managed_db: + docs = await _query_documents_pgvector( + managed_db, + workspace_name, + observer, + observed, + embedding, + filters, + max_distance, + top_k, + ) + for doc in docs: + managed_db.expunge(doc) + return docs - stmt = apply_filter(stmt, models.Document, filters) - stmt = stmt.order_by( - models.Document.embedding.cosine_distance(embedding) - ).limit(top_k) - - result = await db.execute(stmt) - return list(result.scalars().all()) - - # FALLBACK: Use external vector store (Turbopuffer, LanceDB) - external_vector_store = get_external_vector_store() - if external_vector_store is None: - return [] - - namespace = external_vector_store.get_vector_namespace( - "document", workspace_name, observer, observed - ) - - # Build vector store filters - # Convert filter dict to vector store format (handles level, session_name, etc.) - vector_filters: dict[str, Any] = {} - if filters: - # Direct pass-through for simple equality filters - # The filters dict can contain: level, session_name, or other document fields - # We can push level and session_name to vector store since they're in metadata - for key in ["level", "session_name"]: - if key in filters: - vector_filters[key] = filters[key] - - # Query external vector store for similar documents with filters applied - vector_results = await external_vector_store.query( - namespace, - embedding, + # External vector store — network call first, DB only for the ID fetch + document_ids = await query_external_vector_document_ids( + workspace_name=workspace_name, + observer=observer, + observed=observed, + embedding=embedding, top_k=top_k, max_distance=max_distance, - filters=vector_filters if vector_filters else None, + filters=filters, ) - if not vector_results: + if not document_ids: return [] - # Get document IDs from vector results (vector ID = document ID for documents) - document_ids = [result.id for result in vector_results] - - # Fetch documents from database - stmt = ( - select(models.Document) - .where(models.Document.workspace_name == workspace_name) - .where(models.Document.observer == observer) - .where(models.Document.observed == observed) - .where(models.Document.deleted_at.is_(None)) - .where(models.Document.id.in_(document_ids)) - ) - # Re-apply all filters at the database layer to catch any constraints - # that aren't supported by the vector store metadata. - stmt = apply_filter(stmt, models.Document, filters) - - result = await db.execute(stmt) - documents = {doc.id: doc for doc in result.scalars().all()} - - # Return documents in order of similarity (preserving vector store order) - ordered_docs: list[models.Document] = [] - for vr in vector_results: - if vr.id in documents: - ordered_docs.append(documents[vr.id]) - - return ordered_docs + if db is not None: + return await fetch_documents_by_ids( + db=db, + workspace_name=workspace_name, + observer=observer, + observed=observed, + document_ids=document_ids, + filters=filters, + ) + async with tracked_db("query_documents.fetch") as managed_db: + docs = await fetch_documents_by_ids( + db=managed_db, + workspace_name=workspace_name, + observer=observer, + observed=observed, + document_ids=document_ids, + filters=filters, + ) + for doc in docs: + managed_db.expunge(doc) + return docs async def create_documents( @@ -321,7 +428,7 @@ async def create_documents( observer: str, observed: str, deduplicate: bool = False, -) -> int: +) -> list[schemas.DocumentCreate]: """ Create multiple documents with optional duplicate detection. @@ -333,9 +440,11 @@ async def create_documents( observed: Name of the observed peer Returns: - Count of new documents + List of DocumentCreate schemas that were actually inserted (excludes + duplicates and failures). """ honcho_documents: list[models.Document] = [] + accepted_documents: list[schemas.DocumentCreate] = [] # Store (document_model, embedding) pairs - IDs aren't available until after commit docs_with_embeddings: list[tuple[models.Document, list[float]]] = [] @@ -391,6 +500,7 @@ async def create_documents( if doc.embedding: new_doc.sync_state = "pending" honcho_documents.append(new_doc) + accepted_documents.append(doc) # Track embedding for vector store (ID will be available after commit) if doc.embedding: @@ -453,11 +563,9 @@ async def create_documents( ) ) - # Upsert to external vector store with retry and update sync state + # Upsert to external vector store and update sync state try: - await upsert_with_retry( - external_vector_store, namespace, vector_records - ) + await external_vector_store.upsert_many(namespace, vector_records) # Success: mark as synced await db.execute( update(models.Document) @@ -470,9 +578,21 @@ async def create_documents( ) await db.commit() + except VectorStoreError: + # Vector store unavailable - increment sync_attempts for reconciliation + logger.warning("Vector store unavailable; leaving docs unsynced") + await db.execute( + update(models.Document) + .where(models.Document.id.in_(doc_ids)) + .values( + sync_attempts=models.Document.sync_attempts + 1, + last_sync_at=func.now(), + ) + ) + await db.commit() + except Exception: - # Failed after retries - increment sync_attempts for reconciliation - logger.exception("Failed to upsert vectors after retries") + logger.exception("Unexpected error upserting vectors") await db.execute( update(models.Document) .where(models.Document.id.in_(doc_ids)) @@ -489,7 +609,7 @@ async def create_documents( "Failed to create documents due to integrity constraint violation" ) from e - return len(honcho_documents) + return accepted_documents async def delete_document( @@ -739,11 +859,9 @@ async def create_observations( ) ) - # Upsert to external vector store with retry and update sync state + # Upsert to external vector store and update sync state try: - await upsert_with_retry( - external_vector_store, namespace, vector_records - ) + await external_vector_store.upsert_many(namespace, vector_records) # Success: mark as synced await db.execute( update(models.Document) @@ -756,10 +874,24 @@ async def create_observations( ) await db.commit() + except VectorStoreError: + logger.warning( + "Vector store unavailable for namespace %s; leaving observations unsynced", + namespace, + ) + await db.execute( + update(models.Document) + .where(models.Document.id.in_(doc_ids)) + .values( + sync_attempts=models.Document.sync_attempts + 1, + last_sync_at=func.now(), + ) + ) + await db.commit() + except Exception: - # Failed after retries - increment sync_attempts for reconciliation logger.exception( - f"Failed to upsert vectors for {namespace} after retries" + "Unexpected error upserting vectors for %s", namespace ) await db.execute( update(models.Document) diff --git a/src/crud/message.py b/src/crud/message.py index b73dc414..3334c6e8 100644 --- a/src/crud/message.py +++ b/src/crud/message.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from datetime import datetime from logging import getLogger from typing import Any @@ -8,16 +9,67 @@ 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.exceptions import VectorStoreError from src.utils.filter import apply_filter from src.utils.formatting import ILIKE_ESCAPE_CHAR, escape_ilike_pattern -from src.vector_store import VectorRecord, get_external_vector_store, upsert_with_retry +from src.vector_store import VectorRecord, get_external_vector_store from .session import get_or_create_session logger = getLogger(__name__) +def _deduplicate_messages( + messages: Sequence[models.Message], limit: int +) -> list[models.Message]: + """Deduplicate messages by public_id, preserving input order.""" + seen: set[str] = set() + result: list[models.Message] = [] + for msg in messages: + if msg.public_id not in seen: + seen.add(msg.public_id) + result.append(msg) + if len(result) >= limit: + break + 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]]: @@ -298,11 +350,11 @@ async def create_messages( ) ) - # Upsert to external vector store with retry and update sync state + # Upsert to external vector store and update sync state if vector_records: try: - await upsert_with_retry( - external_vector_store, namespace, vector_records + await external_vector_store.upsert_many( + namespace, vector_records ) # Success: mark as synced if we have DB rows if embedding_ids: @@ -317,10 +369,9 @@ async def create_messages( ) await db.commit() - except Exception: - # Failed after retries - increment sync_attempts for reconciliation - logger.exception( - "Failed to upsert message vectors after retries" + except VectorStoreError: + logger.warning( + "Vector store unavailable; leaving message vectors unsynced" ) if embedding_ids: await db.execute( @@ -334,6 +385,20 @@ async def create_messages( ) await db.commit() + except Exception: + logger.exception("Unexpected error upserting message vectors") + if embedding_ids: + await db.execute( + update(models.MessageEmbedding) + .where(models.MessageEmbedding.id.in_(embedding_ids)) + .values( + sync_attempts=models.MessageEmbedding.sync_attempts + + 1, + last_sync_at=func.now(), + ) + ) + await db.commit() + except Exception: logger.exception( "Failed to generate message embeddings for %s messages in workspace %s and session %s.", @@ -578,41 +643,104 @@ async def update_message( return honcho_message -async def search_messages( +async def _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]: + """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. + """ + external_vector_store = get_external_vector_store() + if external_vector_store is None: + return [] + + namespace = external_vector_store.get_vector_namespace("message", workspace_name) + + 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), + # so fetch extra to compensate for both deduplication and filtering. + has_date_filters = after_date is not None or before_date is not None + oversample = 6 if has_date_filters else 3 + vector_results = await external_vector_store.query( + namespace, + query_embedding, + top_k=limit * oversample, + filters=vector_filters if vector_filters else None, + ) + + if not vector_results: + return [] + + # Deduplicate by message_id preserving similarity order + seen: dict[str, None] = {} + for vr in vector_results: + mid = vr.metadata.get("message_id") + if mid and mid not in seen: + seen[mid] = None + message_ids = list(seen.keys()) + + if not message_ids: + return [] + + 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)) + .where(models.Message.workspace_name == workspace_name) + ) + if after_date: + fetch_stmt = fetch_stmt.where(models.Message.created_at >= after_date) + if before_date: + fetch_stmt = fetch_stmt.where(models.Message.created_at <= before_date) + + result = await db.execute(fetch_stmt) + messages_by_id = {msg.public_id: msg for msg in result.scalars().all()} + + 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: str, + *, + 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, - embedding: list[float] | None = None, ) -> list[tuple[list[models.Message], list[models.Message]]]: - """ - Search for messages using semantic similarity and return conversation snippets. - - Each result includes matched messages plus surrounding context. Overlapping - 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 - - 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) - ) - - # First, find the top matching messages + """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( @@ -621,48 +749,157 @@ async def search_messages( ) .where(models.MessageEmbedding.workspace_name == workspace_name) .order_by(models.MessageEmbedding.embedding.cosine_distance(query_embedding)) - .limit(limit) + .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 = list(result.scalars().all()) + matched_messages = _deduplicate_messages(result.scalars().all(), limit) return await _build_merged_snippets( db, workspace_name, matched_messages, context_window ) -async def grep_messages( +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( + 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. + + Each result includes matched messages plus surrounding context. Overlapping + snippets within the same session are merged to avoid repetition. + + Args: + 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. + """ + query_embedding = ( + embedding if embedding is not None else await embedding_client.embed(query) + ) + 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_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 = ( @@ -677,6 +914,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()) @@ -686,6 +927,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, @@ -694,6 +985,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. @@ -706,14 +998,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: @@ -731,7 +1036,6 @@ async def get_messages_by_date_range( async def search_messages_temporal( - db: AsyncSession, workspace_name: str, session_name: str | None, query: str, @@ -739,6 +1043,8 @@ async def search_messages_temporal( 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]]]: """ Search for messages using semantic similarity with optional date filtering. @@ -747,7 +1053,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 @@ -755,43 +1060,25 @@ async def search_messages_temporal( before_date: Only return messages before this datetime 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. """ - # Generate embedding for the search query - query_embedding = await embedding_client.embed(query) - - # Build query with date filters - match_stmt = ( - select(models.Message) - .join( - models.MessageEmbedding, - models.Message.public_id == models.MessageEmbedding.message_id, - ) - .where(models.MessageEmbedding.workspace_name == workspace_name) + query_embedding = ( + embedding if embedding is not None else await embedding_client.embed(query) ) - - 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) - - result = await db.execute(match_stmt) - matched_messages = list(result.scalars().all()) - - 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/representation.py b/src/crud/representation.py index 83f0617a..de97d0d3 100644 --- a/src/crud/representation.py +++ b/src/crud/representation.py @@ -80,7 +80,8 @@ class RepresentationManager: embeddings = await embedding_client.simple_batch_embed(observation_texts) except ValueError as e: raise exceptions.ValidationException( - f"Observation content exceeds maximum token limit of {settings.MAX_EMBEDDING_TOKENS}." + "Observation content exceeds maximum token limit of " + + f"{settings.EMBEDDING.MAX_INPUT_TOKENS}." ) from e batch_embed_duration = (time.perf_counter() - batch_embed_start) * 1000 @@ -162,7 +163,7 @@ class RepresentationManager: ) # Use bulk creation with optional duplicate detection - new_documents = await crud.create_documents( + accepted_documents = await crud.create_documents( db, documents_to_create, self.workspace_name, @@ -177,7 +178,7 @@ class RepresentationManager: except Exception as e: logger.warning(f"Failed to check dream scheduling: {e}") - return new_documents + return len(accepted_documents) async def get_working_representation( self, @@ -366,6 +367,7 @@ class RepresentationManager: models.Document.workspace_name == self.workspace_name, models.Document.observer == self.observer, models.Document.observed == self.observed, + models.Document.deleted_at.is_(None), *( [models.Document.session_name == session_name] if session_name is not None @@ -391,6 +393,7 @@ class RepresentationManager: models.Document.workspace_name == self.workspace_name, models.Document.observer == self.observer, models.Document.observed == self.observed, + models.Document.deleted_at.is_(None), ) .order_by(models.Document.times_derived.desc()) ) 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/dependencies.py b/src/dependencies.py index 77699150..66b1931e 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -17,14 +17,21 @@ async def get_db(): db: AsyncSession = SessionLocal() try: if settings.DB.TRACING: - await db.execute(text(f"SET application_name = '{context}'")) + await db.execute( + text("SELECT set_config('application_name', :name, false)"), + {"name": context}, + ) yield db except Exception: await db.rollback() raise finally: - if db.in_transaction(): - await db.rollback() + # Always send ROLLBACK unconditionally so the wire-level transaction + # is closed before the TCP connection drops. Supavisor v2 does NOT + # clean up orphaned transactions on client disconnect in transaction- + # pooling mode, so relying on `in_transaction()` (Python-side state) + # can leave the backend pinned with an open BEGIN. + await db.rollback() await db.close() @@ -45,7 +52,8 @@ async def tracked_db(operation_name: str | None = None): try: if settings.DB.TRACING: await db.execute( - text(f"SET application_name = '{context or f'task:{operation_name}'}'") + text("SELECT set_config('application_name', :name, false)"), + {"name": context or f"task:{operation_name}"}, ) yield db @@ -53,8 +61,8 @@ async def tracked_db(operation_name: str | None = None): await db.rollback() raise finally: - if db.in_transaction(): - await db.rollback() + # Always send ROLLBACK unconditionally — see get_db() comment. + await db.rollback() await db.close() if token: # Only reset if we set it request_context.reset(token) diff --git a/src/deriver/__main__.py b/src/deriver/__main__.py index e7934678..c3d498b2 100644 --- a/src/deriver/__main__.py +++ b/src/deriver/__main__.py @@ -50,7 +50,6 @@ def setup_logging(): logging.getLogger("httpcore").setLevel(logging.WARNING) logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("openai._base_client").setLevel(logging.WARNING) - logging.getLogger("groq._base_client").setLevel(logging.WARNING) async def run_deriver(): diff --git a/src/deriver/consumer.py b/src/deriver/consumer.py index 984144cd..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: @@ -356,16 +355,16 @@ async def process_reconciler(payload: ReconcilerPayload) -> None: elif reconciler_type == ReconcilerType.CLEANUP_QUEUE: logger.debug("Processing cleanup_queue task") - await cleanup_queue_items() + deleted_count = await cleanup_queue_items() duration_ms = (time.perf_counter() - start_time) * 1000 - # Emit telemetry event for cleanup stale items - emit( - CleanupStaleItemsCompletedEvent( - total_duration_ms=duration_ms, + if deleted_count > 0: + # Emit telemetry event for cleanup stale items + emit( + CleanupStaleItemsCompletedEvent( + total_duration_ms=duration_ms, + ) ) - ) - else: raise ValueError(f"Unsupported reconciler type: {reconciler_type}") diff --git a/src/deriver/deriver.py b/src/deriver/deriver.py index b8735e3c..979a9288 100644 --- a/src/deriver/deriver.py +++ b/src/deriver/deriver.py @@ -2,9 +2,10 @@ import logging import time from src import crud -from src.config import settings +from src.config import ConfiguredModelSettings, settings from src.crud.representation import RepresentationManager from src.dependencies import tracked_db +from src.llm import honcho_llm_call from src.models import Message from src.schemas import ResolvedConfiguration from src.telemetry import prometheus_metrics @@ -16,7 +17,6 @@ from src.telemetry.prometheus.metrics import ( TokenTypes, ) from src.telemetry.sentry import with_sentry_transaction -from src.utils.clients import honcho_llm_call from src.utils.config_helpers import get_configuration from src.utils.formatting import format_new_turn_with_timestamp from src.utils.representation import PromptRepresentation, Representation @@ -27,6 +27,10 @@ from .prompts import estimate_minimal_deriver_prompt_tokens, minimal_deriver_pro logger = logging.getLogger(__name__) +def _get_deriver_model_config() -> ConfiguredModelSettings: + return settings.DERIVER.MODEL_CONFIG + + @with_sentry_transaction("minimal_deriver_batch", op="deriver") async def process_representation_tasks_batch( messages: list[Message], @@ -119,22 +123,20 @@ async def process_representation_tasks_batch( ) # validation on settings means max_tokens will always be > 0 - max_tokens = settings.DERIVER.MAX_OUTPUT_TOKENS or settings.LLM.DEFAULT_MAX_TOKENS + base_model_config = _get_deriver_model_config() + max_tokens = base_model_config.max_output_tokens or settings.LLM.DEFAULT_MAX_TOKENS + model_config = base_model_config # Single LLM call llm_start = time.perf_counter() response = await honcho_llm_call( - llm_settings=settings.DERIVER, + model_config=model_config, prompt=prompt, max_tokens=max_tokens, track_name="Minimal Deriver", response_model=PromptRepresentation, json_mode=True, - temperature=settings.DERIVER.TEMPERATURE, - stop_seqs=[" \n", "\n\n\n\n"], - thinking_budget_tokens=settings.DERIVER.THINKING_BUDGET_TOKENS, max_input_tokens=settings.DERIVER.MAX_INPUT_TOKENS, - reasoning_effort="minimal", enable_retry=True, retry_attempts=3, trace_name="minimal_deriver", 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/dialectic/chat.py b/src/dialectic/chat.py index 5ea0e11f..e9659a21 100644 --- a/src/dialectic/chat.py +++ b/src/dialectic/chat.py @@ -39,13 +39,12 @@ async def agentic_chat( Returns: The synthesized answer string """ - async with tracked_db("dialectic.agentic_chat") as db: - # Validate that the peers exist before proceeding + # Short-lived DB session for validation + config + async with tracked_db("dialectic.preflight") as db: await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observer)) if observer != observed: await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observed)) - # Resolve configuration to check if peer cards should be used session = None if session_name: session = await crud.get_session( @@ -54,7 +53,6 @@ async def agentic_chat( workspace = await crud.get_workspace(db, workspace_name=workspace_name) configuration = get_configuration(None, session, workspace) - # Get peer cards for context (if enabled) observer_peer_card = None observed_peer_card = None if configuration.peer_card.use: @@ -65,22 +63,19 @@ async def agentic_chat( observed_peer_card = await crud.get_peer_card( db, workspace_name, observer=observer, observed=observed ) + # DB session closed — agent runs without holding a connection - # Create and run the dialectic agent - agent = DialecticAgent( - db=db, - workspace_name=workspace_name, - session_name=session_name, - observer=observer, - observed=observed, - observer_peer_card=observer_peer_card, - observed_peer_card=observed_peer_card, - reasoning_level=reasoning_level, - ) + agent = DialecticAgent( + workspace_name=workspace_name, + session_name=session_name, + observer=observer, + observed=observed, + observer_peer_card=observer_peer_card, + observed_peer_card=observed_peer_card, + reasoning_level=reasoning_level, + ) - response = await agent.answer(query) - - return response + return await agent.answer(query) async def agentic_chat_stream( @@ -105,13 +100,12 @@ async def agentic_chat_stream( Yields: Chunks of the response text as they are generated """ - async with tracked_db("dialectic.agentic_chat_stream") as db: - # Validate that the peers exist before proceeding + # Short-lived DB session for validation + config + async with tracked_db("dialectic.preflight") as db: await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observer)) if observer != observed: await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observed)) - # Resolve configuration to check if peer cards should be used session = None if session_name: session = await crud.get_session( @@ -120,7 +114,6 @@ async def agentic_chat_stream( workspace = await crud.get_workspace(db, workspace_name=workspace_name) configuration = get_configuration(None, session, workspace) - # Get peer cards for context (if enabled) observer_peer_card = None observed_peer_card = None if configuration.peer_card.use: @@ -131,18 +124,17 @@ async def agentic_chat_stream( observed_peer_card = await crud.get_peer_card( db, workspace_name, observer=observer, observed=observed ) + # DB session closed — agent streams without holding a connection - # Create and run the dialectic agent - agent = DialecticAgent( - db=db, - workspace_name=workspace_name, - session_name=session_name, - observer=observer, - observed=observed, - observer_peer_card=observer_peer_card, - observed_peer_card=observed_peer_card, - reasoning_level=reasoning_level, - ) + agent = DialecticAgent( + workspace_name=workspace_name, + session_name=session_name, + observer=observer, + observed=observed, + observer_peer_card=observer_peer_card, + observed_peer_card=observed_peer_card, + reasoning_level=reasoning_level, + ) - async for chunk in agent.answer_stream(query): - yield chunk + async for chunk in agent.answer_stream(query): + yield chunk diff --git a/src/dialectic/core.py b/src/dialectic/core.py index 6dd215b5..f8f3b841 100644 --- a/src/dialectic/core.py +++ b/src/dialectic/core.py @@ -11,12 +11,16 @@ import uuid from collections.abc import AsyncIterator, Callable from typing import Any, cast -from sqlalchemy.ext.asyncio import AsyncSession - from src import crud -from src.config import ReasoningLevel, settings +from src.config import ConfiguredModelSettings, ReasoningLevel, settings +from src.dependencies import tracked_db from src.dialectic import prompts from src.embedding_client import embedding_client +from src.llm import ( + HonchoLLMCallResponse, + StreamingResponseWithMetadata, + honcho_llm_call, +) from src.telemetry import prometheus_metrics from src.telemetry.events import DialecticCompletedEvent, emit from src.telemetry.logging import ( @@ -31,16 +35,17 @@ from src.utils.agent_tools import ( create_tool_executor, search_memory, ) -from src.utils.clients import ( - HonchoLLMCallResponse, - StreamingResponseWithMetadata, - honcho_llm_call, -) from src.utils.formatting import format_new_turn_with_timestamp logger = logging.getLogger(__name__) +def _get_dialectic_level_model_config( + reasoning_level: ReasoningLevel, +) -> ConfiguredModelSettings: + return settings.DIALECTIC.LEVELS[reasoning_level].MODEL_CONFIG + + class DialecticAgent: """ An agentic dialectic that iteratively gathers context to answer queries. @@ -52,7 +57,6 @@ class DialecticAgent: def __init__( self, - db: AsyncSession, workspace_name: str, session_name: str | None, observer: str, @@ -66,7 +70,6 @@ class DialecticAgent: Initialize the dialectic agent. Args: - db: Database session workspace_name: Workspace identifier session_name: Session identifier (may be None for global queries) observer: The peer making the query @@ -76,7 +79,6 @@ class DialecticAgent: metric_key: Optional key for logging metrics (if provided, agent won't log separately) reasoning_level: Level of reasoning to apply """ - self.db: AsyncSession = db self.workspace_name: str = workspace_name self.session_name: str | None = session_name self.observer: str = observer @@ -118,19 +120,20 @@ class DialecticAgent: token_limit=max_tokens, reverse=False, # chronological order ) - result = await self.db.execute(stmt) - messages = result.scalars().all() + async with tracked_db("dialectic.session_history") as db: + result = await db.execute(stmt) + messages = result.scalars().all() - if not messages: - return + if not messages: + return - # Format messages for injection - formatted_messages: list[str] = [] - for msg in messages: - formatted = format_new_turn_with_timestamp( - msg.content, msg.created_at, msg.peer_name - ) - formatted_messages.append(formatted) + # Format messages for injection (must access ORM attrs before session closes) + formatted_messages: list[str] = [] + for msg in messages: + formatted = format_new_turn_with_timestamp( + msg.content, msg.created_at, msg.peer_name + ) + formatted_messages.append(formatted) session_history_section = ( "\n\n## SESSION HISTORY\n\n" @@ -169,12 +172,12 @@ class DialecticAgent: prefetch_limit = 10 if self.reasoning_level == "minimal" else 25 try: - # Pre-compute embedding once for both searches + # Pre-compute embedding once for both searches (no DB needed) query_embedding = await embedding_client.embed(query) - # Search explicit observations separately + # search_memory manages its own short-lived DB sessions so no + # connection is held during external vector-store calls. explicit_repr = await search_memory( - db=self.db, workspace_name=self.workspace_name, observer=self.observer, observed=self.observed, @@ -184,9 +187,7 @@ class DialecticAgent: embedding=query_embedding, ) - # Search derived observations separately derived_repr = await search_memory( - db=self.db, workspace_name=self.workspace_name, observer=self.observer, observed=self.observed, @@ -280,7 +281,6 @@ class DialecticAgent: tool_executor: Callable[ [str, dict[str, Any]], Any ] = await create_tool_executor( - db=self.db, workspace_name=self.workspace_name, session_name=self.session_name, observer=self.observer, @@ -411,7 +411,7 @@ class DialecticAgent: ) response: HonchoLLMCallResponse[str] = await honcho_llm_call( - llm_settings=level_settings, + model_config=_get_dialectic_level_model_config(self.reasoning_level), prompt="", # Ignored since we pass messages max_tokens=max_tokens, tools=tools, @@ -420,7 +420,6 @@ class DialecticAgent: max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS, messages=self.messages, track_name="Dialectic Agent", - thinking_budget_tokens=level_settings.THINKING_BUDGET_TOKENS, max_input_tokens=settings.DIALECTIC.MAX_INPUT_TOKENS, trace_name="dialectic_chat", ) @@ -477,7 +476,7 @@ class DialecticAgent: response = cast( StreamingResponseWithMetadata, await honcho_llm_call( - llm_settings=level_settings, + model_config=_get_dialectic_level_model_config(self.reasoning_level), prompt="", # Ignored since we pass messages max_tokens=max_tokens, stream=True, @@ -488,7 +487,6 @@ class DialecticAgent: max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS, messages=self.messages, track_name="Dialectic Agent Stream", - thinking_budget_tokens=level_settings.THINKING_BUDGET_TOKENS, max_input_tokens=settings.DIALECTIC.MAX_INPUT_TOKENS, trace_name="dialectic_chat", ), diff --git a/src/dreamer/orchestrator.py b/src/dreamer/orchestrator.py index adb8329b..000d45a6 100644 --- a/src/dreamer/orchestrator.py +++ b/src/dreamer/orchestrator.py @@ -20,7 +20,6 @@ from dataclasses import dataclass from typing import Any import sentry_sdk -from sqlalchemy.ext.asyncio import AsyncSession from src import crud from src.config import settings @@ -64,7 +63,6 @@ class DreamResult: async def run_dream( - db: AsyncSession, workspace_name: str, observer: str, observed: str, @@ -78,8 +76,9 @@ async def run_dream( 1. Deduction specialist: Creates deductive observations from explicit facts 2. Induction specialist: Creates inductive observations from patterns + Uses short-lived DB sessions to avoid holding connections during LLM calls. + Args: - db: Database session workspace_name: Workspace identifier observer: Observer peer name observed: Observed peer name @@ -96,16 +95,17 @@ async def run_dream( f"[{run_id}] Starting dream cycle for {workspace_name}/{observer}/{observed}" ) - if session_name is not None: - session = await crud.get_session( - db, workspace_name=workspace_name, session_name=session_name - ) - else: - session = None + # Short-lived DB session for config resolution + async with tracked_db("dream.config") as db: + if session_name is not None: + session = await crud.get_session( + db, workspace_name=workspace_name, session_name=session_name + ) + else: + session = None - workspace = await crud.get_workspace(db, workspace_name=workspace_name) - - configuration = get_configuration(None, session, workspace) + workspace = await crud.get_workspace(db, workspace_name=workspace_name) + configuration = get_configuration(None, session, workspace) if not configuration.dream.enabled: logger.info( f"[{run_id}] Dreams disabled for {workspace_name}/{session_name}, skipping dream" @@ -129,7 +129,6 @@ async def run_dream( from src.dreamer.surprisal import sample_observations_with_surprisal high_surprisal_obs = await sample_observations_with_surprisal( - db=db, workspace_name=workspace_name, observer=observer, observed=observed, @@ -164,12 +163,11 @@ async def run_dream( accumulate_metric(task_name, "surprisal_error", str(e), "blob") # Specialists will explore freely without hints - # Phase 1: Run deduction specialist + # Phase 1: Run deduction specialist (manages its own DB sessions) logger.info(f"[{run_id}] Phase 1: Running deduction specialist") deduction_specialist = SPECIALISTS["deduction"] try: deduction_result = await deduction_specialist.run( - db=db, workspace_name=workspace_name, observer=observer, observed=observed, @@ -194,7 +192,6 @@ async def run_dream( induction_specialist = SPECIALISTS["induction"] try: induction_result = await induction_specialist.run( - db=db, workspace_name=workspace_name, observer=observer, observed=observed, @@ -311,14 +308,12 @@ DREAM: {payload.dream_type} documents for {workspace_name}/{payload.observer}/{p try: match payload.dream_type: case DreamType.OMNI: - async with tracked_db("dream_orchestrator") as db: - result = await run_dream( - db=db, - workspace_name=workspace_name, - observer=payload.observer, - observed=payload.observed, - session_name=payload.session_name, - ) + result = await run_dream( + workspace_name=workspace_name, + observer=payload.observer, + observed=payload.observed, + session_name=payload.session_name, + ) # Log completion (telemetry event already emitted in run_dream) if result is not None: diff --git a/src/dreamer/specialists.py b/src/dreamer/specialists.py index 70d51c53..c7277586 100644 --- a/src/dreamer/specialists.py +++ b/src/dreamer/specialists.py @@ -18,10 +18,11 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any -from sqlalchemy.ext.asyncio import AsyncSession - from src import crud, schemas -from src.config import settings +from src.config import ConfiguredModelSettings, settings +from src.dependencies import tracked_db +from src.exceptions import ValidationException +from src.llm import HonchoLLMCallResponse, honcho_llm_call from src.schemas import ResolvedConfiguration from src.telemetry import prometheus_metrics from src.telemetry.events import DreamSpecialistEvent, emit @@ -32,11 +33,22 @@ from src.utils.agent_tools import ( INDUCTION_SPECIALIST_TOOLS, create_tool_executor, ) -from src.utils.clients import HonchoLLMCallResponse, honcho_llm_call logger = logging.getLogger(__name__) +def _require_specialist_model_config( + model_config: ConfiguredModelSettings | None, + *, + specialist_name: str, +) -> ConfiguredModelSettings: + if model_config is None: + raise ValidationException( + f"{specialist_name} MODEL_CONFIG must be resolved before use" + ) + return model_config + + @dataclass class SpecialistResult: """Result of a specialist run for telemetry and aggregation.""" @@ -71,8 +83,8 @@ class BaseSpecialist(ABC): ... @abstractmethod - def get_model(self) -> str: - """Get the model to use for this specialist.""" + def get_model_config(self) -> ConfiguredModelSettings: + """Get the configured model to use for this specialist.""" ... def get_max_tokens(self) -> int: @@ -116,7 +128,6 @@ If you update it, send the full deduplicated list and remove stale entries. async def run( self, - db: AsyncSession, workspace_name: str, observer: str, observed: str, @@ -128,8 +139,9 @@ If you update it, send the full deduplicated list and remove stale entries. """ Run the specialist agent. + Uses short-lived DB sessions to avoid holding connections during LLM calls. + Args: - db: Database session workspace_name: Workspace identifier observer: The observing peer observed: The peer being observed @@ -145,23 +157,27 @@ If you update it, send the full deduplicated list and remove stale entries. task_name = f"dreamer_{self.name}_{run_id}" start_time = time.perf_counter() - # Validate that the peers exist before proceeding - await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observer)) - if observer != observed: - await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observed)) + # Short-lived DB session for preflight operations + async with tracked_db("dream.specialist.preflight") as db: + await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observer)) + if observer != observed: + await crud.get_peer( + db, workspace_name, schemas.PeerCreate(name=observed) + ) - # Determine if peer card tools should be included - peer_card_enabled = configuration is None or configuration.peer_card.create + # Determine if peer card tools should be included + peer_card_enabled = configuration is None or configuration.peer_card.create - # Fetch current peer card to inject into prompt (saves a tool call) - current_peer_card: list[str] | None = None - if peer_card_enabled: - current_peer_card = await crud.get_peer_card( - db, - workspace_name=workspace_name, - observer=observer, - observed=observed, - ) + # Fetch current peer card to inject into prompt (saves a tool call) + current_peer_card: list[str] | None = None + if peer_card_enabled: + current_peer_card = await crud.get_peer_card( + db, + workspace_name=workspace_name, + observer=observer, + observed=observed, + ) + # DB session closed — LLM calls happen without holding a connection # Build messages messages: list[dict[str, str]] = [ @@ -181,7 +197,6 @@ If you update it, send the full deduplicated list and remove stale entries. tool_executor: Callable[ [str, dict[str, Any]], Any ] = await create_tool_executor( - db=db, workspace_name=workspace_name, observer=observer, observed=observed, @@ -194,9 +209,18 @@ If you update it, send the full deduplicated list and remove stale entries. parent_category="dream", ) - # Get model with potential override - model = self.get_model() - llm_settings = settings.DREAM.model_copy(update={"MODEL": model}) + model_config = self.get_model_config() + + # Respect operator-configured max_output_tokens on the specialist's + # ModelConfig (e.g. DREAM_DEDUCTION_MODEL_CONFIG__MAX_OUTPUT_TOKENS). + # Only fall back to the specialist's hardcoded default when the + # config leaves max_output_tokens unset or non-positive. + configured_max = model_config.max_output_tokens + effective_max_tokens = ( + configured_max + if configured_max and configured_max > 0 + else self.get_max_tokens() + ) # Track iterations via callback iteration_count = 0 @@ -207,9 +231,9 @@ If you update it, send the full deduplicated list and remove stale entries. # Run the agent loop response: HonchoLLMCallResponse[str] = await honcho_llm_call( - llm_settings=llm_settings, + model_config=model_config, prompt="", # Ignored since we pass messages - max_tokens=self.get_max_tokens(), + max_tokens=effective_max_tokens, tools=self.get_tools(peer_card_enabled=peer_card_enabled), tool_choice=None, tool_executor=tool_executor, @@ -303,8 +327,11 @@ class DeductionSpecialist(BaseSpecialist): if t["name"] not in PEER_CARD_TOOL_NAMES ] - def get_model(self) -> str: - return settings.DREAM.DEDUCTION_MODEL + def get_model_config(self) -> ConfiguredModelSettings: + return _require_specialist_model_config( + settings.DREAM.DEDUCTION_MODEL_CONFIG, + specialist_name="DREAM DEDUCTION", + ) def get_max_tokens(self) -> int: return 8192 @@ -375,11 +402,12 @@ When statements can't both be true (not just updates), flag them: ## CREATING OBSERVATIONS +Use `create_observations_deductive`. + ```json {{ "observations": [{{ "content": "The logical conclusion", - "level": "deductive", // or "contradiction" "source_ids": ["id1", "id2"], "premises": ["premise 1 text", "premise 2 text"] }}] @@ -391,8 +419,9 @@ When statements can't both be true (not just updates), flag them: 1. Don't explain your reasoning - just call tools 2. Create observations based on what you ACTUALLY FIND, not what you expect 3. Always include source_ids linking to the observations you're synthesizing -4. Delete outdated observations - don't leave duplicates -5. Quality over quantity - fewer good deductions beat many weak ones""" +4. Empty or missing source_ids will be rejected +5. Delete outdated observations - don't leave duplicates +6. Quality over quantity - fewer good deductions beat many weak ones""" def build_user_prompt( self, @@ -446,8 +475,11 @@ class InductionSpecialist(BaseSpecialist): if t["name"] not in PEER_CARD_TOOL_NAMES ] - def get_model(self) -> str: - return settings.DREAM.INDUCTION_MODEL + def get_model_config(self) -> ConfiguredModelSettings: + return _require_specialist_model_config( + settings.DREAM.INDUCTION_MODEL_CONFIG, + specialist_name="DREAM INDUCTION", + ) def get_max_tokens(self) -> int: return 8192 @@ -512,11 +544,12 @@ Create inductive observations when you see patterns: ## CREATING OBSERVATIONS +Use `create_observations_inductive`. + ```json {{ "observations": [{{ "content": "The pattern or generalization", - "level": "inductive", "source_ids": ["id1", "id2", "id3"], "sources": ["evidence 1", "evidence 2"], "pattern_type": "tendency", // preference|behavior|personality|tendency|correlation @@ -531,7 +564,8 @@ Create inductive observations when you see patterns: 2. Don't just restate a single fact as a pattern 3. Confidence based on evidence count: 2=low, 3-4=medium, 5+=high 4. Look for HOW things change over time, not just static facts -5. Include source_ids - always link back to evidence""" +5. Include source_ids - always link back to evidence +6. Empty or missing source_ids will be rejected""" def build_user_prompt( self, diff --git a/src/dreamer/surprisal.py b/src/dreamer/surprisal.py index faaf7c7e..093b2d7b 100644 --- a/src/dreamer/surprisal.py +++ b/src/dreamer/surprisal.py @@ -18,22 +18,32 @@ from sqlalchemy.ext.asyncio import AsyncSession from src import models from src.config import settings from src.crud.document import get_all_documents +from src.dependencies import tracked_db from src.dreamer.trees import SurprisalTree, create_tree logger = logging.getLogger(__name__) +@dataclass +class ObservationData: + """Plain data extracted from a Document ORM object (session-safe).""" + + id: str + content: str + level: str | None + embedding: np.ndarray | list[float] + + @dataclass class SurprisalScore: """Container for observation with surprisal score.""" - observation: models.Document + observation: ObservationData surprisal: float embedding: np.ndarray async def sample_observations_with_surprisal( - db: AsyncSession, workspace_name: str, observer: str, observed: str, @@ -42,15 +52,14 @@ async def sample_observations_with_surprisal( Sample observations and compute surprisal scores. Workflow: - 1. Fetch observations based on SAMPLING_STRATEGY - 2. Extract embeddings from DB (already stored) + 1. Fetch observations based on SAMPLING_STRATEGY (short DB scope) + 2. Extract embeddings (already stored on objects, no DB needed) 3. Build tree structure using trees.create_tree() 4. Compute surprisal for each observation 5. Rank by surprisal (highest first) 6. Filter by threshold and take top N Args: - db: Database session workspace_name: Workspace identifier observer: Observer peer name observed: Observed peer name @@ -59,13 +68,29 @@ async def sample_observations_with_surprisal( List of SurprisalScore objects, ranked by surprisal (highest first) """ try: - # 1. Fetch observations - observations = await _fetch_observations( - db=db, - workspace_name=workspace_name, - observer=observer, - observed=observed, - ) + # 1. Fetch observations (short DB scope, closed before compute) + async with tracked_db("dream.surprisal.fetch") as db: + raw_observations = await _fetch_observations( + db=db, + workspace_name=workspace_name, + observer=observer, + observed=observed, + ) + # Extract plain data from ORM objects before session closes, + # skipping any with null embeddings (can't compute surprisal). + observations = [ + ObservationData( + id=obs.id, + content=obs.content, + level=obs.level, + embedding=obs.embedding, + ) + for obs in raw_observations + if obs.embedding is not None + ] + skipped = len(raw_observations) - len(observations) + if skipped: + logger.warning(f"Skipped {skipped} observations with null embeddings") # Edge case: No observations if not observations: @@ -326,7 +351,7 @@ async def _fetch_all_observations( return list(result.scalars().all()) -def _extract_embeddings(observations: list[models.Document]) -> np.ndarray: +def _extract_embeddings(observations: list[ObservationData]) -> np.ndarray: """ Extract embeddings from observations as numpy array. @@ -370,7 +395,7 @@ def _build_tree(embeddings: np.ndarray) -> SurprisalTree: def _compute_surprisal_scores( - observations: list[models.Document], + observations: list[ObservationData], embeddings: np.ndarray, tree: SurprisalTree, ) -> list[SurprisalScore]: @@ -378,7 +403,7 @@ def _compute_surprisal_scores( Compute surprisal score for each observation. Args: - observations: List of Document objects + observations: List of ObservationData objects embeddings: np.ndarray of embeddings matching observations tree: Built SurprisalTree diff --git a/src/embedding_client.py b/src/embedding_client.py index 9798dc02..d6c8b46d 100644 --- a/src/embedding_client.py +++ b/src/embedding_client.py @@ -6,9 +6,10 @@ from typing import NamedTuple import tiktoken from google import genai +from google.genai import types as genai_types from openai import AsyncOpenAI -from .config import settings +from .config import EmbeddingModelConfig, resolve_embedding_model_config, settings logger = logging.getLogger(__name__) @@ -26,49 +27,58 @@ class _EmbeddingClient: Embedding client supporting OpenAI and Gemini with chunking and batching support. """ - def __init__(self, api_key: str | None = None, provider: str | None = None): - self.provider: str = provider or settings.LLM.EMBEDDING_PROVIDER + def __init__( + self, + config: EmbeddingModelConfig, + *, + vector_dimensions: int, + max_input_tokens: int, + max_tokens_per_request: int, + ): + self.transport: str = config.transport + self.model: str = config.model + self.vector_dimensions: int = vector_dimensions - if self.provider == "gemini": - if api_key is None: - api_key = settings.LLM.GEMINI_API_KEY - if not api_key: + if self.transport == "gemini": + if not config.api_key: raise ValueError("Gemini API key is required") - self.client: genai.Client | AsyncOpenAI = genai.Client(api_key=api_key) - self.model: str = "gemini-embedding-001" + http_options = ( + genai_types.HttpOptions(base_url=config.base_url) + if config.base_url + else None + ) + self.client: genai.Client | AsyncOpenAI = genai.Client( + api_key=config.api_key, + http_options=http_options, + ) # Gemini has a 2048 token limit - self.max_embedding_tokens: int = min(settings.MAX_EMBEDDING_TOKENS, 2048) + self.max_embedding_tokens: int = min(max_input_tokens, 2048) # Gemini batch size is not documented, using conservative estimate self.max_batch_size: int = 100 - elif self.provider == "openrouter": - if api_key is None: - api_key = settings.LLM.OPENAI_COMPATIBLE_API_KEY - if not api_key: - raise ValueError( - "OpenRouter API key (LLM_OPENAI_COMPATIBLE_API_KEY) is required" - ) - base_url = ( - settings.LLM.OPENAI_COMPATIBLE_BASE_URL - or "https://openrouter.ai/api/v1" - ) - self.client = AsyncOpenAI(api_key=api_key, base_url=base_url) - self.model = "openai/text-embedding-3-small" - self.max_embedding_tokens = settings.MAX_EMBEDDING_TOKENS - self.max_batch_size = 2048 # Same as OpenAI else: # openai - if api_key is None: - api_key = settings.LLM.OPENAI_API_KEY - if not api_key: + if not config.api_key: raise ValueError("OpenAI API key is required") - self.client = AsyncOpenAI(api_key=api_key) - self.model = "text-embedding-3-small" - self.max_embedding_tokens = settings.MAX_EMBEDDING_TOKENS + self.client = AsyncOpenAI( + api_key=config.api_key, + base_url=config.base_url, + ) + self.max_embedding_tokens = max_input_tokens self.max_batch_size = 2048 # OpenAI batch limit self.encoding: tiktoken.Encoding = tiktoken.get_encoding("o200k_base") - self.max_embedding_tokens_per_request: int = ( - settings.MAX_EMBEDDING_TOKENS_PER_REQUEST - ) + self.max_embedding_tokens_per_request: int = max_tokens_per_request + + @property + def provider(self) -> str: + return self.transport + + def _validate_embedding_dimensions(self, embedding: list[float]) -> list[float]: + if len(embedding) != self.vector_dimensions: + raise ValueError( + f"Embedding dimension mismatch for {self.transport}:{self.model}. " + + f"Expected {self.vector_dimensions}, got {len(embedding)}." + ) + return embedding async def embed(self, query: str) -> list[float]: token_count = len(self.encoding.encode(query)) @@ -82,16 +92,16 @@ class _EmbeddingClient: response = await self.client.aio.models.embed_content( model=self.model, contents=query, - config={"output_dimensionality": 1536}, + config={"output_dimensionality": self.vector_dimensions}, ) if not response.embeddings or not response.embeddings[0].values: raise ValueError("No embedding returned from Gemini API") - return response.embeddings[0].values + return self._validate_embedding_dimensions(response.embeddings[0].values) else: # openai response = await self.client.embeddings.create( - model=self.model, input=query + model=self.model, input=[query] ) - return response.data[0].embedding + return self._validate_embedding_dimensions(response.data[0].embedding) async def simple_batch_embed(self, texts: list[str]) -> list[list[float]]: """ @@ -116,18 +126,25 @@ class _EmbeddingClient: response = await self.client.aio.models.embed_content( model=self.model, contents=batch, # pyright: ignore[reportArgumentType] - config={"output_dimensionality": 1536}, + config={"output_dimensionality": self.vector_dimensions}, ) if response.embeddings: for emb in response.embeddings: if emb.values: - embeddings.append(emb.values) + embeddings.append( + self._validate_embedding_dimensions(emb.values) + ) else: # openai response = await self.client.embeddings.create( input=batch, model=self.model, ) - embeddings.extend([data.embedding for data in response.data]) + embeddings.extend( + [ + self._validate_embedding_dimensions(data.embedding) + for data in response.data + ] + ) except Exception as e: # Check if it's a token limit error and re-raise as ValueError for consistency if "token" in str(e).lower(): @@ -252,7 +269,7 @@ class _EmbeddingClient: response = await self.client.aio.models.embed_content( model=self.model, contents=[item.text for item in batch], - config={"output_dimensionality": 1536}, + config={"output_dimensionality": self.vector_dimensions}, ) if response.embeddings: for item, embedding in zip( @@ -260,15 +277,19 @@ class _EmbeddingClient: ): if embedding.values: result[item.text_id][item.chunk_index] = ( - embedding.values + self._validate_embedding_dimensions( + embedding.values + ) ) - else: # openai / openrouter + else: # openai response = await self.client.embeddings.create( model=self.model, input=[item.text for item in batch] ) for item, embedding_data in zip(batch, response.data, strict=True): result[item.text_id][item.chunk_index] = ( - embedding_data.embedding + self._validate_embedding_dimensions( + embedding_data.embedding + ) ) return dict(result) @@ -358,6 +379,7 @@ class EmbeddingClient: """ _instance: "_EmbeddingClient | None" = None + _instance_signature: tuple[object, ...] | None = None _lock: threading.Lock = threading.Lock() _wrapper_instance: "EmbeddingClient | None" = None @@ -374,26 +396,41 @@ class EmbeddingClient: Uses double-checked locking for thread-safe lazy initialization. """ - if self._instance is None: + signature = self._get_settings_signature() + if self._instance is None or self._instance_signature != signature: with self._lock: - if self._instance is None: - provider = settings.LLM.EMBEDDING_PROVIDER - if provider == "gemini": - api_key = settings.LLM.GEMINI_API_KEY - elif provider == "openrouter": - api_key = settings.LLM.OPENAI_COMPATIBLE_API_KEY - else: - api_key = settings.LLM.OPENAI_API_KEY - + if self._instance is None or self._instance_signature != signature: + runtime_config = self._resolve_runtime_config() self._instance = _EmbeddingClient( - api_key=api_key, provider=provider + runtime_config, + vector_dimensions=settings.EMBEDDING.VECTOR_DIMENSIONS, + max_input_tokens=settings.EMBEDDING.MAX_INPUT_TOKENS, + max_tokens_per_request=settings.EMBEDDING.MAX_TOKENS_PER_REQUEST, ) + self._instance_signature = signature logger.debug( - f"Initialized embedding client with provider: {provider}" + "Initialized embedding client with transport: %s model: %s", + runtime_config.transport, + runtime_config.model, ) return self._instance + def _resolve_runtime_config(self) -> EmbeddingModelConfig: + return resolve_embedding_model_config(settings.EMBEDDING.MODEL_CONFIG) + + def _get_settings_signature(self) -> tuple[object, ...]: + runtime_config = self._resolve_runtime_config() + return ( + runtime_config.transport, + runtime_config.model, + runtime_config.api_key, + runtime_config.base_url, + settings.EMBEDDING.VECTOR_DIMENSIONS, + settings.EMBEDDING.MAX_INPUT_TOKENS, + settings.EMBEDDING.MAX_TOKENS_PER_REQUEST, + ) + async def embed(self, query: str) -> list[float]: """Embed a single query string.""" return await self._get_client().embed(query) @@ -418,11 +455,21 @@ class EmbeddingClient: """Get the model name.""" return self._get_client().model + @property + def transport(self) -> str: + """Get the transport name.""" + return self._get_client().transport + @property def max_embedding_tokens(self) -> int: """Get the maximum embedding tokens.""" return self._get_client().max_embedding_tokens + @property + def vector_dimensions(self) -> int: + """Get the configured embedding dimensions.""" + return self._get_client().vector_dimensions + @property def encoding(self) -> tiktoken.Encoding: """Get the tiktoken encoding.""" diff --git a/src/llm/__init__.py b/src/llm/__init__.py new file mode 100644 index 00000000..ae47bc53 --- /dev/null +++ b/src/llm/__init__.py @@ -0,0 +1,66 @@ +"""Honcho LLM orchestration package — stable public surface. + +Application code should import from `src.llm` (or specific submodules like +`src.llm.api` / `src.llm.types`). The old `src/utils/clients.py` entrypoint +is gone; everything lives here now. +""" + +from __future__ import annotations + +from .api import honcho_llm_call +from .backend import CompletionResult, ProviderBackend, StreamChunk, ToolCallResult +from .credentials import default_transport_api_key, resolve_credentials +from .executor import honcho_llm_call_inner +from .registry import ( + CLIENTS, + backend_for_provider, + client_for_model_config, + get_anthropic_client, + get_anthropic_override_client, + get_backend, + get_gemini_client, + get_gemini_override_client, + get_openai_client, + get_openai_override_client, + history_adapter_for_provider, +) +from .types import ( + HonchoLLMCallResponse, + HonchoLLMCallStreamChunk, + IterationCallback, + IterationData, + ProviderClient, + ReasoningEffortType, + StreamingResponseWithMetadata, + VerbosityType, +) + +__all__ = [ + "CLIENTS", + "CompletionResult", + "HonchoLLMCallResponse", + "HonchoLLMCallStreamChunk", + "IterationCallback", + "IterationData", + "ProviderBackend", + "ProviderClient", + "ReasoningEffortType", + "StreamChunk", + "StreamingResponseWithMetadata", + "ToolCallResult", + "VerbosityType", + "backend_for_provider", + "client_for_model_config", + "default_transport_api_key", + "get_anthropic_client", + "get_anthropic_override_client", + "get_backend", + "get_gemini_client", + "get_gemini_override_client", + "get_openai_client", + "get_openai_override_client", + "history_adapter_for_provider", + "honcho_llm_call", + "honcho_llm_call_inner", + "resolve_credentials", +] diff --git a/src/llm/api.py b/src/llm/api.py new file mode 100644 index 00000000..2a8f0529 --- /dev/null +++ b/src/llm/api.py @@ -0,0 +1,366 @@ +"""Public LLM entrypoint: `honcho_llm_call`. + +Orchestrates: +- Runtime config resolution from ConfiguredModelSettings → ModelConfig. +- Per-attempt planning (primary vs fallback selection). +- Retry with exponential backoff via tenacity. +- Tool-loop delegation when tools are supplied. +- Single-call delegation to the executor otherwise. +- Reasoning-trace telemetry emission. +""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator, Callable +from typing import Any, Literal, TypeVar, cast, overload + +from pydantic import BaseModel +from sentry_sdk.ai.monitoring import ai_track +from tenacity import retry, stop_after_attempt, wait_exponential + +from src.config import ConfiguredModelSettings, ModelConfig +from src.exceptions import ValidationException +from src.telemetry.logging import conditional_observe +from src.telemetry.reasoning_traces import log_reasoning_trace + +from .executor import honcho_llm_call_inner +from .runtime import ( + AttemptPlan, + current_attempt, + effective_temperature, + plan_attempt, + resolve_runtime_model_config, + update_current_langfuse_observation, +) +from .tool_loop import execute_tool_loop +from .types import ( + HonchoLLMCallResponse, + HonchoLLMCallStreamChunk, + IterationCallback, + ReasoningEffortType, + StreamingResponseWithMetadata, +) + +logger = logging.getLogger(__name__) + +M = TypeVar("M", bound=BaseModel) + + +@overload +async def honcho_llm_call( + *, + model_config: ModelConfig | ConfiguredModelSettings, + prompt: str, + max_tokens: int, + track_name: str | None = None, + response_model: type[M], + json_mode: bool = False, + temperature: float | None = None, + stop_seqs: list[str] | None = None, + reasoning_effort: ReasoningEffortType = None, + verbosity: Literal["low", "medium", "high"] | None = None, + thinking_budget_tokens: int | None = None, + enable_retry: bool = True, + retry_attempts: int = 3, + stream: Literal[False] = False, + stream_final_only: bool = False, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + tool_executor: Callable[[str, dict[str, Any]], Any] | None = None, + max_tool_iterations: int = 10, + messages: list[dict[str, Any]] | None = None, + max_input_tokens: int | None = None, + trace_name: str | None = None, + iteration_callback: IterationCallback | None = None, +) -> HonchoLLMCallResponse[M]: ... + + +@overload +async def honcho_llm_call( + *, + model_config: ModelConfig | ConfiguredModelSettings, + prompt: str, + max_tokens: int, + track_name: str | None = None, + response_model: None = None, + json_mode: bool = False, + temperature: float | None = None, + stop_seqs: list[str] | None = None, + reasoning_effort: ReasoningEffortType = None, + verbosity: Literal["low", "medium", "high"] | None = None, + thinking_budget_tokens: int | None = None, + enable_retry: bool = True, + retry_attempts: int = 3, + stream: Literal[False] = False, + stream_final_only: bool = False, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + tool_executor: Callable[[str, dict[str, Any]], Any] | None = None, + max_tool_iterations: int = 10, + messages: list[dict[str, Any]] | None = None, + max_input_tokens: int | None = None, + trace_name: str | None = None, + iteration_callback: IterationCallback | None = None, +) -> HonchoLLMCallResponse[str]: ... + + +@overload +async def honcho_llm_call( + *, + model_config: ModelConfig | ConfiguredModelSettings, + prompt: str, + max_tokens: int, + track_name: str | None = None, + response_model: type[BaseModel] | None = None, + json_mode: bool = False, + temperature: float | None = None, + stop_seqs: list[str] | None = None, + reasoning_effort: ReasoningEffortType = None, + verbosity: Literal["low", "medium", "high"] | None = None, + thinking_budget_tokens: int | None = None, + enable_retry: bool = True, + retry_attempts: int = 3, + stream: Literal[True] = ..., + stream_final_only: bool = False, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + tool_executor: Callable[[str, dict[str, Any]], Any] | None = None, + max_tool_iterations: int = 10, + messages: list[dict[str, Any]] | None = None, + max_input_tokens: int | None = None, + trace_name: str | None = None, + iteration_callback: IterationCallback | None = None, +) -> AsyncIterator[HonchoLLMCallStreamChunk] | StreamingResponseWithMetadata: ... + + +@conditional_observe(name="LLM Call") +async def honcho_llm_call( + *, + model_config: ModelConfig | ConfiguredModelSettings, + prompt: str, + max_tokens: int, + track_name: str | None = None, + response_model: type[BaseModel] | None = None, + json_mode: bool = False, + temperature: float | None = None, + stop_seqs: list[str] | None = None, + reasoning_effort: ReasoningEffortType = None, + verbosity: Literal["low", "medium", "high"] | None = None, + thinking_budget_tokens: int | None = None, + enable_retry: bool = True, + retry_attempts: int = 3, + stream: bool = False, + stream_final_only: bool = False, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + tool_executor: Callable[[str, dict[str, Any]], Any] | None = None, + max_tool_iterations: int = 10, + messages: list[dict[str, Any]] | None = None, + max_input_tokens: int | None = None, + trace_name: str | None = None, + iteration_callback: IterationCallback | None = None, +) -> ( + HonchoLLMCallResponse[Any] + | AsyncIterator[HonchoLLMCallStreamChunk] + | StreamingResponseWithMetadata +): + """Make an LLM call with retry, optional backup failover, and optional tool loop. + + Backup provider/model (if configured on the primary ModelConfig's + `fallback`) is used on the final retry attempt, which is 3 by default. + + Raises: + ValidationException: If streaming and tool calling are combined + without `stream_final_only=True`. + """ + runtime_model_config = resolve_runtime_model_config(model_config) + + # Caller kwargs left at None are resolved downstream by + # effective_config_for_call against whichever ModelConfig wins the + # attempt (primary or fallback). Defaulting here from + # runtime_model_config would clobber a fallback config's own + # temperature/thinking params on the final retry, so we deliberately + # keep the locals as the caller supplied them. + + if stream and tools and not stream_final_only: + raise ValidationException( + "Streaming is not supported with tool calling. " + + "Set stream=False when using tools, or use stream_final_only=True " + + "to stream only the final response after tool calls." + ) + + # tenacity uses 1-indexed attempts. + current_attempt.set(1) + + def _get_attempt_plan() -> AttemptPlan: + plan = plan_attempt( + runtime_model_config=runtime_model_config, + attempt=current_attempt.get(), + retry_attempts=retry_attempts, + call_thinking_budget_tokens=thinking_budget_tokens, + call_reasoning_effort=reasoning_effort, + ) + update_current_langfuse_observation( + plan.provider, + plan.model, + name=track_name, + ) + return plan + + async def _call_with_provider_selection() -> ( + HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk] + ): + """Select provider/model based on current attempt, then call once. + + This closure is what tenacity wraps, so selection re-runs per attempt + (and the fallback kicks in on the final attempt automatically). + """ + plan = _get_attempt_plan() + + if stream: + return await honcho_llm_call_inner( + plan.provider, + plan.model, + prompt, + max_tokens, + response_model, + json_mode, + effective_temperature(temperature), + stop_seqs, + plan.reasoning_effort, + verbosity, + plan.thinking_budget_tokens, + stream=True, + client_override=plan.client, + tools=tools, + tool_choice=tool_choice, + selected_config=plan.selected_config, + ) + return await honcho_llm_call_inner( + plan.provider, + plan.model, + prompt, + max_tokens, + response_model, + json_mode, + effective_temperature(temperature), + stop_seqs, + plan.reasoning_effort, + verbosity, + plan.thinking_budget_tokens, + stream=False, + client_override=plan.client, + tools=tools, + tool_choice=tool_choice, + selected_config=plan.selected_config, + ) + + decorated = _call_with_provider_selection + + if track_name: + decorated = ai_track(track_name)(decorated) + + def before_retry_callback(retry_state: Any) -> None: + """Update attempt counter before each retry + log transient failures. + + tenacity's before_sleep fires AFTER an attempt fails, BEFORE sleeping, + so we increment to the next attempt number here. + """ + next_attempt = retry_state.attempt_number + 1 + current_attempt.set(next_attempt) + exc = retry_state.outcome.exception() if retry_state.outcome else None + if exc: + logger.warning( + f"Error on attempt {retry_state.attempt_number}/{retry_attempts} with " + + f"{runtime_model_config.transport}/{runtime_model_config.model}: {exc}" + ) + logger.info(f"Will retry with attempt {next_attempt}/{retry_attempts}") + + if enable_retry: + decorated = retry( + stop=stop_after_attempt(retry_attempts), + wait=wait_exponential(multiplier=1, min=4, max=10), + before_sleep=before_retry_callback, + )(decorated) + + def _trace_thinking_budget() -> int | None: + # Trace log should reflect what got applied, so fall back to the + # runtime config's value when the caller left the kwarg unset. + return ( + thinking_budget_tokens + if thinking_budget_tokens is not None + else runtime_model_config.thinking_budget_tokens + ) + + def _trace_reasoning_effort() -> ReasoningEffortType: + if reasoning_effort is not None: + return reasoning_effort + config_effort = runtime_model_config.thinking_effort + return cast(ReasoningEffortType, config_effort) if config_effort else None + + def _trace_stop_seqs() -> list[str] | None: + return ( + stop_seqs if stop_seqs is not None else runtime_model_config.stop_sequences + ) + + # Tool-less path: call once and return. + if not tools or not tool_executor: + result: ( + HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk] + ) = await decorated() + if trace_name and isinstance(result, HonchoLLMCallResponse): + log_reasoning_trace( + task_type=trace_name, + model_config=runtime_model_config, + prompt=prompt, + response=result, + max_tokens=max_tokens, + thinking_budget_tokens=_trace_thinking_budget(), + reasoning_effort=_trace_reasoning_effort(), + json_mode=json_mode, + stop_seqs=_trace_stop_seqs(), + messages=messages, + ) + return result + + # execute_tool_loop raises ValidationException on out-of-range + # max_tool_iterations; fail-fast is cheaper than silent clamping here. + result = await execute_tool_loop( + prompt=prompt, + max_tokens=max_tokens, + messages=messages, + tools=tools, + tool_choice=tool_choice, + tool_executor=tool_executor, + max_tool_iterations=max_tool_iterations, + response_model=response_model, + json_mode=json_mode, + temperature=temperature, + stop_seqs=stop_seqs, + verbosity=verbosity, + enable_retry=enable_retry, + retry_attempts=retry_attempts, + max_input_tokens=max_input_tokens, + get_attempt_plan=_get_attempt_plan, + before_retry_callback=before_retry_callback, + stream_final=stream_final_only, + iteration_callback=iteration_callback, + ) + if trace_name and isinstance(result, HonchoLLMCallResponse): + log_reasoning_trace( + task_type=trace_name, + model_config=runtime_model_config, + prompt=prompt, + response=result, + max_tokens=max_tokens, + thinking_budget_tokens=_trace_thinking_budget(), + reasoning_effort=_trace_reasoning_effort(), + json_mode=json_mode, + stop_seqs=_trace_stop_seqs(), + messages=messages, + ) + return result + + +__all__ = ["honcho_llm_call"] diff --git a/src/llm/backend.py b/src/llm/backend.py new file mode 100644 index 00000000..5645998c --- /dev/null +++ b/src/llm/backend.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import Any, Protocol, runtime_checkable + +from pydantic import BaseModel + + +@dataclass(slots=True) +class ToolCallResult: + """Normalized tool call from any provider.""" + + id: str + name: str + input: dict[str, Any] + thought_signature: str | None = None + + +@dataclass(slots=True) +class CompletionResult: + """Normalized completion result returned by provider backends.""" + + content: Any = "" + input_tokens: int = 0 + output_tokens: int = 0 + cache_creation_input_tokens: int = 0 + cache_read_input_tokens: int = 0 + finish_reason: str = "stop" + tool_calls: list[ToolCallResult] = field(default_factory=list) + thinking_content: str | None = None + thinking_blocks: list[dict[str, Any]] = field(default_factory=list) + reasoning_details: list[dict[str, Any]] = field(default_factory=list) + raw_response: Any = None + + +@dataclass(slots=True) +class StreamChunk: + """A single chunk in a streaming response.""" + + content: str = "" + is_done: bool = False + finish_reason: str | None = None + output_tokens: int | None = None + + +@runtime_checkable +class ProviderBackend(Protocol): + """Transport-agnostic interface for LLM providers. + + Credentials are baked into the underlying SDK client at backend construction + time (see src/llm/registry.py), so these method signatures deliberately do + not accept api_key / api_base. + """ + + async def complete( + self, + *, + model: str, + messages: list[dict[str, Any]], + max_tokens: int, + temperature: float | None = None, + stop: list[str] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + thinking_budget_tokens: int | None = None, + thinking_effort: str | None = None, + max_output_tokens: int | None = None, + extra_params: dict[str, Any] | None = None, + ) -> CompletionResult: ... + + def stream( + self, + *, + model: str, + messages: list[dict[str, Any]], + max_tokens: int, + temperature: float | None = None, + stop: list[str] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + thinking_budget_tokens: int | None = None, + thinking_effort: str | None = None, + max_output_tokens: int | None = None, + extra_params: dict[str, Any] | None = None, + ) -> AsyncIterator[StreamChunk]: ... diff --git a/src/llm/backends/__init__.py b/src/llm/backends/__init__.py new file mode 100644 index 00000000..dfba81ec --- /dev/null +++ b/src/llm/backends/__init__.py @@ -0,0 +1,9 @@ +from .anthropic import AnthropicBackend +from .gemini import GeminiBackend +from .openai import OpenAIBackend + +__all__ = [ + "AnthropicBackend", + "GeminiBackend", + "OpenAIBackend", +] diff --git a/src/llm/backends/anthropic.py b/src/llm/backends/anthropic.py new file mode 100644 index 00000000..cdf775be --- /dev/null +++ b/src/llm/backends/anthropic.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +import copy +import json +from collections.abc import AsyncIterator +from typing import Any + +from anthropic.types import TextBlock, ThinkingBlock, ToolUseBlock +from pydantic import BaseModel, ValidationError + +from src.llm.backend import CompletionResult, StreamChunk, ToolCallResult +from src.llm.structured_output import repair_response_model_json + + +class AnthropicBackend: + """Provider backend wrapping the native Anthropic SDK.""" + + def __init__(self, client: Any) -> None: + self._client: Any = client + + async def complete( + self, + *, + model: str, + messages: list[dict[str, Any]], + max_tokens: int, + temperature: float | None = None, + stop: list[str] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + thinking_budget_tokens: int | None = None, + thinking_effort: str | None = None, + max_output_tokens: int | None = None, + extra_params: dict[str, Any] | None = None, + ) -> CompletionResult: + del max_output_tokens + if thinking_effort is not None: + raise ValueError( + "Anthropic backend does not support thinking_effort; use thinking_budget_tokens instead" + ) + + request_messages, system_messages = self._extract_system(messages) + params: dict[str, Any] = { + "model": model, + "max_tokens": max_tokens, + "messages": request_messages, + } + + if temperature is not None: + params["temperature"] = temperature + if stop: + params["stop_sequences"] = stop + if system_messages: + params["system"] = [ + { + "type": "text", + "text": "\n\n".join(system_messages), + "cache_control": {"type": "ephemeral"}, + } + ] + if tools: + params["tools"] = tools + converted_tool_choice = self._convert_tool_choice(tool_choice) + if converted_tool_choice is not None: + params["tool_choice"] = converted_tool_choice + if thinking_budget_tokens: + params["thinking"] = { + "type": "enabled", + "budget_tokens": thinking_budget_tokens, + } + if extra_params: + for key in ("top_p", "top_k"): + if key in extra_params: + params[key] = extra_params[key] + + use_json_prefill = ( + bool(response_format or self._json_mode(extra_params)) + and not thinking_budget_tokens + and self._supports_assistant_prefill(model) + ) + if use_json_prefill and params["messages"]: + if response_format and isinstance(response_format, type): + schema_json = json.dumps(response_format.model_json_schema(), indent=2) + self._append_text_to_last_message( + params["messages"], + f"\n\nRespond with valid JSON matching this schema:\n{schema_json}", + ) + params["messages"].append({"role": "assistant", "content": "{"}) + elif ( + response_format and isinstance(response_format, type) and params["messages"] + ): + schema_json = json.dumps(response_format.model_json_schema(), indent=2) + self._append_text_to_last_message( + params["messages"], + f"\n\nRespond with valid JSON matching this schema:\n{schema_json}", + ) + + response = await self._client.messages.create(**params) + return self._normalize_response( + response=response, + response_format=response_format + if isinstance(response_format, type) + else None, + prefilled_json=use_json_prefill, + model_name=model, + ) + + async def stream( + self, + *, + model: str, + messages: list[dict[str, Any]], + max_tokens: int, + temperature: float | None = None, + stop: list[str] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + thinking_budget_tokens: int | None = None, + thinking_effort: str | None = None, + max_output_tokens: int | None = None, + extra_params: dict[str, Any] | None = None, + ) -> AsyncIterator[StreamChunk]: + is_json_mode = self._json_mode(extra_params) + del max_output_tokens + if thinking_effort is not None: + raise ValueError( + "Anthropic backend does not support thinking_effort; use thinking_budget_tokens instead" + ) + + request_messages, system_messages = self._extract_system(messages) + params: dict[str, Any] = { + "model": model, + "max_tokens": max_tokens, + "messages": request_messages, + } + if temperature is not None: + params["temperature"] = temperature + if stop: + params["stop_sequences"] = stop + if tools: + params["tools"] = tools + converted_tool_choice = self._convert_tool_choice(tool_choice) + if converted_tool_choice is not None: + params["tool_choice"] = converted_tool_choice + if system_messages: + params["system"] = [ + { + "type": "text", + "text": "\n\n".join(system_messages), + "cache_control": {"type": "ephemeral"}, + } + ] + if extra_params: + for key in ("top_p", "top_k"): + if key in extra_params: + params[key] = extra_params[key] + use_json_prefill = ( + bool(response_format or is_json_mode) + and not thinking_budget_tokens + and self._supports_assistant_prefill(model) + ) + if use_json_prefill and params["messages"]: + if response_format and isinstance(response_format, type): + schema_json = json.dumps(response_format.model_json_schema(), indent=2) + self._append_text_to_last_message( + params["messages"], + f"\n\nRespond with valid JSON matching this schema:\n{schema_json}", + ) + params["messages"].append({"role": "assistant", "content": "{"}) + elif ( + response_format and isinstance(response_format, type) and params["messages"] + ): + schema_json = json.dumps(response_format.model_json_schema(), indent=2) + self._append_text_to_last_message( + params["messages"], + f"\n\nRespond with valid JSON matching this schema:\n{schema_json}", + ) + if thinking_budget_tokens: + params["thinking"] = { + "type": "enabled", + "budget_tokens": thinking_budget_tokens, + } + + async with self._client.messages.stream(**params) as stream: + async for chunk in stream: + if ( + chunk.type == "content_block_delta" + and hasattr(chunk, "delta") + and hasattr(chunk.delta, "text") + ): + yield StreamChunk(content=getattr(chunk.delta, "text", "")) + + final_message = await stream.get_final_message() + output_tokens = ( + final_message.usage.output_tokens if final_message.usage else None + ) + yield StreamChunk( + is_done=True, + finish_reason=final_message.stop_reason, + output_tokens=output_tokens, + ) + + def _normalize_response( + self, + *, + response: Any, + response_format: type[BaseModel] | None, + prefilled_json: bool, + model_name: str, + ) -> CompletionResult: + text_blocks: list[str] = [] + thinking_text_blocks: list[str] = [] + thinking_full_blocks: list[dict[str, Any]] = [] + tool_calls: list[ToolCallResult] = [] + + for block in response.content: + if isinstance(block, TextBlock): + text_blocks.append(block.text) + elif isinstance(block, ThinkingBlock): + thinking_text_blocks.append(block.thinking) + thinking_full_blocks.append( + { + "type": "thinking", + "thinking": block.thinking, + "signature": block.signature, + } + ) + elif isinstance(block, ToolUseBlock): + tool_calls.append( + ToolCallResult( + id=block.id, + name=block.name, + input=dict(block.input), + ) + ) + + usage = response.usage + cache_creation_tokens = ( + getattr(usage, "cache_creation_input_tokens", 0) or 0 if usage else 0 + ) + cache_read_tokens = ( + getattr(usage, "cache_read_input_tokens", 0) or 0 if usage else 0 + ) + uncached_tokens = usage.input_tokens if usage else 0 + total_input_tokens = uncached_tokens + cache_creation_tokens + cache_read_tokens + + text_content = "\n".join(text_blocks) + thinking_content = ( + "\n".join(thinking_text_blocks) if thinking_text_blocks else None + ) + + content: Any = text_content + if response_format is not None: + raw_content = f"{{{text_content}" if prefilled_json else text_content + try: + if prefilled_json: + parsed_json = json.loads(raw_content) + content = response_format.model_validate(parsed_json) + else: + content = response_format.model_validate_json(raw_content) + except (json.JSONDecodeError, ValidationError, ValueError): + content = repair_response_model_json( + raw_content, + response_format, + model_name, + ) + + return CompletionResult( + content=content, + input_tokens=total_input_tokens, + output_tokens=usage.output_tokens if usage else 0, + cache_creation_input_tokens=cache_creation_tokens, + cache_read_input_tokens=cache_read_tokens, + finish_reason=response.stop_reason or "stop", + tool_calls=tool_calls, + thinking_content=thinking_content, + thinking_blocks=thinking_full_blocks, + raw_response=response, + ) + + @staticmethod + def _supports_assistant_prefill(model: str) -> bool: + # Claude 4-class models reject assistant-prefill and require the + # conversation to end with a user message. + return not model.startswith( + ( + "claude-opus-4", + "claude-sonnet-4", + "claude-haiku-4", + ) + ) + + @staticmethod + def _extract_system( + messages: list[dict[str, Any]], + ) -> tuple[list[dict[str, Any]], list[str]]: + system_messages: list[str] = [] + non_system_messages: list[dict[str, Any]] = [] + for message in messages: + if message.get("role") == "system" and isinstance( + message.get("content"), + str, + ): + system_messages.append(message["content"]) + else: + non_system_messages.append(copy.deepcopy(message)) + return non_system_messages, system_messages + + @staticmethod + def _convert_tool_choice( + tool_choice: str | dict[str, Any] | None, + ) -> dict[str, Any] | None: + if tool_choice is None: + return None + if isinstance(tool_choice, dict): + return tool_choice + if tool_choice == "auto": + return {"type": "auto"} + if tool_choice in {"any", "required"}: + return {"type": "any"} + if tool_choice == "none": + return {"type": "none"} + return {"type": "tool", "name": tool_choice} + + @staticmethod + def _append_text_to_last_message( + messages: list[dict[str, Any]], suffix: str + ) -> None: + """Append text to the last message, handling both string and list content.""" + last = messages[-1] + content = last.get("content") + if isinstance(content, str): + last["content"] = content + suffix + elif isinstance(content, list): + # Content block list — append to the last text block or add one + blocks: list[dict[str, Any]] = content # pyright: ignore[reportUnknownVariableType] + for block in reversed(blocks): + if block.get("type") == "text": + block["text"] = block["text"] + suffix + return + blocks.append({"type": "text", "text": suffix}) + + @staticmethod + def _json_mode(extra_params: dict[str, Any] | None) -> bool: + return bool(extra_params and extra_params.get("json_mode")) diff --git a/src/llm/backends/gemini.py b/src/llm/backends/gemini.py new file mode 100644 index 00000000..b14cefe4 --- /dev/null +++ b/src/llm/backends/gemini.py @@ -0,0 +1,577 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from datetime import datetime, timedelta, timezone +from typing import Any, ClassVar, cast + +from pydantic import BaseModel + +from src.exceptions import LLMError, ValidationException +from src.llm.backend import CompletionResult, StreamChunk, ToolCallResult +from src.llm.caching import ( + GeminiCacheHandle, + PromptCachePolicy, + build_cache_key, + gemini_cache_store, +) +from src.llm.structured_output import repair_response_model_json + +GEMINI_BLOCKED_FINISH_REASONS = { + "SAFETY", + "RECITATION", + "PROHIBITED_CONTENT", + "BLOCKLIST", +} + + +class GeminiBackend: + """Provider backend wrapping the Google GenAI SDK.""" + + def __init__(self, client: Any) -> None: + self._client: Any = client + + async def complete( + self, + *, + model: str, + messages: list[dict[str, Any]], + max_tokens: int, + temperature: float | None = None, + stop: list[str] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + thinking_budget_tokens: int | None = None, + thinking_effort: str | None = None, + max_output_tokens: int | None = None, + extra_params: dict[str, Any] | None = None, + ) -> CompletionResult: + contents, system_instruction = self._convert_messages(messages) + config = self._build_config( + max_tokens=max_output_tokens or max_tokens, + temperature=temperature, + stop=stop, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + thinking_budget_tokens=thinking_budget_tokens, + thinking_effort=thinking_effort, + extra_params=extra_params, + ) + if system_instruction: + config["system_instruction"] = system_instruction + + cache_policy = ( + extra_params.get("cache_policy") + if extra_params and "cache_policy" in extra_params + else None + ) + if isinstance(cache_policy, PromptCachePolicy) and isinstance(contents, list): + # Cache the history prefix; only the last turn is sent as new input. + cacheable = contents[:-1] if contents else [] + await self._attach_cached_content( + model=model, + config=config, + cache_policy=cache_policy, + contents=cacheable, + tools=tools, + ) + if "cached_content" in config and contents: + contents = contents[-1:] + + if isinstance(contents, list) and not contents: + raise LLMError( + "No non-system messages to send to Gemini", + provider="gemini", + model=model, + ) + + response = await self._client.aio.models.generate_content( + model=model, + contents=contents, + config=config or None, + ) + return self._normalize_response( + response=response, + response_format=response_format + if isinstance(response_format, type) + else None, + model_name=model, + ) + + async def stream( + self, + *, + model: str, + messages: list[dict[str, Any]], + max_tokens: int, + temperature: float | None = None, + stop: list[str] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + thinking_budget_tokens: int | None = None, + thinking_effort: str | None = None, + max_output_tokens: int | None = None, + extra_params: dict[str, Any] | None = None, + ) -> AsyncIterator[StreamChunk]: + contents, system_instruction = self._convert_messages(messages) + config = self._build_config( + max_tokens=max_output_tokens or max_tokens, + temperature=temperature, + stop=stop, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + thinking_budget_tokens=thinking_budget_tokens, + thinking_effort=thinking_effort, + extra_params=extra_params, + ) + if system_instruction: + config["system_instruction"] = system_instruction + + cache_policy = ( + extra_params.get("cache_policy") + if extra_params and "cache_policy" in extra_params + else None + ) + if isinstance(cache_policy, PromptCachePolicy) and isinstance(contents, list): + # Cache the history prefix; only the last turn is sent as new input. + cacheable = contents[:-1] if contents else [] + await self._attach_cached_content( + model=model, + config=config, + cache_policy=cache_policy, + contents=cacheable, + tools=tools, + ) + if "cached_content" in config and contents: + contents = contents[-1:] + + if isinstance(contents, list) and not contents: + raise LLMError( + "No non-system messages to send to Gemini", + provider="gemini", + model=model, + ) + + stream = await self._client.aio.models.generate_content_stream( + model=model, + contents=contents, + config=config or None, + ) + + final_chunk = None + any_text = False + async for chunk in stream: + if chunk.text: + any_text = True + yield StreamChunk(content=chunk.text) + final_chunk = chunk + + finish_reason = "stop" + output_tokens: int | None = None + if ( + final_chunk + and getattr(final_chunk, "candidates", None) + and final_chunk.candidates[0].finish_reason + ): + finish_reason = final_chunk.candidates[0].finish_reason.name + if ( + final_chunk + and getattr(final_chunk, "usage_metadata", None) + and getattr(final_chunk.usage_metadata, "candidates_token_count", None) + ): + output_tokens = final_chunk.usage_metadata.candidates_token_count or None + + # Mirror complete()'s behavior on SAFETY / RECITATION / etc. — if + # Gemini blocked the response and produced no usable text, raise + # LLMError rather than silently yielding a terminal chunk carrying + # the blocked finish_reason. Downstream callers should get a clean + # exception and a chance to retry / fall back. + if not any_text and finish_reason in GEMINI_BLOCKED_FINISH_REASONS: + raise LLMError( + f"Gemini response blocked (finish_reason={finish_reason})", + provider="gemini", + model=model, + finish_reason=finish_reason, + ) + + yield StreamChunk( + is_done=True, + finish_reason=finish_reason, + output_tokens=output_tokens, + ) + + def _build_config( + self, + *, + max_tokens: int, + temperature: float | None, + stop: list[str] | None, + tools: list[dict[str, Any]] | None, + tool_choice: str | dict[str, Any] | None, + response_format: type[BaseModel] | dict[str, Any] | None, + thinking_budget_tokens: int | None, + thinking_effort: str | None, + extra_params: dict[str, Any] | None, + ) -> dict[str, Any]: + config: dict[str, Any] = { + "max_output_tokens": max_tokens, + } + if temperature is not None: + config["temperature"] = temperature + if stop: + config["stop_sequences"] = stop + if tools: + config["tools"] = self._convert_tools(tools) + if tool_choice: + config["tool_config"] = self._convert_tool_choice(tool_choice) + if response_format is not None: + config["response_mime_type"] = "application/json" + config["response_schema"] = response_format + elif extra_params and extra_params.get("json_mode") and not tools: + config["response_mime_type"] = "application/json" + thinking_config: dict[str, Any] = {} + if thinking_budget_tokens is not None: + thinking_config["thinking_budget"] = thinking_budget_tokens + if thinking_effort is not None: + thinking_config["thinking_level"] = thinking_effort + if len(thinking_config) > 1: + raise ValidationException( + "Gemini backend does not support sending both thinking_budget_tokens and thinking_effort in the same request" + ) + if thinking_config: + config["thinking_config"] = thinking_config + for key in ("top_p", "top_k", "frequency_penalty", "presence_penalty", "seed"): + if extra_params and key in extra_params: + config[key] = extra_params[key] + return config + + def _normalize_response( + self, + *, + response: Any, + response_format: type[BaseModel] | None, + model_name: str, + ) -> CompletionResult: + candidate = response.candidates[0] if response.candidates else None + finish_reason = ( + candidate.finish_reason.name + if candidate is not None and candidate.finish_reason + else "stop" + ) + + text_parts: list[str] = [] + tool_calls: list[ToolCallResult] = [] + candidate_parts = ( + cast(list[Any] | None, getattr(candidate.content, "parts", None)) + if candidate is not None and getattr(candidate, "content", None) + else None + ) + if isinstance(candidate_parts, list): + for part in candidate_parts: + part_text = getattr(part, "text", None) + if isinstance(part_text, str) and part_text: + text_parts.append(part_text) + function_call = getattr(part, "function_call", None) + if function_call is not None: + function_name = getattr(function_call, "name", None) + function_args = getattr(function_call, "args", None) + if not isinstance(function_name, str): + continue + tool_calls.append( + ToolCallResult( + id=f"call_{function_name}_{len(tool_calls)}", + name=function_name, + input=dict(cast(dict[str, Any], function_args)) + if function_args + else {}, + thought_signature=getattr(part, "thought_signature", None), + ) + ) + response_text = getattr(response, "text", None) + if not text_parts and isinstance(response_text, str) and response_text: + text_parts.append(response_text) + response_function_calls = cast( + list[Any] | None, + getattr(response, "function_calls", None), + ) + if not tool_calls and isinstance(response_function_calls, list): + for function_call in response_function_calls: + function_name = getattr(function_call, "name", None) + function_args = getattr(function_call, "args", None) + if not isinstance(function_name, str): + continue + tool_calls.append( + ToolCallResult( + id=f"call_{function_name}_{len(tool_calls)}", + name=function_name, + input=dict(cast(dict[str, Any], function_args)) + if function_args + else {}, + ) + ) + + content: Any = "\n".join(text_parts) if text_parts else "" + if response_format is not None: + parsed_response = getattr(response, "parsed", None) + if isinstance(parsed_response, response_format): + content = parsed_response + elif isinstance(parsed_response, dict): + content = response_format.model_validate(parsed_response) + elif isinstance(parsed_response, str): + content = response_format.model_validate_json(parsed_response) + else: + if finish_reason in GEMINI_BLOCKED_FINISH_REASONS: + raise LLMError( + f"Gemini response blocked (finish_reason={finish_reason})", + provider="gemini", + model=model_name, + finish_reason=finish_reason, + ) + raw_text = "".join(text_parts) + content = repair_response_model_json( + raw_text, + response_format, + model_name, + ) + elif ( + not content + and not tool_calls + and finish_reason in GEMINI_BLOCKED_FINISH_REASONS + ): + raise LLMError( + f"Gemini response blocked (finish_reason={finish_reason})", + provider="gemini", + model=model_name, + finish_reason=finish_reason, + ) + + usage = response.usage_metadata + cache_read_input_tokens = 0 + if usage is not None: + cached_tokens = getattr(usage, "cached_content_token_count", 0) + if isinstance(cached_tokens, int): + cache_read_input_tokens = cached_tokens + return CompletionResult( + content=content, + input_tokens=usage.prompt_token_count if usage else 0, + output_tokens=usage.candidates_token_count if usage else 0, + cache_read_input_tokens=cache_read_input_tokens, + finish_reason=finish_reason, + tool_calls=tool_calls, + raw_response=response, + ) + + async def _attach_cached_content( + self, + *, + model: str, + config: dict[str, Any], + cache_policy: PromptCachePolicy, + contents: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + ) -> None: + if cache_policy.mode != "gemini_cached_content": + return + # Worth caching if there are history messages, system instruction, or tools + has_cacheable = bool( + contents or config.get("system_instruction") or config.get("tools") + ) + if not has_cacheable: + return + + cache_key = build_cache_key( + config=self._cache_model_config(model), + cache_policy=cache_policy, + cacheable_messages=contents, + tools=tools, + system_instruction=config.get("system_instruction"), + tool_config=config.get("tool_config"), + ) + cached_handle = gemini_cache_store.get(cache_key) + if cached_handle is None: + ttl_seconds = cache_policy.ttl_seconds or 300 + cache_config: dict[str, Any] = { + "system_instruction": config.get("system_instruction"), + "tools": config.get("tools"), + "tool_config": config.get("tool_config"), + "ttl": f"{ttl_seconds}s", + } + if contents: + cache_config["contents"] = contents + cached_content = await self._client.aio.caches.create( + model=model, + config=cache_config, + ) + expires_at = getattr(cached_content, "expire_time", None) + if expires_at is None: + expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds) + cached_handle = gemini_cache_store.set( + GeminiCacheHandle( + key=cache_key, + cached_content_name=cached_content.name, + expires_at=expires_at, + ) + ) + # Once a cached-content handle is attached, Gemini rejects repeating + # system/tool configuration on the generate call. + config.pop("system_instruction", None) + config.pop("tools", None) + config.pop("tool_config", None) + config["cached_content"] = cached_handle.cached_content_name + + @staticmethod + def _cache_model_config(model: str): + from src.config import ModelConfig + + return ModelConfig(transport="gemini", model=model) + + @staticmethod + def _convert_messages( + messages: list[dict[str, Any]], + ) -> tuple[list[dict[str, Any]] | str, str | None]: + system_messages: list[str] = [] + contents: list[dict[str, Any]] = [] + + for message in messages: + role = message.get("role", "user") + if role == "system": + if isinstance(message.get("content"), str): + system_messages.append(message["content"]) + continue + + if role == "assistant": + role = "model" + + if isinstance(message.get("parts"), list): + message_copy = message.copy() + message_copy["role"] = role + contents.append(message_copy) + continue + + if isinstance(message.get("content"), str): + contents.append({"role": role, "parts": [{"text": message["content"]}]}) + continue + + if isinstance(message.get("content"), list): + parts: list[dict[str, Any]] = [] + for block in message["content"]: + block_type = block.get("type") + if block_type == "text": + parts.append({"text": block["text"]}) + else: + # Silently dropping non-"text" blocks would mask real + # input-shape bugs — e.g., an Anthropic-shaped + # tool_use/tool_result payload accidentally routed to + # the Gemini backend without going through the + # history adapter. Fail fast so the caller knows. + raise ValidationException( + "Gemini backend cannot translate content block " + + f"of type {block_type!r}; translate to " + + "Gemini-native 'parts' via the history adapter " + + "before passing to the backend" + ) + if parts: + contents.append({"role": role, "parts": parts}) + + system_instruction = "\n\n".join(system_messages) if system_messages else None + return contents, system_instruction + + @staticmethod + def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + if tools and "function_declarations" in tools[0]: + return tools + return [ + { + "function_declarations": [ + { + "name": tool["name"], + "description": tool["description"], + "parameters": GeminiBackend._sanitize_schema( + tool["input_schema"] + ), + } + for tool in tools + ] + } + ] + + # JSON-Schema keywords Gemini's function_declarations validator accepts. + # See https://ai.google.dev/api/caching#Schema. Anything outside this set + # (e.g. additionalProperties, allOf, if/then/else, $ref, anyOf, oneOf, + # patternProperties) triggers an INVALID_ARGUMENT 400 at call time, so we + # strip on the way out. Other backends keep the richer schema. + _GEMINI_ALLOWED_SCHEMA_KEYS: ClassVar[frozenset[str]] = frozenset( + { + "type", + "format", + "description", + "nullable", + "enum", + "properties", + "required", + "items", + "minItems", + "maxItems", + "minimum", + "maximum", + "title", + } + ) + + @staticmethod + def _sanitize_schema(schema: Any) -> Any: + """Recursively strip JSON-Schema keywords Gemini rejects. + + ``properties`` holds user-supplied field names → sub-schemas, so we + recurse into its values but preserve its keys. ``required`` and + ``enum`` are lists of literals (field names / allowed values) and are + passed through verbatim. Everything else is a scalar schema keyword. + """ + if not isinstance(schema, dict): + return schema + schema_dict = cast(dict[str, Any], schema) + cleaned: dict[str, Any] = {} + for key, value in schema_dict.items(): + if key not in GeminiBackend._GEMINI_ALLOWED_SCHEMA_KEYS: + continue + if key == "properties" and isinstance(value, dict): + cleaned["properties"] = { + prop_name: GeminiBackend._sanitize_schema(prop_schema) + for prop_name, prop_schema in cast(dict[str, Any], value).items() + } + elif key == "items": + cleaned["items"] = GeminiBackend._sanitize_schema(value) + elif key == "required" and isinstance(value, list): + cleaned["required"] = list(cast(list[Any], value)) + elif key == "enum" and isinstance(value, list): + cleaned["enum"] = list(cast(list[Any], value)) + else: + cleaned[key] = value + return cleaned + + @staticmethod + def _convert_tool_choice( + tool_choice: str | dict[str, Any], + ) -> dict[str, Any]: + if isinstance(tool_choice, dict) and "name" in tool_choice: + return { + "function_calling_config": { + "mode": "ANY", + "allowed_function_names": [tool_choice["name"]], + } + } + if tool_choice == "auto": + return {"function_calling_config": {"mode": "AUTO"}} + if tool_choice in {"any", "required"}: + return {"function_calling_config": {"mode": "ANY"}} + if tool_choice == "none": + return {"function_calling_config": {"mode": "NONE"}} + return { + "function_calling_config": { + "mode": "ANY", + "allowed_function_names": [tool_choice], + } + } diff --git a/src/llm/backends/openai.py b/src/llm/backends/openai.py new file mode 100644 index 00000000..1e01e78a --- /dev/null +++ b/src/llm/backends/openai.py @@ -0,0 +1,427 @@ +from __future__ import annotations + +import json +import logging +from collections.abc import AsyncIterator +from typing import Any, cast + +from openai import BadRequestError, LengthFinishReasonError +from pydantic import BaseModel, ValidationError + +from src.exceptions import ValidationException +from src.llm.backend import CompletionResult, StreamChunk, ToolCallResult +from src.llm.structured_output import ( + repair_response_model_json, + validate_structured_output, +) + +logger = logging.getLogger(__name__) + + +def _uses_max_completion_tokens(model: str) -> bool: + """OpenAI reasoning models (gpt-5 family + o-series) require + ``max_completion_tokens`` instead of the classic ``max_tokens`` parameter. + + Matches: gpt-5, gpt-5-anything, gpt-5.anything (incl. gpt-5.4, gpt-5.4-mini), + o1*, o3*, o4*. Anything else (gpt-4.x, gpt-4o, chat models on proxies) + stays on ``max_tokens``. + """ + m = model.lower() + if m == "gpt-5" or m.startswith("gpt-5-") or m.startswith("gpt-5."): + return True + for prefix in ("o1", "o3", "o4"): + if m == prefix or m.startswith(prefix + "-"): + return True + return False + + +def extract_openai_reasoning_content(response: Any) -> str | None: + try: + message = response.choices[0].message + if hasattr(message, "reasoning_details") and message.reasoning_details: + reasoning_parts: list[str] = [] + for detail in message.reasoning_details: + detail_content = getattr(detail, "content", None) + if isinstance(detail_content, str) and detail_content: + reasoning_parts.append(detail_content) + elif isinstance(detail, dict): + detail_dict = cast(dict[str, Any], detail) + dict_content = detail_dict.get("content") + if isinstance(dict_content, str) and dict_content: + reasoning_parts.append(dict_content) + if reasoning_parts: + return "\n".join(reasoning_parts) + if hasattr(message, "reasoning_content") and message.reasoning_content: + return message.reasoning_content + except (AttributeError, IndexError, TypeError): + return None + return None + + +def extract_openai_reasoning_details(response: Any) -> list[dict[str, Any]]: + try: + message = response.choices[0].message + if hasattr(message, "reasoning_details") and message.reasoning_details: + details: list[dict[str, Any]] = [] + for detail in message.reasoning_details: + if hasattr(detail, "model_dump"): + dumped = detail.model_dump() + if isinstance(dumped, dict): + details.append(cast(dict[str, Any], dumped)) + elif isinstance(detail, dict): + details.append(cast(dict[str, Any], detail)) + else: + detail_content = getattr(detail, "content", None) + if isinstance(detail_content, str) and detail_content: + details.append({"content": detail_content}) + return details + except (AttributeError, IndexError, TypeError): + return [] + return [] + + +def extract_openai_cache_tokens(usage: Any) -> tuple[int, int]: + if not usage: + return 0, 0 + + cache_read = 0 + if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details: + details = usage.prompt_tokens_details + if hasattr(details, "cached_tokens") and details.cached_tokens: + cache_read = details.cached_tokens + + if cache_read == 0: + if hasattr(usage, "cache_read_input_tokens") and usage.cache_read_input_tokens: + cache_read = usage.cache_read_input_tokens + elif hasattr(usage, "cached_tokens") and usage.cached_tokens: + cache_read = usage.cached_tokens + + cache_creation = 0 + if ( + hasattr(usage, "cache_creation_input_tokens") + and usage.cache_creation_input_tokens + ): + cache_creation = usage.cache_creation_input_tokens + + return cache_creation, cache_read + + +class OpenAIBackend: + """Provider backend wrapping AsyncOpenAI.""" + + def __init__(self, client: Any) -> None: + self._client: Any = client + + async def complete( + self, + *, + model: str, + messages: list[dict[str, Any]], + max_tokens: int, + temperature: float | None = None, + stop: list[str] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + thinking_budget_tokens: int | None = None, + thinking_effort: str | None = None, + max_output_tokens: int | None = None, + extra_params: dict[str, Any] | None = None, + ) -> CompletionResult: + if thinking_budget_tokens is not None: + raise ValidationException( + "OpenAI backend does not support thinking_budget_tokens; use thinking_effort instead" + ) + + params = self._build_params( + model=model, + messages=messages, + max_tokens=max_output_tokens or max_tokens, + temperature=temperature, + stop=stop, + tools=tools, + tool_choice=tool_choice, + thinking_effort=thinking_effort, + extra_params=extra_params, + ) + + if isinstance(response_format, type): + params["response_format"] = response_format + try: + response = await self._client.chat.completions.parse(**params) + except LengthFinishReasonError as exc: + truncated = exc.completion + raw_content = truncated.choices[0].message.content or "" + content = repair_response_model_json( + raw_content, + response_format, + model, + ) + return self._normalize_response( + truncated, + content_override=content, + ) + except (BadRequestError, json.JSONDecodeError, ValidationError): + fallback_response = await self._create_structured_response( + params=params, + response_format=response_format, + ) + content = self._parse_or_repair_structured_content( + fallback_response, + response_format, + model, + ) + return self._normalize_response( + fallback_response, + content_override=content, + ) + parsed = response.choices[0].message.parsed + raw_content = response.choices[0].message.content or "" + if parsed is None and raw_content: + content = repair_response_model_json( + raw_content, + response_format, + model, + ) + return self._normalize_response(response, content_override=content) + if parsed is None: + refusal = getattr(response.choices[0].message, "refusal", None) + if refusal: + return self._normalize_response( + response, + content_override=refusal, + ) + raise ValidationException("No parsed content in structured response") + return self._normalize_response( + response, + content_override=validate_structured_output(parsed, response_format), + ) + if response_format is not None: + params["response_format"] = response_format + + if extra_params and extra_params.get("json_mode"): + params["response_format"] = {"type": "json_object"} + + response = await self._client.chat.completions.create(**params) + return self._normalize_response(response) + + async def stream( + self, + *, + model: str, + messages: list[dict[str, Any]], + max_tokens: int, + temperature: float | None = None, + stop: list[str] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + thinking_budget_tokens: int | None = None, + thinking_effort: str | None = None, + max_output_tokens: int | None = None, + extra_params: dict[str, Any] | None = None, + ) -> AsyncIterator[StreamChunk]: + if thinking_budget_tokens is not None: + raise ValidationException( + "OpenAI backend does not support thinking_budget_tokens; use thinking_effort instead" + ) + + params = self._build_params( + model=model, + messages=messages, + max_tokens=max_output_tokens or max_tokens, + temperature=temperature, + stop=stop, + tools=tools, + tool_choice=tool_choice, + thinking_effort=thinking_effort, + extra_params=extra_params, + ) + params["stream"] = True + params["stream_options"] = {"include_usage": True} + if isinstance(response_format, type): + # parse() supports BaseModel types but streaming create() does not — + # convert to a json_schema dict so the streaming path works. + params["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": response_format.__name__, + "schema": response_format.model_json_schema(), + }, + } + elif response_format is not None: + params["response_format"] = response_format + elif extra_params and extra_params.get("json_mode"): + params["response_format"] = {"type": "json_object"} + + response_stream = await self._client.chat.completions.create(**params) + finish_reason: str | None = None + usage_chunk_received = False + async for chunk in response_stream: + if chunk.choices and chunk.choices[0].delta.content: + yield StreamChunk(content=chunk.choices[0].delta.content) + if chunk.choices and chunk.choices[0].finish_reason: + finish_reason = chunk.choices[0].finish_reason + if hasattr(chunk, "usage") and chunk.usage: + yield StreamChunk( + is_done=True, + finish_reason=finish_reason, + output_tokens=chunk.usage.completion_tokens, + ) + usage_chunk_received = True + + if not usage_chunk_received and finish_reason: + yield StreamChunk(is_done=True, finish_reason=finish_reason) + + def _build_params( + self, + *, + model: str, + messages: list[dict[str, Any]], + max_tokens: int, + temperature: float | None, + stop: list[str] | None, + tools: list[dict[str, Any]] | None, + tool_choice: str | dict[str, Any] | None, + thinking_effort: str | None, + extra_params: dict[str, Any] | None, + ) -> dict[str, Any]: + params: dict[str, Any] = { + "model": model, + "messages": messages, + } + + if _uses_max_completion_tokens(model): + params["max_completion_tokens"] = max_tokens + if extra_params and extra_params.get("verbosity"): + params["verbosity"] = extra_params["verbosity"] + else: + params["max_tokens"] = max_tokens + + if temperature is not None: + params["temperature"] = temperature + + if thinking_effort: + params["reasoning_effort"] = thinking_effort + + if stop: + params["stop"] = stop + if tools: + params["tools"] = self._convert_tools(tools) + if tool_choice is not None: + params["tool_choice"] = tool_choice + if extra_params: + for key in ( + "top_p", + "frequency_penalty", + "presence_penalty", + "seed", + ): + if key in extra_params: + params[key] = extra_params[key] + return params + + def _normalize_response( + self, + response: Any, + *, + content_override: Any | None = None, + ) -> CompletionResult: + usage = response.usage + finish_reason = response.choices[0].finish_reason + tool_calls: list[ToolCallResult] = [] + message = response.choices[0].message + if getattr(message, "tool_calls", None): + for tool_call in message.tool_calls: + tool_input: dict[str, Any] = {} + if tool_call.function.arguments: + try: + tool_input = json.loads(tool_call.function.arguments) + except (json.JSONDecodeError, TypeError) as exc: + # Don't log the raw arguments payload — LLM-generated + # tool calls can mirror user PII from the prompt into + # their arguments, and this runs at WARN level. + logger.warning( + "Malformed tool arguments for %s (id=%s): %s", + tool_call.function.name, + tool_call.id, + exc.__class__.__name__, + ) + tool_calls.append( + ToolCallResult( + id=tool_call.id, + name=tool_call.function.name, + input=tool_input, + ) + ) + + cache_creation, cache_read = extract_openai_cache_tokens(usage) + return CompletionResult( + content=content_override + if content_override is not None + else (message.content or ""), + input_tokens=usage.prompt_tokens if usage else 0, + output_tokens=usage.completion_tokens if usage else 0, + cache_creation_input_tokens=cache_creation, + cache_read_input_tokens=cache_read, + finish_reason=finish_reason or "stop", + tool_calls=tool_calls, + thinking_content=extract_openai_reasoning_content(response), + reasoning_details=extract_openai_reasoning_details(response), + raw_response=response, + ) + + async def _create_structured_response( + self, + *, + params: dict[str, Any], + response_format: type[BaseModel], + ) -> Any: + structured_params = dict(params) + structured_params["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": response_format.__name__, + "schema": response_format.model_json_schema(), + }, + } + return await self._client.chat.completions.create(**structured_params) + + @staticmethod + def _parse_or_repair_structured_content( + response: Any, + response_format: type[BaseModel], + model: str, + ) -> BaseModel | str: + raw_content = response.choices[0].message.content or "" + if raw_content: + return repair_response_model_json(raw_content, response_format, model) + refusal = getattr(response.choices[0].message, "refusal", None) + if refusal: + return refusal + raise ValidationException( + "No raw content available for structured output repair" + ) + + @staticmethod + def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + if not tools or tools[0].get("type") == "function": + return tools + # Tool schemas in src/utils/agent_tools.py use optional fields with + # defaults and don't declare additionalProperties: false. OpenAI's + # strict function-calling mode forbids both, so we intentionally + # don't set strict: True. Standard function calling on GPT-4.x / + # GPT-5 remains reliable, and this stays compatible with + # OpenAI-compatible proxies (OpenRouter, Together, vLLM, Ollama) + # whose strict-mode support is inconsistent. + return [ + { + "type": "function", + "function": { + "name": tool["name"], + "description": tool["description"], + "parameters": tool["input_schema"], + }, + } + for tool in tools + ] diff --git a/src/llm/caching.py b/src/llm/caching.py new file mode 100644 index 00000000..75020dce --- /dev/null +++ b/src/llm/caching.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import hashlib +import json +from collections import OrderedDict +from datetime import datetime, timezone +from threading import Lock +from typing import Any + +from pydantic import BaseModel + +from src.config import ModelConfig, PromptCachePolicy + +__all__ = [ + "GeminiCacheHandle", + "InMemoryGeminiCacheStore", + "PromptCachePolicy", + "build_cache_key", + "gemini_cache_store", +] + + +class GeminiCacheHandle(BaseModel): + key: str + cached_content_name: str + expires_at: datetime + + +def build_cache_key( + *, + config: ModelConfig, + cache_policy: PromptCachePolicy, + cacheable_messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + system_instruction: str | None = None, + tool_config: dict[str, Any] | None = None, +) -> str: + """Deterministic key over the cacheable shape of a request. + + ``system_instruction`` and ``tool_config`` must be part of the key + because the provider's cached-content handle captures them at creation + time — two requests that differ only by system prompt or tool + constraints would otherwise hit the same cached handle and silently get + the wrong system prompt / tool policy. + """ + payload = { + "transport": config.transport, + "model": config.model, + "cache_policy": cache_policy.model_dump(mode="json"), + "messages": cacheable_messages, + "tools": tools, + "system_instruction": system_instruction, + "tool_config": tool_config, + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")) + digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest() + return f"llm-cache:{cache_policy.key_version}:{digest}" + + +class InMemoryGeminiCacheStore: + """Best-effort local cache for Gemini cached-content handles. + + Uses LRU eviction with a max entry limit to prevent unbounded growth. + """ + + MAX_ENTRIES: int = 1024 + + def __init__(self) -> None: + self._handles: OrderedDict[str, GeminiCacheHandle] = OrderedDict() + self._lock: Lock = Lock() + + def get(self, key: str) -> GeminiCacheHandle | None: + with self._lock: + handle = self._handles.get(key) + if handle is None: + return None + if handle.expires_at <= datetime.now(timezone.utc): + self._handles.pop(key, None) + return None + self._handles.move_to_end(key) + return handle + + def set(self, handle: GeminiCacheHandle) -> GeminiCacheHandle: + with self._lock: + now = datetime.now(timezone.utc) + expired = [k for k, h in self._handles.items() if h.expires_at <= now] + for k in expired: + self._handles.pop(k, None) + if handle.key in self._handles: + self._handles.move_to_end(handle.key) + self._handles[handle.key] = handle + while len(self._handles) > self.MAX_ENTRIES: + self._handles.popitem(last=False) + return handle + + +gemini_cache_store = InMemoryGeminiCacheStore() diff --git a/src/llm/conversation.py b/src/llm/conversation.py new file mode 100644 index 00000000..4697b819 --- /dev/null +++ b/src/llm/conversation.py @@ -0,0 +1,185 @@ +"""Conversation-shaping helpers: token counting + tool-aware truncation. + +Moved out of src/utils/clients.py as part of the migration into src/llm/. +These are pure helpers with no orchestration dependencies. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, cast + +from src.utils.tokens import estimate_tokens + +logger = logging.getLogger(__name__) + + +def count_message_tokens(messages: list[dict[str, Any]]) -> int: + """Count tokens in a list of messages using tiktoken.""" + total = 0 + for msg in messages: + content = msg.get("content", "") + if isinstance(content, str): + total += estimate_tokens(content) + elif isinstance(content, list): + # Anthropic-style content blocks + total += estimate_tokens(json.dumps(content)) + if "parts" in msg: + try: + total += estimate_tokens(json.dumps(msg["parts"])) + except TypeError: + # Non-JSON-serializable content (e.g. bytes) — estimate from repr. + total += estimate_tokens(str(msg["parts"])) + return total + + +def _is_tool_use_message(msg: dict[str, Any]) -> bool: + """Check if a message contains tool calls (any format). + + Recognizes: + - Anthropic: ``content`` is a list containing a ``{"type": "tool_use"}`` block. + - Gemini: ``parts`` is a list containing a ``{"function_call": …}`` entry. + - OpenAI: assistant message with a non-empty ``tool_calls`` field. + """ + content = msg.get("content") + if isinstance(content, list): + for block in cast(list[dict[str, Any]], content): + if block.get("type") == "tool_use": + return True + parts = msg.get("parts") + if isinstance(parts, list): + for part in cast(list[dict[str, Any]], parts): + if "function_call" in part: + return True + return bool(msg.get("tool_calls")) + + +def _is_tool_result_message(msg: dict[str, Any]) -> bool: + """Check if a message contains tool results (any format). + + Recognizes: + - Anthropic: ``content`` is a list containing a ``{"type": "tool_result"}`` block. + - Gemini: ``parts`` is a list containing a ``{"function_response": …}`` entry. + - OpenAI: message with ``role == "tool"``. + """ + content = msg.get("content") + if isinstance(content, list): + for block in cast(list[dict[str, Any]], content): + if block.get("type") == "tool_result": + return True + parts = msg.get("parts") + if isinstance(parts, list): + for part in cast(list[dict[str, Any]], parts): + if "function_response" in part: + return True + return msg.get("role") == "tool" + + +def _group_into_units( + messages: list[dict[str, Any]], +) -> list[list[dict[str, Any]]]: + """Group messages into logical conversation units. + + A unit is either: + - A tool_use message + ALL consecutive tool_result messages that follow + - A single non-tool message + + Keeps tool_use / tool_result pairs together so truncation never breaks + them apart. + """ + units: list[list[dict[str, Any]]] = [] + i = 0 + + while i < len(messages): + msg = messages[i] + + if _is_tool_use_message(msg): + j = i + 1 + while j < len(messages) and _is_tool_result_message(messages[j]): + j += 1 + unit = messages[i:j] + if len(unit) > 1: + units.append(unit) + i = j + else: + # Orphaned tool_use with no results — skip it. + logger.debug(f"Skipping orphaned tool_use at index {i}") + i += 1 + elif _is_tool_result_message(msg): + # Orphaned tool_result — skip it. + logger.debug(f"Skipping orphaned tool_result at index {i}") + i += 1 + else: + units.append([msg]) + i += 1 + + return units + + +def truncate_messages_to_fit( + messages: list[dict[str, Any]], + max_tokens: int, + preserve_system: bool = True, +) -> list[dict[str, Any]]: + """Truncate messages to fit within a token limit while maintaining valid structure. + + Strategy: + 1. Group messages into units (tool_use + results together, or single messages) + 2. Remove oldest units first to preserve recent context + 3. Units stay intact so tool_use/tool_result pairs are never broken + """ + current_tokens = count_message_tokens(messages) + if current_tokens <= max_tokens: + return messages + + logger.info(f"Truncating: {current_tokens} tokens exceeds {max_tokens} limit") + + system_messages: list[dict[str, Any]] = [] + conversation: list[dict[str, Any]] = [] + + for msg in messages: + if msg.get("role") == "system" and preserve_system: + system_messages.append(msg) + else: + conversation.append(msg) + + system_tokens = count_message_tokens(system_messages) + available_tokens = max_tokens - system_tokens + + if available_tokens <= 0: + logger.warning("System message exceeds max_input_tokens") + return messages + + units = _group_into_units(conversation) + + if not units: + logger.warning("No valid conversation units") + return system_messages + + # Drop oldest units until conversation fits, but keep at least one unit so + # we never erase the entire non-system conversation. + while len(units) > 1: + flat_messages = [m for unit in units for m in unit] + if count_message_tokens(flat_messages) <= available_tokens: + break + removed_unit = units.pop(0) + logger.debug( + "Dropping conversation unit with " + + f"{len(removed_unit)} messages " + + f"(~{count_message_tokens(removed_unit)} tokens)" + ) + + result = system_messages + [m for unit in units for m in unit] + result_tokens = count_message_tokens(result) + logger.info( + f"Truncation complete: {current_tokens} → {result_tokens} tokens " + + f"({len(messages)} → {len(result)} messages)" + ) + return result + + +__all__ = [ + "count_message_tokens", + "truncate_messages_to_fit", +] diff --git a/src/llm/credentials.py b/src/llm/credentials.py new file mode 100644 index 00000000..9b41e77d --- /dev/null +++ b/src/llm/credentials.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from src.config import ModelConfig, settings +from src.exceptions import ValidationException + + +def resolve_credentials(config: ModelConfig) -> dict[str, str | None]: + """Resolve credentials for the effective model transport.""" + + default_api_key = default_transport_api_key(config.transport) + return { + "api_key": config.api_key or default_api_key, + "api_base": config.base_url, + } + + +def default_transport_api_key(transport: str) -> str | None: + """Fall back to the global LLM API key for the matching transport.""" + if transport == "anthropic": + return settings.LLM.ANTHROPIC_API_KEY + if transport == "openai": + return settings.LLM.OPENAI_API_KEY + if transport == "gemini": + return settings.LLM.GEMINI_API_KEY + raise ValidationException(f"Unknown transport: {transport}") diff --git a/src/llm/executor.py b/src/llm/executor.py new file mode 100644 index 00000000..d96008af --- /dev/null +++ b/src/llm/executor.py @@ -0,0 +1,226 @@ +"""Single-call executor: the inner LLM-call path without tool-loop orchestration. + +`honcho_llm_call_inner` handles one backend call (complete or stream), building +the effective ModelConfig and delegating to request_builder. Result / stream +chunk types are bridged to the public Honcho* shapes here. + +Used by: +- src/llm/api.py (the public entrypoint, for both tool-less and tool-enabled paths) +- src/llm/tool_loop.py (each iteration of the tool loop calls this) +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any, Literal, TypeVar, overload + +from pydantic import BaseModel + +from src.config import ModelConfig, ModelTransport + +from .backend import CompletionResult as BackendCompletionResult +from .backend import StreamChunk as BackendStreamChunk +from .backend import ToolCallResult +from .registry import CLIENTS, backend_for_provider +from .request_builder import execute_completion, execute_stream +from .runtime import effective_config_for_call +from .types import ( + HonchoLLMCallResponse, + HonchoLLMCallStreamChunk, + ProviderClient, + ReasoningEffortType, +) + +M = TypeVar("M", bound=BaseModel) + + +def _tool_call_result_to_dict(tool_call: ToolCallResult) -> dict[str, Any]: + result = { + "id": tool_call.id, + "name": tool_call.name, + "input": tool_call.input, + } + if tool_call.thought_signature is not None: + result["thought_signature"] = tool_call.thought_signature + return result + + +def completion_result_to_response( + result: BackendCompletionResult, +) -> HonchoLLMCallResponse[Any]: + return HonchoLLMCallResponse( + content=result.content, + input_tokens=result.input_tokens, + output_tokens=result.output_tokens, + cache_creation_input_tokens=result.cache_creation_input_tokens, + cache_read_input_tokens=result.cache_read_input_tokens, + finish_reasons=[result.finish_reason] if result.finish_reason else [], + tool_calls_made=[_tool_call_result_to_dict(tc) for tc in result.tool_calls], + thinking_content=result.thinking_content, + thinking_blocks=result.thinking_blocks, + reasoning_details=result.reasoning_details, + ) + + +def stream_chunk_to_response_chunk( + chunk: BackendStreamChunk, +) -> HonchoLLMCallStreamChunk: + return HonchoLLMCallStreamChunk( + content=chunk.content, + is_done=chunk.is_done, + finish_reasons=[chunk.finish_reason] if chunk.finish_reason else [], + output_tokens=chunk.output_tokens, + ) + + +@overload +async def honcho_llm_call_inner( + provider: ModelTransport, + model: str, + prompt: str, + max_tokens: int, + response_model: type[M], + json_mode: bool = False, + temperature: float | None = None, + stop_seqs: list[str] | None = None, + reasoning_effort: ReasoningEffortType = None, + verbosity: Literal["low", "medium", "high"] | None = None, + thinking_budget_tokens: int | None = None, + stream: Literal[False] = False, + client_override: ProviderClient | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + messages: list[dict[str, Any]] | None = None, + selected_config: ModelConfig | None = None, +) -> HonchoLLMCallResponse[M]: ... + + +@overload +async def honcho_llm_call_inner( + provider: ModelTransport, + model: str, + prompt: str, + max_tokens: int, + response_model: None = None, + json_mode: bool = False, + temperature: float | None = None, + stop_seqs: list[str] | None = None, + reasoning_effort: ReasoningEffortType = None, + verbosity: Literal["low", "medium", "high"] | None = None, + thinking_budget_tokens: int | None = None, + stream: Literal[False] = False, + client_override: ProviderClient | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + messages: list[dict[str, Any]] | None = None, + selected_config: ModelConfig | None = None, +) -> HonchoLLMCallResponse[str]: ... + + +@overload +async def honcho_llm_call_inner( + provider: ModelTransport, + model: str, + prompt: str, + max_tokens: int, + response_model: type[BaseModel] | None = None, + json_mode: bool = False, + temperature: float | None = None, + stop_seqs: list[str] | None = None, + reasoning_effort: ReasoningEffortType = None, + verbosity: Literal["low", "medium", "high"] | None = None, + thinking_budget_tokens: int | None = None, + stream: Literal[True] = ..., + client_override: ProviderClient | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + messages: list[dict[str, Any]] | None = None, + selected_config: ModelConfig | None = None, +) -> AsyncIterator[HonchoLLMCallStreamChunk]: ... + + +async def honcho_llm_call_inner( + provider: ModelTransport, + model: str, + prompt: str, + max_tokens: int, + response_model: type[BaseModel] | None = None, + json_mode: bool = False, + temperature: float | None = None, + stop_seqs: list[str] | None = None, + reasoning_effort: ReasoningEffortType = None, + verbosity: Literal["low", "medium", "high"] | None = None, + thinking_budget_tokens: int | None = None, + stream: bool = False, + client_override: ProviderClient | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + messages: list[dict[str, Any]] | None = None, + selected_config: ModelConfig | None = None, +) -> HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk]: + """One backend call. No retry, no fallback, no tool loop. + + The outer src/llm/api.py `honcho_llm_call` handles retry + fallback + + tool orchestration on top of this. + """ + client = client_override or CLIENTS.get(provider) + if client is None: + raise ValueError(f"Missing client for {provider}") + + if messages is None: + messages = [{"role": "user", "content": prompt}] + + backend = backend_for_provider(provider, client) + + effective_config = effective_config_for_call( + selected_config=selected_config, + provider=provider, + model=model, + temperature=temperature, + stop_seqs=stop_seqs, + thinking_budget_tokens=thinking_budget_tokens, + reasoning_effort=reasoning_effort, + ) + # json_mode + verbosity are per-call transport toggles, not ModelConfig + # knobs — they pass through extra_params. execute_completion merges + # build_config_extra_params(effective_config) on top for top_p/seed/etc. + call_extras: dict[str, Any] = {"json_mode": json_mode, "verbosity": verbosity} + + if stream: + + async def _stream() -> AsyncIterator[HonchoLLMCallStreamChunk]: + stream_iter = await execute_stream( + backend, + effective_config, + messages=messages, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_model, + cache_policy=effective_config.cache_policy, + extra_params=call_extras, + ) + async for chunk in stream_iter: + yield stream_chunk_to_response_chunk(chunk) + + return _stream() + + result = await execute_completion( + backend, + effective_config, + messages=messages, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_model, + cache_policy=effective_config.cache_policy, + extra_params=call_extras, + ) + return completion_result_to_response(result) + + +__all__ = [ + "completion_result_to_response", + "honcho_llm_call_inner", + "stream_chunk_to_response_chunk", +] diff --git a/src/llm/history_adapters.py b/src/llm/history_adapters.py new file mode 100644 index 00000000..02d2ea05 --- /dev/null +++ b/src/llm/history_adapters.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import json +from typing import Any, Protocol + +from .backend import CompletionResult + + +class HistoryAdapter(Protocol): + def format_assistant_tool_message( + self, + result: CompletionResult, + ) -> dict[str, Any]: ... + + def format_tool_results( + self, + tool_results: list[dict[str, Any]], + ) -> list[dict[str, Any]]: ... + + +class AnthropicHistoryAdapter: + def format_assistant_tool_message( + self, + result: CompletionResult, + ) -> dict[str, Any]: + content_blocks: list[dict[str, Any]] = [] + if result.thinking_blocks: + content_blocks.extend(result.thinking_blocks) + if isinstance(result.content, str) and result.content: + content_blocks.append({"type": "text", "text": result.content}) + for tool_call in result.tool_calls: + content_blocks.append( + { + "type": "tool_use", + "id": tool_call.id, + "name": tool_call.name, + "input": tool_call.input, + } + ) + return {"role": "assistant", "content": content_blocks} + + def format_tool_results( + self, + tool_results: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + return [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tr["tool_id"], + "content": str(tr["result"]), + "is_error": tr.get("is_error", False), + } + for tr in tool_results + ], + } + ] + + +class GeminiHistoryAdapter: + def format_assistant_tool_message( + self, + result: CompletionResult, + ) -> dict[str, Any]: + parts: list[dict[str, Any]] = [] + if isinstance(result.content, str) and result.content: + parts.append({"text": result.content}) + for tool_call in result.tool_calls: + part: dict[str, Any] = { + "function_call": { + "name": tool_call.name, + "args": tool_call.input, + } + } + if tool_call.thought_signature is not None: + part["thought_signature"] = tool_call.thought_signature + parts.append(part) + return {"role": "model", "parts": parts} + + def format_tool_results( + self, + tool_results: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + return [ + { + "role": "user", + "parts": [ + { + "function_response": { + "name": tr["tool_name"], + "response": {"result": str(tr["result"])}, + } + } + for tr in tool_results + ], + } + ] + + +class OpenAIHistoryAdapter: + def format_assistant_tool_message( + self, + result: CompletionResult, + ) -> dict[str, Any]: + message: dict[str, Any] = { + "role": "assistant", + "content": result.content if isinstance(result.content, str) else None, + "tool_calls": [ + { + "id": tool_call.id, + "type": "function", + "function": { + "name": tool_call.name, + "arguments": json.dumps(tool_call.input), + }, + } + for tool_call in result.tool_calls + ], + } + if result.reasoning_details: + message["reasoning_details"] = result.reasoning_details + return message + + def format_tool_results( + self, + tool_results: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + return [ + { + "role": "tool", + "tool_call_id": tr["tool_id"], + "content": str(tr["result"]), + } + for tr in tool_results + ] diff --git a/src/llm/registry.py b/src/llm/registry.py new file mode 100644 index 00000000..73cf60c8 --- /dev/null +++ b/src/llm/registry.py @@ -0,0 +1,185 @@ +"""Single owner of provider runtime objects: clients, backends, history adapters. + +Consolidates wiring that previously lived in both `src/llm/__init__.py` and +`src/utils/clients.py`. Everything that touches provider SDKs at runtime +(default client construction, override client caching, backend selection, +history adapter selection) lives here now. +""" + +from __future__ import annotations + +from functools import lru_cache +from typing import assert_never + +from anthropic import AsyncAnthropic +from google import genai +from google.genai import types as genai_types +from openai import AsyncOpenAI + +from src.config import ModelConfig, ModelTransport, settings +from src.exceptions import ValidationException + +from .backend import ProviderBackend +from .backends.anthropic import AnthropicBackend +from .backends.gemini import GeminiBackend +from .backends.openai import OpenAIBackend +from .credentials import default_transport_api_key +from .history_adapters import ( + AnthropicHistoryAdapter, + GeminiHistoryAdapter, + HistoryAdapter, + OpenAIHistoryAdapter, +) +from .types import ProviderClient + + +@lru_cache(maxsize=1) +def get_anthropic_client() -> AsyncAnthropic: + """Default Anthropic client built from settings.LLM.ANTHROPIC_API_KEY.""" + return AsyncAnthropic( + api_key=settings.LLM.ANTHROPIC_API_KEY, + timeout=600.0, + ) + + +@lru_cache(maxsize=1) +def get_openai_client() -> AsyncOpenAI: + """Default OpenAI client built from settings.LLM.OPENAI_API_KEY.""" + return AsyncOpenAI( + api_key=settings.LLM.OPENAI_API_KEY, + ) + + +@lru_cache(maxsize=1) +def get_gemini_client() -> genai.Client: + """Default Gemini client built from settings.LLM.GEMINI_API_KEY.""" + return genai.Client(api_key=settings.LLM.GEMINI_API_KEY) + + +# Bounded cache — in practice the (base_url, api_key) key space is small +# and process-scoped, but maxsize=128 keeps worst-case memory predictable. +@lru_cache(maxsize=128) +def get_openai_override_client( + base_url: str | None, api_key: str | None +) -> AsyncOpenAI: + """OpenAI client for a specific (base_url, api_key) pair. Cached by key.""" + return AsyncOpenAI(api_key=api_key, base_url=base_url) + + +@lru_cache(maxsize=128) +def get_anthropic_override_client( + base_url: str | None, + api_key: str | None, +) -> AsyncAnthropic: + """Anthropic client for a specific (base_url, api_key) pair. Cached by key.""" + return AsyncAnthropic(api_key=api_key, base_url=base_url, timeout=600.0) + + +@lru_cache(maxsize=128) +def get_gemini_override_client( + base_url: str | None, api_key: str | None +) -> genai.Client: + """Gemini client for a specific (base_url, api_key) pair. Cached by key.""" + http_options = genai_types.HttpOptions(base_url=base_url) if base_url else None + return genai.Client(api_key=api_key, http_options=http_options) + + +# Module-level default-client registry, populated at import time. Tests patch +# this dict via `patch.dict(CLIENTS, {...})` to inject mock provider clients. +CLIENTS: dict[ModelTransport, ProviderClient] = {} + +if settings.LLM.ANTHROPIC_API_KEY: + CLIENTS["anthropic"] = AsyncAnthropic( + api_key=settings.LLM.ANTHROPIC_API_KEY, + timeout=600.0, + ) + +if settings.LLM.OPENAI_API_KEY: + CLIENTS["openai"] = AsyncOpenAI( + api_key=settings.LLM.OPENAI_API_KEY, + ) + +if settings.LLM.GEMINI_API_KEY: + CLIENTS["gemini"] = genai.client.Client( + api_key=settings.LLM.GEMINI_API_KEY, + ) + + +def client_for_model_config( + provider: ModelTransport, + model_config: ModelConfig, +) -> ProviderClient: + """Resolve the provider client for a ModelConfig. + + Fast path: no overrides → reuse the module-level default client from + CLIENTS (the test-mockable seam). Otherwise route through the cached + override factories. + """ + if model_config.api_key is None and model_config.base_url is None: + existing_client = CLIENTS.get(provider) + if existing_client is not None: + return existing_client + + api_key = model_config.api_key or default_transport_api_key(provider) + base_url = model_config.base_url + if not api_key: + raise ValidationException(f"Missing API key for {provider} model config") + + if provider == "anthropic": + return get_anthropic_override_client(base_url, api_key) + if provider == "openai": + return get_openai_override_client(base_url, api_key) + if provider == "gemini": + return get_gemini_override_client(base_url, api_key) + assert_never(provider) + + +def backend_for_provider( + provider: ModelTransport, + client: ProviderClient, +) -> ProviderBackend: + """Wrap a raw provider SDK client in the matching ProviderBackend adapter.""" + if provider == "anthropic": + return AnthropicBackend(client) + if provider == "openai": + return OpenAIBackend(client) + if provider == "gemini": + return GeminiBackend(client) + assert_never(provider) + + +def history_adapter_for_provider(provider: ModelTransport) -> HistoryAdapter: + """Provider-appropriate HistoryAdapter for assistant/tool message formatting.""" + if provider == "anthropic": + return AnthropicHistoryAdapter() + if provider == "gemini": + return GeminiHistoryAdapter() + return OpenAIHistoryAdapter() + + +def get_backend(config: ModelConfig) -> ProviderBackend: + """High-level one-shot backend factory: ModelConfig → ProviderBackend. + + Delegates client resolution to ``client_for_model_config``, which owns + the CLIENTS fast-path and the missing-API-key validation. Both the + production path (via ``honcho_llm_call_inner``) and the live-test path + (via this function) now construct clients through the same helper, so + validation behavior stays consistent. + """ + client = client_for_model_config(config.transport, config) + return backend_for_provider(config.transport, client) + + +__all__ = [ + "CLIENTS", + "backend_for_provider", + "client_for_model_config", + "get_anthropic_client", + "get_anthropic_override_client", + "get_backend", + "get_gemini_client", + "get_gemini_override_client", + "get_openai_client", + "get_openai_override_client", + "history_adapter_for_provider", +] diff --git a/src/llm/request_builder.py b/src/llm/request_builder.py new file mode 100644 index 00000000..d6be5a22 --- /dev/null +++ b/src/llm/request_builder.py @@ -0,0 +1,119 @@ +"""Low-level request assembly: flatten a ModelConfig into backend calls. + +Does NOT own: retry, fallback, tool loop, provider selection. Those live in +src/llm/api.py, src/llm/tool_loop.py, src/llm/runtime.py. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any + +from pydantic import BaseModel + +from src.config import ModelConfig, PromptCachePolicy + +from .backend import CompletionResult, ProviderBackend, StreamChunk + + +def build_config_extra_params(config: ModelConfig) -> dict[str, Any]: + """Flatten ModelConfig's optional knobs and provider_params into extra_params. + + Backends read per-call tuning parameters (top_p, top_k, frequency_penalty, + presence_penalty, seed) and the free-form provider_params passthrough out + of ``extra_params``. Single source of truth for that translation. + """ + extra_params: dict[str, Any] = {} + if config.top_p is not None: + extra_params["top_p"] = config.top_p + if config.top_k is not None: + extra_params["top_k"] = config.top_k + if config.frequency_penalty is not None: + extra_params["frequency_penalty"] = config.frequency_penalty + if config.presence_penalty is not None: + extra_params["presence_penalty"] = config.presence_penalty + if config.seed is not None: + extra_params["seed"] = config.seed + + if config.provider_params: + extra_params.update(config.provider_params) + + return extra_params + + +async def execute_completion( + backend: ProviderBackend, + config: ModelConfig, + *, + messages: list[dict[str, Any]], + max_tokens: int, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + stop: list[str] | None = None, + cache_policy: PromptCachePolicy | None = None, + extra_params: dict[str, Any] | None = None, +) -> CompletionResult: + # Preserve 0 as an explicit "disable thinking" value (used by Gemini); + # only convert to None when the field is truly unset. + effective_max_tokens = config.max_output_tokens or max_tokens + + merged_extra_params = { + **build_config_extra_params(config), + **(extra_params or {}), + } + if cache_policy is not None: + merged_extra_params["cache_policy"] = cache_policy + + return await backend.complete( + model=config.model, + messages=messages, + max_tokens=effective_max_tokens, + temperature=config.temperature, + stop=stop if stop is not None else config.stop_sequences, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + thinking_budget_tokens=config.thinking_budget_tokens, + thinking_effort=config.thinking_effort, + max_output_tokens=effective_max_tokens, + extra_params=merged_extra_params, + ) + + +async def execute_stream( + backend: ProviderBackend, + config: ModelConfig, + *, + messages: list[dict[str, Any]], + max_tokens: int, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + stop: list[str] | None = None, + cache_policy: PromptCachePolicy | None = None, + extra_params: dict[str, Any] | None = None, +) -> AsyncIterator[StreamChunk]: + effective_max_tokens = config.max_output_tokens or max_tokens + + merged_extra_params = { + **build_config_extra_params(config), + **(extra_params or {}), + } + if cache_policy is not None: + merged_extra_params["cache_policy"] = cache_policy + + return backend.stream( + model=config.model, + messages=messages, + max_tokens=effective_max_tokens, + temperature=config.temperature, + stop=stop if stop is not None else config.stop_sequences, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + thinking_budget_tokens=config.thinking_budget_tokens, + thinking_effort=config.thinking_effort, + max_output_tokens=effective_max_tokens, + extra_params=merged_extra_params, + ) diff --git a/src/llm/runtime.py b/src/llm/runtime.py new file mode 100644 index 00000000..2c29f397 --- /dev/null +++ b/src/llm/runtime.py @@ -0,0 +1,236 @@ +"""Runtime config planning and retry/fallback selection. + +Owns: +- Resolution of ConfiguredModelSettings → ModelConfig. +- Per-attempt planning (AttemptPlan) including primary/fallback selection and + reasoning-effort/thinking-budget resolution. +- Per-call effective config construction (applying caller kwarg overrides onto + the selected ModelConfig). +- Retry attempt tracking via a ContextVar, plus the temperature-bump heuristic. +""" + +from __future__ import annotations + +import logging +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Any + +from src.config import ( + ConfiguredModelSettings, + ModelConfig, + ModelTransport, + resolve_model_config, + settings, +) + +from .registry import backend_for_provider, client_for_model_config +from .types import ProviderClient, ReasoningEffortType + +logger = logging.getLogger(__name__) + +# ContextVar tracking the current retry attempt for provider switching. +current_attempt: ContextVar[int] = ContextVar("current_attempt", default=0) + + +def update_current_langfuse_observation( + provider: ModelTransport, + model: str, + *, + name: str | None = None, +) -> None: + """Best-effort annotation of the current Langfuse span with LLM routing.""" + if not settings.LANGFUSE_PUBLIC_KEY: + return + + try: + from langfuse import get_client + + update_kwargs: dict[str, Any] = { + "metadata": { + "namespace": settings.NAMESPACE, + "provider": provider, + "model": model, + } + } + if name is not None: + update_kwargs["name"] = name + get_client().update_current_span(**update_kwargs) + except Exception as exc: # pragma: no cover - best-effort telemetry + logger.debug("Failed to update Langfuse span metadata: %s", exc) + + +@dataclass(frozen=True) +class AttemptPlan: + """Per-attempt plan produced by `plan_attempt`. + + Replaces the old loose tuple-of-six (`ProviderSelection`) with a single + dataclass. Carries everything the executor / tool loop needs to make one + backend call without re-resolving configuration mid-call. + """ + + provider: ModelTransport + model: str + client: ProviderClient + thinking_budget_tokens: int | None + reasoning_effort: ReasoningEffortType + selected_config: ModelConfig + + +def resolve_runtime_model_config( + model_config: ModelConfig | ConfiguredModelSettings, +) -> ModelConfig: + """Return a runtime ModelConfig, resolving settings-shape inputs if needed.""" + if isinstance(model_config, ModelConfig): + return model_config + return resolve_model_config(model_config) + + +def select_model_config_for_attempt( + model_config: ModelConfig, + *, + attempt: int, + retry_attempts: int, +) -> ModelConfig: + """Pick the effective config for this attempt. + + Primary config on all attempts except the last, which swaps to the + resolved fallback (if any). + """ + if attempt != retry_attempts or model_config.fallback is None: + return model_config + + fb = model_config.fallback + return ModelConfig( + model=fb.model, + transport=fb.transport, + fallback=None, + api_key=fb.api_key, + base_url=fb.base_url, + temperature=fb.temperature, + top_p=fb.top_p, + top_k=fb.top_k, + frequency_penalty=fb.frequency_penalty, + presence_penalty=fb.presence_penalty, + seed=fb.seed, + thinking_effort=fb.thinking_effort, + thinking_budget_tokens=fb.thinking_budget_tokens, + provider_params=fb.provider_params, + max_output_tokens=fb.max_output_tokens, + stop_sequences=fb.stop_sequences, + cache_policy=fb.cache_policy, + ) + + +def plan_attempt( + *, + runtime_model_config: ModelConfig, + attempt: int, + retry_attempts: int, + call_thinking_budget_tokens: int | None, + call_reasoning_effort: ReasoningEffortType, +) -> AttemptPlan: + """Build the AttemptPlan for `attempt`. + + Reasoning params are drawn from the caller when we're still on the + primary config, and from the fallback config otherwise, so cross-transport + fallbacks use provider-appropriate params. + """ + selected = select_model_config_for_attempt( + runtime_model_config, + attempt=attempt, + retry_attempts=retry_attempts, + ) + provider = selected.transport + client = client_for_model_config(provider, selected) + + is_primary = selected is runtime_model_config + attempt_thinking_budget = ( + call_thinking_budget_tokens if is_primary else selected.thinking_budget_tokens + ) + attempt_reasoning_effort: ReasoningEffortType = ( + call_reasoning_effort if is_primary else selected.thinking_effort + ) + + if attempt == retry_attempts and runtime_model_config.fallback is not None: + logger.warning( + f"Final retry attempt {attempt}/{retry_attempts}: switching from " + + f"{runtime_model_config.transport}/{runtime_model_config.model} to " + + f"backup {provider}/{selected.model}" + ) + + return AttemptPlan( + provider=provider, + model=selected.model, + client=client, + thinking_budget_tokens=attempt_thinking_budget, + reasoning_effort=attempt_reasoning_effort, + selected_config=selected, + ) + + +def effective_config_for_call( + *, + selected_config: ModelConfig | None, + provider: ModelTransport, + model: str, + temperature: float | None, + stop_seqs: list[str] | None, + thinking_budget_tokens: int | None, + reasoning_effort: ReasoningEffortType, +) -> ModelConfig: + """Build the ModelConfig passed to the executor / request_builder. + + Per-call kwargs (temperature, stop_seqs, thinking_*) win when set; otherwise + the selected_config's values are used. When selected_config is None + (test-only callers passing provider+model directly) a minimal ModelConfig + is synthesized. + + max_output_tokens is forced to None so the per-call max_tokens kwarg is + authoritative — matching historical honcho_llm_call_inner behavior. + """ + if selected_config is None: + return ModelConfig( + model=model, + transport=provider, + temperature=temperature, + stop_sequences=stop_seqs, + thinking_budget_tokens=thinking_budget_tokens, + thinking_effort=reasoning_effort, + ) + updates: dict[str, Any] = {"max_output_tokens": None} + if temperature is not None: + updates["temperature"] = temperature + if stop_seqs is not None: + updates["stop_sequences"] = stop_seqs + if thinking_budget_tokens is not None: + updates["thinking_budget_tokens"] = thinking_budget_tokens + if reasoning_effort is not None: + updates["thinking_effort"] = reasoning_effort + return selected_config.model_copy(update=updates) + + +def effective_temperature(temperature: float | None) -> float | None: + """Bump temperature from 0.0 → 0.2 on retry attempts for variety.""" + if temperature == 0.0 and current_attempt.get() > 1: + logger.debug("Bumping temperature from 0.0 to 0.2 on retry") + return 0.2 + return temperature + + +def resolve_backend_for_plan(plan: AttemptPlan) -> Any: + """Convenience helper: plan → ready-to-call ProviderBackend.""" + return backend_for_provider(plan.provider, plan.client) + + +__all__ = [ + "AttemptPlan", + "current_attempt", + "effective_config_for_call", + "effective_temperature", + "plan_attempt", + "resolve_backend_for_plan", + "resolve_runtime_model_config", + "select_model_config_for_attempt", + "update_current_langfuse_observation", +] diff --git a/src/llm/structured_output.py b/src/llm/structured_output.py new file mode 100644 index 00000000..76c0690a --- /dev/null +++ b/src/llm/structured_output.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable +from typing import Literal + +from pydantic import BaseModel, ValidationError + +from src.utils.json_parser import validate_and_repair_json +from src.utils.representation import PromptRepresentation + +from .backend import CompletionResult + +StructuredOutputFailurePolicy = Literal[ + "raise", + "repair_then_raise", + "repair_then_empty", +] + + +class StructuredOutputError(ValueError): + """Raised when structured output cannot be validated or repaired.""" + + +def repair_response_model_json( + raw_content: str, + response_model: type[BaseModel], + _model: str, +) -> BaseModel: + """Repair truncated or malformed JSON and validate against the response model.""" + + try: + final = validate_and_repair_json(raw_content) + repaired_data = json.loads(final) + + if ( + response_model is PromptRepresentation + and "deductive" in repaired_data + and isinstance(repaired_data["deductive"], list) + ): + for item in repaired_data["deductive"]: + if isinstance(item, dict): + if "conclusion" not in item and "premises" in item: + if item["premises"]: + item["conclusion"] = ( + f"[Incomplete reasoning from premises: {item['premises'][0][:100]}...]" + ) + else: + item["conclusion"] = ( + "[Incomplete reasoning - conclusion missing]" + ) + if "premises" not in item: + item["premises"] = [] + + final = json.dumps(repaired_data) + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + final = "" + + try: + return response_model.model_validate_json(final) + except ValidationError: + if response_model is PromptRepresentation: + return PromptRepresentation(explicit=[]) + raise + + +def validate_structured_output( + content: object, + response_model: type[BaseModel], +) -> BaseModel: + if isinstance(content, response_model): + return content + if isinstance(content, str): + return response_model.model_validate_json(content) + if isinstance(content, dict): + return response_model.model_validate(content) + raise StructuredOutputError( + f"Unsupported structured output payload: {type(content).__name__}" + ) + + +def attempt_structured_output_repair( + content: object, + response_model: type[BaseModel], + model: str, +) -> BaseModel | None: + if not isinstance(content, str): + return None + try: + return repair_response_model_json(content, response_model, model) + except (StructuredOutputError, ValidationError): + return None + + +def empty_structured_output(response_model: type[BaseModel]) -> BaseModel: + if response_model is PromptRepresentation: + return PromptRepresentation(explicit=[]) + return response_model.model_validate({}) + + +async def execute_structured_output_call( + executor: Callable[[], Awaitable[CompletionResult]], + *, + response_model: type[BaseModel], + model_name: str, + failure_policy: StructuredOutputFailurePolicy = "repair_then_raise", +) -> CompletionResult: + result = await executor() + + try: + result.content = validate_structured_output(result.content, response_model) + return result + except (StructuredOutputError, ValidationError): + if failure_policy == "raise": + raise + + repaired = attempt_structured_output_repair( + result.content, + response_model, + model_name, + ) + if repaired is not None: + result.content = repaired + return result + + if failure_policy == "repair_then_empty": + result.content = empty_structured_output(response_model) + return result + + raise StructuredOutputError( + f"Failed to produce valid structured output for {model_name}" + ) diff --git a/src/llm/tool_loop.py b/src/llm/tool_loop.py new file mode 100644 index 00000000..2db87e9a --- /dev/null +++ b/src/llm/tool_loop.py @@ -0,0 +1,491 @@ +"""Agentic/tool orchestration — the multi-iteration tool execution loop. + +`execute_tool_loop` owns: +- initial tool-enabled call +- tool execution +- conversation augmentation with assistant messages + tool results +- max-iteration handling and synthesis call +- stream-final-only mode +- empty-response retry (one retry nudge when the model returns empty content) +""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator, Callable +from typing import Any + +from pydantic import BaseModel +from tenacity import retry, stop_after_attempt, wait_exponential + +from src.config import ModelTransport +from src.exceptions import ValidationException +from src.utils.types import set_current_iteration + +from .executor import honcho_llm_call_inner +from .registry import history_adapter_for_provider +from .runtime import ( + AttemptPlan, + current_attempt, + effective_temperature, +) +from .types import ( + HonchoLLMCallResponse, + HonchoLLMCallStreamChunk, + IterationCallback, + IterationData, + StreamingResponseWithMetadata, + VerbosityType, +) + +logger = logging.getLogger(__name__) + +# Bounds for max_tool_iterations to prevent runaway loops. +MIN_TOOL_ITERATIONS = 1 +MAX_TOOL_ITERATIONS = 100 + + +def format_assistant_tool_message( + provider: ModelTransport, + content: Any, + tool_calls: list[dict[str, Any]], + thinking_blocks: list[dict[str, Any]] | None = None, + reasoning_details: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Format an assistant message with tool calls in provider-native shape.""" + from .backend import CompletionResult as BackendCompletionResult + from .backend import ToolCallResult + + adapter = history_adapter_for_provider(provider) + result = BackendCompletionResult( + content=content, + tool_calls=[ + ToolCallResult( + id=tool_call["id"], + name=tool_call["name"], + input=tool_call["input"], + thought_signature=tool_call.get("thought_signature"), + ) + for tool_call in tool_calls + ], + thinking_blocks=thinking_blocks or [], + reasoning_details=reasoning_details or [], + ) + return adapter.format_assistant_tool_message(result) + + +def append_tool_results( + provider: ModelTransport, + tool_results: list[dict[str, Any]], + conversation_messages: list[dict[str, Any]], +) -> None: + """Append tool results to `conversation_messages` in provider-native shape.""" + adapter = history_adapter_for_provider(provider) + conversation_messages.extend(adapter.format_tool_results(tool_results)) + + +async def stream_final_response( + *, + winning_plan: AttemptPlan, + prompt: str, + max_tokens: int, + conversation_messages: list[dict[str, Any]], + response_model: type[BaseModel] | None, + json_mode: bool, + temperature: float | None, + stop_seqs: list[str] | None, + verbosity: VerbosityType, + enable_retry: bool, + retry_attempts: int, + before_retry_callback: Callable[[Any], None], +) -> AsyncIterator[HonchoLLMCallStreamChunk]: + """Stream the final response after tool execution is complete. + + Uses the AttemptPlan captured at the moment streaming began (typically + the plan whose inner LLM call just succeeded) and pins it across any + retries of the stream setup. Re-running provider selection here would + bleed the outer current_attempt ContextVar into streaming retries, + potentially rolling the selection back to primary after the tool loop + had already settled on fallback. Tenacity retries re-issue the same + streaming call against the same pinned model for transient errors. + """ + + async def _setup_stream() -> AsyncIterator[HonchoLLMCallStreamChunk]: + return await honcho_llm_call_inner( + winning_plan.provider, + winning_plan.model, + prompt, + max_tokens, + response_model, + json_mode, + effective_temperature(temperature), + stop_seqs, + winning_plan.reasoning_effort, + verbosity, + winning_plan.thinking_budget_tokens, + stream=True, + client_override=winning_plan.client, + tools=None, + tool_choice=None, + messages=conversation_messages, + selected_config=winning_plan.selected_config, + ) + + if enable_retry: + wrapped = retry( + stop=stop_after_attempt(retry_attempts), + wait=wait_exponential(multiplier=1, min=4, max=10), + before_sleep=before_retry_callback, + )(_setup_stream) + stream = await wrapped() + else: + stream = await _setup_stream() + + async for chunk in stream: + yield chunk + + +async def execute_tool_loop( + *, + prompt: str, + max_tokens: int, + messages: list[dict[str, Any]] | None, + tools: list[dict[str, Any]], + tool_choice: str | dict[str, Any] | None, + tool_executor: Callable[[str, dict[str, Any]], Any], + max_tool_iterations: int, + response_model: type[BaseModel] | None, + json_mode: bool, + temperature: float | None, + stop_seqs: list[str] | None, + verbosity: VerbosityType, + enable_retry: bool, + retry_attempts: int, + max_input_tokens: int | None, + get_attempt_plan: Callable[[], AttemptPlan], + before_retry_callback: Callable[[Any], None], + stream_final: bool = False, + iteration_callback: IterationCallback | None = None, +) -> HonchoLLMCallResponse[Any] | StreamingResponseWithMetadata: + """Run the iterative tool calling loop for agentic LLM interactions. + + Loop per iteration: + 1. Make an LLM call with tools available + 2. Execute any tool calls the LLM requests + 3. Append tool results to the conversation + 4. Repeat until the LLM stops calling tools or max iterations reached + + Returns: + Final HonchoLLMCallResponse with accumulated token counts and tool call + history, or a StreamingResponseWithMetadata if stream_final=True. + """ + from .conversation import truncate_messages_to_fit + + if not MIN_TOOL_ITERATIONS <= max_tool_iterations <= MAX_TOOL_ITERATIONS: + raise ValidationException( + "max_tool_iterations must be in " + + f"[{MIN_TOOL_ITERATIONS}, {MAX_TOOL_ITERATIONS}]; " + + f"got {max_tool_iterations}" + ) + + conversation_messages: list[dict[str, Any]] = ( + messages.copy() if messages else [{"role": "user", "content": prompt}] + ) + + iteration = 0 + all_tool_calls: list[dict[str, Any]] = [] + total_input_tokens = 0 + total_output_tokens = 0 + total_cache_creation_tokens = 0 + total_cache_read_tokens = 0 + empty_response_retries = 0 + # Track effective tool_choice — switches from "required"/"any" to "auto" after iter 1. + effective_tool_choice = tool_choice + + while iteration < max_tool_iterations: + # Reset attempt counter so each iteration starts with the primary provider. + current_attempt.set(1) + logger.debug(f"Tool execution iteration {iteration + 1}/{max_tool_iterations}") + + if max_input_tokens is not None: + conversation_messages = truncate_messages_to_fit( + conversation_messages, max_input_tokens + ) + + async def _call_with_messages( + effective_tool_choice: str | dict[str, Any] | None = effective_tool_choice, + conversation_messages: list[dict[str, Any]] = conversation_messages, + ) -> HonchoLLMCallResponse[Any]: + plan = get_attempt_plan() + return await honcho_llm_call_inner( + plan.provider, + plan.model, + prompt, # ignored when messages is passed + max_tokens, + response_model, + json_mode, + effective_temperature(temperature), + stop_seqs, + plan.reasoning_effort, + verbosity, + plan.thinking_budget_tokens, + stream=False, + client_override=plan.client, + tools=tools, + tool_choice=effective_tool_choice, + messages=conversation_messages, + selected_config=plan.selected_config, + ) + + if enable_retry: + call_func = retry( + stop=stop_after_attempt(retry_attempts), + wait=wait_exponential(multiplier=1, min=4, max=10), + before_sleep=before_retry_callback, + )(_call_with_messages) + else: + call_func = _call_with_messages + + response = await call_func() + + total_input_tokens += response.input_tokens + total_output_tokens += response.output_tokens + total_cache_creation_tokens += response.cache_creation_input_tokens + total_cache_read_tokens += response.cache_read_input_tokens + + if not response.tool_calls_made: + logger.debug("No tool calls in response, finishing") + + if ( + isinstance(response.content, str) + and not response.content.strip() + and empty_response_retries < 1 + and iteration < max_tool_iterations - 1 + ): + empty_response_retries += 1 + conversation_messages.append( + { + "role": "user", + "content": ( + "Your last response was empty. Provide a concise answer " + "to the original query using the available context." + ), + } + ) + iteration += 1 + continue + + if stream_final: + # Snapshot the plan that just succeeded — streaming retries + # pin to this exact client/model so we don't bounce back to + # primary after the tool loop settled on fallback. + winning_plan = get_attempt_plan() + stream = stream_final_response( + winning_plan=winning_plan, + prompt=prompt, + max_tokens=max_tokens, + conversation_messages=conversation_messages, + response_model=response_model, + json_mode=json_mode, + temperature=temperature, + stop_seqs=stop_seqs, + verbosity=verbosity, + enable_retry=enable_retry, + retry_attempts=retry_attempts, + before_retry_callback=before_retry_callback, + ) + return StreamingResponseWithMetadata( + stream=stream, + tool_calls_made=all_tool_calls, + input_tokens=total_input_tokens, + output_tokens=total_output_tokens, + cache_creation_input_tokens=total_cache_creation_tokens, + cache_read_input_tokens=total_cache_read_tokens, + thinking_content=response.thinking_content, + iterations=iteration + 1, + ) + + response.tool_calls_made = all_tool_calls + response.input_tokens = total_input_tokens + response.output_tokens = total_output_tokens + response.cache_creation_input_tokens = total_cache_creation_tokens + response.cache_read_input_tokens = total_cache_read_tokens + response.iterations = iteration + 1 + return response + + current_provider = get_attempt_plan().provider + + assistant_message = format_assistant_tool_message( + current_provider, + response.content, + response.tool_calls_made, + response.thinking_blocks, + response.reasoning_details, + ) + conversation_messages.append(assistant_message) + + # Telemetry context — 1-indexed iteration. + set_current_iteration(iteration + 1) + + tool_results: list[dict[str, Any]] = [] + for tool_call in response.tool_calls_made: + tool_name = tool_call["name"] + tool_input = tool_call["input"] + tool_id = tool_call.get("id", "") + + logger.debug(f"Executing tool: {tool_name}") + + try: + tool_result = await tool_executor(tool_name, tool_input) + tool_results.append( + { + "tool_id": tool_id, + "tool_name": tool_name, + "result": tool_result, + } + ) + all_tool_calls.append( + { + "tool_name": tool_name, + "tool_input": tool_input, + "tool_result": tool_result, + } + ) + except Exception as e: + logger.error(f"Tool execution failed for {tool_name}: {e}") + tool_results.append( + { + "tool_id": tool_id, + "tool_name": tool_name, + "result": f"Error: {str(e)}", + "is_error": True, + } + ) + + append_tool_results(current_provider, tool_results, conversation_messages) + + if iteration_callback is not None: + try: + iteration_data = IterationData( + iteration=iteration + 1, + tool_calls=[tc["name"] for tc in response.tool_calls_made], + input_tokens=response.input_tokens, + output_tokens=response.output_tokens, + cache_read_tokens=response.cache_read_input_tokens or 0, + cache_creation_tokens=response.cache_creation_input_tokens or 0, + ) + iteration_callback(iteration_data) + except Exception: + logger.warning("iteration_callback failed", exc_info=True) + + # After first iteration, switch "required"/"any" → "auto" so the model can stop. + if iteration == 0 and effective_tool_choice in ("required", "any"): + effective_tool_choice = "auto" + logger.debug( + "Switched tool_choice from 'required'/'any' to 'auto' after first iteration" + ) + + iteration += 1 + + logger.warning( + f"Tool execution loop reached max iterations ({max_tool_iterations})" + ) + + synthesis_prompt = ( + "You have reached the maximum number of tool calls. " + "Based on all the information you have gathered, provide your final response now. " + "Do not attempt to call any more tools." + ) + conversation_messages.append({"role": "user", "content": synthesis_prompt}) + + # Truncate again — the per-iteration truncate ran before the last tool + # call, so appending synthesis_prompt could nudge us back over the cap. + if max_input_tokens is not None: + conversation_messages = truncate_messages_to_fit( + conversation_messages, max_input_tokens + ) + + if stream_final: + # Snapshot the plan the loop settled on — streaming retries pin to + # this exact client/model rather than re-running provider selection. + winning_plan = get_attempt_plan() + stream = stream_final_response( + winning_plan=winning_plan, + prompt=prompt, + max_tokens=max_tokens, + conversation_messages=conversation_messages, + response_model=response_model, + json_mode=json_mode, + temperature=temperature, + stop_seqs=stop_seqs, + verbosity=verbosity, + enable_retry=enable_retry, + retry_attempts=retry_attempts, + before_retry_callback=before_retry_callback, + ) + return StreamingResponseWithMetadata( + stream=stream, + tool_calls_made=all_tool_calls, + input_tokens=total_input_tokens, + output_tokens=total_output_tokens, + cache_creation_input_tokens=total_cache_creation_tokens, + cache_read_input_tokens=total_cache_read_tokens, + thinking_content=None, + iterations=iteration + 1, + ) + + current_attempt.set(1) + + async def _final_call() -> HonchoLLMCallResponse[Any]: + plan = get_attempt_plan() + return await honcho_llm_call_inner( + plan.provider, + plan.model, + prompt, + max_tokens, + response_model, + json_mode, + effective_temperature(temperature), + stop_seqs, + plan.reasoning_effort, + verbosity, + plan.thinking_budget_tokens, + stream=False, + client_override=plan.client, + tools=None, + tool_choice=None, + messages=conversation_messages, + selected_config=plan.selected_config, + ) + + if enable_retry: + final_call_func = retry( + stop=stop_after_attempt(retry_attempts), + wait=wait_exponential(multiplier=1, min=4, max=10), + before_sleep=before_retry_callback, + )(_final_call) + else: + final_call_func = _final_call + + final_response = await final_call_func() + final_response.tool_calls_made = all_tool_calls + final_response.iterations = iteration + 1 + final_response.input_tokens = total_input_tokens + final_response.input_tokens + final_response.output_tokens = total_output_tokens + final_response.output_tokens + final_response.cache_creation_input_tokens = ( + total_cache_creation_tokens + final_response.cache_creation_input_tokens + ) + final_response.cache_read_input_tokens = ( + total_cache_read_tokens + final_response.cache_read_input_tokens + ) + return final_response + + +__all__ = [ + "MAX_TOOL_ITERATIONS", + "MIN_TOOL_ITERATIONS", + "append_tool_results", + "execute_tool_loop", + "format_assistant_tool_message", + "stream_final_response", +] diff --git a/src/llm/types.py b/src/llm/types.py new file mode 100644 index 00000000..7af5372d --- /dev/null +++ b/src/llm/types.py @@ -0,0 +1,138 @@ +"""Public response/stream/iteration types for the LLM API. + +These used to live in src/utils/clients.py and have been moved here as part +of the migration toward src/llm/ owning all non-embedding LLM orchestration. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable +from dataclasses import dataclass +from typing import Any, Generic, Literal, TypeVar + +from anthropic import AsyncAnthropic +from google import genai +from openai import AsyncOpenAI +from pydantic import BaseModel, Field + +T = TypeVar("T") + +# OpenAI GPT-5 specific reasoning levels. +ReasoningEffortType = ( + Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"] | None +) +VerbosityType = Literal["low", "medium", "high"] | None + +# Raw SDK client union used by the provider-selection layer. +ProviderClient = AsyncAnthropic | AsyncOpenAI | genai.Client + + +@dataclass +class IterationData: + """Data passed to iteration callbacks after each tool execution loop iteration.""" + + iteration: int + """1-indexed iteration number.""" + tool_calls: list[str] + """List of tool names called in this iteration.""" + input_tokens: int + """Input tokens used in this iteration's LLM call.""" + output_tokens: int + """Output tokens generated in this iteration's LLM call.""" + cache_read_tokens: int = 0 + """Tokens read from cache in this iteration.""" + cache_creation_tokens: int = 0 + """Tokens written to cache in this iteration.""" + + +IterationCallback = Callable[[IterationData], None] + + +class HonchoLLMCallResponse(BaseModel, Generic[T]): + """Response object for LLM calls. + + Note: + Uncached input tokens = input_tokens - cache_read_input_tokens + + cache_creation_input_tokens + (cache_creation costs 25% more, cache_read costs 90% less) + """ + + content: T + input_tokens: int = 0 + output_tokens: int + cache_creation_input_tokens: int = 0 + cache_read_input_tokens: int = 0 + finish_reasons: list[str] + tool_calls_made: list[dict[str, Any]] = Field(default_factory=list) + iterations: int = 0 + """Number of LLM calls made in the tool execution loop.""" + thinking_content: str | None = None + # Full thinking blocks with signatures for multi-turn replay (Anthropic only). + thinking_blocks: list[dict[str, Any]] = Field(default_factory=list) + # OpenRouter reasoning_details for Gemini models — must be preserved across turns. + reasoning_details: list[dict[str, Any]] = Field(default_factory=list) + + +class HonchoLLMCallStreamChunk(BaseModel): + """A single chunk in a streaming LLM response.""" + + content: str + is_done: bool = False + finish_reasons: list[str] = Field(default_factory=list) + output_tokens: int | None = None + + +class StreamingResponseWithMetadata: + """Streaming response wrapper carrying metadata from a completed tool loop. + + Lets callers read tool_calls_made / token counts / thinking_content from + the tool-execution phase while still iterating the final streamed answer. + """ + + _stream: AsyncIterator[HonchoLLMCallStreamChunk] + tool_calls_made: list[dict[str, Any]] + input_tokens: int + output_tokens: int + cache_creation_input_tokens: int + cache_read_input_tokens: int + thinking_content: str | None + iterations: int + + def __init__( + self, + stream: AsyncIterator[HonchoLLMCallStreamChunk], + tool_calls_made: list[dict[str, Any]], + input_tokens: int, + output_tokens: int, + cache_creation_input_tokens: int, + cache_read_input_tokens: int, + thinking_content: str | None = None, + iterations: int = 0, + ): + self._stream = stream + self.tool_calls_made = tool_calls_made + self.input_tokens = input_tokens + self.output_tokens = output_tokens + self.cache_creation_input_tokens = cache_creation_input_tokens + self.cache_read_input_tokens = cache_read_input_tokens + self.thinking_content = thinking_content + self.iterations = iterations + + def __aiter__(self) -> AsyncIterator[HonchoLLMCallStreamChunk]: + return self._stream.__aiter__() + + async def __anext__(self) -> HonchoLLMCallStreamChunk: + return await self._stream.__anext__() + + +__all__ = [ + "HonchoLLMCallResponse", + "HonchoLLMCallStreamChunk", + "IterationCallback", + "IterationData", + "ProviderClient", + "ReasoningEffortType", + "StreamingResponseWithMetadata", + "T", + "VerbosityType", +] diff --git a/src/main.py b/src/main.py index db053318..64439bee 100644 --- a/src/main.py +++ b/src/main.py @@ -154,7 +154,7 @@ app = FastAPI( title="Honcho API", summary="The Identity Layer for the Agentic World", description="""Honcho is a platform for giving agents user-centric memory and social cognition.""", - version="3.0.3", + version="3.0.6", contact={ "name": "Plastic Labs", "url": "https://honcho.dev", @@ -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): diff --git a/src/reconciler/queue_cleanup.py b/src/reconciler/queue_cleanup.py index 1ac63abf..4263e920 100644 --- a/src/reconciler/queue_cleanup.py +++ b/src/reconciler/queue_cleanup.py @@ -6,8 +6,9 @@ This module provides a periodic cleanup job that removes old processed queue ite import logging from datetime import datetime, timedelta, timezone +from typing import Any, cast -from sqlalchemy import delete +from sqlalchemy import CursorResult, delete from src import models from src.config import settings @@ -16,12 +17,15 @@ from src.dependencies import tracked_db logger = logging.getLogger(__name__) -async def cleanup_queue_items() -> None: +async def cleanup_queue_items() -> int: """ Delete processed queue items. Successfully processed queue items are deleted immediately, while errored queue items are deleted after retention window. + + Returns: + The number of queue items deleted. """ async with tracked_db("cleanup_queue_items") as db: now = datetime.now(timezone.utc) @@ -29,17 +33,22 @@ async def cleanup_queue_items() -> None: seconds=settings.DERIVER.QUEUE_ERROR_RETENTION_SECONDS ) - await db.execute( - delete(models.QueueItem).where( - models.QueueItem.processed - & ( - models.QueueItem.error.is_(None) - | ( - models.QueueItem.error.is_not(None) - & (models.QueueItem.created_at < error_cutoff) + result = cast( + CursorResult[Any], + await db.execute( + delete(models.QueueItem).where( + models.QueueItem.processed + & ( + models.QueueItem.error.is_(None) + | ( + models.QueueItem.error.is_not(None) + & (models.QueueItem.created_at < error_cutoff) + ) ) ) - ) + ), ) await db.commit() - logger.info("Queue cleanup completed") + deleted_count = result.rowcount + logger.info("Queue cleanup completed, deleted %d items", deleted_count) + return deleted_count diff --git a/src/reconciler/sync_vectors.py b/src/reconciler/sync_vectors.py index 32de0d19..4a17e40e 100644 --- a/src/reconciler/sync_vectors.py +++ b/src/reconciler/sync_vectors.py @@ -19,6 +19,7 @@ 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 VectorStoreError from src.vector_store import VectorRecord, VectorStore, get_external_vector_store logger = logging.getLogger(__name__) @@ -259,8 +260,16 @@ async def _sync_documents( .values(sync_state="synced", last_sync_at=func.now(), sync_attempts=0) ) synced_count += len(docs_to_sync) + except VectorStoreError: + logger.warning( + "Vector store unavailable while syncing namespace %s", namespace + ) + await _bump_document_sync_attempts(db, docs_to_sync) + failed_count += len(docs_to_sync) except Exception: - logger.exception("Failed to sync documents to namespace %s", namespace) + logger.exception( + "Unexpected error syncing documents to namespace %s", namespace + ) await _bump_document_sync_attempts(db, docs_to_sync) failed_count += len(docs_to_sync) @@ -399,9 +408,17 @@ async def _sync_message_embeddings( .values(sync_state="synced", last_sync_at=func.now(), sync_attempts=0) ) synced_count += len(embs_to_sync) + except VectorStoreError: + logger.warning( + "Vector store unavailable while syncing message embeddings to namespace %s", + namespace, + ) + await _bump_message_embedding_sync_attempts(db, embs_to_sync) + failed_count += len(embs_to_sync) except Exception: logger.exception( - "Failed to sync message embeddings to namespace %s", namespace + "Unexpected error syncing message embeddings to namespace %s", + namespace, ) await _bump_message_embedding_sync_attempts(db, embs_to_sync) failed_count += len(embs_to_sync) diff --git a/src/routers/peers.py b/src/routers/peers.py index ba30b959..fb765737 100644 --- a/src/routers/peers.py +++ b/src/routers/peers.py @@ -143,7 +143,6 @@ async def get_sessions_for_peer( @router.post( "/{peer_id}/chat", - summary="Query a Peer's representation using natural language", responses={ 200: { "content": { @@ -447,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/schemas/__init__.py b/src/schemas/__init__.py new file mode 100644 index 00000000..85d5da44 --- /dev/null +++ b/src/schemas/__init__.py @@ -0,0 +1,162 @@ +"""Pydantic schemas for Honcho. + +Re-exports all public names from submodules so that existing +``from src.schemas import X`` imports continue to work unchanged. +""" + +from src.schemas.api import ( + RESOURCE_NAME_PATTERN, + Conclusion, + ConclusionBatchCreate, + ConclusionCreate, + ConclusionGet, + ConclusionQuery, + DialecticOptions, + DialecticResponse, + DialecticStreamChunk, + DialecticStreamDelta, + Message, + MessageBase, + MessageBatchCreate, + MessageCreate, + MessageGet, + MessageSearchOptions, + MessageUpdate, + MessageUploadCreate, + Peer, + PeerBase, + PeerCardResponse, + PeerCardSet, + PeerContext, + PeerCreate, + PeerGet, + PeerRepresentationGet, + PeerUpdate, + QueueStatus, + RepresentationResponse, + ScheduleDreamRequest, + Session, + SessionBase, + SessionContext, + SessionCreate, + SessionGet, + SessionQueueStatus, + SessionSummaries, + SessionUpdate, + Summary, + WebhookEndpoint, + WebhookEndpointBase, + WebhookEndpointCreate, + Workspace, + WorkspaceBase, + WorkspaceCreate, + WorkspaceGet, + WorkspaceUpdate, +) +from src.schemas.configuration import ( + DreamConfiguration, + DreamType, + MessageConfiguration, + PeerCardConfiguration, + PeerConfig, + ReasoningConfiguration, + ResolvedConfiguration, + ResolvedDreamConfiguration, + ResolvedPeerCardConfiguration, + ResolvedReasoningConfiguration, + ResolvedSummaryConfiguration, + SessionConfiguration, + SessionPeerConfig, + SummaryConfiguration, + WorkspaceConfiguration, +) +from src.schemas.internal import ( + DocumentBase, + DocumentCreate, + DocumentMetadata, + MessageBulkData, + ObservationInput, + QueueCounts, + QueueStatusRow, + ReconcilerType, + SessionCounts, + SessionPeerData, +) + +__all__ = [ + # configuration + "DreamConfiguration", + "DreamType", + "MessageConfiguration", + "PeerCardConfiguration", + "PeerConfig", + "ReasoningConfiguration", + "ResolvedConfiguration", + "ResolvedDreamConfiguration", + "ResolvedPeerCardConfiguration", + "ResolvedReasoningConfiguration", + "ResolvedSummaryConfiguration", + "SessionConfiguration", + "SessionPeerConfig", + "SummaryConfiguration", + "WorkspaceConfiguration", + # api + "Conclusion", + "ConclusionBatchCreate", + "ConclusionCreate", + "ConclusionGet", + "ConclusionQuery", + "DialecticOptions", + "DialecticResponse", + "DialecticStreamChunk", + "DialecticStreamDelta", + "Message", + "MessageBase", + "MessageBatchCreate", + "MessageCreate", + "MessageGet", + "MessageSearchOptions", + "MessageUpdate", + "MessageUploadCreate", + "Peer", + "PeerBase", + "PeerCardResponse", + "PeerCardSet", + "PeerContext", + "PeerCreate", + "PeerGet", + "PeerRepresentationGet", + "PeerUpdate", + "QueueStatus", + "RESOURCE_NAME_PATTERN", + "RepresentationResponse", + "ScheduleDreamRequest", + "Session", + "SessionBase", + "SessionContext", + "SessionCreate", + "SessionGet", + "SessionQueueStatus", + "SessionSummaries", + "SessionUpdate", + "Summary", + "WebhookEndpoint", + "WebhookEndpointBase", + "WebhookEndpointCreate", + "Workspace", + "WorkspaceBase", + "WorkspaceCreate", + "WorkspaceGet", + "WorkspaceUpdate", + # internal + "DocumentBase", + "DocumentCreate", + "DocumentMetadata", + "MessageBulkData", + "ObservationInput", + "QueueCounts", + "QueueStatusRow", + "ReconcilerType", + "SessionCounts", + "SessionPeerData", +] diff --git a/src/schemas.py b/src/schemas/api.py similarity index 58% rename from src/schemas.py rename to src/schemas/api.py index 55685e64..8be19492 100644 --- a/src/schemas.py +++ b/src/schemas/api.py @@ -1,13 +1,19 @@ +"""Pydantic schemas for API request/response validation. + +These schemas are consumed by the FastAPI routers and define the public +API contract. +""" + import datetime import ipaddress -from enum import Enum -from typing import Annotated, Any, Literal, Self, cast +from typing import Annotated, Any, Self, cast from urllib.parse import urlparse import tiktoken from pydantic import ( AliasChoices, BaseModel, + BeforeValidator, ConfigDict, Field, PrivateAttr, @@ -16,192 +22,72 @@ from pydantic import ( ) from src.config import ReasoningLevel, settings -from src.utils.types import DocumentLevel +from src.schemas.configuration import ( + DreamType, + MessageConfiguration, + SessionConfiguration, + SessionPeerConfig, + WorkspaceConfiguration, +) + +# --------------------------------------------------------------------------- +# Metadata validation helpers +# --------------------------------------------------------------------------- RESOURCE_NAME_PATTERN = r"^[a-zA-Z0-9_-]+$" - -class DreamType(str, Enum): - """Types of dreams that can be triggered.""" - - OMNI = "omni" +_METADATA_MAX_KEYS = 100 +_METADATA_MAX_DEPTH = 5 -class ReconcilerType(str, Enum): - """Types of reconciler tasks that can be performed.""" - - SYNC_VECTORS = "sync_vectors" - CLEANUP_QUEUE = "cleanup_queue" +def _sanitize_value(v: Any) -> Any: + """Recursively strip NUL bytes from strings in nested data structures.""" + if isinstance(v, str): + return v.replace("\x00", "") + if isinstance(v, dict): + d = cast(dict[str, Any], v) + return {_sanitize_value(k): _sanitize_value(val) for k, val in d.items()} + if isinstance(v, list): + lst = cast(list[Any], v) + return [_sanitize_value(item) for item in lst] + return v -class ReasoningConfiguration(BaseModel): - enabled: bool | None = Field( - default=None, - description="Whether to enable reasoning functionality.", - ) - custom_instructions: str | None = Field( - default=None, - description="TODO: currently unused. Custom instructions to use for the reasoning system on this workspace/session/message.", - ) - - -class PeerCardConfiguration(BaseModel): - use: bool | None = Field( - default=None, - description="Whether to use peer card related to this peer during reasoning process.", - ) - create: bool | None = Field( - default=None, - description="Whether to generate peer card based on content.", - ) - - -class SummaryConfiguration(BaseModel): - enabled: bool | None = Field( - default=None, - description="Whether to enable summary functionality.", - ) - messages_per_short_summary: int | None = Field( - default=None, - ge=10, - description="Number of messages per short summary. Must be positive, greater than or equal to 10, and less than messages_per_long_summary.", - ) - messages_per_long_summary: int | None = Field( - default=None, - ge=20, - description="Number of messages per long summary. Must be positive, greater than or equal to 20, and greater than messages_per_short_summary.", - ) - - @model_validator(mode="after") - def validate_summary_thresholds(self) -> Self: - """Validate that short summary threshold <= long summary threshold.""" - short = self.messages_per_short_summary - long = self.messages_per_long_summary - - if short is not None and long is not None and short >= long: - raise ValueError( - "messages_per_short_summary must be less than messages_per_long_summary" +def _check_metadata_limits( + data: dict[str, Any], + *, + _current_depth: int = 1, +) -> None: + """Validate metadata dict doesn't exceed key count or nesting depth limits.""" + if _current_depth > _METADATA_MAX_DEPTH: + raise ValueError( + f"Metadata nesting exceeds maximum depth of {_METADATA_MAX_DEPTH}" + ) + if _current_depth == 1 and len(data) > _METADATA_MAX_KEYS: + raise ValueError( + f"Metadata exceeds maximum of {_METADATA_MAX_KEYS} top-level keys" + ) + for v in data.values(): + if isinstance(v, dict): + _check_metadata_limits( + cast(dict[str, Any], v), _current_depth=_current_depth + 1 ) - return self + +def _validate_metadata(v: Any) -> Any: + """Validate and sanitize a metadata dict: enforce limits and strip NUL bytes.""" + if not isinstance(v, dict): + return v + data = cast(dict[str, Any], v) + _check_metadata_limits(data) + return _sanitize_value(data) -class DreamConfiguration(BaseModel): - enabled: bool | None = Field( - default=None, - description="Whether to enable dream functionality. If reasoning is disabled, dreams will also be disabled and this setting will be ignored.", - ) +_SanitizedMetadata = Annotated[dict[str, Any], BeforeValidator(_validate_metadata)] - -class WorkspaceConfiguration(BaseModel): - """ - The set of options that can be in a workspace DB-level configuration dictionary. - - All fields are optional. Session-level configuration overrides workspace-level configuration, which overrides global configuration. - """ - - model_config = ConfigDict(extra="allow") # pyright: ignore - - reasoning: ReasoningConfiguration | None = Field( - default=None, - description="Configuration for reasoning functionality.", - ) - peer_card: PeerCardConfiguration | None = Field( - default=None, - description="Configuration for peer card functionality. If reasoning is disabled, peer cards will also be disabled and these settings will be ignored.", - ) - summary: SummaryConfiguration | None = Field( - default=None, - description="Configuration for summary functionality.", - ) - dream: DreamConfiguration | None = Field( - default=None, - description="Configuration for dream functionality. If reasoning is disabled, dreams will also be disabled and these settings will be ignored.", - ) - - -class SessionConfiguration(WorkspaceConfiguration): - """ - The set of options that can be in a session DB-level configuration dictionary. - - All fields are optional. Session-level configuration overrides workspace-level configuration, which overrides global configuration. - """ - - pass - - -class MessageConfiguration(BaseModel): - """ - The set of options that can be in a message DB-level configuration dictionary. - - All fields are optional. Message-level configuration overrides all other configurations. - """ - - reasoning: ReasoningConfiguration | None = Field( - default=None, - description="Configuration for reasoning functionality.", - ) - - -class ResolvedReasoningConfiguration(BaseModel): - enabled: bool - - -class ResolvedPeerCardConfiguration(BaseModel): - use: bool - create: bool - - -class ResolvedSummaryConfiguration(BaseModel): - enabled: bool - messages_per_short_summary: int - messages_per_long_summary: int - - -class ResolvedDreamConfiguration(BaseModel): - enabled: bool - - -class ResolvedConfiguration(BaseModel): - """ - The final resolved configuration for a given message. - Hierarchy: message > session > workspace > global configuration - """ - - reasoning: ResolvedReasoningConfiguration - peer_card: ResolvedPeerCardConfiguration - summary: ResolvedSummaryConfiguration - dream: ResolvedDreamConfiguration - - @model_validator(mode="before") - @classmethod - def migrate_deriver_to_reasoning(cls, data: Any) -> Any: - """Handle v3.0.0 migration: 'deriver' was renamed to 'reasoning'.""" - if not isinstance(data, dict): - return data - - config = cast(dict[str, Any], data) - - if "deriver" in config and "reasoning" not in config: - config["reasoning"] = config.pop("deriver") - - return config - - -class PeerConfig(BaseModel): - # TODO: Update description - should say "Whether honcho forms a representation of the peer itself" - observe_me: bool | None = Field( - default=None, - description="Whether Honcho will use reasoning to form a representation of this peer", - ) - - -class SessionPeerConfig(PeerConfig): - # TODO: Update description - should say "Whether this peer forms representations of other peers in the session" - observe_others: bool | None = Field( - default=None, - description="Whether this peer should form a session-level theory-of-mind representation of other peers in the session", - ) +# --------------------------------------------------------------------------- +# Workspace schemas +# --------------------------------------------------------------------------- class WorkspaceBase(BaseModel): @@ -213,7 +99,7 @@ class WorkspaceCreate(WorkspaceBase): str, Field(alias="id", min_length=1, max_length=100, pattern=RESOURCE_NAME_PATTERN), ] - metadata: dict[str, Any] = {} + metadata: _SanitizedMetadata = {} configuration: WorkspaceConfiguration = Field( default_factory=WorkspaceConfiguration ) @@ -226,7 +112,7 @@ class WorkspaceGet(WorkspaceBase): class WorkspaceUpdate(WorkspaceBase): - metadata: dict[str, Any] | None = None + metadata: _SanitizedMetadata | None = None configuration: WorkspaceConfiguration | None = None @@ -243,6 +129,11 @@ class Workspace(WorkspaceBase): ) +# --------------------------------------------------------------------------- +# Peer schemas +# --------------------------------------------------------------------------- + + class PeerBase(BaseModel): pass @@ -252,7 +143,7 @@ class PeerCreate(PeerBase): str, Field(alias="id", min_length=1, max_length=100, pattern=RESOURCE_NAME_PATTERN), ] - metadata: dict[str, Any] | None = None + metadata: _SanitizedMetadata | None = None configuration: dict[str, Any] | None = None model_config = ConfigDict(populate_by_name=True) # pyright: ignore @@ -263,7 +154,7 @@ class PeerGet(PeerBase): class PeerUpdate(PeerBase): - metadata: dict[str, Any] | None = None + metadata: _SanitizedMetadata | None = None configuration: dict[str, Any] | None = None @@ -330,6 +221,21 @@ class PeerCardResponse(BaseModel): class PeerCardSet(BaseModel): peer_card: list[str] = Field(..., description="The peer card content to set") + @field_validator("peer_card", mode="before") + @classmethod + def sanitize_peer_card(cls, v: Any) -> Any: + if isinstance(v, list): + return [ + item.replace("\x00", "") if isinstance(item, str) else item + for item in cast(list[Any], v) + ] + return v + + +# --------------------------------------------------------------------------- +# Message schemas +# --------------------------------------------------------------------------- + class MessageBase(BaseModel): pass @@ -338,12 +244,17 @@ class MessageBase(BaseModel): class MessageCreate(MessageBase): content: Annotated[str, Field(min_length=0, max_length=settings.MAX_MESSAGE_SIZE)] peer_name: str = Field(alias="peer_id") - metadata: dict[str, Any] | None = None + metadata: _SanitizedMetadata | None = None configuration: MessageConfiguration | None = None created_at: datetime.datetime | None = None _encoded_message: list[int] = PrivateAttr(default=[]) + @field_validator("content", mode="after") + @classmethod + def sanitize_content(cls, v: str) -> str: + return v.replace("\x00", "") + @property def encoded_message(self) -> list[int]: return self._encoded_message @@ -362,7 +273,7 @@ class MessageGet(MessageBase): class MessageUpdate(MessageBase): - metadata: dict[str, Any] | None = None + metadata: _SanitizedMetadata | None = None class Message(MessageBase): @@ -392,13 +303,18 @@ class MessageUploadCreate(BaseModel): """Schema for message creation from file uploads""" peer_id: str = Field(..., description="ID of the peer creating the message") - metadata: dict[str, Any] | None = None + metadata: _SanitizedMetadata | None = None configuration: MessageConfiguration | None = None created_at: datetime.datetime | None = None model_config = ConfigDict(populate_by_name=True) # pyright: ignore +# --------------------------------------------------------------------------- +# Session schemas +# --------------------------------------------------------------------------- + + class SessionBase(BaseModel): pass @@ -408,7 +324,7 @@ class SessionCreate(SessionBase): str, Field(alias="id", min_length=1, max_length=100, pattern=RESOURCE_NAME_PATTERN), ] - metadata: dict[str, Any] | None = None + metadata: _SanitizedMetadata | None = None peer_names: dict[str, SessionPeerConfig] | None = Field(default=None, alias="peers") configuration: SessionConfiguration | None = None @@ -420,7 +336,7 @@ class SessionGet(SessionBase): class SessionUpdate(SessionBase): - metadata: dict[str, Any] | None = None + metadata: _SanitizedMetadata | None = None configuration: SessionConfiguration | None = None @@ -505,95 +421,9 @@ class SessionSummaries(SessionBase): ) -class DocumentBase(BaseModel): - pass - - -class DocumentMetadata(BaseModel): - message_ids: list[int] = Field( - description="The ID range(s) of the messages that this document was derived from. Acts as a link to the primary source of the document. Note that as a document gets deduplicated, additional ranges will be added, because the same document could be derived from completely separate message ranges." - ) - message_created_at: str = Field( - description="The timestamp of the message that this document was derived from. Note that this is not the same as the created_at timestamp of the document. This timestamp is usually only saved with second-level precision." - ) - source_ids: list[str] | None = Field( - default=None, - description="Document IDs of source documents for tree traversal -- required for deductive and inductive documents", - ) - premises: list[str] | None = Field( - default=None, - description="Human-readable premise text for display -- only applicable for deductive documents", - ) - sources: list[str] | None = Field( - default=None, - description="Human-readable source text for display -- only applicable for inductive documents", - ) - pattern_type: str | None = Field( - default=None, - description="Type of pattern identified (preference, behavior, personality, tendency, correlation) -- only applicable for inductive documents", - ) - confidence: str | None = Field( - default=None, - description="Confidence level (high, medium, low) -- only applicable for inductive documents", - ) - - -class DocumentCreate(DocumentBase): - content: Annotated[str, Field(min_length=1, max_length=100000)] - session_name: str | None = Field( - default=None, - description="The session from which the document was derived (NULL for global observations)", - ) - level: DocumentLevel = Field( - default="explicit", - description="The level of the document (explicit, deductive, inductive, or contradiction)", - ) - times_derived: int = Field( - default=1, - ge=1, - description="The number of times that a semantic duplicate document to this one has been derived", - ) - metadata: DocumentMetadata = Field() - embedding: list[float] = Field() - # Tree linkage field - source_ids: list[str] | None = Field( - default=None, - description="Document IDs of source/premise documents -- for deductive and inductive documents", - ) - - -class ObservationInput(BaseModel): - """Validated observation input from LLM tool calls.""" - - content: str = Field(min_length=1) - level: DocumentLevel = "explicit" - source_ids: list[str] | None = None - premises: list[str] | None = None - sources: list[str] | None = None - pattern_type: ( - Literal["preference", "behavior", "personality", "tendency", "correlation"] - | None - ) = None - confidence: Literal["high", "medium", "low"] | None = None - - @model_validator(mode="after") - def validate_level_fields(self) -> Self: - """Validate that level-specific fields are present when required.""" - if self.level == "deductive" and not self.source_ids: - raise ValueError( - "deductive observations require 'source_ids' field with document IDs of premises" - ) - if self.level == "inductive" and not self.source_ids: - raise ValueError( - "inductive observations require 'source_ids' field with document IDs of sources" - ) - if self.level == "contradiction" and ( - not self.source_ids or len(self.source_ids) < 2 - ): - raise ValueError( - "contradiction observations require 'source_ids' field with at least 2 IDs of contradicting observations" - ) - return self +# --------------------------------------------------------------------------- +# Conclusion schemas +# --------------------------------------------------------------------------- class ConclusionGet(BaseModel): @@ -659,6 +489,11 @@ class ConclusionCreate(BaseModel): _token_count: int = PrivateAttr(default=0) + @field_validator("content", mode="after") + @classmethod + def sanitize_content(cls, v: str) -> str: + return v.replace("\x00", "") + @model_validator(mode="after") def validate_token_count(self) -> Self: """Validate that content doesn't exceed embedding token limit.""" @@ -666,9 +501,10 @@ class ConclusionCreate(BaseModel): tokens = encoding.encode(self.content) self._token_count = len(tokens) - if self._token_count > settings.MAX_EMBEDDING_TOKENS: + if self._token_count > settings.EMBEDDING.MAX_INPUT_TOKENS: raise ValueError( - f"Content exceeds maximum embedding token limit of {settings.MAX_EMBEDDING_TOKENS} " + "Content exceeds maximum embedding token limit of " + + f"{settings.EMBEDDING.MAX_INPUT_TOKENS} " + f"(got {self._token_count} tokens)" ) return self @@ -685,8 +521,13 @@ class ConclusionBatchCreate(BaseModel): ) +# --------------------------------------------------------------------------- +# Search schemas +# --------------------------------------------------------------------------- + + class MessageSearchOptions(BaseModel): - query: str = Field(..., description="Search query") + query: Annotated[str, Field(..., description="Search query")] filters: dict[str, Any] | None = Field( default=None, description="Filters to scope the search" ) @@ -697,6 +538,16 @@ class MessageSearchOptions(BaseModel): description="Number of results to return", ) + @field_validator("query", mode="after") + @classmethod + def sanitize_query(cls, v: str) -> str: + return v.replace("\x00", "") + + +# --------------------------------------------------------------------------- +# Dialectic schemas +# --------------------------------------------------------------------------- + class DialecticOptions(BaseModel): session_id: str | None = Field( @@ -715,6 +566,11 @@ class DialecticOptions(BaseModel): description="Level of reasoning to apply: minimal, low, medium, high, or max", ) + @field_validator("query", mode="after") + @classmethod + def sanitize_query(cls, v: str) -> str: + return v.replace("\x00", "") + class DialecticResponse(BaseModel): content: str | None @@ -737,50 +593,9 @@ class DialecticStreamChunk(BaseModel): done: bool = False -class SessionCounts(BaseModel): - """Counts for a specific session in queue processing.""" - - completed: int - in_progress: int - pending: int - - -class QueueCounts(BaseModel): - """Aggregated counts for queue processing status.""" - - total: int - completed: int - in_progress: int - pending: int - sessions: dict[str, SessionCounts] - - -class QueueStatusRow(BaseModel): - """Represents a row from the queue status SQL query result.""" - - session_id: str | None - total: int - completed: int - in_progress: int - pending: int - session_total: int - session_completed: int - session_in_progress: int - session_pending: int - - -class SessionPeerData(BaseModel): - """Data for managing session peer relationships.""" - - peer_names: dict[str, SessionPeerConfig] - - -class MessageBulkData(BaseModel): - """Data for bulk message operations.""" - - messages: list[MessageCreate] - session_name: str - workspace_name: str +# --------------------------------------------------------------------------- +# Queue status schemas +# --------------------------------------------------------------------------- class SessionQueueStatus(BaseModel): @@ -822,6 +637,11 @@ class QueueStatus(BaseModel): ) +# --------------------------------------------------------------------------- +# Dream scheduling schemas +# --------------------------------------------------------------------------- + + class ScheduleDreamRequest(BaseModel): observer: str = Field(..., description="Observer peer name") observed: str | None = Field( @@ -833,7 +653,11 @@ class ScheduleDreamRequest(BaseModel): ) -# Webhook endpoint schemas +# --------------------------------------------------------------------------- +# Webhook schemas +# --------------------------------------------------------------------------- + + class WebhookEndpointBase(BaseModel): pass diff --git a/src/schemas/configuration.py b/src/schemas/configuration.py new file mode 100644 index 00000000..29180247 --- /dev/null +++ b/src/schemas/configuration.py @@ -0,0 +1,186 @@ +"""Configuration schemas for hierarchical settings resolution. + +Covers workspace, session, and message-level configuration as well as +the fully-resolved variants used at runtime. +""" + +from enum import Enum +from typing import Any, Self, cast + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class DreamType(str, Enum): + """Types of dreams that can be triggered.""" + + OMNI = "omni" + + +class ReasoningConfiguration(BaseModel): + enabled: bool | None = Field( + default=None, + description="Whether to enable reasoning functionality.", + ) + custom_instructions: str | None = Field( + default=None, + description="TODO: currently unused. Custom instructions to use for the reasoning system on this workspace/session/message.", + ) + + +class PeerCardConfiguration(BaseModel): + use: bool | None = Field( + default=None, + description="Whether to use peer card related to this peer during reasoning process.", + ) + create: bool | None = Field( + default=None, + description="Whether to generate peer card based on content.", + ) + + +class SummaryConfiguration(BaseModel): + enabled: bool | None = Field( + default=None, + description="Whether to enable summary functionality.", + ) + messages_per_short_summary: int | None = Field( + default=None, + ge=10, + description="Number of messages per short summary. Must be positive, greater than or equal to 10, and less than messages_per_long_summary.", + ) + messages_per_long_summary: int | None = Field( + default=None, + ge=20, + description="Number of messages per long summary. Must be positive, greater than or equal to 20, and greater than messages_per_short_summary.", + ) + + @model_validator(mode="after") + def validate_summary_thresholds(self) -> Self: + """Validate that short summary threshold <= long summary threshold.""" + short = self.messages_per_short_summary + long = self.messages_per_long_summary + + if short is not None and long is not None and short >= long: + raise ValueError( + "messages_per_short_summary must be less than messages_per_long_summary" + ) + + return self + + +class DreamConfiguration(BaseModel): + enabled: bool | None = Field( + default=None, + description="Whether to enable dream functionality. If reasoning is disabled, dreams will also be disabled and this setting will be ignored.", + ) + + +class WorkspaceConfiguration(BaseModel): + """ + The set of options that can be in a workspace DB-level configuration dictionary. + + All fields are optional. Session-level configuration overrides workspace-level configuration, which overrides global configuration. + """ + + model_config = ConfigDict(extra="allow") # pyright: ignore + + reasoning: ReasoningConfiguration | None = Field( + default=None, + description="Configuration for reasoning functionality.", + ) + peer_card: PeerCardConfiguration | None = Field( + default=None, + description="Configuration for peer card functionality. If reasoning is disabled, peer cards will also be disabled and these settings will be ignored.", + ) + summary: SummaryConfiguration | None = Field( + default=None, + description="Configuration for summary functionality.", + ) + dream: DreamConfiguration | None = Field( + default=None, + description="Configuration for dream functionality. If reasoning is disabled, dreams will also be disabled and these settings will be ignored.", + ) + + +class SessionConfiguration(WorkspaceConfiguration): + """ + The set of options that can be in a session DB-level configuration dictionary. + + All fields are optional. Session-level configuration overrides workspace-level configuration, which overrides global configuration. + """ + + pass + + +class MessageConfiguration(BaseModel): + """ + The set of options that can be in a message DB-level configuration dictionary. + + All fields are optional. Message-level configuration overrides all other configurations. + """ + + reasoning: ReasoningConfiguration | None = Field( + default=None, + description="Configuration for reasoning functionality.", + ) + + +class ResolvedReasoningConfiguration(BaseModel): + enabled: bool + + +class ResolvedPeerCardConfiguration(BaseModel): + use: bool + create: bool + + +class ResolvedSummaryConfiguration(BaseModel): + enabled: bool + messages_per_short_summary: int + messages_per_long_summary: int + + +class ResolvedDreamConfiguration(BaseModel): + enabled: bool + + +class ResolvedConfiguration(BaseModel): + """ + The final resolved configuration for a given message. + Hierarchy: message > session > workspace > global configuration + """ + + reasoning: ResolvedReasoningConfiguration + peer_card: ResolvedPeerCardConfiguration + summary: ResolvedSummaryConfiguration + dream: ResolvedDreamConfiguration + + @model_validator(mode="before") + @classmethod + def migrate_deriver_to_reasoning(cls, data: Any) -> Any: + """Handle v3.0.0 migration: 'deriver' was renamed to 'reasoning'.""" + if not isinstance(data, dict): + return data + + config = cast(dict[str, Any], data) + + if "deriver" in config and "reasoning" not in config: + config["reasoning"] = config.pop("deriver") + + return config + + +class PeerConfig(BaseModel): + # TODO: Update description - should say "Whether honcho forms a representation of the peer itself" + observe_me: bool | None = Field( + default=None, + description="Whether Honcho will use reasoning to form a representation of this peer", + ) + + +class SessionPeerConfig(PeerConfig): + # TODO: Update description - should say "Whether this peer forms representations of other peers in the session" + observe_others: bool | None = Field( + default=None, + description="Whether this peer should form a session-level theory-of-mind representation of other peers in the session", + ) diff --git a/src/schemas/internal.py b/src/schemas/internal.py new file mode 100644 index 00000000..e014431f --- /dev/null +++ b/src/schemas/internal.py @@ -0,0 +1,177 @@ +"""Internal schemas used by the deriver, dreamer, and other background systems. + +These are not part of the public API contract and may change without notice. +""" + +from enum import Enum +from typing import Annotated, Literal, Self + +from pydantic import BaseModel, Field, field_validator, model_validator + +from src.schemas.api import MessageCreate +from src.schemas.configuration import SessionPeerConfig +from src.utils.types import DocumentLevel + + +class ReconcilerType(str, Enum): + """Types of reconciler tasks that can be performed.""" + + SYNC_VECTORS = "sync_vectors" + CLEANUP_QUEUE = "cleanup_queue" + + +# --------------------------------------------------------------------------- +# Document / observation schemas (vector storage internals) +# --------------------------------------------------------------------------- + + +class DocumentBase(BaseModel): + pass + + +class DocumentMetadata(BaseModel): + message_ids: list[int] = Field( + description="The ID range(s) of the messages that this document was derived from. Acts as a link to the primary source of the document. Note that as a document gets deduplicated, additional ranges will be added, because the same document could be derived from completely separate message ranges." + ) + message_created_at: str = Field( + description="The timestamp of the message that this document was derived from. Note that this is not the same as the created_at timestamp of the document. This timestamp is usually only saved with second-level precision." + ) + source_ids: list[str] | None = Field( + default=None, + description="Document IDs of source documents for tree traversal -- required for deductive and inductive documents", + ) + premises: list[str] | None = Field( + default=None, + description="Human-readable premise text for display -- only applicable for deductive documents", + ) + sources: list[str] | None = Field( + default=None, + description="Human-readable source text for display -- only applicable for inductive documents", + ) + pattern_type: str | None = Field( + default=None, + description="Type of pattern identified (preference, behavior, personality, tendency, correlation) -- only applicable for inductive documents", + ) + confidence: str | None = Field( + default=None, + description="Confidence level (high, medium, low) -- only applicable for inductive documents", + ) + + +class DocumentCreate(DocumentBase): + content: Annotated[str, Field(min_length=1, max_length=100000)] + session_name: str | None = Field( + default=None, + description="The session from which the document was derived (NULL for global observations)", + ) + level: DocumentLevel = Field( + default="explicit", + description="The level of the document (explicit, deductive, inductive, or contradiction)", + ) + times_derived: int = Field( + default=1, + ge=1, + description="The number of times that a semantic duplicate document to this one has been derived", + ) + metadata: DocumentMetadata = Field() + embedding: list[float] = Field() + # Tree linkage field + source_ids: list[str] | None = Field( + default=None, + description="Document IDs of source/premise documents -- for deductive and inductive documents", + ) + + +class ObservationInput(BaseModel): + """Validated observation input from LLM tool calls.""" + + content: Annotated[str, Field(min_length=1)] + level: DocumentLevel = "explicit" + source_ids: list[str] | None = None + premises: list[str] | None = None + sources: list[str] | None = None + pattern_type: ( + Literal["preference", "behavior", "personality", "tendency", "correlation"] + | None + ) = None + confidence: Literal["high", "medium", "low"] | None = None + + @field_validator("content", mode="after") + @classmethod + def sanitize_content(cls, v: str) -> str: + return v.replace("\x00", "") + + @model_validator(mode="after") + def validate_level_fields(self) -> Self: + """Validate that level-specific fields are present when required.""" + if self.level == "deductive" and not self.source_ids: + raise ValueError( + "deductive observations require 'source_ids' field with document IDs of premises" + ) + if self.level == "inductive" and not self.source_ids: + raise ValueError( + "inductive observations require 'source_ids' field with document IDs of sources" + ) + if self.level == "contradiction" and ( + not self.source_ids or len(self.source_ids) < 2 + ): + raise ValueError( + "contradiction observations require 'source_ids' field with at least 2 IDs of contradicting observations" + ) + return self + + +# --------------------------------------------------------------------------- +# Queue internals +# --------------------------------------------------------------------------- + + +class SessionCounts(BaseModel): + """Counts for a specific session in queue processing.""" + + completed: int + in_progress: int + pending: int + + +class QueueCounts(BaseModel): + """Aggregated counts for queue processing status.""" + + total: int + completed: int + in_progress: int + pending: int + sessions: dict[str, SessionCounts] + + +class QueueStatusRow(BaseModel): + """Represents a row from the queue status SQL query result.""" + + session_id: str | None + total: int + completed: int + in_progress: int + pending: int + session_total: int + session_completed: int + session_in_progress: int + session_pending: int + + +# --------------------------------------------------------------------------- +# Internal data containers +# --------------------------------------------------------------------------- + + +class SessionPeerData(BaseModel): + """Data for managing session peer relationships.""" + + peer_names: dict[str, SessionPeerConfig] + + +class MessageBulkData(BaseModel): + """Data for bulk message operations.""" + + messages: list[MessageCreate] + session_name: str + workspace_name: str diff --git a/src/telemetry/reasoning_traces.py b/src/telemetry/reasoning_traces.py index b208d08b..1f4f03e3 100644 --- a/src/telemetry/reasoning_traces.py +++ b/src/telemetry/reasoning_traces.py @@ -12,7 +12,11 @@ from typing import Any from pydantic import BaseModel -from src.config import LLMComponentSettings, settings +from src.config import ( + ConfiguredModelSettings, + ModelConfig, + settings, +) def get_reasoning_traces_file_path() -> Path | None: @@ -24,7 +28,7 @@ def get_reasoning_traces_file_path() -> Path | None: def log_reasoning_trace( task_type: str, - llm_settings: LLMComponentSettings, + model_config: ModelConfig | ConfiguredModelSettings, prompt: str, response: Any, *, @@ -40,7 +44,7 @@ def log_reasoning_trace( Args: task_type: Type of task (e.g., "minimal_deriver", "dialectic_chat") - llm_settings: LLM settings used for the call + model_config: Model configuration used for the call prompt: The full prompt text sent to the LLM (used if messages is None) response: HonchoLLMCallResponse object with the LLM response max_tokens: Max output tokens setting @@ -62,8 +66,8 @@ def log_reasoning_trace( trace_entry: dict[str, Any] = { "timestamp": time.time(), "task_type": task_type, - "provider": llm_settings.PROVIDER, - "model": llm_settings.MODEL, + "provider": model_config.transport, + "model": model_config.model, "settings": { "max_tokens": max_tokens, "thinking_budget_tokens": thinking_budget_tokens, diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index e4b38255..36168f34 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -1,5 +1,6 @@ import asyncio import logging +import weakref from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -11,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src import crud, models, schemas from src.config import settings +from src.dependencies import tracked_db from src.embedding_client import embedding_client from src.models import Document from src.schemas import ResolvedConfiguration @@ -31,6 +33,205 @@ logger = logging.getLogger(__name__) MAX_PEER_CARD_FACTS = 40 +def _base_observation_properties() -> dict[str, Any]: + return { + "content": { + "type": "string", + "description": "The observation content", + }, + "level": { + "type": "string", + "enum": [ + "explicit", + "deductive", + "inductive", + "contradiction", + ], + "description": ( + "Level: 'explicit' for direct facts, 'deductive' for logical " + + "necessities, 'inductive' for patterns, 'contradiction' for " + + "conflicting statements" + ), + }, + "source_ids": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Document IDs of source or premise observations. Required and " + + "must be non-empty for deductive, inductive, and contradiction " + + "observations." + ), + }, + "premises": { + "type": "array", + "items": {"type": "string"}, + "description": "(For deductive) Human-readable premise text for display", + }, + "sources": { + "type": "array", + "items": {"type": "string"}, + "description": "(For inductive/contradiction) Human-readable source text for display", + }, + "pattern_type": { + "type": "string", + "enum": [ + "preference", + "behavior", + "personality", + "tendency", + "correlation", + ], + "description": "(For inductive only) Type of pattern being identified", + }, + "confidence": { + "type": "string", + "enum": ["high", "medium", "low"], + "description": ( + "(For inductive only) Confidence level: 'high' for 5+ sources, " + + "'medium' for 3-4, 'low' for 2" + ), + }, + } + + +def _generic_observation_item_schema() -> dict[str, Any]: + return { + "type": "object", + "properties": _base_observation_properties(), + "required": ["content", "level"], + "additionalProperties": False, + "allOf": [ + { + "if": {"properties": {"level": {"const": "deductive"}}}, + "then": { + "required": ["source_ids", "premises"], + "properties": { + "source_ids": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + }, + "premises": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + }, + }, + }, + }, + { + "if": {"properties": {"level": {"const": "inductive"}}}, + "then": { + "required": [ + "source_ids", + "sources", + "pattern_type", + "confidence", + ], + "properties": { + "source_ids": { + "type": "array", + "items": {"type": "string"}, + "minItems": 2, + }, + "sources": { + "type": "array", + "items": {"type": "string"}, + "minItems": 2, + }, + }, + }, + }, + { + "if": {"properties": {"level": {"const": "contradiction"}}}, + "then": { + "required": ["source_ids", "sources"], + "properties": { + "source_ids": { + "type": "array", + "items": {"type": "string"}, + "minItems": 2, + }, + "sources": { + "type": "array", + "items": {"type": "string"}, + "minItems": 2, + }, + }, + }, + }, + ], + } + + +def _deductive_observation_item_schema() -> dict[str, Any]: + return { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "The deductive conclusion as a self-contained statement", + }, + "source_ids": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "description": "Required non-empty list of source observation IDs supporting the deduction", + }, + "premises": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "description": "Required human-readable premise text matching the source observations", + }, + }, + "required": ["content", "source_ids", "premises"], + "additionalProperties": False, + } + + +def _inductive_observation_item_schema() -> dict[str, Any]: + return { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "The inductive pattern or generalization as a self-contained statement", + }, + "source_ids": { + "type": "array", + "items": {"type": "string"}, + "minItems": 2, + "description": "Required list of at least two source observation IDs supporting the pattern", + }, + "sources": { + "type": "array", + "items": {"type": "string"}, + "minItems": 2, + "description": "Required human-readable evidence text matching the source observations", + }, + "pattern_type": { + "type": "string", + "enum": [ + "preference", + "behavior", + "personality", + "tendency", + "correlation", + ], + "description": "Required pattern category", + }, + "confidence": { + "type": "string", + "enum": ["high", "medium", "low"], + "description": "Required confidence level based on evidence count", + }, + }, + "required": ["content", "source_ids", "sources", "pattern_type", "confidence"], + "additionalProperties": False, + } + + def _safe_int(value: Any, default: int) -> int: """Coerce a tool input value to int, returning default on failure. @@ -46,7 +247,14 @@ def _safe_int(value: Any, default: int) -> int: # Module-level lock registry for thread-safe observation creation. # Keyed by (workspace_name, observer, observed) to ensure all tool executors # operating on the same data share the same lock. -_observation_locks: dict[tuple[str, str, str], asyncio.Lock] = {} +# +# Uses WeakValueDictionary so entries are automatically removed when no +# ToolContext holds a reference to the lock (i.e., all executors for that +# key have finished and been garbage collected). This prevents unbounded +# growth over the lifetime of a long-running deriver process. +_observation_locks: weakref.WeakValueDictionary[tuple[str, str, str], asyncio.Lock] = ( + weakref.WeakValueDictionary() +) _registry_lock = asyncio.Lock() @@ -59,6 +267,11 @@ async def get_observation_lock( This ensures that concurrent tool executors operating on the same observation space share a lock, preventing race conditions during document creation. + The lock is stored as a weak reference — it stays alive as long as at least + one ToolContext (via create_tool_executor) holds a strong reference. Once all + executors for a key finish and are garbage collected, the entry is + automatically removed from the registry. + Args: workspace_name: Workspace identifier observer: The observing peer @@ -69,9 +282,11 @@ async def get_observation_lock( """ key = (workspace_name, observer, observed) async with _registry_lock: - if key not in _observation_locks: - _observation_locks[key] = asyncio.Lock() - return _observation_locks[key] + lock = _observation_locks.get(key) + if lock is None: + lock = asyncio.Lock() + _observation_locks[key] = lock + return lock @dataclass @@ -161,88 +376,44 @@ def _extract_pattern_snippet( TOOLS: dict[str, dict[str, Any]] = { "create_observations": { "name": "create_observations", - "description": "Create observations at any level: explicit (facts), deductive (logical necessities), inductive (patterns), or contradiction (conflicting statements). Use this to record facts, logical inferences, patterns, or note when the user has said contradictory things.", + "description": "Create observations at any level: explicit (facts), deductive (logical necessities), inductive (patterns), or contradiction (conflicting statements). For deductive, inductive, and contradiction observations, missing or empty source_ids are invalid and will be rejected.", "input_schema": { "type": "object", "properties": { "observations": { "type": "array", "description": "List of observations to create", - "items": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "The observation content", - }, - "level": { - "type": "string", - "enum": [ - "explicit", - "deductive", - "inductive", - "contradiction", - ], - "description": "Level: 'explicit' for direct facts, 'deductive' for logical necessities, 'inductive' for patterns, 'contradiction' for conflicting statements", - }, - "source_ids": { - "type": "array", - "items": {"type": "string"}, - "description": "(For deductive/inductive/contradiction) Document IDs of source/premise observations - REQUIRED", - }, - "premises": { - "type": "array", - "items": {"type": "string"}, - "description": "(For deductive) Human-readable premise text for display", - }, - "sources": { - "type": "array", - "items": {"type": "string"}, - "description": "(For inductive/contradiction) Human-readable source text for display", - }, - "pattern_type": { - "type": "string", - "enum": [ - "preference", - "behavior", - "personality", - "tendency", - "correlation", - ], - "description": "(For inductive only) Type of pattern being identified", - }, - "confidence": { - "type": "string", - "enum": ["high", "medium", "low"], - "description": "(For inductive only) Confidence level: 'high' for 3+ sources, 'medium' for 2+, 'low' for tentative", - }, - }, - "required": ["content", "level"], - }, + "items": _generic_observation_item_schema(), }, }, "required": ["observations"], }, }, "create_observations_deductive": { - "name": "create_observations", - "description": "Create new deductive observations discovered while answering the query. Use this when you infer something new about the peer that isn't already captured in existing observations. Only use for novel deductions - not for restating existing facts.", + "name": "create_observations_deductive", + "description": "Create new deductive observations discovered while answering the query. Every observation must include non-empty source_ids and premise text. Use this only for novel deductions grounded in existing observations.", "input_schema": { "type": "object", "properties": { "observations": { "type": "array", "description": "List of new deductive observations to create", - "items": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "The observation content - should be a self-contained statement about the peer", - }, - }, - "required": ["content"], - }, + "items": _deductive_observation_item_schema(), + }, + }, + "required": ["observations"], + }, + }, + "create_observations_inductive": { + "name": "create_observations_inductive", + "description": "Create new inductive observations discovered while answering the query. Every observation must include source_ids, source text, pattern_type, and confidence. Use this only for patterns supported by multiple observations.", + "input_schema": { + "type": "object", + "properties": { + "observations": { + "type": "array", + "description": "List of new inductive observations to create", + "items": _inductive_observation_item_schema(), }, }, "required": ["observations"], @@ -579,7 +750,7 @@ DEDUCTION_SPECIALIST_TOOLS: list[dict[str, Any]] = [ TOOLS["search_memory"], TOOLS["search_messages"], # Action tools - TOOLS["create_observations"], + TOOLS["create_observations_deductive"], TOOLS["delete_observations"], TOOLS["update_peer_card"], ] @@ -594,13 +765,12 @@ INDUCTION_SPECIALIST_TOOLS: list[dict[str, Any]] = [ TOOLS["search_memory"], TOOLS["search_messages"], # Action tools - TOOLS["create_observations"], + TOOLS["create_observations_inductive"], TOOLS["update_peer_card"], ] async def create_observations( - db: AsyncSession, observations: list[schemas.ObservationInput], observer: str, observed: str, @@ -612,8 +782,9 @@ async def create_observations( """ Create multiple observations (documents) in the memory system in a single call. + Uses short-lived DB sessions to avoid holding connections during embedding API calls. + Args: - db: Database session observations: List of validated observation inputs observer: The peer making the observation observed: The peer being observed @@ -629,18 +800,17 @@ async def create_observations( logger.warning("create_observations called with empty list") return ObservationsCreatedResult(created_count=0, created_levels=[], failed=[]) - # Get or create collection - await crud.get_or_create_collection( - db, - workspace_name, - observer=observer, - observed=observed, - ) + # Phase 1: Ensure collection exists (short DB scope) + async with tracked_db("create_observations.collection") as db: + await crud.get_or_create_collection( + db, + workspace_name, + observer=observer, + observed=observed, + ) + # Phase 2: Compute embeddings (no DB needed) contents = [obs.content for obs in observations] - - # Batch embed all observation contents. - # If batching fails, fall back to per-observation embedding. embeddings_by_index: dict[int, list[float]] | None = None try: embeddings = await embedding_client.simple_batch_embed(contents) @@ -706,27 +876,29 @@ async def create_observations( ) documents.append(doc) - # Bulk create all documents + # Phase 3: Bulk create all documents (short DB scope) + accepted: list[schemas.DocumentCreate] = [] if documents: - await crud.create_documents( - db, - documents=documents, - workspace_name=workspace_name, - observer=observer, - observed=observed, - deduplicate=True, - ) + async with tracked_db("create_observations.save") as db: + accepted = await crud.create_documents( + db, + documents=documents, + workspace_name=workspace_name, + observer=observer, + observed=observed, + deduplicate=True, + ) logger.info( "Created %d observations in %s/%s/%s", - len(documents), + len(accepted), workspace_name, observer, observed, ) return ObservationsCreatedResult( - created_count=len(documents), - created_levels=[doc.level for doc in documents], + created_count=len(accepted), + created_levels=[doc.level for doc in accepted], failed=failed, ) @@ -786,7 +958,6 @@ async def get_recent_history( async def search_memory( - db: AsyncSession, workspace_name: str, observer: str, observed: str, @@ -798,8 +969,10 @@ async def search_memory( """ Search for observations in memory using semantic similarity. + Does not require a DB session — ``query_documents`` manages its own + short-lived sessions so no connection is held during external calls. + Args: - db: Database session workspace_name: Workspace identifier observer: The peer who made the observations observed: The peer who was observed @@ -818,7 +991,7 @@ async def search_memory( filters = {"level": {"in": levels}} documents = await crud.query_documents( - db=db, + db=None, workspace_name=workspace_name, observer=observer, observed=observed, @@ -836,6 +1009,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. @@ -849,6 +1023,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 @@ -856,6 +1032,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) @@ -865,6 +1052,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") @@ -887,6 +1076,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()) @@ -895,10 +1086,10 @@ async def get_observation_context( async def extract_preferences( - db: AsyncSession, 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. @@ -907,10 +1098,11 @@ async def extract_preferences( This is language-agnostic and doesn't rely on keyword matching. Args: - db: Database session 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 @@ -927,7 +1119,7 @@ async def extract_preferences( "things user wants or does not want", ] - # Batch embed all queries in a single API call. + # Batch embed all queries in a single API call (no DB needed). # If batching fails, each search call will generate its own embedding. query_embeddings_by_query: dict[str, list[float]] | None = None try: @@ -944,7 +1136,6 @@ async def extract_preferences( for query in semantic_queries: try: snippets = await crud.search_messages( - db, workspace_name=workspace_name, session_name=session_name, query=query, @@ -955,6 +1146,7 @@ async def extract_preferences( if query_embeddings_by_query is not None else None ), + observer=observer, ) for matches, _ in snippets: for msg in matches: @@ -977,7 +1169,6 @@ async def extract_preferences( class ToolContext: """Context object passed to tool handlers.""" - db: AsyncSession workspace_name: str observer: str observed: str @@ -997,8 +1188,11 @@ class ToolContext: parent_category: str | None = None # Parent category for CloudEvents -async def _handle_create_observations( - ctx: ToolContext, tool_input: dict[str, Any] +async def _handle_create_observations_impl( + ctx: ToolContext, + tool_input: dict[str, Any], + *, + forced_level: str | None = None, ) -> str: """Handle create_observations tool.""" raw_observations = tool_input.get("observations", []) @@ -1009,7 +1203,10 @@ async def _handle_create_observations( # Set context-specific default level before Pydantic validation default_level = "explicit" if ctx.current_messages else "deductive" for obs in raw_observations: - obs.setdefault("level", default_level) + if forced_level is not None: + obs["level"] = forced_level + else: + obs.setdefault("level", default_level) # Validate observations individually so valid ones are still processed observations: list[schemas.ObservationInput] = [] @@ -1053,7 +1250,6 @@ async def _handle_create_observations( # Use lock to serialize database writes (prevents concurrent commit issues) async with ctx.db_lock: result = await create_observations( - ctx.db, observations=observations, observer=ctx.observer, observed=ctx.observed, @@ -1104,6 +1300,32 @@ async def _handle_create_observations( return response +async def _handle_create_observations( + ctx: ToolContext, tool_input: dict[str, Any] +) -> str: + return await _handle_create_observations_impl(ctx, tool_input) + + +async def _handle_create_observations_deductive( + ctx: ToolContext, tool_input: dict[str, Any] +) -> str: + return await _handle_create_observations_impl( + ctx, + tool_input, + forced_level="deductive", + ) + + +async def _handle_create_observations_inductive( + ctx: ToolContext, tool_input: dict[str, Any] +) -> str: + return await _handle_create_observations_impl( + ctx, + tool_input, + forced_level="inductive", + ) + + async def _handle_update_peer_card(ctx: ToolContext, tool_input: dict[str, Any]) -> str: """Handle update_peer_card tool.""" # Check if peer card creation is disabled via configuration @@ -1163,9 +1385,9 @@ async def _handle_update_peer_card(ctx: ToolContext, tool_input: dict[str, Any]) ) normalized_peer_card = normalized_peer_card[:MAX_PEER_CARD_FACTS] - async with ctx.db_lock: + async with ctx.db_lock, tracked_db("tool.update_peer_card") as db: await crud.set_peer_card( - ctx.db, + db, workspace_name=ctx.workspace_name, peer_card=normalized_peer_card, observer=ctx.observer, @@ -1199,18 +1421,19 @@ async def _handle_get_recent_history( ) -> str: """Handle get_recent_history tool.""" _ = tool_input - history: list[models.Message] = await get_recent_history( - ctx.db, - workspace_name=ctx.workspace_name, - session_name=ctx.session_name, - observed=ctx.observed, - token_limit=ctx.history_token_limit, - ) - if not history: - return "No conversation history available" - history_text = "\n".join( - [f"{m.peer_name}: {_truncate_message_content(m.content)}" for m in history] - ) + async with tracked_db("tool.get_recent_history") as db: + history: list[models.Message] = await get_recent_history( + db, + workspace_name=ctx.workspace_name, + session_name=ctx.session_name, + observed=ctx.observed, + token_limit=ctx.history_token_limit, + ) + if not history: + return "No conversation history available" + history_text = "\n".join( + [f"{m.peer_name}: {_truncate_message_content(m.content)}" for m in history] + ) scope = ( f"from session {ctx.session_name}" if ctx.session_name @@ -1227,10 +1450,13 @@ async def _handle_search_memory(ctx: ToolContext, tool_input: dict[str, Any]) -> try: query_embedding = await embedding_client.embed(query) except ValueError: - return f"ERROR: Query exceeds maximum token limit of {settings.MAX_EMBEDDING_TOKENS}. Please use a shorter query." + return ( + "ERROR: Query exceeds maximum token limit of " + + f"{settings.EMBEDDING.MAX_INPUT_TOKENS}. Please use a shorter query." + ) documents = await crud.query_documents( - db=ctx.db, + db=None, workspace_name=ctx.workspace_name, observer=ctx.observer, observed=ctx.observed, @@ -1248,19 +1474,21 @@ async def _handle_search_memory(ctx: ToolContext, tool_input: dict[str, Any]) -> # automatically search the message history for relevant information. if ctx.agent_type == "dialectic": limit = min(_safe_int(tool_input.get("top_k"), 20), 20) + message_output = None snippets = await crud.search_messages( - ctx.db, 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 message_output: return ( f"No observations yet. Message search results:\n\n{message_output}" ) @@ -1277,24 +1505,26 @@ async def _handle_get_observation_context( ctx: ToolContext, tool_input: dict[str, Any] ) -> str: """Handle get_observation_context tool.""" - messages = await get_observation_context( - ctx.db, - workspace_name=ctx.workspace_name, - session_name=ctx.session_name, - message_ids=tool_input["message_ids"], - ) - if not messages: - return f"No messages found for IDs {tool_input['message_ids']}" - messages_text = "\n".join( - [ - format_new_turn_with_timestamp( - _truncate_message_content(m.content), - m.created_at, - m.peer_name, - ) - for m in messages - ] - ) + async with tracked_db("tool.get_observation_context") as db: + messages = await get_observation_context( + db, + 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']}" + messages_text = "\n".join( + [ + format_new_turn_with_timestamp( + _truncate_message_content(m.content), + m.created_at, + m.peer_name, + ) + for m in messages + ] + ) output = f"Retrieved {len(messages)} messages with context:\n{messages_text}" return _truncate_tool_output(output) @@ -1303,18 +1533,22 @@ async def _handle_search_messages(ctx: ToolContext, tool_input: dict[str, Any]) """Handle search_messages tool.""" query = tool_input["query"] limit = min(_safe_int(tool_input.get("limit"), 10), 20) # Cap at 20 + # 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) snippets = await crud.search_messages( - ctx.db, 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}'" - - return _format_message_snippets(snippets, f"for query '{query}'") + formatted = _format_message_snippets(snippets, f"for query '{query}'") + return formatted async def _handle_grep_messages(ctx: ToolContext, tool_input: dict[str, Any]) -> str: @@ -1328,12 +1562,12 @@ async def _handle_grep_messages(ctx: ToolContext, tool_input: dict[str, Any]) -> ) # Cap context snippets = await crud.grep_messages( - ctx.db, 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}'" @@ -1388,15 +1622,30 @@ async def _handle_get_messages_by_date_range( if isinstance(before_date, str): return before_date # Error message - messages = await crud.get_messages_by_date_range( - ctx.db, - workspace_name=ctx.workspace_name, - session_name=ctx.session_name, - after_date=after_date, - before_date=before_date, - limit=limit, - order=order, - ) + async with tracked_db("tool.get_messages_by_date_range") as db: + messages = await crud.get_messages_by_date_range( + db, + workspace_name=ctx.workspace_name, + session_name=ctx.session_name, + after_date=after_date, + before_date=before_date, + limit=limit, + order=order, + observer=ctx.observer, + ) + msg_count = len(messages) + messages_text = ( + "\n".join( + [ + format_new_turn_with_timestamp( + _truncate_message_content(m.content), m.created_at, m.peer_name + ) + for m in messages + ] + ) + if messages + else "" + ) date_range: list[str] = [] if after_date_str: @@ -1404,23 +1653,16 @@ async def _handle_get_messages_by_date_range( if before_date_str: date_range.append(f"before {before_date_str}") - if not messages: + if not msg_count: range_desc = " and ".join(date_range) if date_range else "specified range" return f"No messages found {range_desc}" - messages_text = "\n".join( - [ - format_new_turn_with_timestamp( - _truncate_message_content(m.content), m.created_at, m.peer_name - ) - for m in messages - ] - ) - range_desc = " and ".join(date_range) if date_range else "all time" order_desc = "oldest first" if order == "asc" else "newest first" - output = f"Found {len(messages)} messages ({range_desc}, {order_desc}):\n\n{messages_text}" + output = ( + f"Found {msg_count} messages ({range_desc}, {order_desc}):\n\n{messages_text}" + ) return _truncate_tool_output(output) @@ -1445,8 +1687,10 @@ async def _handle_search_messages_temporal( if isinstance(before_date, str): return before_date + # Pre-compute embedding outside DB session to avoid holding a connection + # during the external API call. + query_embedding = await embedding_client.embed(query) snippets = await crud.search_messages_temporal( - ctx.db, workspace_name=ctx.workspace_name, session_name=ctx.session_name, query=query, @@ -1454,8 +1698,9 @@ async def _handle_search_messages_temporal( 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}") @@ -1466,7 +1711,8 @@ async def _handle_search_messages_temporal( if not snippets: return f"No messages found for query '{query}'{filter_desc}" - return _format_message_snippets(snippets, f"for query '{query}'{filter_desc}") + formatted = _format_message_snippets(snippets, f"for query '{query}'{filter_desc}") + return formatted async def _handle_get_recent_observations( @@ -1474,15 +1720,16 @@ async def _handle_get_recent_observations( ) -> str: """Handle get_recent_observations tool.""" session_only = tool_input.get("session_only", False) - documents = await crud.query_documents_recent( - db=ctx.db, - workspace_name=ctx.workspace_name, - observer=ctx.observer, - observed=ctx.observed, - limit=min(_safe_int(tool_input.get("limit"), 10), 100), - session_name=ctx.session_name if session_only else None, - ) - representation = Representation.from_documents(documents) + async with tracked_db("tool.get_recent_observations") as db: + documents = await crud.query_documents_recent( + db=db, + workspace_name=ctx.workspace_name, + observer=ctx.observer, + observed=ctx.observed, + limit=min(_safe_int(tool_input.get("limit"), 10), 100), + session_name=ctx.session_name if session_only else None, + ) + representation = Representation.from_documents(documents) total_count = representation.len() if total_count == 0: return "No recent observations found" @@ -1499,14 +1746,15 @@ async def _handle_get_most_derived_observations( ctx: ToolContext, tool_input: dict[str, Any] ) -> str: """Handle get_most_derived_observations tool.""" - documents = await crud.query_documents_most_derived( - db=ctx.db, - workspace_name=ctx.workspace_name, - observer=ctx.observer, - observed=ctx.observed, - limit=min(_safe_int(tool_input.get("limit"), 10), 100), - ) - representation = Representation.from_documents(documents) + async with tracked_db("tool.get_most_derived_observations") as db: + documents = await crud.query_documents_most_derived( + db=db, + workspace_name=ctx.workspace_name, + observer=ctx.observer, + observed=ctx.observed, + limit=min(_safe_int(tool_input.get("limit"), 10), 100), + ) + representation = Representation.from_documents(documents) total_count = representation.len() if total_count == 0: return "No established observations found" @@ -1530,9 +1778,10 @@ async def _handle_get_session_summary( if summary_type == "long" else summarizer.SummaryType.SHORT ) - summary = await summarizer.get_summary( - ctx.db, ctx.workspace_name, ctx.session_name, st - ) + async with tracked_db("tool.get_session_summary") as db: + summary = await summarizer.get_summary( + db, ctx.workspace_name, ctx.session_name, st + ) if not summary: return "No session summary available yet" return f"Session summary ({summary['summary_type']}):\n{summary['content']}" @@ -1541,12 +1790,13 @@ async def _handle_get_session_summary( async def _handle_get_peer_card(ctx: ToolContext, tool_input: dict[str, Any]) -> str: """Handle get_peer_card tool.""" _ = tool_input - peer_card = await crud.get_peer_card( - ctx.db, - workspace_name=ctx.workspace_name, - observer=ctx.observer, - observed=ctx.observed, - ) + async with tracked_db("tool.get_peer_card") as db: + peer_card = await crud.get_peer_card( + db, + workspace_name=ctx.workspace_name, + observer=ctx.observer, + observed=ctx.observed, + ) if not peer_card: return f"No peer card available for {ctx.observed}" return f"Peer card for {ctx.observed}:\n" + "\n".join( @@ -1563,11 +1813,11 @@ async def _handle_delete_observations( return "ERROR: observation_ids list is empty" deleted_count = 0 - async with ctx.db_lock: + async with ctx.db_lock, tracked_db("tool.delete_observations") as db: for obs_id in observation_ids: try: await crud.delete_document( - ctx.db, + db, workspace_name=ctx.workspace_name, document_id=obs_id, observer=ctx.observer, @@ -1610,10 +1860,10 @@ async def _handle_extract_preferences( """Handle extract_preferences tool.""" _ = tool_input results = await extract_preferences( - ctx.db, workspace_name=ctx.workspace_name, session_name=ctx.session_name, observed=ctx.observed, + observer=ctx.observer, ) messages = results.get("messages", []) @@ -1670,79 +1920,83 @@ async def _handle_get_reasoning_chain( return f"ERROR: Invalid direction '{direction}'. Must be 'premises', 'conclusions', or 'both'" # Get the observation itself - docs = await crud.get_documents_by_ids(ctx.db, ctx.workspace_name, [observation_id]) - if not docs or not docs[0]: - return f"ERROR: Observation '{observation_id}' not found" + async with tracked_db("tool.get_reasoning_chain") as db: + docs = await crud.get_documents_by_ids(db, ctx.workspace_name, [observation_id]) + if not docs or not docs[0]: + return f"ERROR: Observation '{observation_id}' not found" - doc: Document = docs[0] + doc: Document = docs[0] - output_parts: list[str] = [] + output_parts: list[str] = [] - # Format the main observation - level = doc.level or "explicit" - output_parts.append(f"**Observation [id:{doc.id}] ({level}):**\n{doc.content}") + # Format the main observation + level = doc.level or "explicit" + output_parts.append(f"**Observation [id:{doc.id}] ({level}):**\n{doc.content}") - # Get premises/sources if requested - if direction in ("premises", "both"): - if level == "deductive" and doc.source_ids: - premises = await crud.get_documents_by_ids( - ctx.db, ctx.workspace_name, doc.source_ids - ) - if premises: - premise_lines: list[Any] = [] - for p in premises: - p_level = p.level or "explicit" - premise_lines.append(f" - [id:{p.id}] ({p_level}): {p.content}") + # Get premises/sources if requested + if direction in ("premises", "both"): + if level == "deductive" and doc.source_ids: + premises = await crud.get_documents_by_ids( + db, ctx.workspace_name, doc.source_ids + ) + if premises: + premise_lines: list[Any] = [] + for p in premises: + p_level = p.level or "explicit" + premise_lines.append( + f" - [id:{p.id}] ({p_level}): {p.content}" + ) + output_parts.append( + f"\n**Premises ({len(premises)}):**\n" + + "\n".join(premise_lines) + ) + else: + output_parts.append( + f"\n**Premises:** Referenced {len(doc.source_ids)} premise IDs but none found in database" + ) + elif level == "inductive" and doc.source_ids: + sources = await crud.get_documents_by_ids( + db, ctx.workspace_name, doc.source_ids + ) + if sources: + source_lines: list[Any] = [] + for s in sources: + s_level = s.level or "explicit" + source_lines.append(f" - [id:{s.id}] ({s_level}): {s.content}") + output_parts.append( + f"\n**Sources ({len(sources)}):**\n" + "\n".join(source_lines) + ) + else: + output_parts.append( + f"\n**Sources:** Referenced {len(doc.source_ids)} source IDs but none found in database" + ) + elif level == "explicit": output_parts.append( - f"\n**Premises ({len(premises)}):**\n" + "\n".join(premise_lines) + "\n**Premises/Sources:** N/A (explicit observations have no premises)" ) else: - output_parts.append( - f"\n**Premises:** Referenced {len(doc.source_ids)} premise IDs but none found in database" - ) - elif level == "inductive" and doc.source_ids: - sources = await crud.get_documents_by_ids( - ctx.db, ctx.workspace_name, doc.source_ids + output_parts.append("\n**Premises/Sources:** None recorded") + + # Get conclusions if requested + if direction in ("conclusions", "both"): + children = await crud.get_child_observations( + db, + ctx.workspace_name, + observation_id, + observer=ctx.observer, + observed=ctx.observed, ) - if sources: - source_lines: list[Any] = [] - for s in sources: - s_level = s.level or "explicit" - source_lines.append(f" - [id:{s.id}] ({s_level}): {s.content}") + if children: + child_lines: list[Any] = [] + for c in children: + c_level = c.level or "explicit" + child_lines.append(f" - [id:{c.id}] ({c_level}): {c.content}") output_parts.append( - f"\n**Sources ({len(sources)}):**\n" + "\n".join(source_lines) + f"\n**Derived Conclusions ({len(children)}):**\n" + + "\n".join(child_lines) ) else: - output_parts.append( - f"\n**Sources:** Referenced {len(doc.source_ids)} source IDs but none found in database" - ) - elif level == "explicit": - output_parts.append( - "\n**Premises/Sources:** N/A (explicit observations have no premises)" - ) - else: - output_parts.append("\n**Premises/Sources:** None recorded") - - # Get conclusions if requested - if direction in ("conclusions", "both"): - children = await crud.get_child_observations( - ctx.db, - ctx.workspace_name, - observation_id, - observer=ctx.observer, - observed=ctx.observed, - ) - if children: - child_lines: list[Any] = [] - for c in children: - c_level = c.level or "explicit" - child_lines.append(f" - [id:{c.id}] ({c_level}): {c.content}") - output_parts.append( - f"\n**Derived Conclusions ({len(children)}):**\n" - + "\n".join(child_lines) - ) - else: - output_parts.append("\n**Derived Conclusions:** None found") + output_parts.append("\n**Derived Conclusions:** None found") return "\n".join(output_parts) @@ -1750,6 +2004,8 @@ async def _handle_get_reasoning_chain( # Tool handler dispatch table _TOOL_HANDLERS: dict[str, Callable[[ToolContext, dict[str, Any]], Any]] = { "create_observations": _handle_create_observations, + "create_observations_deductive": _handle_create_observations_deductive, + "create_observations_inductive": _handle_create_observations_inductive, "update_peer_card": _handle_update_peer_card, "get_recent_history": _handle_get_recent_history, "search_memory": _handle_search_memory, @@ -1770,7 +2026,6 @@ _TOOL_HANDLERS: dict[str, Callable[[ToolContext, dict[str, Any]], Any]] = { async def create_tool_executor( - db: AsyncSession, workspace_name: str, observer: str, observed: str, @@ -1789,8 +2044,10 @@ async def create_tool_executor( This factory function captures the agent's context and returns an async callable that can execute any tool from AGENT_TOOLS or DIALECTIC_AGENT_TOOLS. + Each tool handler manages its own short-lived DB sessions via tracked_db(), + so no long-lived database session is needed. + Args: - db: Database session workspace_name: Workspace identifier observer: The peer making observations/queries observed: The peer being observed/queried about @@ -1811,7 +2068,6 @@ async def create_tool_executor( shared_lock = await get_observation_lock(workspace_name, observer, observed) ctx = ToolContext( - db=db, workspace_name=workspace_name, observer=observer, observed=observed, @@ -1862,9 +2118,8 @@ async def create_tool_executor( # We don't re-raise because the LLM should be able to continue with other tools error_msg = f"Tool {tool_name} failed unexpectedly: {type(e).__name__}: {e}" logger.error(error_msg, exc_info=True) - # Rollback the transaction to clear any failed state - # This is critical for PostgreSQL which blocks subsequent queries on failed transactions - await ctx.db.rollback() + # No explicit rollback needed — each handler uses tracked_db() which + # handles rollback in its finally block return error_msg return execute_tool diff --git a/src/utils/clients.py b/src/utils/clients.py deleted file mode 100644 index 1c042bff..00000000 --- a/src/utils/clients.py +++ /dev/null @@ -1,2575 +0,0 @@ -import json -import logging -from collections.abc import AsyncIterator, Callable -from contextvars import ContextVar -from dataclasses import dataclass -from typing import Any, Generic, Literal, TypeVar, cast, overload - -from anthropic import AsyncAnthropic -from anthropic.types import TextBlock, ThinkingBlock, ToolUseBlock -from anthropic.types.message import Message as AnthropicMessage -from anthropic.types.usage import Usage -from google import genai -from google.genai.types import ( - ContentListUnionDict, - GenerateContentConfigDict, - GenerateContentResponse, -) -from groq import AsyncGroq -from openai import AsyncOpenAI -from openai.types.chat import ChatCompletion, ChatCompletionChunk -from pydantic import BaseModel, Field, ValidationError -from sentry_sdk.ai.monitoring import ai_track -from tenacity import retry, stop_after_attempt, wait_exponential - -from src.config import LLMComponentSettings, settings -from src.exceptions import LLMError -from src.telemetry.logging import conditional_observe -from src.telemetry.reasoning_traces import log_reasoning_trace -from src.utils.json_parser import validate_and_repair_json -from src.utils.representation import PromptRepresentation -from src.utils.tokens import estimate_tokens -from src.utils.types import SupportedProviders, set_current_iteration - -logger = logging.getLogger(__name__) - -# Gemini finish reasons that indicate the response was blocked by safety or policy -# filters. When these occur, the response typically has no usable text content and -# retrying with a backup provider is appropriate. -GEMINI_BLOCKED_FINISH_REASONS = { - "SAFETY", - "RECITATION", - "PROHIBITED_CONTENT", - "BLOCKLIST", -} - - -@dataclass -class IterationData: - """Data passed to iteration callbacks after each tool execution loop iteration.""" - - iteration: int - """1-indexed iteration number.""" - tool_calls: list[str] - """List of tool names called in this iteration.""" - input_tokens: int - """Input tokens used in this iteration's LLM call.""" - output_tokens: int - """Output tokens generated in this iteration's LLM call.""" - cache_read_tokens: int = 0 - """Tokens read from cache in this iteration.""" - cache_creation_tokens: int = 0 - """Tokens written to cache in this iteration.""" - - -# Type alias for iteration callback -IterationCallback = Callable[[IterationData], None] - -T = TypeVar("T") - -# Type aliases for OpenAI GPT-5 specific parameters -ReasoningEffortType = Literal["low", "medium", "high", "minimal"] | None -VerbosityType = Literal["low", "medium", "high"] | None - - -def count_message_tokens(messages: list[dict[str, Any]]) -> int: - """Count tokens in a list of messages using tiktoken.""" - total = 0 - for msg in messages: - content = msg.get("content", "") - if isinstance(content, str): - total += estimate_tokens(content) - elif isinstance(content, list): - # Handle Anthropic-style content blocks - total += estimate_tokens(json.dumps(content)) - # Also count parts for Google format - if "parts" in msg: - try: - total += estimate_tokens(json.dumps(msg["parts"])) - except TypeError: - # Handle non-JSON-serializable content (e.g., bytes) by estimating based on string representation - total += estimate_tokens(str(msg["parts"])) - return total - - -def _is_tool_use_message(msg: dict[str, Any]) -> bool: - """Check if a message contains tool calls (any format).""" - # Anthropic format: content is a list with tool_use blocks - content = msg.get("content") - if isinstance(content, list): - for block in cast(list[dict[str, Any]], content): - if block.get("type") == "tool_use": - return True - - # OpenAI format: tool_calls field on assistant message - return bool(msg.get("tool_calls")) - - -def _is_tool_result_message(msg: dict[str, Any]) -> bool: - """Check if a message contains tool results (any format).""" - # Anthropic format: content is a list with tool_result blocks - content = msg.get("content") - if isinstance(content, list): - for block in cast(list[dict[str, Any]], content): - if block.get("type") == "tool_result": - return True - - # OpenAI format: role is "tool" - return msg.get("role") == "tool" - - -def _group_into_units(messages: list[dict[str, Any]]) -> list[list[dict[str, Any]]]: - """ - Group messages into logical conversation units. - - A unit is either: - - A tool_use message + ALL consecutive tool_result messages that follow - - A single non-tool message - - This ensures tool_use and tool_results stay together. - """ - units: list[list[dict[str, Any]]] = [] - i = 0 - - while i < len(messages): - msg = messages[i] - - if _is_tool_use_message(msg): - # Collect this tool_use and ALL following tool_results - j = i + 1 - while j < len(messages) and _is_tool_result_message(messages[j]): - j += 1 - - # Create unit with tool_use + all tool_results - unit = messages[i:j] - if len(unit) > 1: # Has at least one tool_result - units.append(unit) - i = j - else: - # Orphaned tool_use (no results) - skip it - logger.debug(f"Skipping orphaned tool_use at index {i}") - i += 1 - elif _is_tool_result_message(msg): - # Orphaned tool_result - skip it - logger.debug(f"Skipping orphaned tool_result at index {i}") - i += 1 - else: - # Regular message - its own unit - units.append([msg]) - i += 1 - - return units - - -def truncate_messages_to_fit( - messages: list[dict[str, Any]], - max_tokens: int, - preserve_system: bool = True, -) -> list[dict[str, Any]]: - """ - Truncate messages to fit within a token limit while maintaining valid structure. - - Strategy: - 1. Group messages into units (tool_use + results together, or single messages) - 2. Remove oldest units first to preserve recent context - 3. Units stay intact so tool_use/tool_result pairs are never broken - """ - current_tokens = count_message_tokens(messages) - if current_tokens <= max_tokens: - return messages - - logger.info(f"Truncating: {current_tokens} tokens exceeds {max_tokens} limit") - - # Separate system messages from conversation - system_messages: list[dict[str, Any]] = [] - conversation: list[dict[str, Any]] = [] - - for msg in messages: - if msg.get("role") == "system" and preserve_system: - system_messages.append(msg) - else: - conversation.append(msg) - - system_tokens = count_message_tokens(system_messages) - available_tokens = max_tokens - system_tokens - - if available_tokens <= 0: - logger.warning("System message exceeds max_input_tokens") - return messages - - # Group messages into units - units = _group_into_units(conversation) - - if not units: - logger.warning("No valid conversation units") - return system_messages - - # Remove oldest units until we fit - while len(units) > 1: # Keep at least one unit - # Calculate current token count - flat_messages = [msg for unit in units for msg in unit] - if count_message_tokens(flat_messages) <= available_tokens: - break - - # Remove the oldest unit - removed_unit = units.pop(0) - logger.debug( - f"Removed unit with {len(removed_unit)} messages " - + f"(~{count_message_tokens(removed_unit)} tokens)" - ) - - # Flatten remaining units - result_conversation = [msg for unit in units for msg in unit] - - result = system_messages + result_conversation - result_tokens = count_message_tokens(result) - logger.info( - f"Truncation complete: {len(messages)} -> {len(result)} messages, " - + f"{current_tokens} -> {result_tokens} tokens, " - + f"{len(units)} units kept" - ) - return result - - -M = TypeVar("M", bound=BaseModel) - -# Context variable to track retry attempts for provider switching -_current_attempt: ContextVar[int] = ContextVar("current_attempt", default=0) - - -def _get_effective_temperature(temperature: float | None) -> float | None: - """Adjust temperature on retries - bump 0.0 to 0.2 to get different results.""" - if temperature == 0.0 and _current_attempt.get() > 1: - logger.debug("Bumping temperature from 0.0 to 0.2 on retry") - return 0.2 - return temperature - - -CLIENTS: dict[ - SupportedProviders, - AsyncAnthropic | AsyncOpenAI | genai.Client | AsyncGroq, -] = {} - -if settings.LLM.ANTHROPIC_API_KEY: - anthropic = AsyncAnthropic( - api_key=settings.LLM.ANTHROPIC_API_KEY, - timeout=600.0, # 10 minutes timeout for long-running operations - ) - CLIENTS["anthropic"] = anthropic - -if settings.LLM.OPENAI_API_KEY: - openai_client = AsyncOpenAI( - api_key=settings.LLM.OPENAI_API_KEY, - ) - CLIENTS["openai"] = openai_client - -if settings.LLM.OPENAI_COMPATIBLE_API_KEY and settings.LLM.OPENAI_COMPATIBLE_BASE_URL: - CLIENTS["custom"] = AsyncOpenAI( - api_key=settings.LLM.OPENAI_COMPATIBLE_API_KEY, - base_url=settings.LLM.OPENAI_COMPATIBLE_BASE_URL, - ) - -# vLLM uses separate settings for local model serving -if settings.LLM.VLLM_API_KEY and settings.LLM.VLLM_BASE_URL: - CLIENTS["vllm"] = AsyncOpenAI( - api_key=settings.LLM.VLLM_API_KEY, - base_url=settings.LLM.VLLM_BASE_URL, - ) - -if settings.LLM.GEMINI_API_KEY: - google = genai.client.Client(api_key=settings.LLM.GEMINI_API_KEY) - CLIENTS["google"] = google - -if settings.LLM.GROQ_API_KEY: - groq = AsyncGroq(api_key=settings.LLM.GROQ_API_KEY) - CLIENTS["groq"] = groq - -SELECTED_PROVIDERS = [ - ("Summary", settings.SUMMARY.PROVIDER), - ("Deriver", settings.DERIVER.PROVIDER), -] - -# Add all dialectic level providers -for level, level_settings in settings.DIALECTIC.LEVELS.items(): - SELECTED_PROVIDERS.append((f"Dialectic ({level})", level_settings.PROVIDER)) - -for provider_name, provider_value in SELECTED_PROVIDERS: - if provider_value not in CLIENTS: - raise ValueError(f"Missing client for {provider_name}: {provider_value}") - -# Validate backup providers are initialized if configured -BACKUP_PROVIDERS: list[tuple[str, SupportedProviders | None]] = [ - ("Deriver", settings.DERIVER.BACKUP_PROVIDER), - ("Summary", settings.SUMMARY.BACKUP_PROVIDER), - ("Dream", settings.DREAM.BACKUP_PROVIDER), -] - -# Add all dialectic level backup providers -for level, level_settings in settings.DIALECTIC.LEVELS.items(): - BACKUP_PROVIDERS.append((f"Dialectic ({level})", level_settings.BACKUP_PROVIDER)) - -for component_name, backup_provider in BACKUP_PROVIDERS: - if backup_provider is not None and backup_provider not in CLIENTS: - raise ValueError( - f"Backup provider for {component_name} is set to {backup_provider}, " - + "but this provider is not initialized. Please set the required API key/URL environment " - + "variables or remove the backup configuration." - ) - - -def convert_tools_for_provider( - tools: list[dict[str, Any]], - provider: SupportedProviders, -) -> list[dict[str, Any]]: - """ - Convert tool definitions to provider-specific format. - - Args: - tools: List of tool definitions in Anthropic format (with input_schema) - provider: The target provider to convert tools for - - Returns: - List of tool definitions in the provider's native format - """ - if provider == "anthropic": - # Anthropic format: input_schema - return tools - elif provider in ("openai", "custom", "vllm"): - # OpenAI format: parameters instead of input_schema - # custom and vllm use AsyncOpenAI client so need OpenAI format - return [ - { - "type": "function", - "function": { - "name": tool["name"], - "description": tool["description"], - "parameters": tool["input_schema"], - }, - } - for tool in tools - ] - elif provider == "google": - # Google format: function_declarations wrapped in a tool object - return [ - { - "function_declarations": [ - { - "name": tool["name"], - "description": tool["description"], - "parameters": tool["input_schema"], - } - for tool in tools - ] - } - ] - else: - # For unsupported providers, return as-is (will likely error if tools are used) - logger.warning( - f"Tool calling not implemented for provider {provider}, returning tools as-is" - ) - return tools - - -def extract_openai_reasoning_content(response: Any) -> str | None: - """ - Extract reasoning/thinking content from an OpenAI ChatCompletion response. - - GPT-5 and o1 models include reasoning_details in the response message. - Custom OpenAI-compatible providers may also include this field. - - Args: - response: OpenAI ChatCompletion response object - - Returns: - Concatenated reasoning content string, or None if not present - """ - try: - message = response.choices[0].message - # Check for reasoning_details (GPT-5/o1 models) - if hasattr(message, "reasoning_details") and message.reasoning_details: - # reasoning_details is a list of reasoning steps - reasoning_parts: list[Any] = [] - for detail in message.reasoning_details: - if hasattr(detail, "content") and detail.content: - reasoning_parts.append(detail.content) - elif isinstance(detail, dict) and detail.get("content"): # pyright: ignore[reportUnknownMemberType] - reasoning_parts.append(detail["content"]) - if reasoning_parts: - return "\n".join(reasoning_parts) - # Check for reasoning_content (some custom providers) - if hasattr(message, "reasoning_content") and message.reasoning_content: - return message.reasoning_content - except (AttributeError, IndexError, TypeError): - pass - return None - - -def extract_openai_reasoning_details(response: Any) -> list[dict[str, Any]]: - """ - Extract reasoning_details array from an OpenAI/OpenRouter ChatCompletion response. - - OpenRouter returns reasoning blocks in reasoning_details that must be preserved - and passed back in subsequent requests for Gemini models with tool use. - - Args: - response: OpenAI ChatCompletion response object - - Returns: - List of reasoning detail objects, or empty list if not present - """ - try: - message = response.choices[0].message - # Check for reasoning_details (OpenRouter/Gemini) - if hasattr(message, "reasoning_details") and message.reasoning_details: - # Return the full array for preservation - return [ - detail.model_dump() if hasattr(detail, "model_dump") else dict(detail) - for detail in message.reasoning_details - ] - except (AttributeError, IndexError, TypeError): - pass - return [] - - -def extract_openai_cache_tokens(usage: Any) -> tuple[int, int]: - """ - Extract cache token counts from OpenAI-style usage objects. - - OpenAI reports cached tokens in usage.prompt_tokens_details.cached_tokens. - OpenRouter and some proxies may report in different locations. - - Args: - usage: OpenAI CompletionUsage object or similar - - Returns: - Tuple of (cache_creation_tokens, cache_read_tokens). - For OpenAI-style APIs, cache_creation is always 0 (automatic caching), - and cache_read is the cached_tokens count. - """ - if not usage: - return 0, 0 - - cache_read = 0 - - # OpenAI native: usage.prompt_tokens_details.cached_tokens - if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details: - details = usage.prompt_tokens_details - if hasattr(details, "cached_tokens") and details.cached_tokens: - cache_read = details.cached_tokens - - # OpenRouter style: usage.cache_read_input_tokens or usage.cached_tokens - if cache_read == 0: - if hasattr(usage, "cache_read_input_tokens") and usage.cache_read_input_tokens: - cache_read = usage.cache_read_input_tokens - elif hasattr(usage, "cached_tokens") and usage.cached_tokens: - cache_read = usage.cached_tokens - - # OpenRouter/Anthropic-proxy style: cache_creation_input_tokens - cache_creation = 0 - if ( - hasattr(usage, "cache_creation_input_tokens") - and usage.cache_creation_input_tokens - ): - cache_creation = usage.cache_creation_input_tokens - - return cache_creation, cache_read - - -class HonchoLLMCallResponse(BaseModel, Generic[T]): - """ - Response object for LLM calls. - - Args: - content: The response content. When a response_model is provided, this will be - the parsed object of that type. Otherwise, it will be a string. - input_tokens: Total number of input tokens (including cached). - output_tokens: Number of tokens generated in the response. - cache_creation_input_tokens: Number of tokens written to cache. - cache_read_input_tokens: Number of tokens read from cache. - finish_reasons: List of finish reasons for the response. - tool_calls_made: Optional list of all tool calls executed during the request. - - Note: - Uncached input tokens = input_tokens - cache_read_input_tokens + cache_creation_input_tokens - (cache_creation costs 25% more, cache_read costs 90% less) - """ - - content: T - input_tokens: int = 0 - output_tokens: int - cache_creation_input_tokens: int = 0 - cache_read_input_tokens: int = 0 - finish_reasons: list[str] - tool_calls_made: list[dict[str, Any]] = Field(default_factory=list) - iterations: int = 0 - """Number of LLM calls made in the tool execution loop (1 = single response, 2+ = tool use iterations plus final synthesis).""" - thinking_content: str | None = None - # Full thinking blocks with signatures for multi-turn conversation replay (Anthropic only) - thinking_blocks: list[dict[str, Any]] = Field(default_factory=list) - # OpenRouter reasoning_details for Gemini models - must be preserved across turns - reasoning_details: list[dict[str, Any]] = Field(default_factory=list) - - -class HonchoLLMCallStreamChunk(BaseModel): - """ - A single chunk in a streaming LLM response. - - Args: - content: The text content for this chunk. Empty for chunks that only contain metadata. - is_done: Whether this is the final chunk in the stream. - finish_reasons: List of finish reasons if the stream is complete. - output_tokens: Number of tokens generated in the response. Only set on the final chunk. - """ - - content: str - is_done: bool = False - finish_reasons: list[str] = Field(default_factory=list) - output_tokens: int | None = None - - -class StreamingResponseWithMetadata: - """ - Wrapper for streaming responses that includes metadata from the tool execution phase. - - This allows callers to access tool call counts, token usage, and thinking content - from the tool loop while still streaming the final response. - """ - - _stream: AsyncIterator[HonchoLLMCallStreamChunk] - tool_calls_made: list[dict[str, Any]] - input_tokens: int - output_tokens: int - cache_creation_input_tokens: int - cache_read_input_tokens: int - thinking_content: str | None - iterations: int - - def __init__( - self, - stream: AsyncIterator[HonchoLLMCallStreamChunk], - tool_calls_made: list[dict[str, Any]], - input_tokens: int, - output_tokens: int, - cache_creation_input_tokens: int, - cache_read_input_tokens: int, - thinking_content: str | None = None, - iterations: int = 0, - ): - self._stream = stream - self.tool_calls_made = tool_calls_made - self.input_tokens = input_tokens - self.output_tokens = output_tokens - self.cache_creation_input_tokens = cache_creation_input_tokens - self.cache_read_input_tokens = cache_read_input_tokens - self.thinking_content = thinking_content - self.iterations = iterations - - def __aiter__(self) -> AsyncIterator[HonchoLLMCallStreamChunk]: - return self._stream.__aiter__() - - async def __anext__(self) -> HonchoLLMCallStreamChunk: - return await self._stream.__anext__() - - -# Bounds for max_tool_iterations to prevent runaway loops -MIN_TOOL_ITERATIONS = 1 -MAX_TOOL_ITERATIONS = 100 - - -async def _stream_final_response( - llm_settings: "LLMComponentSettings", - prompt: str, - max_tokens: int, - conversation_messages: list[dict[str, Any]], - response_model: type[BaseModel] | None, - json_mode: bool, - temperature: float | None, - stop_seqs: list[str] | None, - reasoning_effort: ReasoningEffortType, - verbosity: VerbosityType, - thinking_budget_tokens: int | None, -) -> AsyncIterator[HonchoLLMCallStreamChunk]: - """ - Stream the final response after tool execution is complete. - - Makes a streaming LLM call with the accumulated conversation messages - (which include all tool call results) to generate the final answer. - - Args: - llm_settings: Settings for the LLM provider - prompt: Original prompt (used as fallback) - max_tokens: Maximum tokens to generate - conversation_messages: Full conversation history including tool results - response_model: Optional Pydantic model for structured output - json_mode: Whether to use JSON mode - temperature: Temperature for the LLM - stop_seqs: Stop sequences - reasoning_effort: OpenAI reasoning effort (GPT-5 only) - verbosity: OpenAI verbosity (GPT-5 only) - thinking_budget_tokens: Anthropic thinking budget - - Yields: - HonchoLLMCallStreamChunk objects containing the streaming response - """ - provider = llm_settings.PROVIDER - model = llm_settings.MODEL - - client = CLIENTS.get(provider) - if not client: - raise ValueError(f"Missing client for {provider}") - - # Make a streaming call without tools - stream_response = await honcho_llm_call_inner( - provider, - model, - prompt, - max_tokens, - response_model, - json_mode, - _get_effective_temperature(temperature), - stop_seqs, - reasoning_effort, - verbosity, - thinking_budget_tokens, - True, # stream=True - None, # No tools - None, # No tool_choice - conversation_messages, - ) - - # Yield chunks from the streaming response - async for chunk in stream_response: - yield chunk - - -async def _execute_tool_loop( - llm_settings: "LLMComponentSettings", - prompt: str, - max_tokens: int, - messages: list[dict[str, Any]] | None, - tools: list[dict[str, Any]], - tool_choice: str | dict[str, Any] | None, - tool_executor: Callable[[str, dict[str, Any]], Any], - max_tool_iterations: int, - response_model: type[BaseModel] | None, - json_mode: bool, - temperature: float | None, - stop_seqs: list[str] | None, - reasoning_effort: ReasoningEffortType, - verbosity: VerbosityType, - thinking_budget_tokens: int | None, - enable_retry: bool, - retry_attempts: int, - max_input_tokens: int | None, - get_provider_and_model: Callable[ - [], - tuple[SupportedProviders, str, int | None, ReasoningEffortType, VerbosityType], - ], - before_retry_callback: Callable[[Any], None], - stream_final: bool = False, - iteration_callback: IterationCallback | None = None, -) -> HonchoLLMCallResponse[Any] | StreamingResponseWithMetadata: - """ - Execute the tool calling loop for agentic LLM interactions. - - This function handles the iterative process of: - 1. Making an LLM call with tools available - 2. Executing any tool calls the LLM requests - 3. Feeding tool results back to the LLM - 4. Repeating until the LLM stops calling tools or max iterations reached - - Args: - llm_settings: Settings for the LLM provider - prompt: Initial prompt (used if messages is None) - max_tokens: Maximum tokens to generate per call - messages: Conversation history - tools: Tool definitions in Anthropic format - tool_choice: Tool selection strategy - tool_executor: Async function to execute tools - max_tool_iterations: Maximum iterations before forcing completion - response_model: Optional Pydantic model for structured output - json_mode: Whether to use JSON mode - temperature: Temperature for the LLM (default **none**, only some models support this) - stop_seqs: Stop sequences - reasoning_effort: OpenAI reasoning effort (GPT-5 only) - verbosity: OpenAI verbosity (GPT-5 only) - thinking_budget_tokens: Anthropic thinking budget - enable_retry: Whether to enable retry with exponential backoff - retry_attempts: Number of retry attempts - max_input_tokens: Maximum input tokens (for truncation) - get_provider_and_model: Function to get current provider/model based on attempt - before_retry_callback: Callback for retry events - stream_final: If True, stream the final response instead of returning it synchronously - iteration_callback: Optional callback invoked after each iteration with IterationData - - Returns: - Final HonchoLLMCallResponse with accumulated token counts and tool call history, - or an AsyncIterator of HonchoLLMCallStreamChunk if stream_final=True - """ - # Initialize conversation messages - conversation_messages: list[dict[str, Any]] = ( - messages.copy() if messages else [{"role": "user", "content": prompt}] - ) - - iteration = 0 - all_tool_calls: list[dict[str, Any]] = [] - total_input_tokens = 0 - total_output_tokens = 0 - total_cache_creation_tokens = 0 - total_cache_read_tokens = 0 - empty_response_retries = 0 - # Track effective tool_choice - switches from "required" to "auto" after first iteration - effective_tool_choice = tool_choice - - while iteration < max_tool_iterations: - # Reset attempt counter so each iteration starts with the primary provider - _current_attempt.set(1) - logger.debug(f"Tool execution iteration {iteration + 1}/{max_tool_iterations}") - - # Truncate BEFORE making the API call to avoid context length errors - if max_input_tokens is not None: - conversation_messages = truncate_messages_to_fit( - conversation_messages, max_input_tokens - ) - - # Create a wrapper that injects our messages - async def _call_with_messages( - effective_tool_choice: str | dict[str, Any] | None = effective_tool_choice, - conversation_messages: list[dict[str, Any]] = conversation_messages, - ) -> HonchoLLMCallResponse[Any]: - # Use shared provider selection helper - provider, model, thinking_budget, gpt5_reasoning_effort, gpt5_verbosity = ( - get_provider_and_model() - ) - - client = CLIENTS.get(provider) - if not client: - raise ValueError(f"Missing client for {provider}") - - converted_tools = ( - convert_tools_for_provider(tools, provider) if tools else None - ) - - return await honcho_llm_call_inner( - provider, - model, - prompt, # Will be ignored since we pass messages - max_tokens, - response_model, - json_mode, - _get_effective_temperature(temperature), - stop_seqs, - gpt5_reasoning_effort, - gpt5_verbosity, - thinking_budget, - False, - converted_tools, - effective_tool_choice, - conversation_messages, - ) - - # Apply retry if enabled - if enable_retry: - call_func = retry( - stop=stop_after_attempt(retry_attempts), - wait=wait_exponential(multiplier=1, min=4, max=10), - before_sleep=before_retry_callback, - )(_call_with_messages) - else: - call_func = _call_with_messages - - # Make the call - response = await call_func() - - # Accumulate tokens from this iteration - total_input_tokens += response.input_tokens - total_output_tokens += response.output_tokens - total_cache_creation_tokens += response.cache_creation_input_tokens - total_cache_read_tokens += response.cache_read_input_tokens - - # Check if there are tool calls - if not response.tool_calls_made: - # No tool calls, return final response - logger.debug("No tool calls in response, finishing") - - if ( - isinstance(response.content, str) - and not response.content.strip() - and empty_response_retries < 1 - and iteration < max_tool_iterations - 1 - ): - empty_response_retries += 1 - conversation_messages.append( - { - "role": "user", - "content": ( - "Your last response was empty. Provide a concise answer " - "to the original query using the available context." - ), - } - ) - iteration += 1 - continue - - if stream_final: - # Stream the final response with metadata from tool execution - stream = _stream_final_response( - llm_settings=llm_settings, - prompt=prompt, - max_tokens=max_tokens, - conversation_messages=conversation_messages, - response_model=response_model, - json_mode=json_mode, - temperature=temperature, - stop_seqs=stop_seqs, - reasoning_effort=reasoning_effort, - verbosity=verbosity, - thinking_budget_tokens=thinking_budget_tokens, - ) - return StreamingResponseWithMetadata( - stream=stream, - tool_calls_made=all_tool_calls, - input_tokens=total_input_tokens, - output_tokens=total_output_tokens, - cache_creation_input_tokens=total_cache_creation_tokens, - cache_read_input_tokens=total_cache_read_tokens, - thinking_content=response.thinking_content, - iterations=iteration + 1, - ) - - response.tool_calls_made = all_tool_calls - response.input_tokens = total_input_tokens - response.output_tokens = total_output_tokens - response.cache_creation_input_tokens = total_cache_creation_tokens - response.cache_read_input_tokens = total_cache_read_tokens - response.iterations = iteration + 1 - return response - - # Determine which provider we're using (reuse the helper) - current_provider, _, _, _, _ = get_provider_and_model() - - # Add assistant message with tool calls to conversation - assistant_message = _format_assistant_tool_message( - current_provider, - response.content, - response.tool_calls_made, - response.thinking_blocks, - response.reasoning_details, - ) - conversation_messages.append(assistant_message) - - # Set current iteration for telemetry context (1-indexed) - set_current_iteration(iteration + 1) - - # Execute tools and add results - tool_results: list[dict[str, Any]] = [] - for tool_call in response.tool_calls_made: - tool_name = tool_call["name"] - tool_input = tool_call["input"] - tool_id = tool_call.get("id", "") - - logger.debug(f"Executing tool: {tool_name}") - - try: - # Execute the tool - tool_result = await tool_executor(tool_name, tool_input) - - # Store for Anthropic format - tool_results.append( - { - "tool_id": tool_id, - "tool_name": tool_name, - "result": tool_result, - } - ) - - all_tool_calls.append( - { - "tool_name": tool_name, - "tool_input": tool_input, - "tool_result": tool_result, - } - ) - - except Exception as e: - logger.error(f"Tool execution failed for {tool_name}: {e}") - tool_results.append( - { - "tool_id": tool_id, - "tool_name": tool_name, - "result": f"Error: {str(e)}", - "is_error": True, - } - ) - - # Add tool result message in provider-specific format - _append_tool_results(current_provider, tool_results, conversation_messages) - - # Call iteration callback if provided - if iteration_callback is not None: - try: - iteration_data = IterationData( - iteration=iteration + 1, # 1-indexed - tool_calls=[tc["name"] for tc in response.tool_calls_made], - input_tokens=response.input_tokens, - output_tokens=response.output_tokens, - cache_read_tokens=response.cache_read_input_tokens or 0, - cache_creation_tokens=response.cache_creation_input_tokens or 0, - ) - iteration_callback(iteration_data) - except Exception: - logger.warning("iteration_callback failed", exc_info=True) - - # After first iteration, switch from "required" to "auto" to allow model to stop - if iteration == 0 and effective_tool_choice in ("required", "any"): - effective_tool_choice = "auto" - logger.debug( - "Switched tool_choice from 'required'/'any' to 'auto' after first iteration" - ) - - iteration += 1 - - # Max iterations reached - logger.warning( - f"Tool execution loop reached max iterations ({max_tool_iterations})" - ) - - # Add a synthesis prompt to help the model generate a response - # without tool calls - the conversation currently ends with tool results - # and the model may not know to produce text output - synthesis_prompt = ( - "You have reached the maximum number of tool calls. " - "Based on all the information you have gathered, provide your final response now. " - "Do not attempt to call any more tools." - ) - conversation_messages.append({"role": "user", "content": synthesis_prompt}) - - # If streaming the final response, use the streaming helper with metadata - if stream_final: - stream = _stream_final_response( - llm_settings=llm_settings, - prompt=prompt, - max_tokens=max_tokens, - conversation_messages=conversation_messages, - response_model=response_model, - json_mode=json_mode, - temperature=temperature, - stop_seqs=stop_seqs, - reasoning_effort=reasoning_effort, - verbosity=verbosity, - thinking_budget_tokens=thinking_budget_tokens, - ) - return StreamingResponseWithMetadata( - stream=stream, - tool_calls_made=all_tool_calls, - input_tokens=total_input_tokens, - output_tokens=total_output_tokens, - cache_creation_input_tokens=total_cache_creation_tokens, - cache_read_input_tokens=total_cache_read_tokens, - thinking_content=None, # No thinking content at max iterations - iterations=iteration + 1, # +1 for the synthesis call - ) - - # Make one final call to get a text response - _current_attempt.set(1) # Reset attempt counter - - async def _final_call() -> HonchoLLMCallResponse[Any]: - # Use shared provider selection helper for backup failover support - provider, model, thinking_budget, gpt5_reasoning_effort, gpt5_verbosity = ( - get_provider_and_model() - ) - - client = CLIENTS.get(provider) - if not client: - raise ValueError(f"Missing client for {provider}") - - # No tools for final call - return await honcho_llm_call_inner( - provider, - model, - prompt, - max_tokens, - response_model, - json_mode, - _get_effective_temperature(temperature), - stop_seqs, - gpt5_reasoning_effort, - gpt5_verbosity, - thinking_budget, - False, - None, # No tools - None, # No tool_choice - conversation_messages, - ) - - if enable_retry: - final_call_func = retry( - stop=stop_after_attempt(retry_attempts), - wait=wait_exponential(multiplier=1, min=4, max=10), - before_sleep=before_retry_callback, - )(_final_call) - else: - final_call_func = _final_call - - final_response = await final_call_func() - final_response.tool_calls_made = all_tool_calls - final_response.iterations = iteration + 1 # +1 for the synthesis call - # Include accumulated tokens from all iterations plus the final call - final_response.input_tokens = total_input_tokens + final_response.input_tokens - final_response.output_tokens = total_output_tokens + final_response.output_tokens - final_response.cache_creation_input_tokens = ( - total_cache_creation_tokens + final_response.cache_creation_input_tokens - ) - final_response.cache_read_input_tokens = ( - total_cache_read_tokens + final_response.cache_read_input_tokens - ) - return final_response - - -def _format_assistant_tool_message( - provider: SupportedProviders, - content: Any, - tool_calls: list[dict[str, Any]], - thinking_blocks: list[dict[str, Any]] | None = None, - reasoning_details: list[dict[str, Any]] | None = None, -) -> dict[str, Any]: - """ - Format an assistant message with tool calls for a specific provider. - - Args: - provider: The LLM provider - content: The text content from the response - tool_calls: List of tool call dicts with id, name, input keys - thinking_blocks: Full thinking blocks with signatures for multi-turn replay (Anthropic only) - reasoning_details: OpenRouter reasoning_details for Gemini models (must be preserved) - - Returns: - Provider-formatted assistant message dict - """ - if provider == "anthropic": - # Anthropic requires content to be a list of blocks including tool use blocks - content_blocks: list[dict[str, Any]] = [] - - # Add thinking blocks FIRST if present (required by Anthropic when extended thinking is enabled) - # These include signatures which are required for multi-turn conversation replay - if thinking_blocks: - content_blocks.extend(thinking_blocks) - - # Add text content if present - if isinstance(content, str) and content: - content_blocks.append({"type": "text", "text": content}) - - # Add tool use blocks - for tool_call in tool_calls: - content_blocks.append( - { - "type": "tool_use", - "id": tool_call["id"], - "name": tool_call["name"], - "input": tool_call["input"], - } - ) - - return { - "role": "assistant", - "content": content_blocks, - } - elif provider == "google": - # Google format: model role with function_call parts - parts: list[dict[str, Any]] = [] - - # Add text content if present - if isinstance(content, str) and content: - parts.append({"text": content}) - - # Add function call parts with thought_signature if present - for tool_call in tool_calls: - part_data: dict[str, Any] = { - "function_call": { - "name": tool_call["name"], - "args": tool_call["input"], - } - } - # Include thought_signature if present (required by Gemini) - if "thought_signature" in tool_call: - part_data["thought_signature"] = tool_call["thought_signature"] - parts.append(part_data) - - return { - "role": "model", - "parts": parts, - } - else: - # OpenAI format - must include tool_calls in the assistant message - openai_tool_calls: list[Any] = [] - for tool_call in tool_calls: - openai_tool_calls.append( - { - "id": tool_call["id"], - "type": "function", - "function": { - "name": tool_call["name"], - "arguments": json.dumps(tool_call["input"]), - }, - } - ) - msg: dict[str, Any] = { - "role": "assistant", - "content": content if isinstance(content, str) else None, - "tool_calls": openai_tool_calls, - } - # Include reasoning_details for OpenRouter/Gemini (required for multi-turn tool use) - if reasoning_details: - msg["reasoning_details"] = reasoning_details - return msg - - -def _append_tool_results( - provider: SupportedProviders, - tool_results: list[dict[str, Any]], - conversation_messages: list[dict[str, Any]], -) -> None: - """ - Append tool results to conversation messages in provider-specific format. - - Args: - provider: The LLM provider - tool_results: List of tool result dicts with tool_id, tool_name, result, is_error keys - conversation_messages: The conversation to append to (modified in place) - """ - if provider == "anthropic": - # Anthropic requires tool results in specific content blocks - result_blocks: list[dict[str, Any]] = [] - for tr in tool_results: - result_blocks.append( - { - "type": "tool_result", - "tool_use_id": tr["tool_id"], - "content": str(tr["result"]), - "is_error": tr.get("is_error", False), - } - ) - - conversation_messages.append( - { - "role": "user", - "content": result_blocks, - } - ) - elif provider == "google": - # Google format: user role with function_response parts - response_parts: list[dict[str, Any]] = [] - for tr in tool_results: - response_parts.append( - { - "function_response": { - "name": tr["tool_name"], - "response": {"result": str(tr["result"])}, - } - } - ) - - conversation_messages.append( - { - "role": "user", - "parts": response_parts, - } - ) - else: - # OpenAI format - add each tool result as a separate message with role="tool" - for tr in tool_results: - conversation_messages.append( - { - "role": "tool", - "tool_call_id": tr["tool_id"], - "content": str(tr["result"]), - } - ) - - -@overload -async def honcho_llm_call( - llm_settings: LLMComponentSettings, - prompt: str, - max_tokens: int, - track_name: str | None = None, - *, - response_model: type[M], - json_mode: bool = False, - temperature: float | None = None, - stop_seqs: list[str] | None = None, - reasoning_effort: Literal["low", "medium", "high", "minimal"] - | None = None, # OpenAI only - verbosity: Literal["low", "medium", "high"] | None = None, # OpenAI only - thinking_budget_tokens: int | None = None, - enable_retry: bool = True, - retry_attempts: int = 3, - stream: Literal[False] = False, - stream_final_only: bool = False, - tools: list[dict[str, Any]] | None = None, - tool_choice: str | dict[str, Any] | None = None, - tool_executor: Callable[[str, dict[str, Any]], Any] | None = None, - max_tool_iterations: int = 10, - messages: list[dict[str, Any]] | None = None, - max_input_tokens: int | None = None, - trace_name: str | None = None, - iteration_callback: IterationCallback | None = None, -) -> HonchoLLMCallResponse[M]: ... - - -@overload -async def honcho_llm_call( - llm_settings: LLMComponentSettings, - prompt: str, - max_tokens: int, - track_name: str | None = None, - response_model: None = None, - json_mode: bool = False, - temperature: float | None = None, - stop_seqs: list[str] | None = None, - reasoning_effort: Literal["low", "medium", "high", "minimal"] - | None = None, # OpenAI only - verbosity: Literal["low", "medium", "high"] | None = None, # OpenAI only - thinking_budget_tokens: int | None = None, - enable_retry: bool = True, - retry_attempts: int = 3, - stream: Literal[False] = False, - stream_final_only: bool = False, - tools: list[dict[str, Any]] | None = None, - tool_choice: str | dict[str, Any] | None = None, - tool_executor: Callable[[str, dict[str, Any]], Any] | None = None, - max_tool_iterations: int = 10, - messages: list[dict[str, Any]] | None = None, - max_input_tokens: int | None = None, - trace_name: str | None = None, - iteration_callback: IterationCallback | None = None, -) -> HonchoLLMCallResponse[str]: ... - - -@overload -async def honcho_llm_call( - llm_settings: LLMComponentSettings, - prompt: str, - max_tokens: int, - track_name: str | None = None, - response_model: type[BaseModel] | None = None, - json_mode: bool = False, - temperature: float | None = None, - stop_seqs: list[str] | None = None, - reasoning_effort: Literal["low", "medium", "high", "minimal"] - | None = None, # OpenAI only - verbosity: Literal["low", "medium", "high"] | None = None, # OpenAI only - thinking_budget_tokens: int | None = None, - enable_retry: bool = True, - retry_attempts: int = 3, - stream: Literal[True] = ..., - stream_final_only: bool = False, - tools: list[dict[str, Any]] | None = None, - tool_choice: str | dict[str, Any] | None = None, - tool_executor: Callable[[str, dict[str, Any]], Any] | None = None, - max_tool_iterations: int = 10, - messages: list[dict[str, Any]] | None = None, - max_input_tokens: int | None = None, - trace_name: str | None = None, - iteration_callback: IterationCallback | None = None, -) -> AsyncIterator[HonchoLLMCallStreamChunk] | StreamingResponseWithMetadata: ... - - -@conditional_observe(name="LLM Call") -async def honcho_llm_call( - llm_settings: LLMComponentSettings, - prompt: str, - max_tokens: int, - track_name: str | None = None, - response_model: type[BaseModel] | None = None, - json_mode: bool = False, - temperature: float | None = None, - stop_seqs: list[str] | None = None, - reasoning_effort: Literal["low", "medium", "high", "minimal"] - | None = None, # OpenAI only - verbosity: Literal["low", "medium", "high"] | None = None, # OpenAI only - thinking_budget_tokens: int | None = None, - enable_retry: bool = True, - retry_attempts: int = 3, - stream: bool = False, - stream_final_only: bool = False, - tools: list[dict[str, Any]] | None = None, - tool_choice: str | dict[str, Any] | None = None, - tool_executor: Callable[[str, dict[str, Any]], Any] | None = None, - max_tool_iterations: int = 10, - messages: list[dict[str, Any]] | None = None, - max_input_tokens: int | None = None, - trace_name: str | None = None, - iteration_callback: IterationCallback | None = None, -) -> ( - HonchoLLMCallResponse[Any] - | AsyncIterator[HonchoLLMCallStreamChunk] - | StreamingResponseWithMetadata -): - """ - Make an LLM call with automatic backup provider failover. Backup provider/model - is used on the final retry attempt, which is 3 by default. - - Args: - llm_settings: Settings object containing PROVIDER, MODEL, - BACKUP_PROVIDER, and BACKUP_MODEL - prompt: The prompt to send to the LLM (used if messages is None) - max_tokens: Maximum tokens to generate - track_name: Optional name for AI tracking - response_model: Optional Pydantic model for structured output - json_mode: Whether to use JSON mode - temperature: Temperature for the LLM (default **none**, only some models support this) - stop_seqs: Stop sequences - reasoning_effort: OpenAI reasoning effort (GPT-5 only) - verbosity: OpenAI verbosity (GPT-5 only) - thinking_budget_tokens: Anthropic thinking budget - enable_retry: Whether to enable retry with exponential backoff - retry_attempts: Number of retry attempts - stream: Whether to stream the response - stream_final_only: If True with tools, run tool loop non-streaming then stream final answer - tools: Tool definitions for tool calling (Anthropic/OpenAI format) - tool_choice: Tool selection strategy (auto/required/specific tool) - tool_executor: Async callable to execute tools, receives (tool_name, tool_input) - max_tool_iterations: Maximum number of tool execution loops - messages: Optional message list for multi-turn conversations (overrides prompt) - iteration_callback: Optional callback invoked after each tool iteration with IterationData - - Returns: - HonchoLLMCallResponse or AsyncIterator depending on stream parameter - - Raises: - ValueError: If provider is not configured - """ - # Validate that streaming and tools are not used together - # (unless stream_final_only is set, which streams only the final response after tool calls) - if stream and tools and not stream_final_only: - raise ValueError( - "Streaming is not supported with tool calling. Set stream=False when using tools, " - + "or use stream_final_only=True to stream only the final response after tool calls." - ) - - # Set attempt counter to 1 for first call (tenacity uses 1-indexed attempts) - _current_attempt.set(1) - - def _get_provider_and_model() -> ( - tuple[SupportedProviders, str, int | None, ReasoningEffortType, VerbosityType] - ): - """ - Get the provider and model to use based on current attempt. - - Returns: - Tuple of (provider, model, thinking_budget, reasoning_effort, verbosity) - """ - attempt = _current_attempt.get() - - provider: SupportedProviders - model: str - thinking_budget: int | None - gpt5_reasoning_effort: ReasoningEffortType - gpt5_verbosity: VerbosityType - - # Use backup on final retry attempt (when attempt == retry_attempts) - if ( - attempt == retry_attempts - and llm_settings.BACKUP_PROVIDER is not None - and llm_settings.BACKUP_MODEL is not None - and llm_settings.BACKUP_PROVIDER in CLIENTS - ): - provider = llm_settings.BACKUP_PROVIDER - model = llm_settings.BACKUP_MODEL - thinking_budget = thinking_budget_tokens - gpt5_reasoning_effort = reasoning_effort - gpt5_verbosity = verbosity - - # Filter out incompatible parameters when using backup - if provider != "anthropic" and thinking_budget: - logger.warning( - f"thinking_budget_tokens not supported by {provider}, ignoring" - ) - thinking_budget = None - - if "gpt-5" not in model and (gpt5_reasoning_effort or gpt5_verbosity): - logger.warning( - "reasoning_effort/verbosity only supported by GPT-5 models, ignoring" - ) - gpt5_reasoning_effort = None - gpt5_verbosity = None - - logger.warning( - f"Final retry attempt {attempt}/{retry_attempts}: switching from " - + f"{llm_settings.PROVIDER}/{llm_settings.MODEL} to " - + f"backup {provider}/{model}" - ) - else: - provider = llm_settings.PROVIDER - model = llm_settings.MODEL - thinking_budget = thinking_budget_tokens - gpt5_reasoning_effort = reasoning_effort - gpt5_verbosity = verbosity - - return provider, model, thinking_budget, gpt5_reasoning_effort, gpt5_verbosity - - async def _call_with_provider_selection() -> ( - HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk] - ): - """ - Inner function that selects provider/model based on current attempt. - This function is retried, so provider selection happens on each attempt. - """ - provider, model, thinking_budget, gpt5_reasoning_effort, gpt5_verbosity = ( - _get_provider_and_model() - ) - - # Validate client exists - client = CLIENTS.get(provider) - if not client: - raise ValueError(f"Missing client for {provider}") - - # Convert tools to provider-specific format if provided - converted_tools = convert_tools_for_provider(tools, provider) if tools else None - - if stream: - return await honcho_llm_call_inner( - provider, - model, - prompt, - max_tokens, - response_model, - json_mode, - _get_effective_temperature(temperature), - stop_seqs, - gpt5_reasoning_effort, - gpt5_verbosity, - thinking_budget, - True, # type: ignore[arg-type] - converted_tools, - tool_choice, - ) - else: - return await honcho_llm_call_inner( - provider, - model, - prompt, - max_tokens, - response_model, - json_mode, - _get_effective_temperature(temperature), - stop_seqs, - gpt5_reasoning_effort, - gpt5_verbosity, - thinking_budget, - False, # type: ignore[arg-type] - converted_tools, - tool_choice, - ) - - decorated = _call_with_provider_selection - - # apply tracking - if track_name: - decorated = ai_track(track_name)(decorated) - - # Define retry callback for updating attempt counter and logging - def before_retry_callback(retry_state: Any) -> None: - """Update attempt counter before each retry. - - Note: before_sleep is called AFTER an attempt fails and BEFORE sleeping, - so we need to increment to the next attempt number. - """ - next_attempt = retry_state.attempt_number + 1 - _current_attempt.set(next_attempt) - exc = retry_state.outcome.exception() if retry_state.outcome else None - if exc: - logger.warning( - f"Error on attempt {retry_state.attempt_number}/{retry_attempts} with " - + f"{llm_settings.PROVIDER}/{llm_settings.MODEL}: {exc}" - ) - logger.info(f"Will retry with attempt {next_attempt}/{retry_attempts}") - - # apply retry logic - retries on ANY exception - if enable_retry: - decorated = retry( - stop=stop_after_attempt(retry_attempts), - wait=wait_exponential(multiplier=1, min=4, max=10), - before_sleep=before_retry_callback, - )(decorated) - - # If no tools or no tool_executor, just call once and return - if not tools or not tool_executor: - result: ( - HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk] - ) = await decorated() - if trace_name and isinstance(result, HonchoLLMCallResponse): - log_reasoning_trace( - task_type=trace_name, - llm_settings=llm_settings, - prompt=prompt, - response=result, - max_tokens=max_tokens, - thinking_budget_tokens=thinking_budget_tokens, - reasoning_effort=reasoning_effort, - json_mode=json_mode, - stop_seqs=stop_seqs, - messages=messages, - ) - return result - - # Validate and clamp max_tool_iterations - clamped_iterations = max( - MIN_TOOL_ITERATIONS, min(max_tool_iterations, MAX_TOOL_ITERATIONS) - ) - if clamped_iterations != max_tool_iterations: - logger.warning( - f"max_tool_iterations {max_tool_iterations} clamped to {clamped_iterations} " - + f"(valid range: {MIN_TOOL_ITERATIONS}-{MAX_TOOL_ITERATIONS})" - ) - - # Delegate to the tool execution loop - result = await _execute_tool_loop( - llm_settings=llm_settings, - prompt=prompt, - max_tokens=max_tokens, - messages=messages, - tools=tools, - tool_choice=tool_choice, - tool_executor=tool_executor, - max_tool_iterations=clamped_iterations, - response_model=response_model, - json_mode=json_mode, - temperature=temperature, - stop_seqs=stop_seqs, - reasoning_effort=reasoning_effort, - verbosity=verbosity, - thinking_budget_tokens=thinking_budget_tokens, - enable_retry=enable_retry, - retry_attempts=retry_attempts, - max_input_tokens=max_input_tokens, - get_provider_and_model=_get_provider_and_model, - before_retry_callback=before_retry_callback, - stream_final=stream_final_only, - iteration_callback=iteration_callback, - ) - if trace_name and isinstance(result, HonchoLLMCallResponse): - log_reasoning_trace( - task_type=trace_name, - llm_settings=llm_settings, - prompt=prompt, - response=result, - max_tokens=max_tokens, - thinking_budget_tokens=thinking_budget_tokens, - reasoning_effort=reasoning_effort, - json_mode=json_mode, - stop_seqs=stop_seqs, - messages=messages, - ) - return result - - -@overload -async def honcho_llm_call_inner( - provider: SupportedProviders, - model: str, - prompt: str, - max_tokens: int, - response_model: type[M], - json_mode: bool = False, - temperature: float | None = None, - stop_seqs: list[str] | None = None, - reasoning_effort: Literal["low", "medium", "high", "minimal"] - | None = None, # OpenAI only - verbosity: Literal["low", "medium", "high"] | None = None, # OpenAI only - thinking_budget_tokens: int | None = None, # Anthropic only - stream: Literal[False] = False, - tools: list[dict[str, Any]] | None = None, - tool_choice: str | dict[str, Any] | None = None, - messages: list[dict[str, Any]] | None = None, -) -> HonchoLLMCallResponse[M]: ... - - -@overload -async def honcho_llm_call_inner( - provider: SupportedProviders, - model: str, - prompt: str, - max_tokens: int, - response_model: None = None, - json_mode: bool = False, - temperature: float | None = None, - stop_seqs: list[str] | None = None, - reasoning_effort: Literal["low", "medium", "high", "minimal"] - | None = None, # OpenAI only - verbosity: Literal["low", "medium", "high"] | None = None, # OpenAI only - thinking_budget_tokens: int | None = None, # Anthropic only - stream: Literal[False] = False, - tools: list[dict[str, Any]] | None = None, - tool_choice: str | dict[str, Any] | None = None, - messages: list[dict[str, Any]] | None = None, -) -> HonchoLLMCallResponse[str]: ... - - -@overload -async def honcho_llm_call_inner( - provider: SupportedProviders, - model: str, - prompt: str, - max_tokens: int, - response_model: type[BaseModel] | None = None, - json_mode: bool = False, - temperature: float | None = None, - stop_seqs: list[str] | None = None, - reasoning_effort: Literal["low", "medium", "high", "minimal"] - | None = None, # OpenAI only - verbosity: Literal["low", "medium", "high"] | None = None, # OpenAI only - thinking_budget_tokens: int | None = None, # Anthropic only - stream: Literal[True] = ..., - tools: list[dict[str, Any]] | None = None, - tool_choice: str | dict[str, Any] | None = None, - messages: list[dict[str, Any]] | None = None, -) -> AsyncIterator[HonchoLLMCallStreamChunk]: ... - - -async def honcho_llm_call_inner( - provider: SupportedProviders, - model: str, - prompt: str, - max_tokens: int, - response_model: type[BaseModel] | None = None, - json_mode: bool = False, - temperature: float | None = None, - stop_seqs: list[str] | None = None, - reasoning_effort: Literal["low", "medium", "high", "minimal"] - | None = None, # OpenAI only - verbosity: Literal["low", "medium", "high"] | None = None, # OpenAI only - thinking_budget_tokens: int | None = None, # Anthropic only - stream: bool = False, - tools: list[dict[str, Any]] | None = None, - tool_choice: str | dict[str, Any] | None = None, - messages: list[dict[str, Any]] | None = None, -) -> HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk]: - # has already been validated by honcho_llm_call - client = CLIENTS[provider] - - # Use messages if provided, otherwise convert prompt to message - if messages is None: - messages = [{"role": "user", "content": prompt}] - - params: dict[str, Any] = { - "model": model, - "max_tokens": max_tokens, - "messages": messages, - "stream": stream, - } - - if temperature is not None: - params["temperature"] = temperature - - if stream: - # Return async generator for streaming responses - return handle_streaming_response( - client, - params, - json_mode, - thinking_budget_tokens, - response_model, - reasoning_effort, - verbosity, - ) - - # Remove stream parameter for non-streaming calls as some providers don't accept it - params.pop("stream", None) - - system_messages: list[str] = [] - non_system_messages: list[dict[str, Any]] = [] - - match client: - case AsyncAnthropic(): - # Anthropic requires system messages to be passed as a top-level parameter - # Extract system messages and non-system messages - for msg in params["messages"]: - if msg.get("role") == "system": - system_messages.append(msg["content"]) - else: - non_system_messages.append(msg) - - anthropic_params: dict[str, Any] = { - "model": params["model"], - "max_tokens": params["max_tokens"], - "messages": non_system_messages, - } - - if temperature is not None: - anthropic_params["temperature"] = temperature - - # Add system parameter if there are system messages - # Use cache_control for prompt caching - if system_messages: - anthropic_params["system"] = [ - { - "type": "text", - "text": "\n\n".join(system_messages), - "cache_control": {"type": "ephemeral"}, - } - ] - - # Add tools if provided - if tools: - anthropic_params["tools"] = tools - if tool_choice: - # Convert tool_choice to Anthropic format - if isinstance(tool_choice, str): - if tool_choice == "auto": - anthropic_params["tool_choice"] = {"type": "auto"} - elif tool_choice in ("any", "required"): - anthropic_params["tool_choice"] = {"type": "any"} - elif tool_choice == "none": - # Don't set tool_choice, let Anthropic default - pass - else: - # Assume it's a tool name - anthropic_params["tool_choice"] = { - "type": "tool", - "name": tool_choice, - } - else: - # Already in dict format, use as-is - anthropic_params["tool_choice"] = tool_choice - - # For response models, we need to request JSON and parse manually - # Note: tools and response_model should not be used together - if response_model or json_mode: - # Add JSON schema instructions to the prompt if using response_model - if response_model: - schema_json = json.dumps( - response_model.model_json_schema(), indent=2 - ) - anthropic_params["messages"][-1]["content"] += ( - f"\n\nRespond with valid JSON matching this schema:\n{schema_json}" - ) - anthropic_params["messages"].append( - {"role": "assistant", "content": "{"} - ) - - if thinking_budget_tokens: - anthropic_params["thinking"] = { - "type": "enabled", - "budget_tokens": thinking_budget_tokens, - } - - anthropic_response: AnthropicMessage = cast( - AnthropicMessage, await client.messages.create(**anthropic_params) - ) - - # Extract text content, thinking blocks, and tool use blocks from content blocks - text_blocks: list[str] = [] - thinking_text_blocks: list[str] = [] - thinking_full_blocks: list[dict[str, Any]] = [] - tool_calls: list[dict[str, Any]] = [] - for block in anthropic_response.content: - if isinstance(block, TextBlock): - text_blocks.append(block.text) - elif isinstance(block, ThinkingBlock): - thinking_text_blocks.append(block.thinking) - # Store full block with signature for multi-turn replay - thinking_full_blocks.append( - { - "type": "thinking", - "thinking": block.thinking, - "signature": block.signature, - } - ) - elif isinstance(block, ToolUseBlock): - tool_calls.append( - { - "id": block.id, - "name": block.name, - "input": block.input, - } - ) - - # Safely extract usage and stop_reason - usage: Any | Usage = anthropic_response.usage - stop_reason = anthropic_response.stop_reason - - text_content = "\n".join(text_blocks) - thinking_content = ( - "\n".join(thinking_text_blocks) if thinking_text_blocks else None - ) - - # Extract cache token counts from Anthropic usage - # Anthropic's input_tokens = uncached tokens only - # Total = input_tokens + cache_read + cache_creation - cache_creation_tokens = ( - getattr(usage, "cache_creation_input_tokens", 0) or 0 if usage else 0 - ) - cache_read_tokens = ( - getattr(usage, "cache_read_input_tokens", 0) or 0 if usage else 0 - ) - uncached_tokens = usage.input_tokens if usage else 0 - # Calculate total input tokens for consistent reporting - total_input_tokens = ( - uncached_tokens + cache_read_tokens + cache_creation_tokens - ) - - # If using response_model, parse the JSON response - if response_model: - try: - # Add back the opening brace that we prefilled - json_content = "{" + text_content - parsed_json = json.loads(json_content) - parsed_content = response_model.model_validate(parsed_json) - - return HonchoLLMCallResponse( - content=parsed_content, - input_tokens=total_input_tokens, - output_tokens=usage.output_tokens if usage else 0, - cache_creation_input_tokens=cache_creation_tokens, - cache_read_input_tokens=cache_read_tokens, - finish_reasons=[stop_reason] if stop_reason else [], - tool_calls_made=tool_calls, - thinking_content=thinking_content, - thinking_blocks=thinking_full_blocks, - ) - except (json.JSONDecodeError, ValidationError, ValueError) as e: - raise ValueError( - f"Failed to parse Anthropic response as {response_model}: {e}. Raw content: {text_content}" - ) from e - - return HonchoLLMCallResponse( - content=text_content, - input_tokens=total_input_tokens, - output_tokens=usage.output_tokens if usage else 0, - cache_creation_input_tokens=cache_creation_tokens, - cache_read_input_tokens=cache_read_tokens, - finish_reasons=[stop_reason] if stop_reason else [], - tool_calls_made=tool_calls, - thinking_content=thinking_content, - thinking_blocks=thinking_full_blocks, - ) - - case AsyncOpenAI(): - # For custom providers (e.g., OpenRouter), add cache_control to system messages - # This enables prompt caching for Anthropic models proxied via OpenAI-compatible APIs - processed_messages: list[dict[str, Any]] = params["messages"] - if provider == "custom": - processed_messages = [] - for msg in params["messages"]: - if msg.get("role") == "system" and isinstance( - msg.get("content"), str - ): - # Convert system message to content block format with cache_control - processed_messages.append( - { - "role": "system", - "content": [ - { - "type": "text", - "text": msg["content"], - "cache_control": {"type": "ephemeral"}, - } - ], - } - ) - else: - processed_messages.append(msg) - - openai_params: dict[str, Any] = { - "model": params["model"], - "messages": processed_messages, - } - - if temperature is not None and "gpt-5" not in model: - openai_params["temperature"] = temperature - - if "gpt-5" in model: - openai_params["max_completion_tokens"] = params["max_tokens"] - if reasoning_effort: - openai_params["reasoning_effort"] = reasoning_effort - if verbosity: - openai_params["verbosity"] = verbosity - else: - openai_params["max_tokens"] = params["max_tokens"] - - # Add tools if provided (not compatible with response_model for most cases) - if tools and not response_model: - openai_params["tools"] = tools - if tool_choice: - openai_params["tool_choice"] = tool_choice - - if json_mode and provider != "vllm": - openai_params["response_format"] = {"type": "json_object"} - - # custom shim for vLLM response model formatting - # NOTE: this is all specific to the Representation model. - # Do not call with any other response model. - if provider == "vllm" and response_model: - if response_model is not PromptRepresentation: - raise NotImplementedError( - "vLLM structured output currently supports only PromptRepresentation" - ) - openai_params["response_format"] = { - "type": "json_schema", - "json_schema": { - "name": response_model.__name__, - "schema": response_model.model_json_schema(), - }, - } - if stop_seqs: - openai_params["stop"] = stop_seqs - vllm_response: ChatCompletion = cast( - ChatCompletion, - await client.chat.completions.create(**openai_params), - ) - - usage = vllm_response.usage - finish_reason = vllm_response.choices[0].finish_reason - - try: - test_rep = "" - if vllm_response.choices[0].message.content is not None: - test_rep = vllm_response.choices[0].message.content - - final = validate_and_repair_json(test_rep) - - # Schema-aware repair: ensure deductive observations have required fields - - repaired_data = json.loads(final) - - # Fix deductive observations that might be missing conclusion - if "deductive" in repaired_data and isinstance( - repaired_data["deductive"], list - ): - for i, item in enumerate(repaired_data["deductive"]): - if isinstance(item, dict): - # If conclusion is missing but premises exist, create a placeholder - if "conclusion" not in item and "premises" in item: - logger.warning( - f"Deductive observation {i} missing conclusion, adding placeholder" - ) - # Try to generate a conclusion from premises if possible - if item["premises"]: - item["conclusion"] = ( - f"[Incomplete reasoning from premises: {item['premises'][0][:100]}...]" - ) - else: - item["conclusion"] = ( - "[Incomplete reasoning - conclusion missing]" - ) - # If premises is missing, add empty list (it's optional with default) - if "premises" not in item: - item["premises"] = [] - - final = json.dumps(repaired_data) - except (json.JSONDecodeError, KeyError, TypeError) as e: - final = "" - logger.warning(f"Could not perform schema-aware repair: {e}") - # Continue with original final value if repair fails - - try: - response_obj = PromptRepresentation.model_validate_json(final) - except ValidationError as e: - logger.error(f"Validation error after repair: {e}") - logger.debug(f"Problematic JSON: {final}") - - # Fallback: return empty response rather than failing - logger.warning( - "Using fallback empty Representation due to validation error" - ) - response_obj = PromptRepresentation(explicit=[]) # , deductive=[]) - - cache_creation, cache_read = extract_openai_cache_tokens(usage) - return HonchoLLMCallResponse( - content=response_obj, - input_tokens=usage.prompt_tokens if usage else 0, - output_tokens=usage.completion_tokens if usage else 0, - cache_creation_input_tokens=cache_creation, - cache_read_input_tokens=cache_read, - finish_reasons=[finish_reason] if finish_reason else [], - tool_calls_made=[], - thinking_content=extract_openai_reasoning_content(vllm_response), - ) - elif response_model: - openai_params["response_format"] = response_model - response: ChatCompletion = await client.chat.completions.parse( # pyright: ignore - **openai_params - ) - # Extract the parsed object for structured output - parsed_content = response.choices[0].message.parsed - if parsed_content is None: - raise ValueError("No parsed content in structured response") - - usage = response.usage - finish_reason = response.choices[0].finish_reason - - # Validate that parsed content matches the response model - if not isinstance(parsed_content, response_model): - raise ValueError( - f"Parsed content does not match the response model: {parsed_content} != {response_model}" - ) - - # Extract tool calls if present (though unlikely with structured output) - parsed_tool_calls: list[dict[str, Any]] = [] - if ( - hasattr(response.choices[0].message, "tool_calls") - and response.choices[0].message.tool_calls - ): - for tool_call in response.choices[0].message.tool_calls: - parsed_tool_calls.append( - { - "id": tool_call.id, - "name": tool_call.function.name, - "input": json.loads(tool_call.function.arguments) - if tool_call.function.arguments - else {}, - } - ) - - cache_creation, cache_read = extract_openai_cache_tokens(usage) - return HonchoLLMCallResponse( - content=parsed_content, - input_tokens=usage.prompt_tokens if usage else 0, - output_tokens=usage.completion_tokens if usage else 0, - cache_creation_input_tokens=cache_creation, - cache_read_input_tokens=cache_read, - finish_reasons=[finish_reason] if finish_reason else [], - tool_calls_made=parsed_tool_calls, - thinking_content=extract_openai_reasoning_content(response), - ) - else: - response: ChatCompletion = await client.chat.completions.create( # pyright: ignore - **openai_params - ) - - usage = response.usage # pyright: ignore - finish_reason = response.choices[0].finish_reason # pyright: ignore - - # Extract tool calls if present - tool_calls_list: list[dict[str, Any]] = [] - if response.choices[0].message.tool_calls: # pyright: ignore - for tool_call in response.choices[0].message.tool_calls: # pyright: ignore - tool_calls_list.append( - { - "id": tool_call.id, # pyright: ignore - "name": tool_call.function.name, # pyright: ignore - "input": json.loads(tool_call.function.arguments) # pyright: ignore - if tool_call.function.arguments # pyright: ignore - else {}, - } - ) - - cache_creation, cache_read = extract_openai_cache_tokens(usage) - return HonchoLLMCallResponse( - content=response.choices[0].message.content or "", # pyright: ignore - input_tokens=usage.prompt_tokens if usage else 0, # pyright: ignore - output_tokens=usage.completion_tokens if usage else 0, # pyright: ignore - cache_creation_input_tokens=cache_creation, - cache_read_input_tokens=cache_read, - finish_reasons=[finish_reason] if finish_reason else [], - tool_calls_made=tool_calls_list, - thinking_content=extract_openai_reasoning_content(response), - reasoning_details=extract_openai_reasoning_details(response), - ) - - case genai.Client(): - # Build config for Gemini - gemini_config: dict[str, Any] = {} - - # Gemini uses max_output_tokens, not max_tokens. - gemini_config["max_output_tokens"] = params["max_tokens"] - - if temperature is not None: - gemini_config["temperature"] = temperature - - # Add tools if provided - if tools: - gemini_config["tools"] = tools - # Handle tool_choice - if tool_choice: - if tool_choice == "auto": - gemini_config["tool_config"] = { - "function_calling_config": {"mode": "AUTO"} - } - elif tool_choice == "any" or tool_choice == "required": - gemini_config["tool_config"] = { - "function_calling_config": {"mode": "ANY"} - } - elif tool_choice == "none": - gemini_config["tool_config"] = { - "function_calling_config": {"mode": "NONE"} - } - elif isinstance(tool_choice, dict) and "name" in tool_choice: - # Specific tool selection - gemini_config["tool_config"] = { - "function_calling_config": { - "mode": "ANY", - "allowed_function_names": [tool_choice["name"]], - } - } - - if response_model is None: - if json_mode and not tools: - gemini_config["response_mime_type"] = "application/json" - - # Use messages if provided, otherwise use prompt - if messages: - # Extract system messages for system_instruction parameter - # Gemini doesn't support system role in contents - it causes - # consecutive user messages which results in empty responses - for msg in messages: - if msg.get("role") == "system": - if isinstance(msg.get("content"), str): - system_messages.append(msg["content"]) - else: - non_system_messages.append(msg) - - # Add system instruction if present - if system_messages: - gemini_config["system_instruction"] = "\n\n".join( - system_messages - ) - - # Convert non-system messages to Google format - gemini_contents: list[dict[str, Any]] = [] - for msg in non_system_messages: - # Map roles to Google's expected values (user, model) - role = msg.get("role", "user") - if role == "assistant": - role = "model" - - # Handle different content formats - if isinstance(msg.get("content"), str): - # Simple string content - gemini_contents.append( - {"role": role, "parts": [{"text": msg["content"]}]} - ) - elif isinstance(msg.get("parts"), list): - # Already in Google format (from tool calling loop) - # But still need to ensure role is correct - msg_copy = msg.copy() - msg_copy["role"] = role - gemini_contents.append(msg_copy) - elif isinstance(msg.get("content"), list): - # Content is a list of parts (Anthropic format) - skip for now - # This shouldn't happen with Google provider in tool loop - continue - else: - # Empty or unknown format, skip - continue - contents: ContentListUnionDict = cast( - ContentListUnionDict, gemini_contents - ) - else: - contents = prompt - - gemini_response: GenerateContentResponse = ( - await client.aio.models.generate_content( - model=model, - contents=contents, - config=cast(GenerateContentConfigDict, gemini_config) # pyright: ignore[reportInvalidCast] - if gemini_config - else None, - ) - ) - - # Extract text content and function calls from response - text_parts: list[str] = [] - gemini_tool_calls: list[dict[str, Any]] = [] - - if gemini_response.candidates and gemini_response.candidates[0].content: - for part in gemini_response.candidates[0].content.parts or []: - if hasattr(part, "text") and part.text: - text_parts.append(part.text) - if hasattr(part, "function_call") and part.function_call: - fc = part.function_call - tool_call_data: dict[str, Any] = { - "id": f"call_{fc.name}_{len(gemini_tool_calls)}", - "name": fc.name, - "input": dict(fc.args) if fc.args else {}, - } - # Preserve thought_signature if present (required by Gemini) - if ( - hasattr(part, "thought_signature") - and part.thought_signature - ): - tool_call_data["thought_signature"] = ( - part.thought_signature - ) - gemini_tool_calls.append(tool_call_data) - - text_content = "\n".join(text_parts) if text_parts else "" - input_token_count = ( - gemini_response.usage_metadata.prompt_token_count or 0 - if gemini_response.usage_metadata - else 0 - ) - output_token_count = ( - gemini_response.usage_metadata.candidates_token_count or 0 - if gemini_response.usage_metadata - else 0 - ) - finish_reason = ( - gemini_response.candidates[0].finish_reason.name - if gemini_response.candidates - and gemini_response.candidates[0].finish_reason - else "stop" - ) - - # Raise on blocked responses so retry/backup-provider logic kicks in - if ( - not text_content - and not gemini_tool_calls - and finish_reason in GEMINI_BLOCKED_FINISH_REASONS - ): - raise LLMError( - f"Gemini response blocked (finish_reason={finish_reason})", - provider="google", - model=model, - finish_reason=finish_reason, - ) - - return HonchoLLMCallResponse( - content=text_content, - input_tokens=input_token_count, - output_tokens=output_token_count, - finish_reasons=[finish_reason], - tool_calls_made=gemini_tool_calls, - ) - - else: - gemini_config["response_mime_type"] = "application/json" - gemini_config["response_schema"] = response_model - - gemini_response = await client.aio.models.generate_content( - model=model, - contents=prompt, - config=cast(GenerateContentConfigDict, gemini_config), # pyright: ignore[reportInvalidCast] - ) - - input_token_count = ( - gemini_response.usage_metadata.prompt_token_count or 0 - if gemini_response.usage_metadata - else 0 - ) - output_token_count = ( - gemini_response.usage_metadata.candidates_token_count or 0 - if gemini_response.usage_metadata - else 0 - ) - finish_reason = ( - gemini_response.candidates[0].finish_reason.name - if gemini_response.candidates - and gemini_response.candidates[0].finish_reason - else "stop" - ) - - # Raise on blocked responses before checking parsed content - if ( - not gemini_response.parsed - and finish_reason in GEMINI_BLOCKED_FINISH_REASONS - ): - raise LLMError( - f"Gemini response blocked (finish_reason={finish_reason})", - provider="google", - model=model, - finish_reason=finish_reason, - ) - - # Validate that parsed content matches the response model - if not isinstance(gemini_response.parsed, response_model): - raise ValueError( - f"Parsed content does not match the response model: {gemini_response.parsed} != {response_model}" - ) - - return HonchoLLMCallResponse( - content=gemini_response.parsed, - input_tokens=input_token_count, - output_tokens=output_token_count, - finish_reasons=[finish_reason], - tool_calls_made=[], - ) - - case AsyncGroq(): - groq_params: dict[str, Any] = { - "model": params["model"], - "max_tokens": params["max_tokens"], - "messages": params["messages"], - } - - if temperature is not None: - groq_params["temperature"] = temperature - - if response_model: - groq_params["response_format"] = response_model - elif json_mode: - groq_params["response_format"] = {"type": "json_object"} - - # TODO: figure out why groq returns unknown type and fix it - response: ChatCompletion = await client.chat.completions.create( # pyright: ignore - **groq_params - ) - if response.choices[0].message.content is None: # pyright: ignore - raise ValueError("No content in response") - - # Safely extract usage and finish_reason - usage = response.usage # pyright: ignore - finish_reason = response.choices[0].finish_reason # pyright: ignore - - # Handle response model parsing for Groq - cache_creation, cache_read = extract_openai_cache_tokens(usage) - if response_model: - try: - json_content = json.loads(response.choices[0].message.content) # pyright: ignore - parsed_content = response_model.model_validate(json_content) - - return HonchoLLMCallResponse( - content=parsed_content, - input_tokens=usage.prompt_tokens if usage else 0, # pyright: ignore - output_tokens=usage.completion_tokens if usage else 0, # pyright: ignore - cache_creation_input_tokens=cache_creation, - cache_read_input_tokens=cache_read, - finish_reasons=[finish_reason] if finish_reason else [], - tool_calls_made=[], - ) - except (json.JSONDecodeError, ValidationError, ValueError) as e: - raise ValueError( - f"Failed to parse Groq response as {response_model}: {e}. Raw content: {response.choices[0].message.content}" # pyright: ignore - ) from e - else: - return HonchoLLMCallResponse( - content=response.choices[0].message.content, # pyright: ignore - input_tokens=usage.prompt_tokens if usage else 0, # pyright: ignore - output_tokens=usage.completion_tokens if usage else 0, # pyright: ignore - cache_creation_input_tokens=cache_creation, - cache_read_input_tokens=cache_read, - finish_reasons=[finish_reason] if finish_reason else [], - tool_calls_made=[], - ) - - -async def handle_streaming_response( - client: AsyncAnthropic | AsyncOpenAI | genai.Client | AsyncGroq, - params: dict[str, Any], - json_mode: bool, - thinking_budget_tokens: int | None, - response_model: type[BaseModel] | None = None, - reasoning_effort: Literal["low", "medium", "high", "minimal"] | None = None, - verbosity: Literal["low", "medium", "high"] | None = None, -) -> AsyncIterator[HonchoLLMCallStreamChunk]: - """ - Handle streaming responses for all supported providers. - - Args: - client: The LLM client instance - params: Request parameters including stream=True - json_mode: Whether to use JSON mode - thinking_budget_tokens: Anthropic thinking budget tokens - response_model: Pydantic model for structured output - reasoning_effort: OpenAI reasoning effort level (GPT-5 only) - verbosity: OpenAI verbosity level (GPT-5 only) - - Yields: - HonchoLLMCallStreamChunk: Individual chunks of the streaming response - """ - match client: - case AsyncAnthropic(): - # Anthropic requires system messages as a top-level parameter - messages = params["messages"] - system_content = "\n\n".join( - m["content"] for m in messages if m.get("role") == "system" - ) - anthropic_params: dict[str, Any] = { - "model": params["model"], - "max_tokens": params["max_tokens"], - "messages": [m for m in messages if m.get("role") != "system"], - } - if system_content: - anthropic_params["system"] = [ - { - "type": "text", - "text": system_content, - "cache_control": {"type": "ephemeral"}, - } - ] - - # For response models, we need to request JSON and parse manually - # Note: Streaming with response_model is not ideal but we'll accumulate and parse at the end - if response_model or json_mode: - # Add JSON schema instructions to the prompt if using response_model - if response_model: - schema_json = json.dumps( - response_model.model_json_schema(), indent=2 - ) - anthropic_params["messages"][-1]["content"] += ( - f"\n\nRespond with valid JSON matching this schema:\n{schema_json}" - ) - anthropic_params["messages"].append( - {"role": "assistant", "content": "{"} - ) - - if thinking_budget_tokens: - anthropic_params["thinking"] = { - "type": "enabled", - "budget_tokens": thinking_budget_tokens, - } - - async with client.messages.stream(**anthropic_params) as anthropic_stream: - async for chunk in anthropic_stream: - if ( - chunk.type == "content_block_delta" - and hasattr(chunk, "delta") - and hasattr(chunk.delta, "text") - ): - text_content = getattr(chunk.delta, "text", "") - yield HonchoLLMCallStreamChunk(content=text_content) - final_message = await anthropic_stream.get_final_message() - usage = final_message.usage - output_tokens = usage.output_tokens if usage else None - yield HonchoLLMCallStreamChunk( - content="", - is_done=True, - finish_reasons=[final_message.stop_reason] - if final_message.stop_reason - else [], - output_tokens=output_tokens, - ) - - case AsyncOpenAI(): - openai_params: dict[str, Any] = { - "model": params["model"], - "messages": params["messages"], - "stream": True, - "stream_options": {"include_usage": True}, - } - - model_name = params["model"] - if "gpt-5" in model_name: - openai_params["max_completion_tokens"] = params["max_tokens"] - if reasoning_effort: - openai_params["reasoning_effort"] = reasoning_effort - if verbosity: - openai_params["verbosity"] = verbosity - else: - openai_params["max_tokens"] = params["max_tokens"] - - if response_model: - openai_params["response_format"] = response_model - elif json_mode: - openai_params["response_format"] = {"type": "json_object"} - - openai_stream = await client.chat.completions.create(**openai_params) # pyright: ignore - finish_reason: str | None = None - usage_chunk_received = False - async for chunk in openai_stream: # pyright: ignore - chunk = cast(ChatCompletionChunk, chunk) - if chunk.choices and chunk.choices[0].delta.content: - content = chunk.choices[0].delta.content - yield HonchoLLMCallStreamChunk(content=content) - # Track finish_reason when it appears (before usage chunk) - if chunk.choices and chunk.choices[0].finish_reason: - finish_reason = chunk.choices[0].finish_reason - # Check for usage info in chunk (with include_usage, this is a separate chunk with empty choices) - if hasattr(chunk, "usage") and chunk.usage: - yield HonchoLLMCallStreamChunk( - content="", - is_done=True, - finish_reasons=[finish_reason] if finish_reason else [], - output_tokens=chunk.usage.completion_tokens, - ) - usage_chunk_received = True - - # If stream ended without usage chunk (interrupted), still yield final chunk - if not usage_chunk_received and finish_reason: - logger.warning("OpenAI stream ended without usage chunk (interrupted)") - yield HonchoLLMCallStreamChunk( - content="", - is_done=True, - finish_reasons=[finish_reason], - output_tokens=None, - ) - - case genai.Client(): - prompt_text = params["messages"][0]["content"] if params["messages"] else "" - stream_config: GenerateContentConfigDict = { - "max_output_tokens": cast(int, params["max_tokens"]), - } - - if response_model is not None: - stream_config["response_mime_type"] = "application/json" - stream_config["response_schema"] = response_model - response_stream = await client.aio.models.generate_content_stream( - model=params["model"], - contents=prompt_text, - config=stream_config, - ) - else: - if json_mode: - stream_config["response_mime_type"] = "application/json" - response_stream = await client.aio.models.generate_content_stream( - model=params["model"], - contents=prompt_text, - config=stream_config, - ) - - final_chunk = None - async for chunk in response_stream: - if chunk.text: - yield HonchoLLMCallStreamChunk(content=chunk.text) - final_chunk = chunk - - # NOTE: Blocked-response check is intentionally omitted for streaming. - # Exceptions mid-iteration in an async generator won't be caught by - # the tenacity retry wrapper in honcho_llm_call. - finish_reason = "stop" # Default fallback - gemini_output_tokens: int | None = None - if ( - final_chunk - and hasattr(final_chunk, "candidates") - and final_chunk.candidates - and hasattr(final_chunk.candidates[0], "finish_reason") - and final_chunk.candidates[0].finish_reason - ): - finish_reason = final_chunk.candidates[0].finish_reason.name - - # Extract output tokens from usage_metadata if available - if ( - final_chunk - and hasattr(final_chunk, "usage_metadata") - and final_chunk.usage_metadata - and hasattr(final_chunk.usage_metadata, "candidates_token_count") - ): - gemini_output_tokens = ( - final_chunk.usage_metadata.candidates_token_count or None - ) - - yield HonchoLLMCallStreamChunk( - content="", - is_done=True, - finish_reasons=[finish_reason], - output_tokens=gemini_output_tokens, - ) - - case AsyncGroq(): - groq_params: dict[str, Any] = { - "model": params["model"], - "max_tokens": params["max_tokens"], - "messages": params["messages"], - "stream": True, - } - - if response_model: - groq_params["response_format"] = response_model - elif json_mode: - groq_params["response_format"] = {"type": "json_object"} - - groq_stream = await client.chat.completions.create(**groq_params) # pyright: ignore - async for chunk in groq_stream: # pyright: ignore - chunk = cast(ChatCompletionChunk, chunk) - if chunk.choices and chunk.choices[0].delta.content: - yield HonchoLLMCallStreamChunk( - content=chunk.choices[0].delta.content - ) - if chunk.choices and chunk.choices[0].finish_reason: - yield HonchoLLMCallStreamChunk( - content="", - is_done=True, - finish_reasons=[chunk.choices[0].finish_reason], - ) diff --git a/src/utils/files.py b/src/utils/files.py index 4dff50f4..cdbac514 100644 --- a/src/utils/files.py +++ b/src/utils/files.py @@ -10,7 +10,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from src import schemas from src.config import settings -from src.exceptions import FileProcessingError, UnsupportedFileTypeError +from src.exceptions import ( + FileProcessingError, + UnsupportedFileTypeError, + ValidationException, +) from src.schemas import Message logger = logging.getLogger(__name__) @@ -58,7 +62,19 @@ class JSONProcessor: async def extract_text(self, content: bytes) -> str: import json - data = json.loads(content.decode("utf-8")) + try: + decoded_content = content.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValidationException("JSON uploads must be UTF-8 encoded") from exc + + if not decoded_content.strip(): + return "" + + try: + data = json.loads(decoded_content) + except json.JSONDecodeError as exc: + raise ValidationException("Uploaded JSON is invalid") from exc + # Convert JSON to readable text format return json.dumps(data, ensure_ascii=False) diff --git a/src/utils/filter.py b/src/utils/filter.py index 874bd457..1394a8c6 100644 --- a/src/utils/filter.py +++ b/src/utils/filter.py @@ -107,7 +107,7 @@ def apply_filter( def _build_filter_conditions( - filter_dict: dict[str, Any], model_class: type[Any] + filter_dict: dict[str, Any], model_class: type[Any], *, _depth: int = 0 ) -> ColumnElement[bool] | None: """ Recursively build filter conditions from a filter dictionary. @@ -119,6 +119,9 @@ def _build_filter_conditions( Returns: SQLAlchemy condition object or None """ + if _depth > 5: + raise FilterError("Filter nesting exceeds maximum depth of 5") + conditions: list[ColumnElement[bool]] = [] # Handle logical operators @@ -129,7 +132,11 @@ def _build_filter_conditions( ) and_conditions: list[ColumnElement[bool]] = [] for sub_filter in filter_dict["AND"]: # pyright: ignore - sub_condition = _build_filter_conditions(sub_filter, model_class) # pyright: ignore + sub_condition = _build_filter_conditions( + sub_filter, # pyright: ignore[reportUnknownArgumentType] + model_class, + _depth=_depth + 1, + ) if sub_condition is not None: and_conditions.append(sub_condition) if and_conditions: @@ -142,7 +149,11 @@ def _build_filter_conditions( ) or_conditions: list[ColumnElement[bool]] = [] for sub_filter in filter_dict["OR"]: # pyright: ignore - sub_condition = _build_filter_conditions(sub_filter, model_class) # pyright: ignore + sub_condition = _build_filter_conditions( + sub_filter, # pyright: ignore[reportUnknownArgumentType] + model_class, + _depth=_depth + 1, + ) if sub_condition is not None: or_conditions.append(sub_condition) if or_conditions: @@ -157,7 +168,11 @@ def _build_filter_conditions( ) not_conditions: list[ColumnElement[bool]] = [] for sub_filter in filter_dict["NOT"]: # pyright: ignore - sub_condition = _build_filter_conditions(sub_filter, model_class) # pyright: ignore + sub_condition = _build_filter_conditions( + sub_filter, # pyright: ignore[reportUnknownArgumentType] + model_class, + _depth=_depth + 1, + ) if sub_condition is not None: not_conditions.append( not_(sub_condition) diff --git a/src/utils/search.py b/src/utils/search.py index fcc77273..59c8f137 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.EMBEDDING.MAX_INPUT_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/utils/summarizer.py b/src/utils/summarizer.py index ca1965b9..d964402c 100644 --- a/src/utils/summarizer.py +++ b/src/utils/summarizer.py @@ -11,10 +11,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from src import schemas from src.cache.client import cache as cache_client -from src.config import settings +from src.config import ConfiguredModelSettings, settings from src.crud.session import session_cache_key from src.dependencies import tracked_db from src.exceptions import ResourceNotFoundException +from src.llm import HonchoLLMCallResponse, honcho_llm_call from src.models import Message from src.telemetry import prometheus_metrics from src.telemetry.events import AgentToolSummaryCreatedEvent, emit @@ -24,7 +25,6 @@ from src.telemetry.prometheus.metrics import ( DeriverTaskTypes, TokenTypes, ) -from src.utils.clients import HonchoLLMCallResponse, honcho_llm_call from src.utils.formatting import utc_now_iso from src.utils.tokens import estimate_tokens, track_deriver_input_tokens @@ -78,6 +78,10 @@ __all__ = [ ] +def _get_summary_model_config() -> ConfiguredModelSettings: + return settings.SUMMARY.MODEL_CONFIG + + # Configuration constants for summaries MESSAGES_PER_SHORT_SUMMARY = settings.SUMMARY.MESSAGES_PER_SHORT_SUMMARY MESSAGES_PER_LONG_SUMMARY = settings.SUMMARY.MESSAGES_PER_LONG_SUMMARY @@ -212,7 +216,7 @@ async def create_short_summary( ) return await honcho_llm_call( - llm_settings=settings.SUMMARY, + model_config=_get_summary_model_config(), prompt=prompt, max_tokens=settings.SUMMARY.MAX_TOKENS_SHORT, ) @@ -237,7 +241,7 @@ async def create_long_summary( ) return await honcho_llm_call( - llm_settings=settings.SUMMARY, + model_config=_get_summary_model_config(), prompt=prompt, max_tokens=settings.SUMMARY.MAX_TOKENS_LONG, ) diff --git a/src/utils/types.py b/src/utils/types.py index 0654ed7a..dd66dccf 100644 --- a/src/utils/types.py +++ b/src/utils/types.py @@ -34,7 +34,6 @@ class GetOrCreateResult(Generic[T]): await self.on_commit() -SupportedProviders = Literal["anthropic", "openai", "google", "groq", "custom", "vllm"] TaskType = Literal[ "webhook", "summary", "representation", "dream", "deletion", "reconciler" ] diff --git a/src/vector_store/__init__.py b/src/vector_store/__init__.py index c77064a7..5a22abd9 100644 --- a/src/vector_store/__init__.py +++ b/src/vector_store/__init__.py @@ -50,17 +50,6 @@ class VectorQueryResult(BaseModel): metadata: dict[str, Any] = Field(default_factory=dict) -class VectorUpsertResult(BaseModel): - """Result for a vector upsert operation.""" - - model_config: ClassVar[ConfigDict] = ConfigDict( - extra="forbid", - frozen=True, - ) - - ok: bool - - class VectorStore(ABC): """ Abstract base class for vector store implementations. @@ -123,7 +112,7 @@ class VectorStore(ABC): self, namespace: str, vectors: list[VectorRecord], - ) -> VectorUpsertResult: + ) -> None: """ Upsert multiple vectors into the store. @@ -131,8 +120,8 @@ class VectorStore(ABC): namespace: The namespace to store the vectors in vectors: List of VectorRecord objects to upsert - Returns: - Result describing primary/secondary outcomes. + Raises: + Exception: If the write fails. """ ... @@ -192,9 +181,6 @@ class VectorStore(ABC): ... -from src.vector_store.utils import upsert_with_retry # noqa: E402 - - def _create_store_by_type(store_type: str) -> VectorStore: """Create a vector store instance by type name.""" if store_type == "turbopuffer": @@ -251,9 +237,7 @@ __all__ = [ "VectorStore", "VectorRecord", "VectorQueryResult", - "VectorUpsertResult", "get_external_vector_store", "close_external_vector_store", - "upsert_with_retry", "_hash_namespace_components", ] diff --git a/src/vector_store/lancedb.py b/src/vector_store/lancedb.py index 0853a98d..f63b8cfd 100644 --- a/src/vector_store/lancedb.py +++ b/src/vector_store/lancedb.py @@ -17,7 +17,7 @@ from lancedb import AsyncConnection, AsyncTable from src.config import settings from src.exceptions import VectorStoreError -from . import VectorQueryResult, VectorRecord, VectorStore, VectorUpsertResult +from . import VectorQueryResult, VectorRecord, VectorStore logger = logging.getLogger(__name__) @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) _VALID_IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") # Schema for LanceDB tables -# id: string, vector: fixed_size_list of float32 (1536 dimensions for OpenAI embeddings) +# id: string, vector: fixed_size_list of float32 (dimension from embedding settings) # Additional metadata columns are added dynamically # pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownParameterType=false @@ -93,7 +93,7 @@ class LanceDBVectorStore(VectorStore): fields: list[pa.Field] = [ pa.field("id", pa.string()), pa.field( - "vector", pa.list_(pa.float32(), settings.VECTOR_STORE.DIMENSIONS) + "vector", pa.list_(pa.float32(), settings.EMBEDDING.VECTOR_DIMENSIONS) ), ] fields.extend(self._metadata_fields_for_namespace(namespace)) @@ -156,7 +156,7 @@ class LanceDBVectorStore(VectorStore): self, namespace: str, vectors: list[VectorRecord], - ) -> VectorUpsertResult: + ) -> None: """ Upsert multiple vectors into LanceDB. @@ -165,7 +165,7 @@ class LanceDBVectorStore(VectorStore): vectors: List of VectorRecord objects to upsert """ if not vectors: - return VectorUpsertResult(ok=True) + return try: rows = [self._row_to_dict(v) for v in vectors] @@ -180,7 +180,7 @@ class LanceDBVectorStore(VectorStore): ) logger.debug(f"Upserted {len(vectors)} vectors to namespace {namespace}") - return VectorUpsertResult(ok=True) + return except Exception as e: logger.exception( f"Failed to upsert {len(vectors)} vectors to namespace {namespace}" diff --git a/src/vector_store/turbopuffer.py b/src/vector_store/turbopuffer.py index 39b93f5f..c84c8774 100644 --- a/src/vector_store/turbopuffer.py +++ b/src/vector_store/turbopuffer.py @@ -8,13 +8,14 @@ import logging from collections.abc import Sequence from typing import Any, Literal, cast -from turbopuffer import AsyncTurbopuffer, NotFoundError +from turbopuffer import AsyncTurbopuffer, InternalServerError, NotFoundError from turbopuffer.lib.namespace import AsyncNamespace from turbopuffer.types import Filter from src.config import settings +from src.exceptions import VectorStoreError -from . import VectorQueryResult, VectorRecord, VectorStore, VectorUpsertResult +from . import VectorQueryResult, VectorRecord, VectorStore logger = logging.getLogger(__name__) @@ -62,7 +63,7 @@ class TurbopufferVectorStore(VectorStore): self, namespace: str, vectors: list[VectorRecord], - ) -> VectorUpsertResult: + ) -> None: """ Upsert multiple vectors into Turbopuffer. @@ -71,7 +72,7 @@ class TurbopufferVectorStore(VectorStore): vectors: List of VectorRecord objects to upsert """ if not vectors: - return VectorUpsertResult(ok=True) + return ns = self._get_namespace(namespace) @@ -89,7 +90,18 @@ class TurbopufferVectorStore(VectorStore): upsert_rows=rows, distance_metric=DISTANCE_METRIC, ) - return VectorUpsertResult(ok=True) + return + except InternalServerError as exc: + # Turbopuffer unavailable. SDK implicitly retries 5xx responses, + # so raise a vector store error and let callers leave writes unsynced. + logger.warning( + "Turbopuffer unavailable for upsert to namespace %s (%s after retries)", + namespace, + exc.status_code, + ) + raise VectorStoreError( + f"Turbopuffer unavailable for upsert to namespace {namespace}" + ) from exc except Exception: logger.exception( f"Failed to upsert {len(vectors)} vectors to namespace {namespace}" @@ -183,6 +195,16 @@ class TurbopufferVectorStore(VectorStore): ) return [] + except InternalServerError as exc: + # Turbopuffer unavailable. SDK implicitly retries 5xx responses, + # so we should return []. + logger.warning( + "Turbopuffer unavailable for query on namespace %s (%s after retries), returning empty results", + namespace, + exc.status_code, + ) + return [] + except Exception: logger.exception(f"Failed to query namespace {namespace}") raise @@ -247,6 +269,15 @@ class TurbopufferVectorStore(VectorStore): except NotFoundError: # Namespace doesn't exist - nothing to delete logger.debug(f"Namespace {namespace} does not exist, nothing to delete") + except InternalServerError as exc: + logger.warning( + "Turbopuffer unavailable for delete from namespace %s (%s after retries)", + namespace, + exc.status_code, + ) + raise VectorStoreError( + f"Turbopuffer unavailable while deleting vectors in namespace {namespace}" + ) from exc except Exception: logger.exception( f"Failed to delete {len(ids)} vectors from namespace {namespace}" diff --git a/src/vector_store/utils.py b/src/vector_store/utils.py deleted file mode 100644 index ae613ada..00000000 --- a/src/vector_store/utils.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -Vector store utility functions. -""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING - -from tenacity import ( - AsyncRetrying, - retry_if_exception_type, - stop_after_attempt, - wait_exponential, -) - -if TYPE_CHECKING: - from src.vector_store import VectorRecord, VectorStore, VectorUpsertResult - -logger = logging.getLogger(__name__) - - -async def upsert_with_retry( - vector_store: VectorStore, - namespace: str, - vector_records: list[VectorRecord], - max_attempts: int = 3, -) -> VectorUpsertResult | None: - """ - Upsert vectors with exponential backoff retry. - - Args: - vector_store: The vector store to upsert into - namespace: The namespace for the vectors - vector_records: List of VectorRecord objects to upsert - max_attempts: Maximum number of retry attempts (default 3) - - Returns: - VectorUpsertResult on success, or None if vector_records is empty - - Raises: - Exception: If all retries fail - """ - if not vector_records: - return None - - result: VectorUpsertResult | None = None - async for attempt in AsyncRetrying( - stop=stop_after_attempt(max_attempts), - wait=wait_exponential(multiplier=0.5, min=0.5, max=2.0), - retry=retry_if_exception_type(Exception), - reraise=True, - ): - with attempt: - result = await vector_store.upsert_many(namespace, vector_records) - - return result 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/__init__.py b/tests/__init__.py index e69de29b..7468bd26 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Test package marker for shared helper imports. diff --git a/tests/bench/README.md b/tests/bench/README.md index 02feebbb..3fccc4e0 100644 --- a/tests/bench/README.md +++ b/tests/bench/README.md @@ -131,7 +131,7 @@ When you run the harness, it will start: The harness uses environment variables to configure Honcho's database connection: -- `DB_CONNECTION_URI`: Set to `postgresql+psycopg://testuser:testpwd@localhost:{port}/honcho` +- `DB_CONNECTION_URI`: Derived from the database credentials in `docker-compose.yml.example` (e.g. `postgresql+psycopg://postgres:postgres@localhost:{port}/postgres`) The script will print the actual configuration that Honcho is using after the FastAPI server starts. This gives you complete visibility into how Honcho's configuration system resolved the settings from environment variables, config files, and defaults. diff --git a/tests/bench/harness.py b/tests/bench/harness.py index 9b9c5f51..90f32e44 100755 --- a/tests/bench/harness.py +++ b/tests/bench/harness.py @@ -20,6 +20,7 @@ import tempfile import threading import time from pathlib import Path +from typing import Any import yaml @@ -59,6 +60,35 @@ class HonchoHarness: self.processes: list[tuple[str, subprocess.Popen[str]]] = [] self.env_file_backup: Path | None = None self.output_threads: list[threading.Thread] = [] + # DB credentials — populated from docker-compose.yml.example in create_temp_docker_compose + self.db_user: str = "postgres" + self.db_password: str = "postgres" + self.db_name: str = "postgres" + + def _extract_db_credentials(self, compose_data: dict[str, Any]) -> None: + """Extract POSTGRES_USER/PASSWORD/DB from the database service environment.""" + services: dict[str, Any] = compose_data.get("services", {}) + database: dict[str, Any] = services.get("database", {}) + db_env: list[Any] = database.get("environment", []) + env_map: dict[str, str] = {} + for entry in db_env: + if isinstance(entry, str) and "=" in entry: + # Handle "- KEY=VALUE" format + key, _, value = entry.partition("=") + env_map[key.strip()] = value.strip() + self.db_user = env_map.get("POSTGRES_USER", self.db_user) + self.db_password = env_map.get("POSTGRES_PASSWORD", self.db_password) + self.db_name = env_map.get("POSTGRES_DB", self.db_name) + + @property + def db_connection_uri(self) -> str: + """SQLAlchemy-style connection URI for the test database.""" + return f"postgresql+psycopg://{self.db_user}:{self.db_password}@localhost:{self.db_port}/{self.db_name}" + + @property + def db_connection_uri_plain(self) -> str: + """Plain psycopg connection URI (no +psycopg driver prefix).""" + return f"postgresql://{self.db_user}:{self.db_password}@localhost:{self.db_port}/{self.db_name}" def create_temp_docker_compose(self) -> Path: """ @@ -72,6 +102,9 @@ class HonchoHarness: with open(example_file) as f: compose_data = yaml.safe_load(f) + # Extract DB credentials from the compose file so the harness stays in sync + self._extract_db_credentials(compose_data) + # Update the database port compose_data["services"]["database"]["ports"] = [f"{self.db_port}:5432"] @@ -165,7 +198,7 @@ class HonchoHarness: Dictionary of environment variables for database connection, cache, and API keys """ return { - "DB_CONNECTION_URI": f"postgresql+psycopg://testuser:testpwd@localhost:{self.db_port}/honcho", + "DB_CONNECTION_URI": self.db_connection_uri, "CACHE_ENABLED": "true", "CACHE_URL": f"redis://localhost:{self.redis_port}/0", } @@ -336,7 +369,7 @@ class HonchoHarness: "-p", str(self.db_port), "-U", - "testuser", + self.db_user, ], capture_output=True, text=True, @@ -354,9 +387,7 @@ class HonchoHarness: try: import psycopg - conn = psycopg.connect( - f"postgresql://testuser:testpwd@localhost:{self.db_port}/honcho" - ) + conn = psycopg.connect(self.db_connection_uri_plain) conn.close() print("Database is ready!") return True @@ -401,9 +432,7 @@ class HonchoHarness: import psycopg # Connect to the database using instance-specific connection string - conn_string = ( - f"postgresql://testuser:testpwd@localhost:{self.db_port}/honcho" - ) + conn_string = self.db_connection_uri_plain conn = psycopg.connect(conn_string) with conn.cursor() as cursor: @@ -619,29 +648,69 @@ sys.path.insert(0, str(project_root)) # and will be inherited by this subprocess try: + from pydantic import BaseModel from src.config import settings - # Function to recursively print settings + SENSITIVE_TOKENS = ('password', 'secret', 'key', 'uri') + + def _mask(full_key, value): + if isinstance(full_key, str) and any(t in full_key.lower() for t in SENSITIVE_TOKENS): + return '*' * len(value) if value else 'None' + return value + + def _compact(model): + # Render a pydantic BaseModel as `field=value` pairs, skipping + # None / empty-dict fields and recursing into nested models. + parts = [] + for field_name in type(model).model_fields: + val = getattr(model, field_name) + if val is None: + continue + if isinstance(val, BaseModel): + inner = _compact(val) + if inner: + parts.append(f"{{field_name}}=({{inner}})") + continue + if isinstance(val, dict) and not val: + continue + parts.append(f"{{field_name}}={{val!r}}") + return " ".join(parts) + def print_settings(obj, prefix="", max_depth=3, current_depth=0): if current_depth >= max_depth: return - if hasattr(obj, '__dict__'): - for key, value in obj.__dict__.items(): - if not key.startswith('_'): - full_key = f"{{prefix}}.{{key}}" if prefix else key - # Handle nested settings objects - if hasattr(value, '__dict__') and not isinstance(value, (str, int, float, bool, type(None))): - print(f"\\n📋 {{full_key}}:") - print_settings(value, full_key, max_depth, current_depth + 1) - else: - # Mask sensitive information - if isinstance(full_key, str) and any(sensitive in full_key.lower() for sensitive in ['password', 'secret', 'key', 'uri']): - masked_value = '*' * len(value) if value else 'None' - else: - masked_value = value - print(f" {{key}}: {{masked_value}}") + if not hasattr(obj, '__dict__'): + return + for key, value in obj.__dict__.items(): + if key.startswith('_'): + continue + full_key = f"{{prefix}}.{{key}}" if prefix else key + + # dict-of-BaseModel → print each entry on its own line compactly + if ( + isinstance(value, dict) and value + and all(isinstance(v, BaseModel) for v in value.values()) + ): + print(f"\\n📋 {{full_key}}:") + for k, v in value.items(): + rendered = _compact(v) + print(f" {{k}}: {{rendered}}") + continue + + if isinstance(value, BaseModel): + print(f"\\n📋 {{full_key}}:") + rendered = _compact(value) + if rendered: + print(f" {{rendered}}") + continue + + if hasattr(value, '__dict__') and not isinstance(value, (str, int, float, bool, type(None))): + print(f"\\n📋 {{full_key}}:") + print_settings(value, full_key, max_depth, current_depth + 1) + continue + + print(f" {{key}}: {{_mask(full_key, value)}}") - # Print all settings print_settings(settings) except Exception as e: 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 8ff205ff..3c9b8e63 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -72,8 +72,17 @@ _RUNTIME_MOCK_TEST_BLOCKLIST_PREFIXES = ( "tests/bench/", "tests/alembic/", "tests/unified/", + "tests/live_llm/", + # Pure llm unit tests should stay isolated from the broader app/runtime fixtures. + "tests/llm/", + # LLM transport tests mock providers directly and don't need database/runtime setup. + "tests/utils/test_length_finish_reason.py", + "tests/utils/test_clients.py", ) +_LIVE_LLM_MARKER = "live_llm" +_LIVE_LLM_SKIP_REASON = "live LLM tests are disabled; pass --live-llm to run them" + def _requires_runtime_mocks(nodeid: str) -> bool: return not any( @@ -87,6 +96,28 @@ def _get_nodeid(request: pytest.FixtureRequest) -> str: return nodeid if isinstance(nodeid, str) else "" +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addoption( + "--live-llm", + action="store_true", + default=False, + help="Run opt-in live LLM integration tests that call provider APIs.", + ) + + +def pytest_collection_modifyitems( + config: pytest.Config, + items: list[pytest.Item], +) -> None: + if config.getoption("--live-llm"): + return + + skip_live = pytest.mark.skip(reason=_LIVE_LLM_SKIP_REASON) + for item in items: + if _LIVE_LLM_MARKER in item.keywords: + item.add_marker(skip_live) + + def _get_test_db_url(worker_id: str) -> URL: """Get a worker-specific test database URL for pytest-xdist parallelism.""" @@ -412,9 +443,10 @@ def _content_to_embedding(content: str) -> list[float]: # Hash the content to get a deterministic seed content_hash = hashlib.sha256(content.encode()).digest() - # Use hash bytes to generate 1536 floats between -1 and 1 + vector_dimensions = settings.EMBEDDING.VECTOR_DIMENSIONS + # Use hash bytes to generate deterministic floats between -1 and 1 embedding: list[float] = [] - for i in range(1536): + for i in range(vector_dimensions): # Use different bytes from hash (cycling through) byte_val = content_hash[i % len(content_hash)] # Normalize to [-1, 1] range @@ -431,6 +463,9 @@ def mock_openai_embeddings(request: pytest.FixtureRequest): with ( patch("src.embedding_client.embedding_client.embed") as mock_embed, + patch( + "src.embedding_client.embedding_client.simple_batch_embed" + ) as mock_simple_batch_embed, patch("src.embedding_client.embedding_client.batch_embed") as mock_batch_embed, ): # Mock the embed method to return content-dependent embedding @@ -439,6 +474,11 @@ def mock_openai_embeddings(request: pytest.FixtureRequest): mock_embed.side_effect = embed_side_effect + async def mock_simple_batch_embed_func(texts: list[str]) -> list[list[float]]: + return [_content_to_embedding(text) for text in texts] + + mock_simple_batch_embed.side_effect = mock_simple_batch_embed_func + # Mock the batch_embed method to return content-dependent embeddings async def mock_batch_embed_func( id_resource_dict: dict[str, tuple[str, list[int]]], @@ -450,7 +490,11 @@ def mock_openai_embeddings(request: pytest.FixtureRequest): mock_batch_embed.side_effect = mock_batch_embed_func - yield {"embed": mock_embed, "batch_embed": mock_batch_embed} + yield { + "embed": mock_embed, + "simple_batch_embed": mock_simple_batch_embed, + "batch_embed": mock_batch_embed, + } @pytest.fixture(autouse=True) @@ -465,21 +509,18 @@ def mock_vector_store(request: pytest.FixtureRequest): from src.vector_store import ( VectorQueryResult, VectorRecord, - VectorUpsertResult, _hash_namespace_components, # pyright: ignore[reportPrivateUsage] ) # Create a mock vector store that stores vectors in memory vector_storage: dict[str, dict[str, tuple[list[float], dict[str, Any]]]] = {} - async def mock_upsert_many( - namespace: str, vectors: list[VectorRecord] - ) -> VectorUpsertResult: + async def mock_upsert_many(namespace: str, vectors: list[VectorRecord]) -> None: if namespace not in vector_storage: vector_storage[namespace] = {} for vector in vectors: vector_storage[namespace][vector.id] = (vector.embedding, vector.metadata) - return VectorUpsertResult(ok=True) + return async def mock_query( namespace: str, embedding: list[float], **kwargs: Any @@ -670,10 +711,10 @@ def mock_honcho_llm_call(request: pytest.FixtureRequest): # Patch the honcho_llm_call decorator to prevent actual LLM calls at module level original_decorator = None try: - import src.utils.clients + import src.llm - original_decorator = src.utils.clients.honcho_llm_call - src.utils.clients.honcho_llm_call = lambda *args, **kwargs: lambda func: func # pyright: ignore[reportUnknownLambdaType] + original_decorator = src.llm.honcho_llm_call + src.llm.honcho_llm_call = lambda *args, **kwargs: lambda func: func # pyright: ignore[reportUnknownLambdaType] except ImportError: pass @@ -707,21 +748,21 @@ def mock_honcho_llm_call(request: pytest.FixtureRequest): return mock_llm_decorator - with patch("src.utils.clients.honcho_llm_call", side_effect=decorator_factory): + with patch("src.llm.honcho_llm_call", side_effect=decorator_factory): yield decorator_factory # Restore the original decorator if original_decorator: try: - import src.utils.clients + import src.llm - src.utils.clients.honcho_llm_call = original_decorator + src.llm.honcho_llm_call = original_decorator except ImportError: pass @pytest.fixture(autouse=True) -def mock_tracked_db(db_engine: AsyncEngine, request: pytest.FixtureRequest): +def mock_tracked_db(request: pytest.FixtureRequest): """Mock tracked_db to create fresh sessions per call. Using a session factory instead of a shared session avoids asyncio lock @@ -733,6 +774,7 @@ def mock_tracked_db(db_engine: AsyncEngine, request: pytest.FixtureRequest): from contextlib import asynccontextmanager + db_engine = request.getfixturevalue("db_engine") session_factory = async_sessionmaker(bind=db_engine, expire_on_commit=False) @asynccontextmanager @@ -752,6 +794,14 @@ 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), ): yield diff --git a/tests/crud/test_document.py b/tests/crud/test_document.py index 6bfc0c47..f25b75d2 100644 --- a/tests/crud/test_document.py +++ b/tests/crud/test_document.py @@ -387,7 +387,7 @@ class TestDocumentCRUD: observed=test_peer2.name, ) - assert count == 2 + assert len(count) == 2 # Verify documents were created stmt = select(models.Document).where( diff --git a/tests/crud/test_representation_manager.py b/tests/crud/test_representation_manager.py new file mode 100644 index 00000000..e4c46853 --- /dev/null +++ b/tests/crud/test_representation_manager.py @@ -0,0 +1,136 @@ +import pytest +from nanoid import generate as generate_nanoid +from sqlalchemy import func, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src import models +from src.crud.representation import RepresentationManager + + +class TestRepresentationManagerSoftDelete: + """Tests that RepresentationManager query methods exclude soft-deleted documents.""" + + async def _setup( + self, + db_session: AsyncSession, + test_workspace: models.Workspace, + test_peer: models.Peer, + ) -> tuple[models.Peer, models.Session, models.Collection, RepresentationManager]: + """Create peers, session, collection, and a RepresentationManager.""" + test_peer2 = models.Peer( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add(test_peer2) + await db_session.flush() + + test_session = models.Session( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add(test_session) + await db_session.flush() + + collection = models.Collection( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + db_session.add(collection) + await db_session.flush() + + manager = RepresentationManager( + test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + ) + + return test_peer2, test_session, collection, manager + + @pytest.mark.asyncio + async def test_query_documents_recent_excludes_soft_deleted( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """Soft-deleted documents must not appear in the recent-documents query.""" + test_workspace, test_peer = sample_data + test_peer2, test_session, _, manager = await self._setup( + db_session, test_workspace, test_peer + ) + + # Create two documents + doc_live = models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content="Live observation", + session_name=test_session.name, + ) + doc_deleted = models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content="Deleted observation", + session_name=test_session.name, + ) + db_session.add_all([doc_live, doc_deleted]) + await db_session.flush() + + # Soft-delete one + await db_session.execute( + update(models.Document) + .where(models.Document.id == doc_deleted.id) + .values(deleted_at=func.now()) + ) + await db_session.commit() + + results = await manager._query_documents_recent(db_session, top_k=10) # pyright: ignore[reportPrivateUsage] + + result_ids = [doc.id for doc in results] + assert doc_live.id in result_ids + assert doc_deleted.id not in result_ids + + @pytest.mark.asyncio + async def test_query_documents_most_derived_excludes_soft_deleted( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """Soft-deleted documents must not appear in the most-derived query.""" + test_workspace, test_peer = sample_data + test_peer2, test_session, _, manager = await self._setup( + db_session, test_workspace, test_peer + ) + + # Create two documents with different times_derived + doc_live = models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content="Live observation", + session_name=test_session.name, + times_derived=5, + ) + doc_deleted = models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content="Deleted high-derived observation", + session_name=test_session.name, + times_derived=100, + ) + db_session.add_all([doc_live, doc_deleted]) + await db_session.flush() + + # Soft-delete the high-derived one + await db_session.execute( + update(models.Document) + .where(models.Document.id == doc_deleted.id) + .values(deleted_at=func.now()) + ) + await db_session.commit() + + results = await manager._query_documents_most_derived(db_session, top_k=10) # pyright: ignore[reportPrivateUsage] + + result_ids = [doc.id for doc in results] + assert doc_live.id in result_ids + assert doc_deleted.id not in result_ids diff --git a/tests/deriver/test_deriver_processing.py b/tests/deriver/test_deriver_processing.py index 0cde8a68..5822f4d5 100644 --- a/tests/deriver/test_deriver_processing.py +++ b/tests/deriver/test_deriver_processing.py @@ -1,10 +1,15 @@ import signal +from datetime import datetime, timezone from typing import Any +from unittest.mock import AsyncMock, Mock, patch import pytest from src import models -from src.utils.representation import Representation +from src.config import settings +from src.deriver.deriver import process_representation_tasks_batch +from src.llm import HonchoLLMCallResponse +from src.utils.representation import PromptRepresentation, Representation from src.utils.work_unit import construct_work_unit_key, parse_work_unit_key @@ -12,6 +17,59 @@ from src.utils.work_unit import construct_work_unit_key, parse_work_unit_key class TestDeriverProcessing: """Test suite for deriver processing using the conftest fixtures""" + async def test_process_representation_tasks_batch_uses_model_config(self): + message = Mock( + id=1, + public_id="msg_1", + session_name="session-1", + workspace_name="workspace-1", + peer_name="alice", + content="hello", + token_count=5, + created_at=datetime.now(timezone.utc), + ) + configuration = Mock() + configuration.reasoning.enabled = True + + mock_response = HonchoLLMCallResponse( + content=PromptRepresentation(explicit=[]), + input_tokens=10, + output_tokens=5, + finish_reasons=["STOP"], + ) + + with patch( + "src.deriver.deriver.honcho_llm_call", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_llm_call: + await process_representation_tasks_batch( + messages=[message], + message_level_configuration=configuration, + observers=["bob"], + observed="alice", + queue_item_message_ids=[1], + ) + + await_args = mock_llm_call.await_args + if await_args is None: + raise AssertionError("Expected deriver LLM call") + kwargs = await_args.kwargs + expected_config = settings.DERIVER.MODEL_CONFIG.model_copy( + update={ + "stop_sequences": [" \n", "\n\n\n\n"], + } + ) + assert "model_config" in kwargs + assert kwargs["model_config"].model == expected_config.model + assert kwargs["model_config"].thinking_effort == expected_config.thinking_effort + assert ( + kwargs["model_config"].thinking_budget_tokens + == expected_config.thinking_budget_tokens + ) + assert kwargs["model_config"].stop_sequences == expected_config.stop_sequences + assert "llm_settings" not in kwargs + async def test_work_unit_key_generation( self, sample_session_with_peers: tuple[models.Session, list[models.Peer]], diff --git a/tests/deriver/test_queue_processing.py b/tests/deriver/test_queue_processing.py index 9538c871..540dfab5 100644 --- a/tests/deriver/test_queue_processing.py +++ b/tests/deriver/test_queue_processing.py @@ -1088,8 +1088,16 @@ class TestQueueProcessing: db_session: AsyncSession, sample_session_with_peers: tuple[models.Session, list[models.Peer]], create_queue_payload: Callable[..., Any], + monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test that representation work units below token threshold are not claimed""" + """Test that representation work units below token threshold are not claimed. + + The token-threshold gate in QueueManager.get_and_claim_work_units is + skipped entirely when DERIVER_FLUSH_ENABLED is True, so this test + forces it False regardless of what the process env has set (benches + commonly enable flush mode for immediate processing). + """ + monkeypatch.setattr(settings.DERIVER, "FLUSH_ENABLED", False) session, peers = sample_session_with_peers peer = peers[0] diff --git a/tests/deriver/test_vector_reconciliation.py b/tests/deriver/test_vector_reconciliation.py index 3b8e0e92..cc637597 100644 --- a/tests/deriver/test_vector_reconciliation.py +++ b/tests/deriver/test_vector_reconciliation.py @@ -27,7 +27,6 @@ from src.reconciler.sync_vectors import ( from src.vector_store import ( VectorRecord, VectorStore, - VectorUpsertResult, _hash_namespace_components, # pyright: ignore[reportPrivateUsage] ) @@ -84,9 +83,7 @@ class TestStateTransitions: mock_vector_store.get_vector_namespace = MagicMock( return_value=f"honcho.doc.{_hash_namespace_components(workspace.name, peer1.name, peer1.name)}" ) - mock_vector_store.upsert_many = AsyncMock( - return_value=VectorUpsertResult(ok=True) - ) + mock_vector_store.upsert_many = AsyncMock(return_value=None) # Run sync synced, failed = await _sync_documents(db_session, docs, mock_vector_store) @@ -309,13 +306,11 @@ class TestBatchProcessing: ) -> str: return f"honcho.doc.{_hash_namespace_components(workspace, observer, observed)}" - async def mock_upsert( - namespace: str, vectors: list[VectorRecord] - ) -> VectorUpsertResult: + async def mock_upsert(namespace: str, vectors: list[VectorRecord]) -> None: if namespace not in namespace_calls: namespace_calls[namespace] = [] namespace_calls[namespace].extend(vectors) - return VectorUpsertResult(ok=True) + return mock_vector_store.get_vector_namespace = mock_get_namespace mock_vector_store.upsert_many = mock_upsert @@ -440,9 +435,7 @@ class TestReEmbedding: mock_vector_store.get_vector_namespace = MagicMock( return_value=f"honcho.doc.{_hash_namespace_components(workspace.name, peer1.name, peer1.name)}" ) - mock_vector_store.upsert_many = AsyncMock( - return_value=VectorUpsertResult(ok=True) - ) + mock_vector_store.upsert_many = AsyncMock(return_value=None) # Run sync synced, failed = await _sync_documents(db_session, docs, mock_vector_store) @@ -512,9 +505,7 @@ class TestReEmbedding: mock_vector_store.get_vector_namespace = MagicMock( return_value=f"honcho.doc.{_hash_namespace_components(workspace.name, peer1.name, peer1.name)}" ) - mock_vector_store.upsert_many = AsyncMock( - return_value=VectorUpsertResult(ok=True) - ) + mock_vector_store.upsert_many = AsyncMock(return_value=None) # Run sync await _sync_documents(db_session, docs, mock_vector_store) diff --git a/tests/dialectic/test_model_config_usage.py b/tests/dialectic/test_model_config_usage.py new file mode 100644 index 00000000..03b6e9c9 --- /dev/null +++ b/tests/dialectic/test_model_config_usage.py @@ -0,0 +1,111 @@ +import time +from unittest.mock import AsyncMock, patch + +import pytest + +from src.config import settings +from src.dialectic.core import DialecticAgent +from src.llm import ( + HonchoLLMCallResponse, + HonchoLLMCallStreamChunk, + StreamingResponseWithMetadata, +) + + +async def _stream_chunks() -> StreamingResponseWithMetadata: + async def _stream(): + yield HonchoLLMCallStreamChunk(content="streamed") + yield HonchoLLMCallStreamChunk(content="", is_done=True) + + return StreamingResponseWithMetadata( + _stream(), + tool_calls_made=[], + input_tokens=10, + output_tokens=5, + cache_creation_input_tokens=0, + cache_read_input_tokens=0, + iterations=1, + ) + + +@pytest.mark.asyncio +async def test_dialectic_answer_uses_level_model_config() -> None: + agent = DialecticAgent( + workspace_name="workspace", + session_name="session", + observer="observer", + observed="observed", + reasoning_level="medium", + ) + + mock_response = HonchoLLMCallResponse( + content="answer", + input_tokens=10, + output_tokens=5, + finish_reasons=["stop"], + ) + + with ( + patch.object( + DialecticAgent, + "_prepare_query", + new=AsyncMock( + return_value=(AsyncMock(), "task", "run", time.perf_counter()) + ), + ), + patch.object(DialecticAgent, "_log_response_metrics"), + patch( + "src.dialectic.core.honcho_llm_call", + new=AsyncMock(return_value=mock_response), + ) as mock_llm_call, + ): + result = await agent.answer("What do you know?") + + await_args = mock_llm_call.await_args + if await_args is None: + raise AssertionError("Expected dialectic LLM call") + kwargs = await_args.kwargs + expected_config = settings.DIALECTIC.LEVELS["medium"].MODEL_CONFIG + + assert result == "answer" + assert kwargs["model_config"] == expected_config + assert "llm_settings" not in kwargs + assert "thinking_budget_tokens" not in kwargs + + +@pytest.mark.asyncio +async def test_dialectic_answer_stream_uses_level_model_config() -> None: + agent = DialecticAgent( + workspace_name="workspace", + session_name="session", + observer="observer", + observed="observed", + reasoning_level="medium", + ) + + with ( + patch.object( + DialecticAgent, + "_prepare_query", + new=AsyncMock( + return_value=(AsyncMock(), "task", "run", time.perf_counter()) + ), + ), + patch.object(DialecticAgent, "_log_response_metrics"), + patch( + "src.dialectic.core.honcho_llm_call", + new=AsyncMock(return_value=await _stream_chunks()), + ) as mock_llm_call, + ): + chunks = [chunk async for chunk in agent.answer_stream("What do you know?")] + + await_args = mock_llm_call.await_args + if await_args is None: + raise AssertionError("Expected dialectic streaming LLM call") + kwargs = await_args.kwargs + expected_config = settings.DIALECTIC.LEVELS["medium"].MODEL_CONFIG + + assert chunks == ["streamed"] + assert kwargs["model_config"] == expected_config + assert "llm_settings" not in kwargs + assert "thinking_budget_tokens" not in kwargs diff --git a/tests/dreamer/test_model_config_usage.py b/tests/dreamer/test_model_config_usage.py new file mode 100644 index 00000000..91d1d141 --- /dev/null +++ b/tests/dreamer/test_model_config_usage.py @@ -0,0 +1,56 @@ +from unittest.mock import AsyncMock, patch + +import pytest + +from src.config import settings +from src.dreamer.specialists import DeductionSpecialist +from src.llm import HonchoLLMCallResponse + + +@pytest.mark.asyncio +async def test_deduction_specialist_uses_nested_model_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings.METRICS, "ENABLED", False) + specialist = DeductionSpecialist() + mock_response = HonchoLLMCallResponse( + content="done", + input_tokens=10, + output_tokens=5, + finish_reasons=["stop"], + ) + + with ( + patch( + "src.dreamer.specialists.crud.get_peer", + new=AsyncMock(), + ), + patch( + "src.dreamer.specialists.crud.get_peer_card", + new=AsyncMock(return_value=None), + ), + patch( + "src.dreamer.specialists.create_tool_executor", + new=AsyncMock(return_value=AsyncMock()), + ), + patch( + "src.dreamer.specialists.honcho_llm_call", + new=AsyncMock(return_value=mock_response), + ) as mock_llm_call, + ): + result = await specialist.run( + workspace_name="workspace", + observer="alice", + observed="alice", + session_name="session", + ) + + await_args = mock_llm_call.await_args + if await_args is None: + raise AssertionError("Expected dreamer LLM call") + kwargs = await_args.kwargs + expected_config = settings.DREAM.DEDUCTION_MODEL_CONFIG + + assert result.content == "done" + assert kwargs["model_config"] == expected_config + assert "llm_settings" not in kwargs diff --git a/tests/integration/test_enqueue.py b/tests/integration/test_enqueue.py index 09bc9eda..0f3d50ad 100644 --- a/tests/integration/test_enqueue.py +++ b/tests/integration/test_enqueue.py @@ -641,68 +641,6 @@ class TestEnqueueFunction: assert observer_who_stayed.name in observers assert sender_peer.name in observers - @pytest.mark.asyncio - async def test_sender_not_in_peer_configuration_uses_defaults( - self, - db_session: AsyncSession, - sample_data: tuple[Workspace, Peer], - ): - """Test get_effective_observe_me handles missing sender configuration gracefully""" - test_workspace, existing_peer = sample_data - - # Create observer peer - observer_peer = models.Peer( - workspace_name=test_workspace.name, name=str(generate_nanoid()) - ) - db_session.add(observer_peer) - - # Create session with only observer (sender not in peers_with_configuration) - test_session = ( - await crud.get_or_create_session( - db_session, - schemas.SessionCreate( - name=str(generate_nanoid()), - peers={ - observer_peer.name: schemas.SessionPeerConfig( - observe_others=True - ), - }, - ), - test_workspace.name, - ) - ).resource - await db_session.commit() - - # Create message from peer NOT in the session configuration - # This simulates the race condition where a peer left after sending - payload = await self.create_sample_payload( - db_session, - workspace_name=test_workspace.name, - session_name=test_session.name, - peer_name=existing_peer.name, - ) - - initial_count = await self.count_queue_items(db_session) - await enqueue(payload) - final_count = await self.count_queue_items(db_session) - - # With deduplication: 1 queue item per message with all observers - assert final_count - initial_count == 1 - - result = await db_session.execute( - select(QueueItem).where(QueueItem.session_id == test_session.id) - ) - queue_items = result.scalars().all() - - assert len(queue_items) == 1 - item = queue_items[0] - assert item.payload.get("task_type") == "representation" - assert item.payload.get("observed") == existing_peer.name - observers = item.payload.get("observers") - assert observers is not None - assert existing_peer.name in observers # self-observation (default) - assert observer_peer.name in observers # observer (observing others) - @pytest.mark.asyncio async def test_mixed_active_inactive_peers_complex_scenario( self, diff --git a/tests/integration/test_message_embeddings.py b/tests/integration/test_message_embeddings.py index de544ee0..091e8cf3 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, @@ -269,7 +478,7 @@ async def test_message_chunking_creates_multiple_embeddings( monkeypatch.setattr("src.config.settings.EMBED_MESSAGES", True) # Mock a low token limit to force chunking - monkeypatch.setattr("src.config.settings.MAX_EMBEDDING_TOKENS", 10) + monkeypatch.setattr("src.config.settings.EMBEDDING.MAX_INPUT_TOKENS", 10) test_workspace, test_peer = sample_data diff --git a/tests/integration/test_token_metrics.py b/tests/integration/test_token_metrics.py index 31163474..4e6c1652 100644 --- a/tests/integration/test_token_metrics.py +++ b/tests/integration/test_token_metrics.py @@ -19,6 +19,7 @@ from prometheus_client import Counter from sqlalchemy.ext.asyncio import AsyncSession from src import crud, models, schemas +from src.llm import HonchoLLMCallResponse from src.models import Peer, Workspace from src.schemas import ( ResolvedConfiguration, @@ -31,7 +32,6 @@ from src.telemetry.prometheus.metrics import ( deriver_tokens_processed_counter, dialectic_tokens_processed_counter, ) -from src.utils.clients import HonchoLLMCallResponse from src.utils.representation import ExplicitObservationBase, PromptRepresentation from src.utils.summarizer import ( SummaryType, @@ -707,7 +707,6 @@ class TestDialecticTokenMetrics: before = metric_checker.capture(dialectic_tokens_processed_counter, labels) agent = DialecticAgent( - db=db_session, workspace_name=workspace.name, session_name=session.name, observer=peer.name, @@ -755,7 +754,6 @@ class TestDialecticTokenMetrics: before = metric_checker.capture(dialectic_tokens_processed_counter, labels) agent = DialecticAgent( - db=db_session, workspace_name=workspace.name, session_name=session.name, observer=peer.name, @@ -819,7 +817,6 @@ class TestDialecticTokenMetrics: ) agent = DialecticAgent( - db=db_session, workspace_name=workspace.name, session_name=session.name, observer=peer.name, diff --git a/tests/live_llm/README.md b/tests/live_llm/README.md new file mode 100644 index 00000000..cef10802 --- /dev/null +++ b/tests/live_llm/README.md @@ -0,0 +1,59 @@ +# Live LLM Tests + +These tests call real provider APIs and are disabled by default. + +Run them with: + +```bash +uv run pytest tests/live_llm -n 0 --live-llm --no-header -q +``` + +Required API key env vars: + +- `LLM_ANTHROPIC_API_KEY` +- `LLM_OPENAI_API_KEY` +- `LLM_GEMINI_API_KEY` + +Model-family env vars: + +- `LIVE_LLM_ANTHROPIC_45_PLUS_MODELS` +- `LIVE_LLM_OPENAI_GPT4_MODELS` +- `LIVE_LLM_OPENAI_GPT5_MODELS` +- `LIVE_LLM_OPENAI_OPENROUTER_NON_REASONING_MODELS` (OpenAI-transport → OpenRouter-served non-reasoning models) +- `LIVE_LLM_GEMINI_25_MODELS` +- `LIVE_LLM_GEMINI_30_MODELS` +- `LIVE_LLM_GEMINI_31_MODELS` + +Each model env var accepts a comma-separated list of bare model ids or provider-qualified ids. + +Examples: + +```bash +export LIVE_LLM_ANTHROPIC_45_PLUS_MODELS="claude-sonnet-4-5,claude-sonnet-4-6" +export LIVE_LLM_OPENAI_GPT4_MODELS="gpt-4.1" +export LIVE_LLM_OPENAI_GPT5_MODELS="gpt-5,gpt-5.4,gpt-5.4-mini" +export LIVE_LLM_OPENAI_OPENROUTER_NON_REASONING_MODELS="inception/mercury-2" +export LIVE_LLM_GEMINI_25_MODELS="gemini-2.5-flash,gemini-2.5-pro" +export LIVE_LLM_GEMINI_30_MODELS="gemini-3-flash-preview" +export LIVE_LLM_GEMINI_31_MODELS="gemini-3.1-pro-preview" +``` + +OpenRouter-routed models require additional env for the proxy endpoint: + +```bash +export OPENROUTER_API_KEY="sk-or-v1-..." +# Per-feature config example: +# DERIVER_MODEL_CONFIG__TRANSPORT=openai +# DERIVER_MODEL_CONFIG__MODEL=inception/mercury-2 +# DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1 +# DERIVER_MODEL_CONFIG__OVERRIDES__API_KEY_ENV=OPENROUTER_API_KEY +``` + +Coverage by provider: + +- Anthropic: structured output path, prompt caching metrics, thinking blocks, multi-turn tool replay +- OpenAI GPT-4 class: structured outputs, prompt caching +- OpenAI GPT-5 class (incl. gpt-5.x point-releases): structured outputs, prompt caching, `reasoning_effort`, `max_completion_tokens` routing +- OpenAI transport → OpenRouter non-reasoning models (e.g. `inception/mercury-2`): non-chat / diffusion architectures must stay on `max_tokens`, no `reasoning_effort`, tool-calling parameter-schema compatibility is the canary for exotic OR-served providers +- Gemini 2.5/3.0 classes: structured outputs, cached-content reuse, thought signatures, multi-turn tool replay +- Gemini 3.1 class: thinking and tool replay coverage by default; structured-output/caching coverage should only be added once Google documents support for that path diff --git a/tests/live_llm/__init__.py b/tests/live_llm/__init__.py new file mode 100644 index 00000000..2c935503 --- /dev/null +++ b/tests/live_llm/__init__.py @@ -0,0 +1 @@ +# Live LLM integration test package. diff --git a/tests/live_llm/conftest.py b/tests/live_llm/conftest.py new file mode 100644 index 00000000..d9646383 --- /dev/null +++ b/tests/live_llm/conftest.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest +from pydantic import BaseModel + +from src.config import ModelConfig, settings +from src.llm import get_backend +from src.llm.caching import gemini_cache_store + +from .model_matrix import LiveModelSpec, selected_model_summary_lines + + +class StructuredLiveResponse(BaseModel): + provider: str + family: str + answer: str + + +def pytest_report_header(config: pytest.Config) -> list[str] | None: + if not config.getoption("--live-llm"): + return None + return ["live llm model matrix:"] + [ + f" {line}" for line in selected_model_summary_lines() + ] + + +@pytest.fixture(autouse=True) +def clear_live_gemini_cache_store() -> Iterator[None]: + # The live Gemini cache store is process-local and should not leak state between tests. + gemini_cache_store._handles.clear() # pyright: ignore[reportPrivateUsage] + yield + gemini_cache_store._handles.clear() # pyright: ignore[reportPrivateUsage] + + +def require_provider_key(model_spec: LiveModelSpec) -> None: + key_present = { + "anthropic": bool(settings.LLM.ANTHROPIC_API_KEY), + "openai": bool(settings.LLM.OPENAI_API_KEY), + "gemini": bool(settings.LLM.GEMINI_API_KEY), + }[model_spec.provider] + if not key_present: + pytest.skip(f"Missing API key for live provider {model_spec.provider}") + + +def make_model_config(model_spec: LiveModelSpec, **overrides: Any) -> ModelConfig: + return ModelConfig( + model=model_spec.model, + transport=model_spec.provider, + **overrides, + ) + + +def make_backend( + model_spec: LiveModelSpec, **config_overrides: Any +) -> tuple[Any, ModelConfig]: + config = make_model_config(model_spec, **config_overrides) + return get_backend(config), config + + +def make_large_system_prompt(*, label: str) -> str: + repeated_prefix = " ".join([f"{label}-token-{index % 37}" for index in range(2400)]) + return ( + f"{label} system prompt. Reuse this prefix exactly for prompt-caching validation. " + f"{repeated_prefix}" + ) + + +def favorite_prime_tools() -> list[dict[str, Any]]: + return [ + { + "name": "get_favorite_prime", + "description": "Return the favorite prime number for the current test run.", + "input_schema": { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Why the caller wants the prime number.", + } + }, + "required": ["topic"], + }, + } + ] + + +def execute_local_tool(tool_name: str, tool_input: dict[str, Any]) -> str: + assert tool_name == "get_favorite_prime" + assert isinstance(tool_input, dict) + return "13" + + +def wrap_async_method( + monkeypatch: pytest.MonkeyPatch, + target: Any, + attribute: str, +) -> list[dict[str, Any]]: + original = getattr(target, attribute) + calls: list[dict[str, Any]] = [] + + async def wrapped(*args: Any, **kwargs: Any) -> Any: + calls.append({"args": args, "kwargs": kwargs}) + return await original(*args, **kwargs) + + monkeypatch.setattr(target, attribute, wrapped) + return calls + + +def extract_openai_reasoning_tokens(raw_response: Any) -> int | None: + usage = getattr(raw_response, "usage", None) + if usage is None: + return None + details = getattr(usage, "completion_tokens_details", None) + if details is None: + return None + reasoning_tokens = getattr(details, "reasoning_tokens", None) + return int(reasoning_tokens) if reasoning_tokens is not None else None diff --git a/tests/live_llm/model_matrix.py b/tests/live_llm/model_matrix.py new file mode 100644 index 00000000..a9abf0f9 --- /dev/null +++ b/tests/live_llm/model_matrix.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Literal + +ProviderName = Literal["anthropic", "openai", "gemini"] +FeatureName = Literal["thinking", "structured_output", "caching", "reasoning"] + + +@dataclass(frozen=True) +class LiveModelFamily: + provider: ProviderName + family: str + env_var: str + default_models: tuple[str, ...] = () + supports_thinking: bool = False + supports_structured_output: bool = False + supports_caching: bool = False + supports_reasoning: bool = False + supports_tool_replay: bool = False + docs_url: str | None = None + + +@dataclass(frozen=True) +class LiveModelSpec: + provider: ProviderName + family: str + model: str + env_var: str + supports_thinking: bool + supports_structured_output: bool + supports_caching: bool + supports_reasoning: bool + supports_tool_replay: bool + docs_url: str | None = None + + @property + def id(self) -> str: + return f"{self.provider}:{self.family}:{self.model}" + + +MODEL_FAMILIES: tuple[LiveModelFamily, ...] = ( + LiveModelFamily( + provider="anthropic", + family="claude_4_5_plus", + env_var="LIVE_LLM_ANTHROPIC_45_PLUS_MODELS", + supports_thinking=True, + supports_structured_output=True, + supports_caching=True, + supports_tool_replay=True, + docs_url="https://docs.anthropic.com/en/docs/about-claude/models/all-models", + ), + LiveModelFamily( + provider="openai", + family="gpt_4_class", + env_var="LIVE_LLM_OPENAI_GPT4_MODELS", + default_models=("gpt-4.1",), + supports_structured_output=True, + supports_caching=True, + docs_url="https://platform.openai.com/docs/models/gpt-4.1", + ), + LiveModelFamily( + provider="openai", + family="gpt_5_class", + env_var="LIVE_LLM_OPENAI_GPT5_MODELS", + default_models=("gpt-5", "gpt-5.4", "gpt-5.4-mini"), + supports_structured_output=True, + supports_caching=True, + supports_reasoning=True, + docs_url="https://platform.openai.com/docs/models/gpt-5", + ), + # OpenAI-compatible transport → OpenRouter-served non-reasoning models. + # Best canary for operators routing exotic providers through OpenRouter: + # if honcho works here, it works for most OR-served models. Currently + # anchored on Inception Labs' Mercury-2 diffusion model (non-chat + # architecture, must stay on max_tokens, no reasoning_effort). + LiveModelFamily( + provider="openai", + family="openrouter_non_reasoning", + env_var="LIVE_LLM_OPENAI_OPENROUTER_NON_REASONING_MODELS", + default_models=("inception/mercury-2",), + supports_structured_output=False, + supports_caching=False, + docs_url="https://openrouter.ai/models", + ), + LiveModelFamily( + provider="gemini", + family="gemini_2_5_class", + env_var="LIVE_LLM_GEMINI_25_MODELS", + default_models=("gemini-2.5-flash",), + supports_thinking=True, + supports_structured_output=True, + supports_caching=True, + supports_tool_replay=True, + docs_url="https://ai.google.dev/gemini-api/docs/models/gemini", + ), + LiveModelFamily( + provider="gemini", + family="gemini_3_0_class", + env_var="LIVE_LLM_GEMINI_30_MODELS", + supports_thinking=True, + supports_structured_output=True, + supports_caching=True, + supports_tool_replay=True, + docs_url="https://ai.google.dev/gemini-api/docs/models/gemini", + ), + LiveModelFamily( + provider="gemini", + family="gemini_3_1_class", + env_var="LIVE_LLM_GEMINI_31_MODELS", + supports_thinking=True, + supports_structured_output=False, + supports_caching=False, + supports_tool_replay=True, + docs_url="https://ai.google.dev/gemini-api/docs/models/gemini", + ), +) + + +def _parse_env_models(value: str | None) -> tuple[str, ...]: + if value is None: + return () + models = [model.strip() for model in value.split(",")] + return tuple(model for model in models if model) + + +def iter_live_model_specs() -> tuple[LiveModelSpec, ...]: + specs: list[LiveModelSpec] = [] + for family in MODEL_FAMILIES: + configured_models = _parse_env_models(os.getenv(family.env_var)) + models = configured_models or family.default_models + for model in models: + specs.append( + LiveModelSpec( + provider=family.provider, + family=family.family, + model=model, + env_var=family.env_var, + supports_thinking=family.supports_thinking, + supports_structured_output=family.supports_structured_output, + supports_caching=family.supports_caching, + supports_reasoning=family.supports_reasoning, + supports_tool_replay=family.supports_tool_replay, + docs_url=family.docs_url, + ) + ) + return tuple(specs) + + +def get_live_model_specs( + *, + provider: ProviderName | None = None, + feature: FeatureName | None = None, +) -> tuple[LiveModelSpec, ...]: + specs = iter_live_model_specs() + filtered: list[LiveModelSpec] = [] + + for spec in specs: + if provider is not None and spec.provider != provider: + continue + if feature == "thinking" and not spec.supports_thinking: + continue + if feature == "structured_output" and not spec.supports_structured_output: + continue + if feature == "caching" and not spec.supports_caching: + continue + if feature == "reasoning" and not spec.supports_reasoning: + continue + filtered.append(spec) + + return tuple(filtered) + + +def selected_model_summary_lines() -> list[str]: + lines: list[str] = [] + for family in MODEL_FAMILIES: + configured_models = _parse_env_models(os.getenv(family.env_var)) + models = configured_models or family.default_models + joined_models = ", ".join(models) if models else "(none configured)" + lines.append( + f"{family.env_var} [{family.provider}/{family.family}]: {joined_models}" + ) + return lines diff --git a/tests/live_llm/test_live_anthropic.py b/tests/live_llm/test_live_anthropic.py new file mode 100644 index 00000000..e10d2b10 --- /dev/null +++ b/tests/live_llm/test_live_anthropic.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import pytest + +from src.llm.backend import CompletionResult +from src.llm.history_adapters import AnthropicHistoryAdapter +from src.llm.request_builder import execute_completion + +from .conftest import ( + StructuredLiveResponse, + execute_local_tool, + favorite_prime_tools, + make_backend, + make_large_system_prompt, + require_provider_key, + wrap_async_method, +) +from .model_matrix import LiveModelSpec, get_live_model_specs + +pytestmark = [pytest.mark.live_llm, pytest.mark.requires_anthropic] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model_spec", + get_live_model_specs(provider="anthropic"), + ids=lambda spec: spec.id, +) +async def test_live_anthropic_structured_output_and_prefix_caching( + model_spec: LiveModelSpec, + monkeypatch: pytest.MonkeyPatch, +) -> None: + require_provider_key(model_spec) + backend, config = make_backend(model_spec) + create_calls = wrap_async_method(monkeypatch, backend._client.messages, "create") + + messages = [ + { + "role": "system", + "content": make_large_system_prompt(label=f"anthropic-{model_spec.family}"), + }, + { + "role": "user", + "content": ( + "Return valid JSON with provider='anthropic', " + f"family='{model_spec.family}', and answer='cache-ok'." + ), + }, + ] + + results: list[CompletionResult] = [] + for _ in range(3): + results.append( + await execute_completion( + backend, + config, + messages=messages, + max_tokens=256, + response_format=StructuredLiveResponse, + ) + ) + if len(results) >= 2 and results[-1].cache_read_input_tokens > 0: + break + + first = results[0] + later_results = results[1:] + + assert isinstance(first.content, StructuredLiveResponse) + assert first.content.provider == "anthropic" + assert first.content.family == model_spec.family + assert later_results, "Anthropic caching validation requires at least two calls" + for result in later_results: + assert isinstance(result.content, StructuredLiveResponse) + assert any( + result.cache_read_input_tokens > 0 for result in later_results + ), "Anthropic prompt caching did not report a cache hit after repeated identical requests" + + assert len(create_calls) == len(results) + for call in create_calls: + assert call["kwargs"]["system"][0]["cache_control"] == {"type": "ephemeral"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model_spec", + get_live_model_specs(provider="anthropic"), + ids=lambda spec: spec.id, +) +async def test_live_anthropic_thinking_and_tool_replay( + model_spec: LiveModelSpec, + monkeypatch: pytest.MonkeyPatch, +) -> None: + require_provider_key(model_spec) + backend, config = make_backend(model_spec, thinking_budget_tokens=1024) + create_calls = wrap_async_method(monkeypatch, backend._client.messages, "create") + tools = favorite_prime_tools() + adapter = AnthropicHistoryAdapter() + + initial_messages = [ + { + "role": "user", + "content": ( + "Before answering, call the get_favorite_prime tool exactly once. " + "After you receive the tool result, answer in one sentence that includes " + "the number and the word 'prime'." + ), + } + ] + + first = await execute_completion( + backend, + config, + messages=initial_messages, + max_tokens=2048, + tools=tools, + ) + + assert create_calls[0]["kwargs"]["thinking"] == { + "type": "enabled", + "budget_tokens": 1024, + } + assert first.tool_calls, "Anthropic should issue a tool call in the first turn" + assert first.thinking_blocks, "Anthropic thinking blocks should be preserved" + + tool_call = first.tool_calls[0] + tool_result = execute_local_tool(tool_call.name, tool_call.input) + replay_messages = initial_messages + [ + adapter.format_assistant_tool_message(first), + *adapter.format_tool_results( + [ + { + "tool_id": tool_call.id, + "tool_name": tool_call.name, + "result": tool_result, + } + ] + ), + ] + + second = await execute_completion( + backend, + config, + messages=replay_messages, + max_tokens=2048, + tools=tools, + ) + + assert create_calls[1]["kwargs"]["thinking"] == { + "type": "enabled", + "budget_tokens": 1024, + } + assert isinstance(second.content, str) + assert "13" in second.content + assert "prime" in second.content.lower() diff --git a/tests/live_llm/test_live_gemini.py b/tests/live_llm/test_live_gemini.py new file mode 100644 index 00000000..022b6cf0 --- /dev/null +++ b/tests/live_llm/test_live_gemini.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import pytest + +from src.llm.caching import PromptCachePolicy +from src.llm.history_adapters import GeminiHistoryAdapter +from src.llm.request_builder import execute_completion + +from .conftest import ( + StructuredLiveResponse, + execute_local_tool, + favorite_prime_tools, + make_backend, + make_large_system_prompt, + require_provider_key, + wrap_async_method, +) +from .model_matrix import LiveModelSpec, get_live_model_specs + +pytestmark = [pytest.mark.live_llm, pytest.mark.requires_gemini] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model_spec", + get_live_model_specs(provider="gemini", feature="structured_output"), + ids=lambda spec: spec.id, +) +async def test_live_gemini_structured_output_and_explicit_cache_reuse( + model_spec: LiveModelSpec, + monkeypatch: pytest.MonkeyPatch, +) -> None: + require_provider_key(model_spec) + backend, config = make_backend(model_spec, temperature=0) + cache_create_calls = wrap_async_method( + monkeypatch, + backend._client.aio.caches, + "create", + ) + generate_calls = wrap_async_method( + monkeypatch, + backend._client.aio.models, + "generate_content", + ) + cache_policy = PromptCachePolicy(mode="gemini_cached_content", ttl_seconds=300) + + messages = [ + { + "role": "system", + "content": make_large_system_prompt(label=f"gemini-{model_spec.family}"), + }, + { + "role": "user", + "content": ( + "Return valid JSON with provider='gemini', " + f"family='{model_spec.family}', and answer='cache-ok'. " + "Return JSON only, with no prose or markdown." + ), + }, + ] + + first = await execute_completion( + backend, + config, + messages=messages, + max_tokens=512, + response_format=StructuredLiveResponse, + cache_policy=cache_policy, + ) + second = await execute_completion( + backend, + config, + messages=messages, + max_tokens=512, + response_format=StructuredLiveResponse, + cache_policy=cache_policy, + ) + + assert isinstance(first.content, StructuredLiveResponse) + assert first.content.provider == "gemini" + assert first.content.family == model_spec.family + assert isinstance(second.content, StructuredLiveResponse) + + assert len(cache_create_calls) == 1 + assert len(generate_calls) == 2 + first_cached_content = generate_calls[0]["kwargs"]["config"]["cached_content"] + second_cached_content = generate_calls[1]["kwargs"]["config"]["cached_content"] + assert first_cached_content == second_cached_content + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model_spec", + get_live_model_specs(provider="gemini", feature="thinking"), + ids=lambda spec: spec.id, +) +async def test_live_gemini_thinking_and_tool_replay( + model_spec: LiveModelSpec, + monkeypatch: pytest.MonkeyPatch, +) -> None: + require_provider_key(model_spec) + backend, config = make_backend( + model_spec, + thinking_budget_tokens=512, + temperature=0, + ) + generate_calls = wrap_async_method( + monkeypatch, + backend._client.aio.models, + "generate_content", + ) + tools = favorite_prime_tools() + adapter = GeminiHistoryAdapter() + + initial_messages = [ + { + "role": "user", + "content": ( + "Before answering, call the get_favorite_prime tool exactly once. " + "Do not answer with plain text on this turn. " + "After the tool result arrives, answer with the exact text " + "'13 is prime.'" + ), + } + ] + + first = await execute_completion( + backend, + config, + messages=initial_messages, + max_tokens=512, + tools=tools, + tool_choice="required", + ) + + assert generate_calls[0]["kwargs"]["config"]["thinking_config"] == { + "thinking_budget": 512, + } + assert first.tool_calls, "Gemini should issue a tool call in the first turn" + assert any( + tool_call.thought_signature for tool_call in first.tool_calls + ), "Gemini tool replay should preserve thought signatures" + + tool_call = first.tool_calls[0] + tool_result = execute_local_tool(tool_call.name, tool_call.input) + replay_messages = initial_messages + [ + adapter.format_assistant_tool_message(first), + *adapter.format_tool_results( + [ + { + "tool_id": tool_call.id, + "tool_name": tool_call.name, + "result": tool_result, + } + ] + ), + ] + + second = await execute_completion( + backend, + config, + messages=replay_messages, + max_tokens=512, + tools=tools, + tool_choice="none", + ) + + assert generate_calls[1]["kwargs"]["config"]["thinking_config"] == { + "thinking_budget": 512, + } + assert isinstance(second.content, str) + assert "13" in second.content + assert "prime" in second.content.lower() diff --git a/tests/live_llm/test_live_openai.py b/tests/live_llm/test_live_openai.py new file mode 100644 index 00000000..60d89161 --- /dev/null +++ b/tests/live_llm/test_live_openai.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import pytest + +from src.llm.request_builder import execute_completion + +from .conftest import ( + StructuredLiveResponse, + make_backend, + make_large_system_prompt, + require_provider_key, + wrap_async_method, +) +from .model_matrix import LiveModelSpec, get_live_model_specs + +pytestmark = [pytest.mark.live_llm, pytest.mark.requires_openai] + +_GPT4_SPECS = tuple( + spec + for spec in get_live_model_specs(provider="openai") + if spec.family == "gpt_4_class" +) +_GPT5_SPECS = tuple( + spec + for spec in get_live_model_specs(provider="openai") + if spec.family == "gpt_5_class" +) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_spec", _GPT4_SPECS, ids=lambda spec: spec.id) +async def test_live_openai_gpt4_structured_output_and_prefix_caching( + model_spec: LiveModelSpec, + monkeypatch: pytest.MonkeyPatch, +) -> None: + require_provider_key(model_spec) + backend, config = make_backend(model_spec) + parse_calls = wrap_async_method( + monkeypatch, + backend._client.chat.completions, + "parse", + ) + + messages = [ + { + "role": "system", + "content": make_large_system_prompt(label=f"openai-{model_spec.family}"), + }, + { + "role": "user", + "content": ( + "Return valid JSON with provider='openai', " + f"family='{model_spec.family}', and answer='cache-ok'." + ), + }, + ] + + first = await execute_completion( + backend, + config, + messages=messages, + max_tokens=256, + response_format=StructuredLiveResponse, + ) + second = await execute_completion( + backend, + config, + messages=messages, + max_tokens=256, + response_format=StructuredLiveResponse, + ) + + assert isinstance(first.content, StructuredLiveResponse) + assert first.content.provider == "openai" + assert first.content.family == model_spec.family + assert isinstance(second.content, StructuredLiveResponse) + assert second.cache_read_input_tokens > 0 + + assert parse_calls[0]["kwargs"]["response_format"] is StructuredLiveResponse + assert "max_tokens" in parse_calls[0]["kwargs"] + assert "max_completion_tokens" not in parse_calls[0]["kwargs"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_spec", _GPT5_SPECS, ids=lambda spec: spec.id) +async def test_live_openai_gpt5_reasoning_structured_output_and_prefix_caching( + model_spec: LiveModelSpec, + monkeypatch: pytest.MonkeyPatch, +) -> None: + require_provider_key(model_spec) + backend, config = make_backend(model_spec, reasoning_effort="minimal") + parse_calls = wrap_async_method( + monkeypatch, + backend._client.chat.completions, + "parse", + ) + + messages = [ + { + "role": "system", + "content": make_large_system_prompt(label=f"openai-{model_spec.family}"), + }, + { + "role": "user", + "content": ( + "Return valid JSON with provider='openai', " + f"family='{model_spec.family}', and answer='reasoning-ok'." + ), + }, + ] + + first = await execute_completion( + backend, + config, + messages=messages, + max_tokens=1024, + response_format=StructuredLiveResponse, + ) + second = await execute_completion( + backend, + config, + messages=messages, + max_tokens=1024, + response_format=StructuredLiveResponse, + ) + + assert isinstance(first.content, StructuredLiveResponse) + assert first.content.provider == "openai" + assert first.content.family == model_spec.family + assert isinstance(second.content, StructuredLiveResponse) + assert second.cache_read_input_tokens > 0 + + assert parse_calls[0]["kwargs"]["response_format"] is StructuredLiveResponse + assert parse_calls[0]["kwargs"]["reasoning_effort"] == "minimal" + assert "max_completion_tokens" in parse_calls[0]["kwargs"] + assert "max_tokens" not in parse_calls[0]["kwargs"] diff --git a/tests/llm/conftest.py b/tests/llm/conftest.py new file mode 100644 index 00000000..fccccf2d --- /dev/null +++ b/tests/llm/conftest.py @@ -0,0 +1,30 @@ +from collections.abc import AsyncIterator, Iterator +from typing import Any + +import pytest + +from src.llm.backend import CompletionResult, ProviderBackend, StreamChunk + + +class FakeBackend(ProviderBackend): + """Simple backend for request-builder and orchestration tests.""" + + def __init__(self, responses: list[CompletionResult] | None = None) -> None: + self.calls: list[dict[str, Any]] = [] + self._responses: Iterator[CompletionResult] = iter( + responses or [CompletionResult(content="ok")] + ) + + async def complete(self, **kwargs: Any) -> CompletionResult: + self.calls.append(kwargs) + return next(self._responses) + + async def stream(self, **kwargs: Any) -> AsyncIterator[StreamChunk]: + self.calls.append(kwargs) + result = next(self._responses) + yield StreamChunk(content=result.content, is_done=True) + + +@pytest.fixture +def fake_backend() -> FakeBackend: + return FakeBackend() diff --git a/tests/llm/test_agent_tool_schemas.py b/tests/llm/test_agent_tool_schemas.py new file mode 100644 index 00000000..12c12c11 --- /dev/null +++ b/tests/llm/test_agent_tool_schemas.py @@ -0,0 +1,72 @@ +from typing import Any, cast + +from src.utils.agent_tools import ( + DEDUCTION_SPECIALIST_TOOLS, + INDUCTION_SPECIALIST_TOOLS, + TOOLS, +) + + +def _observation_items_schema(tool_key: str) -> dict[str, Any]: + return cast( + dict[str, Any], + TOOLS[tool_key]["input_schema"]["properties"]["observations"]["items"], + ) + + +def test_generic_create_observations_schema_has_level_specific_requirements() -> None: + items = _observation_items_schema("create_observations") + + assert items["additionalProperties"] is False + + level_requirements = { + condition["if"]["properties"]["level"]["const"]: condition["then"]["required"] + for condition in cast(list[dict[str, Any]], items["allOf"]) + } + + assert level_requirements["deductive"] == ["source_ids", "premises"] + assert level_requirements["inductive"] == [ + "source_ids", + "sources", + "pattern_type", + "confidence", + ] + assert level_requirements["contradiction"] == ["source_ids", "sources"] + + +def test_deductive_specialist_tool_requires_evidence_fields() -> None: + items = _observation_items_schema("create_observations_deductive") + + assert TOOLS["create_observations_deductive"]["name"] == ( + "create_observations_deductive" + ) + assert items["required"] == ["content", "source_ids", "premises"] + assert items["properties"]["source_ids"]["minItems"] == 1 + assert items["properties"]["premises"]["minItems"] == 1 + + +def test_inductive_specialist_tool_requires_pattern_fields() -> None: + items = _observation_items_schema("create_observations_inductive") + + assert TOOLS["create_observations_inductive"]["name"] == ( + "create_observations_inductive" + ) + assert items["required"] == [ + "content", + "source_ids", + "sources", + "pattern_type", + "confidence", + ] + assert items["properties"]["source_ids"]["minItems"] == 2 + assert items["properties"]["sources"]["minItems"] == 2 + + +def test_dreamer_specialists_use_level_specific_creation_tools() -> None: + deduction_tool_names = {tool["name"] for tool in DEDUCTION_SPECIALIST_TOOLS} + induction_tool_names = {tool["name"] for tool in INDUCTION_SPECIALIST_TOOLS} + + assert "create_observations_deductive" in deduction_tool_names + assert "create_observations_inductive" in induction_tool_names + assert "create_observations" not in deduction_tool_names + assert "create_observations" not in induction_tool_names diff --git a/tests/llm/test_backends/test_anthropic.py b/tests/llm/test_backends/test_anthropic.py new file mode 100644 index 00000000..c8f2bbdf --- /dev/null +++ b/tests/llm/test_backends/test_anthropic.py @@ -0,0 +1,135 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from anthropic.types import TextBlock, ThinkingBlock, ToolUseBlock +from pydantic import BaseModel + +from src.llm.backends.anthropic import AnthropicBackend + + +@pytest.mark.asyncio +async def test_anthropic_backend_extracts_text_thinking_and_tool_calls() -> None: + client = Mock() + client.messages.create = AsyncMock( + return_value=SimpleNamespace( + content=[ + ThinkingBlock( + type="thinking", + thinking="internal reasoning", + signature="sig_123", + ), + TextBlock(type="text", text="Hello from Anthropic"), + ToolUseBlock( + type="tool_use", + id="tool_1", + name="search", + input={"query": "honcho"}, + ), + ], + usage=SimpleNamespace( + input_tokens=10, + output_tokens=5, + cache_creation_input_tokens=3, + cache_read_input_tokens=2, + ), + stop_reason="tool_use", + ) + ) + + backend = AnthropicBackend(client) + result = await backend.complete( + model="claude-haiku-4-5", + messages=[ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "Hello"}, + ], + max_tokens=100, + tools=[ + { + "name": "search", + "description": "Search for information", + "input_schema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + }, + } + ], + thinking_budget_tokens=2048, + tool_choice="required", + ) + + assert result.content == "Hello from Anthropic" + assert result.thinking_content == "internal reasoning" + assert result.thinking_blocks == [ + { + "type": "thinking", + "thinking": "internal reasoning", + "signature": "sig_123", + } + ] + assert result.tool_calls[0].name == "search" + assert result.input_tokens == 15 + assert result.output_tokens == 5 + assert result.finish_reason == "tool_use" + + await_args = client.messages.create.await_args + if await_args is None: + raise AssertionError("Expected Anthropic client call") + call = await_args.kwargs + assert call["model"] == "claude-haiku-4-5" + assert call["system"][0]["text"] == "System prompt" + assert call["thinking"] == {"type": "enabled", "budget_tokens": 2048} + assert call["tool_choice"] == {"type": "any"} + + +class StructuredResponse(BaseModel): + answer: str + + +@pytest.mark.asyncio +async def test_anthropic_backend_skips_assistant_prefill_for_claude_4_models() -> None: + client = Mock() + client.messages.create = AsyncMock( + return_value=SimpleNamespace( + content=[TextBlock(type="text", text='{"answer":"ok"}')], + usage=SimpleNamespace( + input_tokens=10, + output_tokens=5, + cache_creation_input_tokens=0, + cache_read_input_tokens=0, + ), + stop_reason="end_turn", + ) + ) + + backend = AnthropicBackend(client) + result = await backend.complete( + model="claude-sonnet-4-5", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=StructuredResponse, + ) + + assert isinstance(result.content, StructuredResponse) + assert result.content.answer == "ok" + await_args = client.messages.create.await_args + if await_args is None: + raise AssertionError("Expected Anthropic client call") + call = await_args.kwargs + assert len(call["messages"]) == 1 + assert call["messages"][0]["role"] == "user" + assert call["messages"][0]["content"].startswith("Hello\n\nRespond with valid JSON") + + +@pytest.mark.asyncio +async def test_anthropic_backend_rejects_thinking_effort() -> None: + backend = AnthropicBackend(Mock()) + + with pytest.raises(ValueError, match="does not support thinking_effort"): + await backend.complete( + model="claude-haiku-4-5", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + thinking_effort="high", + ) diff --git a/tests/llm/test_backends/test_gemini.py b/tests/llm/test_backends/test_gemini.py new file mode 100644 index 00000000..b327c8e4 --- /dev/null +++ b/tests/llm/test_backends/test_gemini.py @@ -0,0 +1,391 @@ +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from pydantic import BaseModel + +from src.exceptions import LLMError, ValidationException +from src.llm.backends.gemini import GeminiBackend +from src.llm.caching import PromptCachePolicy, gemini_cache_store + + +@pytest.mark.asyncio +async def test_gemini_backend_preserves_thought_signature() -> None: + client = Mock() + client.aio.models.generate_content = AsyncMock( + return_value=SimpleNamespace( + candidates=[ + SimpleNamespace( + finish_reason=SimpleNamespace(name="STOP"), + content=SimpleNamespace( + parts=[ + SimpleNamespace(text="Hello from Gemini"), + SimpleNamespace( + function_call=SimpleNamespace( + name="search", + args={"query": "honcho"}, + ), + thought_signature="sig_gemini", + ), + ] + ), + ) + ], + usage_metadata=SimpleNamespace( + prompt_token_count=12, + candidates_token_count=6, + ), + parsed=None, + ) + ) + + backend = GeminiBackend(client) + result = await backend.complete( + model="gemini-2.5-flash", + messages=[ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "Hello"}, + ], + max_tokens=100, + thinking_budget_tokens=256, + ) + + assert result.content == "Hello from Gemini" + assert result.tool_calls[0].name == "search" + assert result.tool_calls[0].thought_signature == "sig_gemini" + + await_args = client.aio.models.generate_content.await_args + if await_args is None: + raise AssertionError("Expected Gemini generate_content call") + call = await_args.kwargs + assert call["model"] == "gemini-2.5-flash" + assert call["config"]["system_instruction"] == "System prompt" + assert call["config"]["thinking_config"] == {"thinking_budget": 256} + + +@pytest.mark.asyncio +async def test_gemini_backend_maps_thinking_effort_to_thinking_level() -> None: + client = Mock() + client.aio.models.generate_content = AsyncMock( + return_value=SimpleNamespace( + candidates=[ + SimpleNamespace( + finish_reason=SimpleNamespace(name="STOP"), + content=SimpleNamespace(parts=[SimpleNamespace(text="ok")]), + ) + ], + usage_metadata=SimpleNamespace( + prompt_token_count=12, + candidates_token_count=6, + ), + parsed=None, + ) + ) + + backend = GeminiBackend(client) + await backend.complete( + model="gemini-3-pro-preview", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + thinking_effort="low", + ) + + await_args = client.aio.models.generate_content.await_args + if await_args is None: + raise AssertionError("Expected Gemini generate_content call") + call = await_args.kwargs + assert call["config"]["thinking_config"] == {"thinking_level": "low"} + + +@pytest.mark.asyncio +async def test_gemini_backend_rejects_budget_and_effort_together() -> None: + backend = GeminiBackend(Mock()) + + with pytest.raises( + ValidationException, + match="does not support sending both thinking_budget_tokens and thinking_effort", + ): + await backend.complete( + model="gemini-3-pro-preview", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + thinking_budget_tokens=256, + thinking_effort="low", + ) + + +@pytest.mark.asyncio +async def test_gemini_backend_raises_on_blocked_response() -> None: + client = Mock() + client.aio.models.generate_content = AsyncMock( + return_value=SimpleNamespace( + candidates=[ + SimpleNamespace( + finish_reason=SimpleNamespace(name="SAFETY"), + content=SimpleNamespace(parts=[]), + ) + ], + usage_metadata=SimpleNamespace( + prompt_token_count=12, + candidates_token_count=0, + ), + parsed=None, + ) + ) + + backend = GeminiBackend(client) + + with pytest.raises(LLMError, match="Gemini response blocked"): + await backend.complete( + model="gemini-2.5-flash", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + ) + + +class StructuredResponse(BaseModel): + answer: str + + +@pytest.mark.asyncio +async def test_gemini_backend_validates_dict_parsed_payload() -> None: + client = Mock() + client.aio.models.generate_content = AsyncMock( + return_value=SimpleNamespace( + candidates=[ + SimpleNamespace( + finish_reason=SimpleNamespace(name="STOP"), + content=SimpleNamespace(parts=[]), + ) + ], + usage_metadata=SimpleNamespace( + prompt_token_count=12, + candidates_token_count=6, + ), + parsed={"answer": "ok"}, + text=None, + function_calls=None, + ) + ) + + backend = GeminiBackend(client) + result = await backend.complete( + model="gemini-2.5-flash", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=StructuredResponse, + ) + + assert isinstance(result.content, StructuredResponse) + assert result.content.answer == "ok" + + +@pytest.mark.asyncio +async def test_gemini_backend_falls_back_to_response_text_and_function_calls() -> None: + client = Mock() + client.aio.models.generate_content = AsyncMock( + return_value=SimpleNamespace( + candidates=[ + SimpleNamespace( + finish_reason=SimpleNamespace(name="STOP"), + content=SimpleNamespace(parts=None), + ) + ], + usage_metadata=SimpleNamespace( + prompt_token_count=12, + candidates_token_count=6, + ), + parsed=None, + text="13 is prime.", + function_calls=[ + SimpleNamespace(name="get_favorite_prime", args={"topic": "test"}) + ], + ) + ) + + backend = GeminiBackend(client) + result = await backend.complete( + model="gemini-2.5-flash", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + ) + + assert result.content == "13 is prime." + assert result.tool_calls[0].name == "get_favorite_prime" + + +@pytest.mark.asyncio +async def test_gemini_backend_ignores_mock_text_and_function_call_placeholders() -> ( + None +): + client = Mock() + client.aio.models.generate_content = AsyncMock( + return_value=Mock( + candidates=[ + Mock( + finish_reason=SimpleNamespace(name="STOP"), + content=None, + ) + ], + usage_metadata=SimpleNamespace( + prompt_token_count=12, + candidates_token_count=0, + ), + parsed=None, + ) + ) + + backend = GeminiBackend(client) + result = await backend.complete( + model="gemini-2.5-flash", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + ) + + assert result.content == "" + assert result.tool_calls == [] + + +@pytest.mark.asyncio +async def test_gemini_backend_strips_system_and_tools_when_using_cached_content() -> ( + None +): + gemini_cache_store._handles.clear() # pyright: ignore[reportPrivateUsage] + client = Mock() + client.aio.caches.create = AsyncMock( + return_value=SimpleNamespace( + name="cachedContents/abc123", + expire_time=datetime.now(timezone.utc) + timedelta(minutes=5), + ) + ) + client.aio.models.generate_content = AsyncMock( + return_value=SimpleNamespace( + candidates=[ + SimpleNamespace( + finish_reason=SimpleNamespace(name="STOP"), + content=SimpleNamespace( + parts=[SimpleNamespace(text="cached result")] + ), + ) + ], + usage_metadata=SimpleNamespace( + prompt_token_count=12, + candidates_token_count=6, + ), + parsed=None, + ) + ) + + backend = GeminiBackend(client) + result = await backend.complete( + model="gemini-2.5-flash", + messages=[ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "Hello"}, + ], + max_tokens=100, + tools=[ + { + "name": "search", + "description": "Search for information", + "input_schema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + }, + } + ], + tool_choice="required", + extra_params={ + "cache_policy": PromptCachePolicy( + mode="gemini_cached_content", + ttl_seconds=300, + ) + }, + ) + + assert result.content == "cached result" + await_args = client.aio.models.generate_content.await_args + if await_args is None: + raise AssertionError("Expected Gemini generate_content call") + call = await_args.kwargs + assert call["config"]["cached_content"] == "cachedContents/abc123" + assert "system_instruction" not in call["config"] + assert "tools" not in call["config"] + assert "tool_config" not in call["config"] + + +def test_gemini_sanitize_schema_strips_unsupported_keywords() -> None: + """Gemini's function-declarations validator rejects JSON-Schema keywords + outside its narrow allowlist (additionalProperties, allOf, if/then, $ref, + anyOf, oneOf, patternProperties, ...). _sanitize_schema must strip them + recursively so tool schemas authored for OpenAI/Anthropic don't 400 here. + """ + raw = { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": {"type": "string"}, + "level": {"type": "string", "enum": ["a", "b"]}, + }, + "required": ["content"], + "additionalProperties": False, + "allOf": [ + { + "if": {"properties": {"level": {"const": "a"}}}, + "then": {"required": ["aux"]}, + } + ], + }, + }, + }, + "required": ["items"], + "$defs": {"Foo": {"type": "string"}}, + } + cleaned = GeminiBackend._sanitize_schema(raw) # pyright: ignore[reportPrivateUsage] + + # Top-level + assert "additionalProperties" not in cleaned + assert "$defs" not in cleaned + assert cleaned["type"] == "object" + assert cleaned["required"] == ["items"] + + # Nested under items + item_schema = cleaned["properties"]["items"]["items"] + assert "additionalProperties" not in item_schema + assert "allOf" not in item_schema + assert item_schema["properties"]["level"]["enum"] == ["a", "b"] + + +def test_gemini_convert_tools_sanitizes_parameters_schema() -> None: + """End-to-end: feeding a Pydantic/OpenAI-style schema through _convert_tools + must produce a Gemini-safe function_declarations payload.""" + tools = [ + { + "name": "create_observations", + "description": "Create observations.", + "input_schema": { + "type": "object", + "properties": { + "observations": { + "type": "array", + "items": { + "type": "object", + "properties": {"content": {"type": "string"}}, + "additionalProperties": False, + }, + } + }, + "required": ["observations"], + "additionalProperties": False, + }, + } + ] + converted = GeminiBackend._convert_tools(tools) # pyright: ignore[reportPrivateUsage] + params = converted[0]["function_declarations"][0]["parameters"] + assert "additionalProperties" not in params + assert "additionalProperties" not in params["properties"]["observations"]["items"] diff --git a/tests/llm/test_backends/test_openai.py b/tests/llm/test_backends/test_openai.py new file mode 100644 index 00000000..81838202 --- /dev/null +++ b/tests/llm/test_backends/test_openai.py @@ -0,0 +1,276 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from src.exceptions import ValidationException +from src.llm.backends.openai import OpenAIBackend + + +@pytest.mark.asyncio +async def test_openai_backend_uses_gpt5_params_and_extracts_reasoning() -> None: + client = Mock() + client.chat.completions.create = AsyncMock( + return_value=SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason="stop", + message=SimpleNamespace( + content="Hello from GPT-5", + tool_calls=[], + reasoning_details=[ + SimpleNamespace( + content="reasoning summary", + model_dump=lambda: { + "type": "reasoning", + "content": "reasoning summary", + }, + ) + ], + ), + ) + ], + usage=SimpleNamespace( + prompt_tokens=10, + completion_tokens=5, + prompt_tokens_details=SimpleNamespace(cached_tokens=4), + ), + ) + ) + + backend = OpenAIBackend(client) + result = await backend.complete( + model="gpt-5-mini", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + thinking_effort="high", + ) + + assert result.content == "Hello from GPT-5" + assert result.thinking_content == "reasoning summary" + assert result.reasoning_details == [ + {"type": "reasoning", "content": "reasoning summary"} + ] + assert result.cache_read_input_tokens == 4 + + await_args = client.chat.completions.create.await_args + if await_args is None: + raise AssertionError("Expected OpenAI create call") + call = await_args.kwargs + assert call["model"] == "gpt-5-mini" + assert call["max_completion_tokens"] == 100 + assert call["reasoning_effort"] == "high" + assert "max_tokens" not in call + + +@pytest.mark.asyncio +async def test_openai_backend_passes_thinking_effort_through_for_non_gpt5_models() -> ( + None +): + client = Mock() + client.chat.completions.create = AsyncMock( + return_value=SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason="stop", + message=SimpleNamespace( + content="Hello from GPT-4.1", + tool_calls=[], + reasoning_details=[], + ), + ) + ], + usage=SimpleNamespace( + prompt_tokens=10, + completion_tokens=5, + prompt_tokens_details=None, + ), + ) + ) + + backend = OpenAIBackend(client) + await backend.complete( + model="gpt-4.1", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + thinking_effort="low", + ) + + await_args = client.chat.completions.create.await_args + if await_args is None: + raise AssertionError("Expected OpenAI create call") + call = await_args.kwargs + assert call["model"] == "gpt-4.1" + assert call["max_tokens"] == 100 + assert call["reasoning_effort"] == "low" + + +@pytest.mark.asyncio +async def test_openai_backend_does_not_treat_proxy_models_with_gpt5_substring_as_gpt5() -> ( + None +): + """Regression: proxy/deployment names containing 'gpt-5' must use `max_tokens`. + + Flexible OpenAI-compatible configuration means operators commonly route through + proxies/Azure deployments with IDs like `azure-gpt-5-deployment` or + `my-gpt-5-proxy`. A naive substring check would incorrectly send + `max_completion_tokens` (a GPT-5-only parameter) to those endpoints. + """ + client = Mock() + client.chat.completions.create = AsyncMock( + return_value=SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason="stop", + message=SimpleNamespace( + content="ok", + tool_calls=[], + reasoning_details=[], + ), + ) + ], + usage=SimpleNamespace( + prompt_tokens=10, + completion_tokens=5, + prompt_tokens_details=None, + ), + ) + ) + + backend = OpenAIBackend(client) + await backend.complete( + model="my-gpt-5-proxy", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + ) + + await_args = client.chat.completions.create.await_args + if await_args is None: + raise AssertionError("Expected OpenAI create call") + call = await_args.kwargs + assert call["max_tokens"] == 100 + assert "max_completion_tokens" not in call + + +@pytest.mark.asyncio +async def test_openai_backend_rejects_thinking_budget_tokens() -> None: + backend = OpenAIBackend(Mock()) + + with pytest.raises( + ValidationException, match="does not support thinking_budget_tokens" + ): + await backend.complete( + model="gpt-5-mini", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + thinking_budget_tokens=256, + ) + + +@pytest.mark.asyncio +async def test_openai_backend_converts_anthropic_style_tools() -> None: + client = Mock() + client.chat.completions.create = AsyncMock( + return_value=SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason="stop", + message=SimpleNamespace( + content="Used tools", + tool_calls=[], + reasoning_details=[], + ), + ) + ], + usage=SimpleNamespace( + prompt_tokens=10, + completion_tokens=5, + prompt_tokens_details=None, + ), + ) + ) + + backend = OpenAIBackend(client) + await backend.complete( + model="gpt-4.1", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + tools=[ + { + "name": "get_weather", + "description": "Lookup weather", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + } + ], + tool_choice="required", + ) + + await_args = client.chat.completions.create.await_args + if await_args is None: + raise AssertionError("Expected OpenAI create call") + call = await_args.kwargs + assert call["tools"] == [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Lookup weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, + } + ] + assert call["tool_choice"] == "required" + + +@pytest.mark.parametrize( + "model", + [ + "gpt-5", + "gpt-5-turbo", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.5-preview", + "o1", + "o1-mini", + "o3", + "o3-mini", + "o4-preview", + ], +) +def test_openai_reasoning_models_use_max_completion_tokens(model: str) -> None: + """Reasoning model families (gpt-5 incl. x.y versions, o1/o3/o4) must send + max_completion_tokens, not max_tokens — OpenAI rejects max_tokens for them + with 400 unsupported_parameter.""" + from src.llm.backends.openai import ( + _uses_max_completion_tokens, # pyright: ignore[reportPrivateUsage] + ) + + assert _uses_max_completion_tokens(model) is True + + +@pytest.mark.parametrize( + "model", + [ + "gpt-4.1", + "gpt-4o", + "gpt-4o-mini", + "gpt-3.5-turbo", + "some-proxy-model", + ], +) +def test_openai_classic_models_use_max_tokens(model: str) -> None: + """Non-reasoning OpenAI and OpenAI-compatible proxy models stay on + the classic max_tokens parameter.""" + from src.llm.backends.openai import ( + _uses_max_completion_tokens, # pyright: ignore[reportPrivateUsage] + ) + + assert _uses_max_completion_tokens(model) is False diff --git a/tests/llm/test_conversation.py b/tests/llm/test_conversation.py new file mode 100644 index 00000000..324803ce --- /dev/null +++ b/tests/llm/test_conversation.py @@ -0,0 +1,103 @@ +from typing import Any + +from src.llm.conversation import ( + _is_tool_result_message, # pyright: ignore[reportPrivateUsage] + _is_tool_use_message, # pyright: ignore[reportPrivateUsage] + truncate_messages_to_fit, +) + + +def test_truncate_messages_to_fit_keeps_last_unit_when_over_limit() -> None: + messages = [ + {"role": "user", "content": "x " * 2000}, + ] + + truncated = truncate_messages_to_fit(messages, max_tokens=1) + + assert truncated == messages + + +def test_truncate_messages_to_fit_preserves_tool_result_pair() -> None: + messages = [ + {"role": "user", "content": "old context " * 1000}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "lookup", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "result"}, + ] + + truncated = truncate_messages_to_fit(messages, max_tokens=5) + + assert truncated == messages[1:] + + +def test_is_tool_use_message_detects_gemini_function_call_in_parts() -> None: + msg: dict[str, Any] = { + "role": "model", + "parts": [ + {"function_call": {"name": "search", "args": {"q": "honcho"}}}, + ], + } + assert _is_tool_use_message(msg) is True + + +def test_is_tool_result_message_detects_gemini_function_response_in_parts() -> None: + msg: dict[str, Any] = { + "role": "user", + "parts": [ + {"function_response": {"name": "search", "response": {"result": "ok"}}}, + ], + } + assert _is_tool_result_message(msg) is True + + +def test_is_tool_use_message_detects_anthropic_tool_use_block() -> None: + msg: dict[str, Any] = { + "role": "assistant", + "content": [ + {"type": "text", "text": "calling lookup"}, + {"type": "tool_use", "id": "t_1", "name": "lookup", "input": {}}, + ], + } + assert _is_tool_use_message(msg) is True + + +def test_truncate_messages_to_fit_preserves_gemini_tool_pair() -> None: + """A Gemini-shaped function_call / function_response pair must stay + grouped when older units get dropped. Regression: before adding the + parts-based detection, neither message would be recognized as a tool + unit, and truncation could split or drop them individually.""" + messages: list[dict[str, Any]] = [ + {"role": "user", "parts": [{"text": "old context " * 1000}]}, + { + "role": "model", + "parts": [ + {"function_call": {"name": "lookup", "args": {}}}, + ], + }, + { + "role": "user", + "parts": [ + { + "function_response": { + "name": "lookup", + "response": {"result": "found"}, + } + } + ], + }, + ] + + truncated = truncate_messages_to_fit(messages, max_tokens=20) + + # The oldest (bulk-text) message should be dropped; the function_call + + # function_response pair stays intact together. + assert truncated == messages[1:] diff --git a/tests/llm/test_credentials.py b/tests/llm/test_credentials.py new file mode 100644 index 00000000..fae9a8cf --- /dev/null +++ b/tests/llm/test_credentials.py @@ -0,0 +1,50 @@ +import pytest + +from src.config import ModelConfig, settings +from src.llm.credentials import resolve_credentials + + +def test_transport_credentials_use_global_settings( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings.LLM, "ANTHROPIC_API_KEY", "anthropic-test-key") + + credentials = resolve_credentials( + ModelConfig(model="claude-haiku-4-5", transport="anthropic") + ) + + assert credentials == {"api_key": "anthropic-test-key", "api_base": None} + + +def test_openai_transport_credentials_use_per_model_config() -> None: + credentials = resolve_credentials( + ModelConfig( + model="my-local-model", + transport="openai", + api_key="local-key", + base_url="http://localhost:8000/v1", + ) + ) + + assert credentials == { + "api_key": "local-key", + "api_base": "http://localhost:8000/v1", + } + + +def test_openai_transport_credentials_fall_back_to_global_defaults( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings.LLM, "OPENAI_API_KEY", "openai-test-key") + + credentials = resolve_credentials( + ModelConfig( + model="my-local-model", + transport="openai", + ) + ) + + assert credentials == { + "api_key": "openai-test-key", + "api_base": None, + } diff --git a/tests/llm/test_embedding_client.py b/tests/llm/test_embedding_client.py new file mode 100644 index 00000000..14def176 --- /dev/null +++ b/tests/llm/test_embedding_client.py @@ -0,0 +1,139 @@ +from types import SimpleNamespace +from typing import Any + +import pytest + +from src.config import EmbeddingModelConfig +from src.embedding_client import _EmbeddingClient # pyright: ignore[reportPrivateUsage] + + +class FakeOpenAIEmbeddingsAPI: + def __init__(self, embedding: list[float]) -> None: + self.embedding: list[float] = embedding + self.calls: list[dict[str, Any]] = [] + + async def create(self, *, model: str, input: str | list[str]) -> SimpleNamespace: + self.calls.append({"model": model, "input": input}) + if isinstance(input, list): + data = [SimpleNamespace(embedding=self.embedding) for _ in input] + else: + data = [SimpleNamespace(embedding=self.embedding)] + return SimpleNamespace(data=data) + + +@pytest.mark.asyncio +async def test_openai_embedding_client_uses_configured_model_and_dimensions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 8) + + class FakeOpenAIClient: + def __init__(self, *, api_key: str | None, base_url: str | None) -> None: + self.api_key: str | None = api_key + self.base_url: str | None = base_url + self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings + + monkeypatch.setattr("src.embedding_client.AsyncOpenAI", FakeOpenAIClient) + + client = _EmbeddingClient( + EmbeddingModelConfig( + transport="openai", + model="text-embedding-3-small", + api_key="test-key", + base_url="http://localhost:8000/v1", + ), + vector_dimensions=8, + max_input_tokens=8192, + max_tokens_per_request=300_000, + ) + + embedding = await client.embed("hello world") + + assert embedding == [0.1] * 8 + assert fake_embeddings.calls == [ + {"model": "text-embedding-3-small", "input": ["hello world"]} + ] + + +@pytest.mark.asyncio +async def test_openai_embedding_client_rejects_dimension_mismatch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 7) + + class FakeOpenAIClient: + def __init__(self, *, api_key: str | None, base_url: str | None) -> None: + self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings + + monkeypatch.setattr("src.embedding_client.AsyncOpenAI", FakeOpenAIClient) + + client = _EmbeddingClient( + EmbeddingModelConfig( + transport="openai", + model="text-embedding-3-small", + api_key="test-key", + ), + vector_dimensions=8, + max_input_tokens=8192, + max_tokens_per_request=300_000, + ) + + with pytest.raises(ValueError, match="Embedding dimension mismatch"): + await client.embed("hello world") + + +@pytest.mark.asyncio +async def test_gemini_embedding_client_uses_output_dimensionality( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[dict[str, Any]] = [] + + class FakeGeminiModels: + async def embed_content( + self, + *, + model: str, + contents: str | list[str], + config: dict[str, Any], + ) -> SimpleNamespace: + calls.append( + { + "model": model, + "contents": contents, + "config": config, + } + ) + return SimpleNamespace( + embeddings=[SimpleNamespace(values=[0.2] * 12)], + ) + + class FakeGeminiClient: + def __init__(self, *, api_key: str | None, http_options: Any) -> None: + self.api_key: str | None = api_key + self.http_options: Any = http_options + self.aio: Any = SimpleNamespace(models=FakeGeminiModels()) + + monkeypatch.setattr("src.embedding_client.genai.Client", FakeGeminiClient) + + client = _EmbeddingClient( + EmbeddingModelConfig( + transport="gemini", + model="gemini-embedding-001", + api_key="gemini-key", + base_url="https://gemini-proxy.example/v1beta", + ), + vector_dimensions=12, + max_input_tokens=4096, + max_tokens_per_request=300_000, + ) + + embedding = await client.embed("hello world") + + assert embedding == [0.2] * 12 + assert calls == [ + { + "model": "gemini-embedding-001", + "contents": "hello world", + "config": {"output_dimensionality": 12}, + } + ] diff --git a/tests/llm/test_history_adapters.py b/tests/llm/test_history_adapters.py new file mode 100644 index 00000000..6881df6a --- /dev/null +++ b/tests/llm/test_history_adapters.py @@ -0,0 +1,67 @@ +from src.llm.backend import CompletionResult, ToolCallResult +from src.llm.history_adapters import ( + AnthropicHistoryAdapter, + GeminiHistoryAdapter, + OpenAIHistoryAdapter, +) + + +def test_anthropic_history_adapter_preserves_thinking_blocks() -> None: + adapter = AnthropicHistoryAdapter() + result = CompletionResult( + content="Done", + thinking_blocks=[ + { + "type": "thinking", + "thinking": "private reasoning", + "signature": "sig_123", + } + ], + tool_calls=[ + ToolCallResult(id="tool_1", name="search", input={"query": "honcho"}) + ], + ) + + message = adapter.format_assistant_tool_message(result) + + assert message["role"] == "assistant" + assert message["content"][0]["type"] == "thinking" + assert message["content"][1] == {"type": "text", "text": "Done"} + assert message["content"][2]["type"] == "tool_use" + + +def test_gemini_history_adapter_preserves_thought_signature() -> None: + adapter = GeminiHistoryAdapter() + result = CompletionResult( + content="Calling a tool", + tool_calls=[ + ToolCallResult( + id="tool_1", + name="search", + input={"query": "honcho"}, + thought_signature="sig_abc", + ) + ], + ) + + message = adapter.format_assistant_tool_message(result) + + assert message["role"] == "model" + assert message["parts"][1]["thought_signature"] == "sig_abc" + + +def test_openai_history_adapter_preserves_reasoning_details() -> None: + adapter = OpenAIHistoryAdapter() + result = CompletionResult( + content="Calling a tool", + reasoning_details=[{"type": "reasoning", "content": "step 1"}], + tool_calls=[ + ToolCallResult(id="tool_1", name="search", input={"query": "honcho"}) + ], + ) + + message = adapter.format_assistant_tool_message(result) + + assert message["role"] == "assistant" + assert message["reasoning_details"] == [{"type": "reasoning", "content": "step 1"}] + assert message["tool_calls"][0]["function"]["name"] == "search" diff --git a/tests/llm/test_model_config.py b/tests/llm/test_model_config.py new file mode 100644 index 00000000..20d4aa7c --- /dev/null +++ b/tests/llm/test_model_config.py @@ -0,0 +1,510 @@ +import os +import re +from pathlib import Path +from typing import Any, cast + +import pytest + +from src.config import ( + AppSettings, + ConfiguredEmbeddingModelSettings, + ConfiguredModelSettings, + DialecticLevelSettings, + DreamSettings, + EmbeddingSettings, + ModelConfig, + ModelOverrideSettings, + SummarySettings, + VectorStoreSettings, + load_toml_config, + resolve_embedding_model_config, + resolve_model_config, +) + + +def test_fallback_config_is_independent() -> None: + """Fallback config has its own transport and reasoning params.""" + from src.config import ResolvedFallbackConfig + + config = ModelConfig( + model="claude-haiku-4-5", + transport="anthropic", + thinking_budget_tokens=1024, + fallback=ResolvedFallbackConfig( + model="gpt-4.1-mini", + transport="openai", + base_url="https://example.com/v1", + ), + ) + assert config.fallback is not None + assert config.fallback.transport == "openai" + assert config.fallback.thinking_budget_tokens is None + assert config.fallback.base_url == "https://example.com/v1" + + +def test_base_url_is_allowed_for_any_transport() -> None: + config = ModelConfig( + model="claude-haiku-4-5", + transport="anthropic", + base_url="https://anthropic-proxy.example/v1", + ) + + assert config.base_url == "https://anthropic-proxy.example/v1" + + +def test_anthropic_thinking_budget_has_minimum() -> None: + with pytest.raises(ValueError, match="thinking_budget_tokens must be >= 1024"): + ModelConfig( + model="claude-haiku-4-5", + transport="anthropic", + thinking_budget_tokens=512, + ) + + +def test_reasoning_effort_alias_populates_generic_thinking_effort() -> None: + config = ModelConfig.model_validate( + { + "model": "gpt-5", + "transport": "openai", + "reasoning_effort": "minimal", + } + ) + + assert config.thinking_effort == "minimal" + assert config.reasoning_effort == "minimal" + + +def test_for_model_overrides_model_and_transport() -> None: + config = ModelConfig( + model="claude-haiku-4-5", + transport="anthropic", + ) + + updated = config.for_model( + "gpt-5-mini", + transport_override="openai", + ) + + assert updated.model == "gpt-5-mini" + assert updated.transport == "openai" + assert config.transport == "anthropic" + + +def test_configured_model_settings_validate_like_runtime_model_config() -> None: + with pytest.raises(ValueError, match="thinking_budget_tokens must be >= 1024"): + ConfiguredModelSettings( + model="claude-haiku-4-5", + transport="anthropic", + thinking_budget_tokens=512, + ) + + +def test_summary_settings_accept_nested_model_config() -> None: + from src.config import FallbackModelSettings + + settings = SummarySettings( + MODEL_CONFIG=ConfiguredModelSettings( + model="claude-haiku-4-5", + transport="anthropic", + fallback=FallbackModelSettings( + model="gemini-2.5-pro", + transport="gemini", + ), + thinking_budget_tokens=1024, + ), + ) + + assert settings.MODEL_CONFIG.model == "claude-haiku-4-5" + assert settings.MODEL_CONFIG.transport == "anthropic" + assert settings.MODEL_CONFIG.fallback is not None + assert settings.MODEL_CONFIG.fallback.model == "gemini-2.5-pro" + assert settings.MODEL_CONFIG.fallback.transport == "gemini" + assert settings.MODEL_CONFIG.thinking_budget_tokens == 1024 + + +def test_resolve_model_config_reads_override_env_and_provider_params( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SUMMARY_LOCAL_API_KEY", "test-key") + + configured = ConfiguredModelSettings( + model="my-local-model", + transport="openai", + overrides=ModelOverrideSettings( + api_key_env="SUMMARY_LOCAL_API_KEY", + base_url="http://localhost:8000/v1", + provider_params={"verbosity": "low"}, + ), + ) + + resolved = resolve_model_config(configured) + + assert resolved.api_key == "test-key" + assert resolved.base_url == "http://localhost:8000/v1" + assert resolved.provider_params == {"verbosity": "low"} + + +def test_resolve_embedding_model_config_reads_override_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("EMBEDDING_LOCAL_API_KEY", "embed-key") + + configured = ConfiguredEmbeddingModelSettings( + transport="openai", + model="text-embedding-3-small", + overrides=ModelOverrideSettings( + api_key_env="EMBEDDING_LOCAL_API_KEY", + base_url="http://localhost:8000/v1", + ), + ) + + resolved = resolve_embedding_model_config(configured) + + assert resolved.api_key == "embed-key" + assert resolved.base_url == "http://localhost:8000/v1" + + +def test_dialectic_level_settings_accepts_nested_model_config() -> None: + from src.config import FallbackModelSettings + + settings = DialecticLevelSettings( + MODEL_CONFIG=ConfiguredModelSettings( + model="claude-haiku-4-5", + transport="anthropic", + fallback=FallbackModelSettings( + model="gemini-2.5-pro", + transport="gemini", + ), + thinking_budget_tokens=1024, + ), + MAX_TOOL_ITERATIONS=2, + ) + + resolved = resolve_model_config(settings.MODEL_CONFIG) + assert resolved.model == "claude-haiku-4-5" + assert resolved.transport == "anthropic" + assert resolved.fallback is not None + assert resolved.fallback.model == "gemini-2.5-pro" + assert resolved.fallback.transport == "gemini" + + +def test_dialectic_level_settings_require_nested_model_config() -> None: + with pytest.raises(ValueError, match="Field required"): + DialecticLevelSettings.model_validate({"MAX_TOOL_ITERATIONS": 2}) + + +def test_dialectic_level_settings_reject_legacy_flat_model_shape() -> None: + with pytest.raises(ValueError, match="Field required"): + DialecticLevelSettings.model_validate( + { + "MODEL": "claude-haiku-4-5", + "THINKING_BUDGET_TOKENS": 1024, + "MAX_TOOL_ITERATIONS": 2, + } + ) + + +def test_legacy_prefixed_model_strings_are_normalized() -> None: + config = ModelConfig.model_validate({"model": "gemini/gemini-2.5-flash"}) + configured = ConfiguredModelSettings.model_validate( + {"model": "anthropic/claude-haiku-4-5"} + ) + + assert config.transport == "gemini" + assert config.model == "gemini-2.5-flash" + assert configured.transport == "anthropic" + assert configured.model == "claude-haiku-4-5" + + +def test_dream_specialist_model_configs_are_independent() -> None: + """Specialist configs carry their own defaults and don't inherit from a parent.""" + + dream = DreamSettings( + DEDUCTION_MODEL_CONFIG=ConfiguredModelSettings( + model="claude-haiku-4-5", + transport="anthropic", + thinking_budget_tokens=2048, + ), + INDUCTION_MODEL_CONFIG=ConfiguredModelSettings( + model="claude-opus-4-1", + transport="anthropic", + max_output_tokens=8000, + ), + ) + + assert dream.DEDUCTION_MODEL_CONFIG.model == "claude-haiku-4-5" + assert dream.DEDUCTION_MODEL_CONFIG.thinking_budget_tokens == 2048 + assert dream.DEDUCTION_MODEL_CONFIG.max_output_tokens is None + + assert dream.INDUCTION_MODEL_CONFIG.model == "claude-opus-4-1" + assert dream.INDUCTION_MODEL_CONFIG.max_output_tokens == 8000 + assert dream.INDUCTION_MODEL_CONFIG.thinking_budget_tokens is None + + +def test_app_settings_propagate_embedding_dimensions_to_vector_store() -> None: + settings = AppSettings( + EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048), + VECTOR_STORE=VectorStoreSettings(TYPE="lancedb", MIGRATED=True), + ) + + assert settings.EMBEDDING.VECTOR_DIMENSIONS == 2048 + assert settings.VECTOR_STORE.DIMENSIONS == 2048 + + +def test_app_settings_require_matching_embedding_and_vector_store_dimensions() -> None: + with pytest.raises( + ValueError, + match=re.escape( + "VECTOR_STORE.DIMENSIONS must match EMBEDDING.VECTOR_DIMENSIONS" + ), + ): + AppSettings( + EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048), + VECTOR_STORE=VectorStoreSettings( + TYPE="lancedb", + MIGRATED=True, + DIMENSIONS=1536, + ), + ) + + +def test_app_settings_reject_non_1536_dimensions_while_pgvector_or_dual_write_active() -> ( + None +): + with pytest.raises( + ValueError, + match=re.escape("EMBEDDING.VECTOR_DIMENSIONS must remain 1536"), + ): + AppSettings( + EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048), + VECTOR_STORE=VectorStoreSettings(TYPE="pgvector", MIGRATED=True), + ) + + with pytest.raises( + ValueError, + match=re.escape("EMBEDDING.VECTOR_DIMENSIONS must remain 1536"), + ): + AppSettings( + EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048), + VECTOR_STORE=VectorStoreSettings(TYPE="lancedb", MIGRATED=False), + ) + + +def test_config_toml_example_uses_nested_model_config_sections() -> None: + config_path = Path(__file__).resolve().parents[2] / "config.toml.example" + config_data = load_toml_config(str(config_path)) + + deriver_config = ConfiguredModelSettings.model_validate( + config_data["deriver"]["model_config"] + ) + minimal_level = DialecticLevelSettings.model_validate( + config_data["dialectic"]["levels"]["minimal"] + ) + max_level = DialecticLevelSettings.model_validate( + config_data["dialectic"]["levels"]["max"] + ) + embedding_config = ConfiguredEmbeddingModelSettings.model_validate( + config_data["embedding"]["model_config"] + ) + summary_config = ConfiguredModelSettings.model_validate( + config_data["summary"]["model_config"] + ) + deduction_model_config = ConfiguredModelSettings.model_validate( + config_data["dream"]["deduction_model_config"] + ) + induction_model_config = ConfiguredModelSettings.model_validate( + config_data["dream"]["induction_model_config"] + ) + dream = DreamSettings.model_validate( + { + "DEDUCTION_MODEL_CONFIG": deduction_model_config, + "INDUCTION_MODEL_CONFIG": induction_model_config, + } + ) + + # config.toml.example ships the same minimal defaults the app uses: + # transport=openai, model=gpt-5.4-mini across every text-generation + # feature, with embeddings on openai/text-embedding-3-small. Asserting + # these keeps the example file and the in-code defaults in lockstep. + assert deriver_config.transport == "openai" + assert deriver_config.model == "gpt-5.4-mini" + assert deriver_config.thinking_budget_tokens is None + assert minimal_level.MODEL_CONFIG.model == "gpt-5.4-mini" + assert minimal_level.MODEL_CONFIG.transport == "openai" + assert max_level.MODEL_CONFIG.model == "gpt-5.4-mini" + assert max_level.MODEL_CONFIG.transport == "openai" + assert max_level.MODEL_CONFIG.thinking_budget_tokens is None + assert embedding_config.transport == "openai" + assert embedding_config.model == "text-embedding-3-small" + assert summary_config.model == "gpt-5.4-mini" + assert summary_config.transport == "openai" + assert dream.DEDUCTION_MODEL_CONFIG.model == "gpt-5.4-mini" + assert dream.INDUCTION_MODEL_CONFIG.model == "gpt-5.4-mini" + + +def test_env_template_uses_nested_model_config_keys() -> None: + env_template_path = Path(__file__).resolve().parents[2] / ".env.template" + env_template = env_template_path.read_text() + + assert "EMBEDDING_MODEL_CONFIG__MODEL" in env_template + assert "EMBEDDING_VECTOR_DIMENSIONS" in env_template + assert "DERIVER_MODEL_CONFIG__MODEL" in env_template + assert "DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL" in env_template + assert "SUMMARY_MODEL_CONFIG__MODEL" in env_template + assert "DREAM_DEDUCTION_MODEL_CONFIG__MODEL" in env_template + + assert "DERIVER_PROVIDER=" not in env_template + assert "SUMMARY_PROVIDER=" not in env_template + assert "DIALECTIC_LEVELS__minimal__PROVIDER=" not in env_template + assert "DREAM_PROVIDER=" not in env_template + assert "DREAM_DEDUCTION_MODEL=" not in env_template + + +def _clear_deriver_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Strip any DERIVER_MODEL_CONFIG__* env that would interfere with + direct-construction tests.""" + for name in list(os.environ): + if name.startswith("DERIVER_MODEL_CONFIG__") or name == "DERIVER_MODEL_CONFIG": + monkeypatch.delenv(name, raising=False) + + +def test_partial_env_override_of_transport_drops_default_thinking_params( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A partial env override of transport must not leak the default's thinking + params into a transport that rejects them. + + Regression: setting DERIVER_MODEL_CONFIG__TRANSPORT=openai + + DERIVER_MODEL_CONFIG__MODEL=gpt-4.1-mini (without clearing the default + thinking_budget_tokens=1024 carried over from the gemini default) used to + produce a merged ConfiguredModelSettings with thinking_budget_tokens=1024, + which the OpenAI backend then rejected at call time. + """ + from src.config import DeriverSettings + + _clear_deriver_env(monkeypatch) + # Exercise the @model_validator(mode="before") merge path with a raw dict + # — pyright can't see through the pre-validator that accepts dict input. + settings = DeriverSettings( + MODEL_CONFIG={"transport": "openai", "model": "gpt-4.1-mini"}, # pyright: ignore[reportArgumentType] + ) + + assert settings.MODEL_CONFIG.transport == "openai" + assert settings.MODEL_CONFIG.model == "gpt-4.1-mini" + assert settings.MODEL_CONFIG.thinking_budget_tokens is None + assert settings.MODEL_CONFIG.thinking_effort is None + + +def test_partial_env_override_same_transport_keeps_default_thinking_params( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When env preserves the default transport, default thinking params still + apply — we only strip on actual transport change. + + The app-level defaults are intentionally minimal (transport + model only) + to avoid clobbering operator config, so this test patches in a deliberately + rich default to exercise the merge-preservation behavior. + """ + from src.config import ConfiguredModelSettings, DeriverSettings + + _clear_deriver_env(monkeypatch) + + def _rich_default() -> ConfiguredModelSettings: + return ConfiguredModelSettings( + transport="gemini", + model="gemini-2.5-flash-lite", + thinking_budget_tokens=1024, + max_output_tokens=4096, + ) + + monkeypatch.setattr(DeriverSettings, "_MODEL_CONFIG_DEFAULT", _rich_default) + + settings = DeriverSettings( + MODEL_CONFIG={"model": "gemini-2.5-pro"}, # pyright: ignore[reportArgumentType] + ) + + assert settings.MODEL_CONFIG.transport == "gemini" + assert settings.MODEL_CONFIG.model == "gemini-2.5-pro" + assert settings.MODEL_CONFIG.thinking_budget_tokens == 1024 + assert settings.MODEL_CONFIG.max_output_tokens == 4096 + + +def test_explicit_thinking_effort_survives_transport_override( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """User-set thinking params in the override are always preserved.""" + from src.config import DeriverSettings + + _clear_deriver_env(monkeypatch) + settings = DeriverSettings( + MODEL_CONFIG={ # pyright: ignore[reportArgumentType] + "transport": "openai", + "model": "gpt-5", + "thinking_effort": "high", + }, + ) + + assert settings.MODEL_CONFIG.transport == "openai" + assert settings.MODEL_CONFIG.thinking_effort == "high" + assert settings.MODEL_CONFIG.thinking_budget_tokens is None + + +def test_dialectic_level_transport_override_drops_default_thinking_params( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Same leak existed in DialecticSettings._merge_level_defaults. + Regression: when a level default has thinking_budget_tokens=0 under a + gemini transport and env flips the override to openai, the 0 used to leak + through and trip the OpenAI backend's thinking-param rejection. + + The app-level defaults are intentionally minimal (transport + model only) + to avoid clobbering operator config, so this test patches in a rich + level default to exercise the strip-on-transport-change behavior. + + Exercises the before-validator directly to avoid DialecticSettings' + "all 5 levels required" constraint. + """ + from src.config import ( + ConfiguredModelSettings, + DialecticLevelSettings, + DialecticSettings, + ) + + def _rich_levels() -> dict[str, DialecticLevelSettings]: + return { + "minimal": DialecticLevelSettings( + MODEL_CONFIG=ConfiguredModelSettings( + transport="gemini", + model="gemini-2.5-flash-lite", + thinking_budget_tokens=0, + ), + MAX_TOOL_ITERATIONS=1, + MAX_OUTPUT_TOKENS=250, + TOOL_CHOICE="any", + ), + } + + monkeypatch.setattr("src.config._default_dialectic_levels", _rich_levels) + + data: dict[str, object] = { + "LEVELS": { + "minimal": { + "MODEL_CONFIG": { + "transport": "openai", + "model": "gpt-4.1-mini", + } + } + } + } + # The @model_validator decorator wraps the classmethod in a descriptor proxy + # that pyright can't see as callable; at runtime pydantic routes it correctly. + merged = cast( + dict[str, Any], + DialecticSettings._merge_level_defaults(data), # pyright: ignore[reportPrivateUsage, reportCallIssue] + ) + levels = cast(dict[str, dict[str, Any]], merged["LEVELS"]) + minimal_mc = cast(dict[str, Any], levels["minimal"]["MODEL_CONFIG"]) + assert minimal_mc["transport"] == "openai" + assert minimal_mc["model"] == "gpt-4.1-mini" + assert "thinking_budget_tokens" not in minimal_mc + assert "thinking_effort" not in minimal_mc diff --git a/tests/llm/test_request_builder.py b/tests/llm/test_request_builder.py new file mode 100644 index 00000000..c8ed7dfd --- /dev/null +++ b/tests/llm/test_request_builder.py @@ -0,0 +1,97 @@ +from pydantic import BaseModel + +from src.config import ModelConfig +from src.llm.caching import PromptCachePolicy +from src.llm.request_builder import execute_completion +from tests.llm.conftest import FakeBackend + + +class SampleResponse(BaseModel): + answer: str + + +async def test_gemini_explicit_budget_passes_tokens_through_without_adjustment( + fake_backend: FakeBackend, +) -> None: + config = ModelConfig( + model="gemini-2.5-flash", + transport="gemini", + thinking_budget_tokens=256, + ) + + await execute_completion( + fake_backend, + config, + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + ) + + call = fake_backend.calls[0] + # No auto-adjustment — operators set explicit values + assert call["max_output_tokens"] == 100 + assert call["max_tokens"] == 100 + assert call["thinking_budget_tokens"] == 256 + + +async def test_thinking_params_are_passed_through_without_capability_dropping( + fake_backend: FakeBackend, +) -> None: + config = ModelConfig( + model="claude-haiku-4-5", + transport="anthropic", + thinking_effort="high", + thinking_budget_tokens=1024, + ) + + await execute_completion( + fake_backend, + config, + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + ) + + call = fake_backend.calls[0] + assert call["thinking_effort"] == "high" + assert call["thinking_budget_tokens"] == 1024 + + +async def test_cache_policy_is_passed_through_extra_params( + fake_backend: FakeBackend, +) -> None: + config = ModelConfig(model="gpt-4.1-mini", transport="openai") + cache_policy = PromptCachePolicy(mode="prefix", ttl_seconds=300) + + await execute_completion( + fake_backend, + config, + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + response_format=SampleResponse, + cache_policy=cache_policy, + ) + + call = fake_backend.calls[0] + assert call["response_format"] is SampleResponse + assert call["extra_params"]["cache_policy"] == cache_policy + + +async def test_provider_params_are_merged_into_extra_params( + fake_backend: FakeBackend, +) -> None: + config = ModelConfig( + model="gpt-4.1-mini", + transport="openai", + top_p=0.9, + provider_params={"custom_flag": True}, + ) + + await execute_completion( + fake_backend, + config, + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + ) + + call = fake_backend.calls[0] + assert call["extra_params"]["top_p"] == 0.9 + assert call["extra_params"]["custom_flag"] is True diff --git a/tests/routes/test_files.py b/tests/routes/test_files.py index e31e2367..3af9f60b 100644 --- a/tests/routes/test_files.py +++ b/tests/routes/test_files.py @@ -134,6 +134,33 @@ async def test_create_messages_with_json_file( assert message["session_id"] == session_name +@pytest.mark.asyncio +async def test_create_messages_with_empty_json_file( + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], +): + """Test that empty JSON uploads do not crash and create empty content.""" + test_workspace, test_peer = sample_data + + test_session = await _create_test_session(db_session, test_workspace) + session_name = test_session.name + + file_data = io.BytesIO(b"") + files = {"file": ("empty.json", file_data, "application/json")} + form_data = {"peer_id": test_peer.name} + + url = _get_upload_url(test_workspace.name, session_name) + response = client.post(url, files=files, data=form_data) + + assert response.status_code == 201 + data = response.json() + assert len(data) == 1 + assert data[0]["content"] == "" + assert data[0]["peer_id"] == test_peer.name + assert data[0]["session_id"] == session_name + + @pytest.mark.asyncio async def test_create_messages_with_unsupported_file_type( client: TestClient, diff --git a/tests/routes/test_peers.py b/tests/routes/test_peers.py index c8047f02..9626ea10 100644 --- a/tests/routes/test_peers.py +++ b/tests/routes/test_peers.py @@ -576,25 +576,6 @@ def test_get_peer_representation_with_all_parameters( assert isinstance(data["representation"], str) -def test_get_peer_representation_structure( - client: TestClient, sample_data: tuple[Workspace, Peer] -): - """Test that peer representation response has correct structure""" - test_workspace, test_peer = sample_data - - # Get representation and validate structure - response = client.post( - f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", - json={}, - ) - assert response.status_code == 200 - data = response.json() - - # Validate response structure - assert "representation" in data - assert isinstance(data["representation"], str) - - def test_get_peer_representation_boundary_values( client: TestClient, sample_data: tuple[Workspace, Peer] ): diff --git a/tests/routes/test_queue_status.py b/tests/routes/test_queue_status.py index af25077b..279ad913 100644 --- a/tests/routes/test_queue_status.py +++ b/tests/routes/test_queue_status.py @@ -74,20 +74,6 @@ class TestDeriverStatusEndpoint: assert response.status_code == 200 assert response.json()["total_work_units"] == 0 - async def test_get_deriver_status_with_include_sender_false( - self, - client: TestClient, - sample_data: tuple[models.Workspace, models.Peer], - ): - """Test getting deriver status with include_sender=False (default)""" - workspace, peer = sample_data - response = client.get( - f"/v3/workspaces/{workspace.name}/queue/status", - params={"observer_id": peer.name}, - ) - assert response.status_code == 200 - assert response.json()["total_work_units"] == 0 - async def test_get_deriver_status_no_parameters( self, client: TestClient, sample_data: tuple[models.Workspace, models.Peer] ): diff --git a/tests/routes/test_scoped_api.py b/tests/routes/test_scoped_api.py index c2d7d2e6..a4d4677a 100644 --- a/tests/routes/test_scoped_api.py +++ b/tests/routes/test_scoped_api.py @@ -20,45 +20,6 @@ def test_create_workspace_with_auth(auth_client: AuthClient): assert response.status_code in [200, 201] -def test_auth_response_time(auth_client: AuthClient): - name = str(generate_nanoid()) - - import time - - start_time = time.time() - - response = auth_client.post( - "/v3/workspaces", json={"name": name, "metadata": {"key": "value"}} - ) - - end_time = time.time() - response_time = end_time - start_time - print( - f"Server response time for client {auth_client.auth_type}: {response_time:.6f} seconds" - ) - - # Check expected behavior based on auth type - if auth_client.auth_type != "admin": - assert response.status_code == 401 - return - - assert response.status_code in [200, 201] - - -def test_get_or_create_workspace_with_auth(auth_client: AuthClient): - name = str(generate_nanoid()) - - response = auth_client.post( - "/v3/workspaces", json={"name": name, "metadata": {"key": "value"}} - ) - - if auth_client.auth_type != "admin": - assert response.status_code == 401 - return - - assert response.status_code in [200, 201] - - def test_get_workspace_with_auth( auth_client: AuthClient, sample_data: tuple[Workspace, Peer] ): diff --git a/tests/sdk/test_session.py b/tests/sdk/test_session.py index 264a5c5c..3a618891 100644 --- a/tests/sdk/test_session.py +++ b/tests/sdk/test_session.py @@ -36,6 +36,62 @@ async def test_session_metadata(client_fixture: tuple[Honcho, str]): assert metadata == {"foo": "bar"} +@pytest.mark.asyncio +async def test_session_fetch_methods_refresh_cached_status_fields( + client_fixture: tuple[Honcho, str], +): + """ + Tests that fetch-style session methods populate created_at and is_active. + """ + honcho_client, client_type = client_fixture + session_id = "test-session-refresh-cache" + + if client_type == "async": + await honcho_client.aio.session(id=session_id) + session = Session(session_id, honcho_client) + + assert session.created_at is None + assert session.is_active is None + + metadata = await session.aio.get_metadata() + assert metadata == {} + assert session.created_at is not None + assert session.is_active is True + + session = Session(session_id, honcho_client) + await session.aio.refresh() + assert session.created_at is not None + assert session.is_active is True + + session = Session(session_id, honcho_client) + configuration = await session.aio.get_configuration() + assert configuration is not None + assert session.created_at is not None + assert session.is_active is True + else: + honcho_client.session(id=session_id) + session = Session(session_id, honcho_client) + + assert session.created_at is None + assert session.is_active is None + + metadata = session.get_metadata() + assert metadata == {} + assert session.created_at is not None + assert session.is_active is True + + session = Session(session_id, honcho_client) + session.refresh() + assert session.created_at is not None + assert session.is_active is True + + session = Session(session_id, honcho_client) + configuration = session.get_configuration() + assert configuration is not None + assert session.created_at is not None + assert session.is_active is True + + @pytest.mark.asyncio async def test_session_peer_management( client_fixture: tuple[Honcho, str], 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_dependencies.py b/tests/test_dependencies.py index 057808c7..c90643ec 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -13,12 +13,12 @@ from src.dependencies import tracked_db as real_tracked_db class FakeSession: def __init__(self, *, in_transaction: bool = False): self._in_transaction: bool = in_transaction - self.execute_calls: list[Any] = [] + self.execute_calls: list[tuple[Any, ...]] = [] self.rollback_calls: int = 0 self.close_calls: int = 0 - async def execute(self, statement: Any) -> None: - self.execute_calls.append(statement) + async def execute(self, statement: Any, params: Any = None) -> None: + self.execute_calls.append((statement, params)) async def rollback(self) -> None: self.rollback_calls += 1 @@ -45,14 +45,14 @@ async def test_get_db_sets_application_name_when_tracing_enabled( db = await anext(dep_gen) assert db is fake_db assert len(fake_db.execute_calls) == 1 - assert "SET application_name = 'request:test-ctx'" in str( - fake_db.execute_calls[0] - ) + stmt, params = fake_db.execute_calls[0] + assert "set_config" in str(stmt) + assert params == {"name": "request:test-ctx"} finally: await dep_gen.aclose() request_context.reset(context_token) - assert fake_db.rollback_calls == 0 + assert fake_db.rollback_calls == 1 # unconditional rollback in finally assert fake_db.close_calls == 1 @@ -70,7 +70,7 @@ async def test_get_db_rolls_back_and_closes_when_consumer_raises( with pytest.raises(RuntimeError, match="boom"): await dep_gen.athrow(RuntimeError("boom")) - assert fake_db.rollback_calls == 1 + assert fake_db.rollback_calls == 2 # once in except, once in finally assert fake_db.close_calls == 1 @@ -96,10 +96,10 @@ async def test_tracked_db_creates_and_resets_task_context( assert request_context.get() is None assert len(fake_db.execute_calls) == 1 - assert "SET application_name = 'task:cleanup_job:12345678'" in str( - fake_db.execute_calls[0] - ) - assert fake_db.rollback_calls == 0 + stmt, params = fake_db.execute_calls[0] + assert "set_config" in str(stmt) + assert params == {"name": "task:cleanup_job:12345678"} + assert fake_db.rollback_calls == 1 # unconditional rollback in finally assert fake_db.close_calls == 1 @@ -119,8 +119,10 @@ async def test_tracked_db_preserves_existing_request_context( request_context.reset(context_token) assert len(fake_db.execute_calls) == 1 - assert "SET application_name = 'request:existing'" in str(fake_db.execute_calls[0]) - assert fake_db.rollback_calls == 0 + stmt, params = fake_db.execute_calls[0] + assert "set_config" in str(stmt) + assert params == {"name": "request:existing"} + assert fake_db.rollback_calls == 1 # unconditional rollback in finally assert fake_db.close_calls == 1 @@ -136,7 +138,7 @@ async def test_tracked_db_rolls_back_on_error_and_closes( async with real_tracked_db("operation"): raise ValueError("failed operation") - assert fake_db.rollback_calls == 1 + assert fake_db.rollback_calls == 2 # once in except, once in finally assert fake_db.close_calls == 1 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 69e47086..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, @@ -118,15 +119,17 @@ async def tool_test_data( for doc in documents: await db_session.refresh(doc) - yield workspace, peer1, peer2, session, messages, documents + # Commit so data is visible to independent tracked_db sessions. + # Tool handlers no longer share the test's db_session — they open + # their own short-lived sessions via tracked_db. + # _truncate_all_tables handles cleanup between tests. + await db_session.commit() - await db_session.rollback() + yield workspace, peer1, peer2, session, messages, documents @pytest.fixture -def make_tool_context( - db_session: AsyncSession, tool_test_data: Any -) -> Callable[..., ToolContext]: +def make_tool_context(tool_test_data: Any) -> Callable[..., ToolContext]: """Factory fixture to create ToolContext with custom parameters.""" workspace, peer1, peer2, session, _messages, _ = tool_test_data shared_lock = asyncio.Lock() @@ -139,7 +142,6 @@ def make_tool_context( session_name: str | None = None, ) -> ToolContext: return ToolContext( - db=db_session, workspace_name=workspace.name, observer=peer1.name, observed=peer2.name, @@ -248,7 +250,6 @@ class TestCreateObservations: async def test_batch_embedding_failure_falls_back_to_individual_embeds( self, - db_session: AsyncSession, tool_test_data: Any, monkeypatch: pytest.MonkeyPatch, ): @@ -271,10 +272,10 @@ class TestCreateObservations: observer: str, observed: str, deduplicate: bool = False, - ) -> int: + ) -> list[Any]: _ = (workspace_name, observer, observed, deduplicate) created_documents.extend(documents) - return len(documents) + return documents monkeypatch.setattr( "src.utils.agent_tools.embedding_client.simple_batch_embed", @@ -289,7 +290,6 @@ class TestCreateObservations: ) result = await create_observations( - db_session, observations=[ schemas.ObservationInput(content="First obs", level="explicit"), schemas.ObservationInput(content="Second obs", level="explicit"), @@ -309,7 +309,6 @@ class TestCreateObservations: async def test_batch_embedding_failure_individual_embed_partial_failure( self, - db_session: AsyncSession, tool_test_data: Any, monkeypatch: pytest.MonkeyPatch, ): @@ -334,10 +333,10 @@ class TestCreateObservations: observer: str, observed: str, deduplicate: bool = False, - ) -> int: + ) -> list[Any]: _ = (workspace_name, observer, observed, deduplicate) created_documents.extend(documents) - return len(documents) + return documents monkeypatch.setattr( "src.utils.agent_tools.embedding_client.simple_batch_embed", @@ -352,7 +351,6 @@ class TestCreateObservations: ) result = await create_observations( - db_session, observations=[ schemas.ObservationInput(content="Embeds fine", level="explicit"), schemas.ObservationInput(content="Fails embed", level="explicit"), @@ -393,6 +391,8 @@ class TestDeleteObservations: assert "Deleted 1 observations" in result + # Expire the document so the identity map picks up the committed soft-delete + db_session.expire(documents[0]) # Verify soft-deletion (document still exists but has deleted_at timestamp) stmt = select(models.Document).where(models.Document.id == doc_id) doc = (await db_session.execute(stmt)).scalar_one_or_none() @@ -482,7 +482,6 @@ class TestSearchMemory: await db_session.flush() ctx = ToolContext( - db=db_session, workspace_name=workspace.name, observer=peer1.name, observed=peer2.name, @@ -530,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, @@ -612,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.""" @@ -655,14 +726,12 @@ class TestGetRecentHistory: async def test_without_session_uses_observed( self, - db_session: AsyncSession, tool_test_data: Any, ): """Without session, retrieves messages from observed peer.""" workspace, peer1, peer2, _, _, _ = tool_test_data ctx = ToolContext( - db=db_session, workspace_name=workspace.name, observer=peer1.name, observed=peer2.name, @@ -779,6 +848,8 @@ class TestUpdatePeerCard: assert "Updated peer card" in result + # Refresh the observer so the identity map picks up the committed update + await db_session.refresh(peer1) # Verify DB state peer_card = await crud.get_peer_card( db_session, @@ -804,6 +875,8 @@ class TestUpdatePeerCard: await _handle_update_peer_card(ctx, {"content": oversized}) + # Refresh the observer so the identity map picks up the committed update + await db_session.refresh(peer1) peer_card = await crud.get_peer_card( db_session, workspace_name=workspace.name, @@ -834,6 +907,8 @@ class TestUpdatePeerCard: result = await _handle_update_peer_card(ctx, {"content": None}) assert "empty" in result.lower() + # Refresh the observer so the identity map picks up the committed update + await db_session.refresh(peer1) # Verify original card is preserved peer_card = await crud.get_peer_card( db_session, @@ -861,6 +936,8 @@ class TestUpdatePeerCard: result = await _handle_update_peer_card(ctx, {"content": []}) assert "empty" in result.lower() + # Refresh the observer so the identity map picks up the committed update + await db_session.refresh(peer1) # Verify original card is preserved peer_card = await crud.get_peer_card( db_session, @@ -915,7 +992,6 @@ class TestGetPeerCard: await db_session.flush() ctx = ToolContext( - db=db_session, workspace_name=workspace.name, observer=peer1.name, observed=peer2.name, @@ -970,7 +1046,6 @@ class TestExtractPreferences: async def test_falls_back_to_per_query_embedding_when_batch_fails( self, - db_session: AsyncSession, tool_test_data: Any, monkeypatch: pytest.MonkeyPatch, ): @@ -989,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, @@ -1023,7 +1098,6 @@ class TestExtractPreferences: ) result = await extract_preferences( - db_session, workspace_name=workspace.name, session_name=session.name, observed=observed_peer.name, @@ -1062,14 +1136,11 @@ class TestFinishConsolidation: class TestToolExecutor: """Tests for create_tool_executor and the executor function.""" - async def test_create_tool_executor_returns_callable( - self, db_session: AsyncSession, tool_test_data: Any - ): + async def test_create_tool_executor_returns_callable(self, tool_test_data: Any): """create_tool_executor returns an async callable.""" workspace, peer1, peer2, session, _, _ = tool_test_data executor = await create_tool_executor( - db=db_session, workspace_name=workspace.name, observer=peer1.name, observed=peer2.name, @@ -1078,14 +1149,11 @@ class TestToolExecutor: assert callable(executor) - async def test_executor_routes_to_correct_handler( - self, db_session: AsyncSession, tool_test_data: Any - ): + async def test_executor_routes_to_correct_handler(self, tool_test_data: Any): """Executor routes tool calls to correct handlers.""" workspace, peer1, peer2, session, _, _ = tool_test_data executor = await create_tool_executor( - db=db_session, workspace_name=workspace.name, observer=peer1.name, observed=peer2.name, @@ -1098,14 +1166,11 @@ class TestToolExecutor: # Should be from get_peer_card handler assert "peer card" in result.lower() or "No peer card" in result - async def test_executor_unknown_tool_returns_error( - self, db_session: AsyncSession, tool_test_data: Any - ): + async def test_executor_unknown_tool_returns_error(self, tool_test_data: Any): """Unknown tool name returns error message.""" workspace, peer1, peer2, session, _, _ = tool_test_data executor = await create_tool_executor( - db=db_session, workspace_name=workspace.name, observer=peer1.name, observed=peer2.name, @@ -1116,14 +1181,11 @@ class TestToolExecutor: assert "Unknown tool" in result - async def test_executor_handles_exceptions_gracefully( - self, db_session: AsyncSession, tool_test_data: Any - ): + async def test_executor_handles_exceptions_gracefully(self, tool_test_data: Any): """Executor converts exceptions to error strings instead of raising.""" workspace, peer1, peer2, session, _, _ = tool_test_data executor = await create_tool_executor( - db=db_session, workspace_name=workspace.name, observer=peer1.name, observed=peer2.name, @@ -1137,13 +1199,12 @@ class TestToolExecutor: # Should contain error info, not raise exception async def test_executor_dreamer_context_includes_observation_ids( - self, db_session: AsyncSession, tool_test_data: Any + self, tool_test_data: Any ): """Dreamer context (include_observation_ids=True) shows IDs in output.""" workspace, peer1, peer2, session, _, _ = tool_test_data executor = await create_tool_executor( - db=db_session, workspace_name=workspace.name, observer=peer1.name, observed=peer2.name, @@ -1160,3 +1221,199 @@ class TestToolExecutor: if "Found" in result and "observations" in result: # IDs should be included in the output assert "[id:" in result or "observations" in result + + +# ============================================================================= +# Observation Lock Registry Tests +# ============================================================================= + + +@pytest.mark.asyncio +class TestObservationLockRegistry: + """Tests for the WeakValueDictionary-based observation lock registry.""" + + async def test_same_key_returns_same_lock(self): + """Concurrent callers with the same key get the same Lock instance.""" + from src.utils.agent_tools import get_observation_lock + + lock_a = await get_observation_lock("ws1", "obs1", "peer1") + lock_b = await get_observation_lock("ws1", "obs1", "peer1") + + assert lock_a is lock_b + + async def test_different_keys_return_different_locks(self): + """Different keys produce independent Lock instances.""" + from src.utils.agent_tools import get_observation_lock + + lock_a = await get_observation_lock("ws_diff_a", "obs", "peer") + lock_b = await get_observation_lock("ws_diff_b", "obs", "peer") + + assert lock_a is not lock_b + + async def test_lock_evicted_after_all_references_dropped(self): + """Lock is removed from registry once no strong references remain.""" + import gc + + from src.utils.agent_tools import ( + _observation_locks, # pyright: ignore[reportPrivateUsage] + get_observation_lock, + ) + + key = ("ws_evict", "obs_evict", "peer_evict") + lock = await get_observation_lock(*key) + assert key in _observation_locks + + # Drop the only strong reference and force GC + del lock + gc.collect() + + assert key not in _observation_locks + + async def test_lock_recreated_after_eviction(self): + """A new lock is created for a key whose previous lock was evicted.""" + import gc + import weakref + + from src.utils.agent_tools import get_observation_lock + + key = ("ws_recreate", "obs_recreate", "peer_recreate") + first_lock = await get_observation_lock(*key) + first_ref = weakref.ref(first_lock) + + # Evict + del first_lock + gc.collect() + + # Confirm the old lock was garbage-collected + assert first_ref() is None + + # Recreate + second_lock = await get_observation_lock(*key) + assert isinstance(second_lock, asyncio.Lock) + + async def test_lock_survives_while_any_reference_held(self): + """Lock stays alive as long as at least one strong reference exists.""" + import gc + + from src.utils.agent_tools import ( + _observation_locks, # pyright: ignore[reportPrivateUsage] + get_observation_lock, + ) + + key = ("ws_survive", "obs_survive", "peer_survive") + ref_a = await get_observation_lock(*key) + ref_b = await get_observation_lock(*key) + assert ref_a is ref_b + + # Drop one reference — lock should survive via the other + del ref_a + gc.collect() + assert key in _observation_locks + + # Drop the last reference — now it should be evicted + del ref_b + gc.collect() + assert key not in _observation_locks + + async def test_concurrent_executors_share_lock_for_mutual_exclusion(self): + """Two coroutines using the same key are serialized by the shared lock.""" + from src.utils.agent_tools import get_observation_lock + + key = ("ws_mutex", "obs_mutex", "peer_mutex") + shared_lock = await get_observation_lock(*key) + + order: list[str] = [] + + async def task(name: str, delay: float): + async with shared_lock: + order.append(f"{name}_start") + await asyncio.sleep(delay) + order.append(f"{name}_end") + + # task_a grabs the lock first, task_b must wait + task_a = asyncio.create_task(task("a", 0.05)) + await asyncio.sleep(0.01) # let task_a acquire the lock + task_b = asyncio.create_task(task("b", 0.01)) + + await asyncio.gather(task_a, task_b) + + # task_a must fully complete before task_b starts + assert order == ["a_start", "a_end", "b_start", "b_end"] + + async def test_no_registry_growth_across_many_keys(self): + """Registry does not retain locks after references are dropped.""" + import gc + + from src.utils.agent_tools import ( + _observation_locks, # pyright: ignore[reportPrivateUsage] + get_observation_lock, + ) + + locks: list[asyncio.Lock] = [] + for i in range(100): + locks.append(await get_observation_lock(f"ws_growth_{i}", "obs", "peer")) + + count_before = sum( + 1 for k in _observation_locks if k[0].startswith("ws_growth_") + ) + assert count_before == 100 + + # Drop all strong references and force GC + locks.clear() + gc.collect() + + # 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/utils/test_clients.py b/tests/utils/test_clients.py index f76cb27c..f5506790 100644 --- a/tests/utils/test_clients.py +++ b/tests/utils/test_clients.py @@ -1,14 +1,12 @@ """ -Comprehensive tests for src/utils/clients.py +Comprehensive tests for the public src.llm orchestration surface. Tests cover: -- All supported LLM providers (Anthropic, OpenAI, Google/Gemini, Groq) +- All supported LLM providers (Anthropic, OpenAI, Google/Gemini) - Streaming and non-streaming responses - Response models (structured output) - Error handling and retries - Provider-specific features -- Client initialization -- Langfuse integration """ from typing import Any @@ -25,13 +23,17 @@ from openai.types.chat.chat_completion_message import ChatCompletionMessage from openai.types.completion_usage import CompletionUsage from pydantic import BaseModel, Field -from src.config import settings -from src.exceptions import LLMError -from src.utils.clients import ( +from src.config import ( + ConfiguredModelSettings, + ModelConfig, + ResolvedFallbackConfig, + settings, +) +from src.exceptions import LLMError, ValidationException +from src.llm import ( CLIENTS, HonchoLLMCallResponse, HonchoLLMCallStreamChunk, - handle_streaming_response, honcho_llm_call, honcho_llm_call_inner, ) @@ -185,45 +187,14 @@ class TestAnthropicClient: model="claude-3-sonnet", prompt="Think about this", max_tokens=100, - thinking_budget_tokens=1000, + thinking_budget_tokens=1024, ) # Verify thinking parameter was passed mock_client.messages.create.assert_called_once() call_args = mock_client.messages.create.call_args thinking_config = call_args.kwargs["thinking"] - assert thinking_config == {"type": "enabled", "budget_tokens": 1000} - - async def test_anthropic_response_model_with_json_parsing(self): - """Test that Anthropic supports response models via JSON schema in prompt""" - from anthropic.types import TextBlock - - # Create an actual Anthropic client mock that passes isinstance checks - mock_messages = AsyncMock() - mock_response = Mock() - # Create an actual TextBlock instance that will pass isinstance checks - text_block = TextBlock(type="text", text='"name": "Alice", "age": 30}') - mock_response.content = [text_block] - mock_response.usage = Mock(output_tokens=10) - mock_response.stop_reason = "end_turn" - mock_messages.create.return_value = mock_response - - # Instead of mocking the CLIENTS dict, we mock the entire AsyncAnthropic class - # to return our configured mock when instantiated - with patch("src.utils.clients.AsyncAnthropic") as mock_anthropic_class: - mock_client_instance = Mock() - mock_client_instance.messages = mock_messages - mock_anthropic_class.return_value = mock_client_instance - - # Also need to patch the CLIENTS dict with an instance that passes isinstance - # Since this is complex, let's verify the simpler behavior - that response_model - # is supported and the prompt is modified (no NotImplementedError) - - # Note: Full integration testing of response_model parsing would require - # a more complex setup with actual Anthropic client mocking. - # This test verifies that the code path for response_model exists and - # modifies the prompt appropriately. - pass # Test simplified - behavior is now supported + assert thinking_config == {"type": "enabled", "budget_tokens": 1024} async def test_anthropic_streaming(self): """Test Anthropic streaming response""" @@ -253,16 +224,16 @@ class TestAnthropicClient: with patch.dict(CLIENTS, {"anthropic": mock_client}): chunks: list[HonchoLLMCallStreamChunk] = [] - async for chunk in handle_streaming_response( - client=mock_client, - params={ - "model": "claude-3-sonnet", - "max_tokens": 100, - "messages": [{"role": "user", "content": "Hello"}], - }, - json_mode=False, - thinking_budget_tokens=None, - ): + stream = await honcho_llm_call_inner( + provider="anthropic", + model="claude-3-sonnet", + prompt="Hello", + max_tokens=100, + stream=True, + client_override=mock_client, + messages=[{"role": "user", "content": "Hello"}], + ) + async for chunk in stream: chunks.append(chunk) assert len(chunks) == 3 # 2 content chunks + 1 final chunk @@ -501,16 +472,16 @@ class TestOpenAIClient: with patch.dict(CLIENTS, {"openai": mock_client}): chunks: list[HonchoLLMCallStreamChunk] = [] - async for chunk in handle_streaming_response( - client=mock_client, - params={ - "model": "gpt-4", - "max_tokens": 100, - "messages": [{"role": "user", "content": "Hello"}], - }, - json_mode=False, - thinking_budget_tokens=None, - ): + stream = await honcho_llm_call_inner( + provider="openai", + model="gpt-4", + prompt="Hello", + max_tokens=100, + stream=True, + client_override=mock_client, + messages=[{"role": "user", "content": "Hello"}], + ) + async for chunk in stream: chunks.append(chunk) assert len(chunks) == 3 @@ -553,9 +524,9 @@ class TestGoogleClient: mock_aio.models.generate_content = AsyncMock(return_value=mock_response) mock_client.aio = mock_aio - with patch.dict(CLIENTS, {"google": mock_client}): + with patch.dict(CLIENTS, {"gemini": mock_client}): response = await honcho_llm_call_inner( - provider="google", + provider="gemini", model="gemini-1.5-pro", prompt="Hello", max_tokens=100, @@ -600,9 +571,9 @@ class TestGoogleClient: mock_aio.models.generate_content = AsyncMock(return_value=mock_response) mock_client.aio = mock_aio - with patch.dict(CLIENTS, {"google": mock_client}): + with patch.dict(CLIENTS, {"gemini": mock_client}): _response = await honcho_llm_call_inner( - provider="google", + provider="gemini", model="gemini-1.5-pro", prompt="Generate JSON", max_tokens=100, @@ -637,9 +608,9 @@ class TestGoogleClient: mock_aio.models.generate_content = AsyncMock(return_value=mock_response) mock_client.aio = mock_aio - with patch.dict(CLIENTS, {"google": mock_client}): + with patch.dict(CLIENTS, {"gemini": mock_client}): response = await honcho_llm_call_inner( - provider="google", + provider="gemini", model="gemini-1.5-pro", prompt="Generate a person", max_tokens=100, @@ -691,18 +662,18 @@ class TestGoogleClient: ) mock_client.aio = mock_aio - with patch.dict(CLIENTS, {"google": mock_client}): + with patch.dict(CLIENTS, {"gemini": mock_client}): chunks: list[HonchoLLMCallStreamChunk] = [] - async for chunk in handle_streaming_response( - client=mock_client, - params={ - "model": "gemini-1.5-pro", - "max_tokens": 100, - "messages": [{"role": "user", "content": "Hello"}], - }, - json_mode=False, - thinking_budget_tokens=None, - ): + stream = await honcho_llm_call_inner( + provider="gemini", + model="gemini-1.5-pro", + prompt="Hello", + max_tokens=100, + stream=True, + client_override=mock_client, + messages=[{"role": "user", "content": "Hello"}], + ) + async for chunk in stream: chunks.append(chunk) assert len(chunks) == 3 @@ -731,9 +702,9 @@ class TestGoogleClient: mock_aio.models.generate_content = AsyncMock(return_value=mock_response) mock_client.aio = mock_aio - with patch.dict(CLIENTS, {"google": mock_client}): + with patch.dict(CLIENTS, {"gemini": mock_client}): response = await honcho_llm_call_inner( - provider="google", + provider="gemini", model="gemini-1.5-pro", prompt="Hello", max_tokens=100, @@ -769,11 +740,11 @@ class TestGoogleClient: mock_client.aio = mock_aio with ( - patch.dict(CLIENTS, {"google": mock_client}), + patch.dict(CLIENTS, {"gemini": mock_client}), pytest.raises(LLMError, match=f"finish_reason={finish_reason}"), ): await honcho_llm_call_inner( - provider="google", + provider="gemini", model="gemini-2.5-flash", prompt="Summarize this", max_tokens=1000, @@ -799,9 +770,9 @@ class TestGoogleClient: mock_aio.models.generate_content = AsyncMock(return_value=mock_response) mock_client.aio = mock_aio - with patch.dict(CLIENTS, {"google": mock_client}): + with patch.dict(CLIENTS, {"gemini": mock_client}): response = await honcho_llm_call_inner( - provider="google", + provider="gemini", model="gemini-2.5-flash", prompt="Hello", max_tokens=100, @@ -829,11 +800,11 @@ class TestGoogleClient: mock_client.aio = mock_aio with ( - patch.dict(CLIENTS, {"google": mock_client}), + patch.dict(CLIENTS, {"gemini": mock_client}), pytest.raises(LLMError, match="finish_reason=SAFETY"), ): await honcho_llm_call_inner( - provider="google", + provider="gemini", model="gemini-2.5-flash", prompt="Generate a person", max_tokens=100, @@ -858,9 +829,9 @@ class TestGoogleClient: mock_aio.models.generate_content = AsyncMock(return_value=mock_response) mock_client.aio = mock_aio - with patch.dict(CLIENTS, {"google": mock_client}): + with patch.dict(CLIENTS, {"gemini": mock_client}): response = await honcho_llm_call_inner( - provider="google", + provider="gemini", model="gemini-2.5-flash", prompt="Generate a person", max_tokens=100, @@ -872,244 +843,6 @@ class TestGoogleClient: assert response.finish_reasons == ["SAFETY"] -@pytest.mark.asyncio -class TestGroqClient: - """Tests for Groq client functionality""" - - async def test_groq_basic_call(self): - """Test basic Groq API call""" - from groq import AsyncGroq - - mock_client = AsyncMock(spec=AsyncGroq) - mock_response = ChatCompletion( - id="test-id", - object="chat.completion", - created=1234567890, - model="llama-3.1-70b", - choices=[ - Choice( - index=0, - message=ChatCompletionMessage( - role="assistant", content="Hello from Groq" - ), - finish_reason="stop", - ) - ], - usage=CompletionUsage( - prompt_tokens=10, completion_tokens=8, total_tokens=18 - ), - ) - mock_client.chat.completions.create = AsyncMock(return_value=mock_response) - - with patch.dict(CLIENTS, {"groq": mock_client}): - response = await honcho_llm_call_inner( - provider="groq", model="llama-3.1-70b", prompt="Hello", max_tokens=100 - ) - - assert isinstance(response, HonchoLLMCallResponse) - assert response.content == "Hello from Groq" - assert response.output_tokens == 8 - assert response.finish_reasons == ["stop"] - - async def test_groq_json_mode(self): - """Test Groq with JSON mode""" - from groq import AsyncGroq - - mock_client = AsyncMock(spec=AsyncGroq) - mock_response = ChatCompletion( - id="test-id", - object="chat.completion", - created=1234567890, - model="llama-3.1-70b", - choices=[ - Choice( - index=0, - message=ChatCompletionMessage( - role="assistant", content='{"success": true}' - ), - finish_reason="stop", - ) - ], - usage=CompletionUsage( - prompt_tokens=10, completion_tokens=5, total_tokens=15 - ), - ) - mock_client.chat.completions.create = AsyncMock(return_value=mock_response) - - with patch.dict(CLIENTS, {"groq": mock_client}): - _response = await honcho_llm_call_inner( - provider="groq", - model="llama-3.1-70b", - prompt="Generate JSON", - max_tokens=100, - json_mode=True, - ) - - # Verify JSON mode was set - mock_client.chat.completions.create.assert_called_once() - call_args = mock_client.chat.completions.create.call_args - assert call_args.kwargs["response_format"] == {"type": "json_object"} - - async def test_groq_response_model(self): - """Test Groq with response model (structured output)""" - from groq import AsyncGroq - - mock_client = AsyncMock(spec=AsyncGroq) - # Mock JSON response that matches SampleTestModel structure - json_content = '{"name": "Bob", "age": 30, "active": true}' - mock_response = ChatCompletion( - id="test-id", - object="chat.completion", - created=1234567890, - model="llama-3.1-70b", - choices=[ - Choice( - index=0, - message=ChatCompletionMessage( - role="assistant", content=json_content - ), - finish_reason="stop", - ) - ], - usage=CompletionUsage( - prompt_tokens=10, completion_tokens=12, total_tokens=22 - ), - ) - mock_client.chat.completions.create = AsyncMock(return_value=mock_response) - - with patch.dict(CLIENTS, {"groq": mock_client}): - response = await honcho_llm_call_inner( - provider="groq", - model="llama-3.1-70b", - prompt="Generate a person", - max_tokens=100, - response_model=SampleTestModel, - ) - - # Verify the response contains the parsed model - assert isinstance(response.content, SampleTestModel) - assert response.content.name == "Bob" - assert response.content.age == 30 - assert response.content.active is True - assert response.output_tokens == 12 - assert response.finish_reasons == ["stop"] - - # Verify the response format was set to the model - mock_client.chat.completions.create.assert_called_once() - call_args = mock_client.chat.completions.create.call_args - assert call_args.kwargs["response_format"] == SampleTestModel - - async def test_groq_no_content_error(self): - """Test Groq error handling when no content in response""" - from groq import AsyncGroq - - mock_client = AsyncMock(spec=AsyncGroq) - mock_response = ChatCompletion( - id="test-id", - object="chat.completion", - created=1234567890, - model="llama-3.1-70b", - choices=[ - Choice( - index=0, - message=ChatCompletionMessage(role="assistant", content=None), - finish_reason="stop", - ) - ], - usage=CompletionUsage( - prompt_tokens=10, completion_tokens=0, total_tokens=10 - ), - ) - mock_client.chat.completions.create = AsyncMock(return_value=mock_response) - - with ( - patch.dict(CLIENTS, {"groq": mock_client}), - pytest.raises(ValueError, match="No content in response"), - ): - await honcho_llm_call_inner( - provider="groq", - model="llama-3.1-70b", - prompt="Hello", - max_tokens=100, - ) - - async def test_groq_streaming(self): - """Test Groq streaming response""" - from groq import AsyncGroq - - mock_client = AsyncMock(spec=AsyncGroq) - - # Create mock streaming chunks - mock_chunks = [ - ChatCompletionChunk( - id="test-id", - object="chat.completion.chunk", - created=1234567890, - model="llama-3.1-70b", - choices=[ - ChunkChoice( - index=0, delta=ChoiceDelta(content="Hello"), finish_reason=None - ) - ], - ), - ChatCompletionChunk( - id="test-id", - object="chat.completion.chunk", - created=1234567890, - model="llama-3.1-70b", - choices=[ - ChunkChoice( - index=0, - delta=ChoiceDelta(content=" from Groq"), - finish_reason=None, - ) - ], - ), - ChatCompletionChunk( - id="test-id", - object="chat.completion.chunk", - created=1234567890, - model="llama-3.1-70b", - choices=[ - ChunkChoice( - index=0, delta=ChoiceDelta(content=None), finish_reason="stop" - ) - ], - ), - ] - - # Create async iterator - async def async_chunk_iterator(): - for chunk in mock_chunks: - yield chunk - - # Mock the create method to return the async generator when awaited - mock_client.chat.completions.create = AsyncMock( - return_value=async_chunk_iterator() - ) - - with patch.dict(CLIENTS, {"groq": mock_client}): - chunks: list[HonchoLLMCallStreamChunk] = [] - async for chunk in handle_streaming_response( - client=mock_client, - params={ - "model": "llama-3.1-70b", - "max_tokens": 100, - "messages": [{"role": "user", "content": "Hello"}], - }, - json_mode=False, - thinking_budget_tokens=None, - ): - chunks.append(chunk) - - assert len(chunks) == 3 - assert chunks[0].content == "Hello" - assert chunks[1].content == " from Groq" - assert chunks[2].content == "" - assert chunks[2].is_done is True - assert chunks[2].finish_reasons == ["stop"] - - @pytest.mark.asyncio class TestMainLLMCallFunction: """Tests for the main honcho_llm_call function""" @@ -1136,11 +869,12 @@ class TestMainLLMCallFunction: mock_client.messages.stream.return_value = mock_stream with patch.dict(CLIENTS, {"anthropic": mock_client}): - settings.DIALECTIC.LEVELS["medium"].PROVIDER = "anthropic" - settings.DIALECTIC.LEVELS["medium"].MODEL = "claude-4-sonnet" chunks: list[HonchoLLMCallStreamChunk] = [] async for chunk in await honcho_llm_call( - llm_settings=settings.DIALECTIC.LEVELS["medium"], + model_config=ConfiguredModelSettings( + model="claude-4-sonnet", + transport="anthropic", + ), prompt="Hello", max_tokens=100, stream=True, @@ -1164,10 +898,11 @@ class TestMainLLMCallFunction: mock_client.messages.create = AsyncMock(return_value=mock_response) with patch.dict(CLIENTS, {"anthropic": mock_client}): - settings.DIALECTIC.LEVELS["medium"].PROVIDER = "anthropic" - settings.DIALECTIC.LEVELS["medium"].MODEL = "claude-4-sonnet" response = await honcho_llm_call( - llm_settings=settings.DIALECTIC.LEVELS["medium"], + model_config=ConfiguredModelSettings( + model="claude-4-sonnet", + transport="anthropic", + ), prompt="Hello", max_tokens=100, enable_retry=False, @@ -1175,6 +910,44 @@ class TestMainLLMCallFunction: assert response.content == "No retry response" + async def test_track_name_updates_langfuse_span_name(self): + """track_name should rename the top-level Langfuse span.""" + + mock_llm_client = AsyncMock(spec=AsyncAnthropic) + mock_response = Mock() + mock_response.content = [TextBlock(text="Named response", type="text")] + mock_response.usage = Usage(input_tokens=5, output_tokens=5) + mock_response.stop_reason = "stop" + mock_llm_client.messages.create = AsyncMock(return_value=mock_response) + + mock_langfuse_client = Mock() + + with ( + patch.dict(CLIENTS, {"anthropic": mock_llm_client}), + patch.object(settings, "LANGFUSE_PUBLIC_KEY", "test-public-key"), + patch("langfuse.get_client", return_value=mock_langfuse_client), + ): + response = await honcho_llm_call( + model_config=ConfiguredModelSettings( + model="claude-4-sonnet", + transport="anthropic", + ), + prompt="Hello", + max_tokens=100, + enable_retry=False, + track_name="Dialectic Agent", + ) + + assert response.content == "Named response" + mock_langfuse_client.update_current_span.assert_called_once_with( + name="Dialectic Agent", + metadata={ + "namespace": settings.NAMESPACE, + "provider": "anthropic", + "model": "claude-4-sonnet", + }, + ) + class TestEdgeCases: """Tests for edge cases and boundary conditions""" @@ -1191,44 +964,399 @@ class TestEdgeCases: assert new_chunk.finish_reasons == [] # Should still be empty -# Test fixtures and utilities -@pytest.fixture -def sample_test_model(): - """Fixture providing a sample SampleTestModel instance""" - return SampleTestModel(name="Test User", age=25, active=True) +@pytest.mark.asyncio +class TestModelConfigCalls: + async def test_honcho_llm_call_accepts_model_config(self): + mock_client = AsyncMock(spec=AsyncAnthropic) + mock_response = Mock() + mock_response.content = [TextBlock(text="ModelConfig response", type="text")] + mock_response.usage = Usage(input_tokens=8, output_tokens=4) + mock_response.stop_reason = "stop" + mock_client.messages.create = AsyncMock(return_value=mock_response) - -@pytest.fixture -def mock_anthropic_client(): - """Fixture providing a mocked Anthropic client""" - mock_client = AsyncMock() - mock_response = Mock() - mock_response.content = [TextBlock(text="Mocked Anthropic response", type="text")] - mock_response.usage = Usage(input_tokens=10, output_tokens=5) - mock_response.stop_reason = "stop" - mock_client.messages.create.return_value = mock_response - return mock_client - - -@pytest.fixture -def mock_openai_client(): - """Fixture providing a mocked OpenAI client""" - mock_client = AsyncMock() - mock_response = ChatCompletion( - id="test-id", - object="chat.completion", - created=1234567890, - model="gpt-4", - choices=[ - Choice( - index=0, - message=ChatCompletionMessage( - role="assistant", content="Mocked OpenAI response" + with patch.dict(CLIENTS, {"anthropic": mock_client}): + response = await honcho_llm_call( + model_config=ModelConfig( + model="claude-haiku-4-5", + transport="anthropic", ), - finish_reason="stop", + prompt="Hello", + max_tokens=100, + enable_retry=False, + ) + + assert response.content == "ModelConfig response" + await_args = mock_client.messages.create.await_args + if await_args is None: + raise AssertionError("Expected Anthropic create call") + call_args = await_args.kwargs + assert call_args["model"] == "claude-haiku-4-5" + + async def test_honcho_llm_call_accepts_configured_model_settings(self): + mock_client = AsyncMock(spec=AsyncAnthropic) + mock_response = Mock() + mock_response.content = [ + TextBlock(text="ConfiguredModelSettings response", type="text") + ] + mock_response.usage = Usage(input_tokens=8, output_tokens=4) + mock_response.stop_reason = "stop" + mock_client.messages.create = AsyncMock(return_value=mock_response) + + with patch.dict(CLIENTS, {"anthropic": mock_client}): + response = await honcho_llm_call( + model_config=ConfiguredModelSettings( + model="claude-haiku-4-5", + transport="anthropic", + thinking_budget_tokens=1024, + ), + prompt="Hello", + max_tokens=100, + enable_retry=False, + ) + + assert response.content == "ConfiguredModelSettings response" + await_args = mock_client.messages.create.await_args + if await_args is None: + raise AssertionError("Expected Anthropic create call") + call_args = await_args.kwargs + assert call_args["model"] == "claude-haiku-4-5" + assert call_args["thinking"] == { + "type": "enabled", + "budget_tokens": 1024, + } + + +@pytest.mark.asyncio +class TestModelConfigExtraParamsPropagation: + """Regression tests — config knobs must reach the backend. + + Prior to the fix, honcho_llm_call_inner built extra_params from only + {json_mode, verbosity}, silently dropping top_p/top_k/frequency_penalty/ + presence_penalty/seed/provider_params off the ModelConfig. These tests + lock in that each backend now receives them. + """ + + async def test_openai_propagates_top_p_frequency_seed(self): + from openai import AsyncOpenAI + + mock_client = AsyncMock(spec=AsyncOpenAI) + mock_response = ChatCompletion( + id="test-id", + object="chat.completion", + created=1234567890, + model="gpt-4.1", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage(role="assistant", content="ok"), + finish_reason="stop", + ) + ], + usage=CompletionUsage( + prompt_tokens=10, completion_tokens=5, total_tokens=15 + ), + ) + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) + + with patch.dict(CLIENTS, {"openai": mock_client}): + await honcho_llm_call( + model_config=ModelConfig( + model="gpt-4.1", + transport="openai", + top_p=0.92, + frequency_penalty=0.5, + presence_penalty=0.1, + seed=42, + ), + prompt="Hello", + max_tokens=100, + enable_retry=False, + ) + + mock_client.chat.completions.create.assert_called_once() + kwargs = mock_client.chat.completions.create.call_args.kwargs + assert kwargs["top_p"] == 0.92 + assert kwargs["frequency_penalty"] == 0.5 + assert kwargs["presence_penalty"] == 0.1 + assert kwargs["seed"] == 42 + + async def test_anthropic_propagates_top_p_top_k(self): + mock_client = AsyncMock(spec=AsyncAnthropic) + mock_response = Mock() + mock_response.content = [TextBlock(text="ok", type="text")] + mock_response.usage = Usage(input_tokens=8, output_tokens=4) + mock_response.stop_reason = "stop" + mock_client.messages.create = AsyncMock(return_value=mock_response) + + with patch.dict(CLIENTS, {"anthropic": mock_client}): + await honcho_llm_call( + model_config=ModelConfig( + model="claude-haiku-4-5", + transport="anthropic", + top_p=0.85, + top_k=40, + ), + prompt="Hello", + max_tokens=100, + enable_retry=False, + ) + + await_args = mock_client.messages.create.await_args + if await_args is None: + raise AssertionError("Expected Anthropic create call") + kwargs = await_args.kwargs + assert kwargs["top_p"] == 0.85 + assert kwargs["top_k"] == 40 + + async def test_provider_params_passthrough(self): + """Operator-supplied provider_params must reach the backend's extra_params. + + Scope: verifies the ModelConfig.provider_params → backend.extra_params + boundary inside honcho_llm_call_inner. This is NOT a guarantee that + arbitrary keys reach the provider SDK — each backend's _build_params + forwards only an allowlist (top_p, top_k, frequency_penalty, seed, + etc.). We assert only that the sentinel key arrives in extra_params + at the backend boundary, which is the internal contract this test + exists to protect. + """ + from openai import AsyncOpenAI + + mock_client = AsyncMock(spec=AsyncOpenAI) + mock_response = ChatCompletion( + id="test-id", + object="chat.completion", + created=1234567890, + model="gpt-4.1", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage(role="assistant", content="ok"), + finish_reason="stop", + ) + ], + usage=CompletionUsage( + prompt_tokens=10, completion_tokens=5, total_tokens=15 + ), + ) + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) + + captured_extra: dict[str, Any] = {} + + from src.llm.backends.openai import OpenAIBackend + + original_complete = OpenAIBackend.complete + + async def capture_extra(self: Any, **kwargs: Any) -> Any: + captured_extra.update(kwargs.get("extra_params") or {}) + return await original_complete(self, **kwargs) + + with ( + patch.dict(CLIENTS, {"openai": mock_client}), + patch.object(OpenAIBackend, "complete", capture_extra), + ): + await honcho_llm_call( + model_config=ModelConfig( + model="gpt-4.1", + transport="openai", + provider_params={"honcho_sentinel": "zap"}, + ), + prompt="Hello", + max_tokens=100, + enable_retry=False, + ) + + assert captured_extra.get("honcho_sentinel") == "zap" + + async def test_cache_policy_reaches_gemini_backend(self): + """PromptCachePolicy set on ModelConfig must reach the Gemini backend's + extra_params as a typed object (so gemini_cached_content reuse fires).""" + from google import genai + + from src.config import PromptCachePolicy + from src.llm.backends.gemini import GeminiBackend + + mock_client = Mock(spec=genai.Client) + mock_client.__class__ = genai.Client # pyright: ignore[reportAttributeAccessIssue] + + import contextlib + + captured_extra: dict[str, Any] = {} + + async def capture_extra(_self: Any, **kwargs: Any) -> Any: + captured_extra.update(kwargs.get("extra_params") or {}) + return None + + policy = PromptCachePolicy(mode="gemini_cached_content", ttl_seconds=300) + + with ( + patch.dict(CLIENTS, {"gemini": mock_client}), + patch.object(GeminiBackend, "complete", capture_extra), + # capture_extra returns None, so downstream normalization will raise; + # we only care that extra_params was observed pre-raise. + contextlib.suppress(Exception), + ): + await honcho_llm_call( + model_config=ModelConfig( + model="gemini-2.5-flash", + transport="gemini", + cache_policy=policy, + ), + prompt="Hello", + max_tokens=100, + enable_retry=False, + ) + + assert captured_extra.get("cache_policy") is policy + + async def test_per_call_kwargs_override_provider_params(self): + """json_mode/verbosity from honcho_llm_call must win over provider_params defaults.""" + from openai import AsyncOpenAI + + from src.llm.backends.openai import OpenAIBackend + + mock_client = AsyncMock(spec=AsyncOpenAI) + mock_response = ChatCompletion( + id="test-id", + object="chat.completion", + created=1234567890, + model="gpt-4.1", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage(role="assistant", content="{}"), + finish_reason="stop", + ) + ], + usage=CompletionUsage( + prompt_tokens=10, completion_tokens=5, total_tokens=15 + ), + ) + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) + + captured_extra: dict[str, Any] = {} + original_complete = OpenAIBackend.complete + + async def capture_extra(self: Any, **kwargs: Any) -> Any: + captured_extra.update(kwargs.get("extra_params") or {}) + return await original_complete(self, **kwargs) + + with ( + patch.dict(CLIENTS, {"openai": mock_client}), + patch.object(OpenAIBackend, "complete", capture_extra), + ): + await honcho_llm_call( + model_config=ModelConfig( + model="gpt-4.1", + transport="openai", + provider_params={"json_mode": False, "verbosity": "low"}, + ), + prompt="Hello", + max_tokens=100, + json_mode=True, + verbosity="high", + enable_retry=False, + ) + + assert captured_extra["json_mode"] is True + assert captured_extra["verbosity"] == "high" + + async def test_fallback_config_thinking_params_applied_on_final_retry( + self, + ) -> None: + """When primary fails, the FALLBACK ModelConfig's own temperature and + thinking_budget_tokens must reach the backend on the final retry — + not the primary's values, and not whatever the caller never set. + + Regression for the 'default caller kwargs from runtime_model_config too + early' bug: if honcho_llm_call pre-populated temperature from + runtime_model_config (the primary) before attempt selection, those + primary values would clobber the fallback's own thinking params via + effective_config_for_call(update={...}). + """ + mock_client = AsyncMock(spec=AsyncAnthropic) + mock_response = Mock() + mock_response.content = [TextBlock(text="from fallback", type="text")] + mock_response.usage = Usage(input_tokens=5, output_tokens=3) + mock_response.stop_reason = "stop" + + # Primary fails twice, then fallback succeeds on attempt 3. + mock_client.messages.create = AsyncMock( + side_effect=[ + RuntimeError("primary attempt 1"), + RuntimeError("primary attempt 2"), + mock_response, + ] + ) + + fallback = ResolvedFallbackConfig( + model="claude-haiku-4-5", + transport="anthropic", + temperature=0.9, + thinking_budget_tokens=2048, + ) + + with patch.dict(CLIENTS, {"anthropic": mock_client}): + await honcho_llm_call( + model_config=ModelConfig( + model="claude-sonnet-4-5", + transport="anthropic", + temperature=0.1, + thinking_budget_tokens=1024, + fallback=fallback, + ), + prompt="Hello", + max_tokens=100, + enable_retry=True, + retry_attempts=3, + ) + + # Final call should carry the FALLBACK's values, not primary's. + final_call = mock_client.messages.create.await_args_list[-1] + kwargs = final_call.kwargs + assert kwargs["model"] == "claude-haiku-4-5" + assert kwargs["temperature"] == 0.9 + assert kwargs["thinking"] == { + "type": "enabled", + "budget_tokens": 2048, + } + + +@pytest.mark.asyncio +class TestToolLoopValidation: + """Lock in the fail-fast behavior on max_tool_iterations out of range.""" + + @pytest.mark.parametrize("bad_value", [0, -1, 101, 1_000]) + async def test_invalid_max_tool_iterations_raises(self, bad_value: int) -> None: + from src.llm.tool_loop import execute_tool_loop + + def _noop_plan() -> Any: # pragma: no cover - never called + raise AssertionError("plan should not be invoked for invalid input") + + def _noop_executor( + _name: str, _input: dict[str, Any] + ) -> str: # pragma: no cover + return "ok" + + def _noop_retry_callback(_state: Any) -> None: # pragma: no cover + return None + + with pytest.raises(ValidationException, match="max_tool_iterations"): + await execute_tool_loop( + prompt="x", + max_tokens=10, + messages=None, + tools=[{"name": "t", "description": "d", "input_schema": {}}], + tool_choice=None, + tool_executor=_noop_executor, + max_tool_iterations=bad_value, + response_model=None, + json_mode=False, + temperature=None, + stop_seqs=None, + verbosity=None, + enable_retry=False, + retry_attempts=3, + max_input_tokens=None, + get_attempt_plan=_noop_plan, + before_retry_callback=_noop_retry_callback, ) - ], - usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=15), - ) - mock_client.chat.completions.create = AsyncMock(return_value=mock_response) - return mock_client diff --git a/tests/utils/test_files.py b/tests/utils/test_files.py new file mode 100644 index 00000000..0b577be7 --- /dev/null +++ b/tests/utils/test_files.py @@ -0,0 +1,39 @@ +import json + +import pytest + +from src.exceptions import ValidationException +from src.utils.files import JSONProcessor + + +@pytest.mark.asyncio +async def test_json_processor_returns_empty_string_for_blank_content(): + processor = JSONProcessor() + + assert await processor.extract_text(b"") == "" + assert await processor.extract_text(b" \n\t") == "" + + +@pytest.mark.asyncio +async def test_json_processor_preserves_valid_json_behavior(): + processor = JSONProcessor() + + result = await processor.extract_text(b'{"name": "test", "count": 1}') + + assert json.loads(result) == {"name": "test", "count": 1} + + +@pytest.mark.asyncio +async def test_json_processor_rejects_non_utf8_content(): + processor = JSONProcessor() + + with pytest.raises(ValidationException, match="UTF-8"): + await processor.extract_text(b"\xff\xfe\x00{") + + +@pytest.mark.asyncio +async def test_json_processor_rejects_invalid_json_content(): + processor = JSONProcessor() + + with pytest.raises(ValidationException, match="invalid"): + await processor.extract_text(b'{"name": }') diff --git a/tests/utils/test_length_finish_reason.py b/tests/utils/test_length_finish_reason.py new file mode 100644 index 00000000..69d3210c --- /dev/null +++ b/tests/utils/test_length_finish_reason.py @@ -0,0 +1,456 @@ +""" +Tests for JSON repair handling across all providers in honcho_llm_call_inner, +and Gemini thinking budget support. + +Verifies that when an LLM hits the max token limit or returns malformed JSON, +the truncated output is repaired and returned instead of crashing. +""" + +import json +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from anthropic import AsyncAnthropic +from anthropic.types import TextBlock, Usage +from openai import AsyncOpenAI, LengthFinishReasonError +from openai.types.chat import ChatCompletion +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_message import ChatCompletionMessage +from openai.types.completion_usage import CompletionUsage +from pydantic import BaseModel, ValidationError + +from src.llm import CLIENTS, HonchoLLMCallResponse, honcho_llm_call_inner +from src.utils.representation import PromptRepresentation + +# --- Test models --- + + +class SimpleModel(BaseModel): + """Non-PromptRepresentation model for testing re-raise behavior.""" + + items: list[str] + + +# --- Helpers --- + +VALID_REPR_JSON = { + "explicit": [ + {"content": "hermes is 25 years old"}, + {"content": "hermes has a dog"}, + ] +} + + +def _make_truncated_completion(content: str) -> ChatCompletion: + """Build a ChatCompletion with finish_reason='length' and the given content.""" + return ChatCompletion( + id="test-truncated", + object="chat.completion", + created=1234567890, + model="test-model", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage(role="assistant", content=content), + finish_reason="length", + ) + ], + usage=CompletionUsage( + prompt_tokens=1000, completion_tokens=2000, total_tokens=3000 + ), + ) + + +def _raise_length_error(content: str) -> AsyncMock: + """Return an AsyncMock that raises LengthFinishReasonError with truncated content.""" + completion = _make_truncated_completion(content) + return AsyncMock(side_effect=LengthFinishReasonError(completion=completion)) + + +def _make_anthropic_mock(text: str, stop_reason: str = "end_turn") -> AsyncMock: + """Build a mocked AsyncAnthropic client returning the given text.""" + mock_client = AsyncMock(spec=AsyncAnthropic) + mock_response = Mock() + mock_response.content = [TextBlock(text=text, type="text")] + mock_response.usage = Usage(input_tokens=100, output_tokens=50) + mock_response.stop_reason = stop_reason + mock_client.messages.create = AsyncMock(return_value=mock_response) + return mock_client + + +def _make_gemini_mock( + text: str | None = None, + parsed: Any = None, + finish_reason_name: str = "STOP", +) -> Mock: + """Build a mocked genai.Client returning the given text/parsed content.""" + mock_client = Mock() + + # Build response + mock_response = Mock() + mock_response.parsed = parsed + + # Candidates + mock_candidate = Mock() + mock_finish_reason = Mock() + mock_finish_reason.name = finish_reason_name + mock_candidate.finish_reason = mock_finish_reason + + # Content parts + if text is not None: + mock_part = Mock() + mock_part.text = text + mock_part.function_call = None + mock_content = Mock() + mock_content.parts = [mock_part] + mock_candidate.content = mock_content + else: + mock_candidate.content = None + + mock_response.candidates = [mock_candidate] + + # Usage + mock_usage = Mock() + mock_usage.prompt_token_count = 200 + mock_usage.candidates_token_count = 100 + mock_response.usage_metadata = mock_usage + + mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) + return mock_client + + +# --------------------------------------------------------------------------- +# OpenAI / Custom provider tests (LengthFinishReasonError path) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestOpenAILengthFinishReasonRepair: + """Tests that LengthFinishReasonError is caught and truncated JSON is repaired.""" + + async def test_truncated_prompt_representation_repaired_openai(self) -> None: + """Truncated but repairable PromptRepresentation JSON should be repaired (openai).""" + truncated_json = json.dumps(VALID_REPR_JSON)[:-2] + + mock_client = AsyncMock(spec=AsyncOpenAI) + mock_client.chat.completions.parse = _raise_length_error(truncated_json) + + with patch.dict(CLIENTS, {"openai": mock_client}): + response = await honcho_llm_call_inner( + provider="openai", + model="test-model", + prompt="Analyze messages", + max_tokens=2000, + response_model=PromptRepresentation, + json_mode=True, + ) + + assert isinstance(response, HonchoLLMCallResponse) + assert isinstance(response.content, PromptRepresentation) + assert len(response.content.explicit) >= 1 + assert response.finish_reasons == ["length"] + assert response.output_tokens == 2000 + + async def test_truncated_prompt_representation_repaired_openai_with_custom_base( + self, + ) -> None: + """Truncated but repairable PromptRepresentation JSON should be repaired.""" + truncated_json = json.dumps(VALID_REPR_JSON)[:-2] + + mock_client = AsyncMock(spec=AsyncOpenAI) + mock_client.chat.completions.parse = _raise_length_error(truncated_json) + + with patch.dict(CLIENTS, {"openai": mock_client}): + response = await honcho_llm_call_inner( + provider="openai", + model="test-model", + prompt="Analyze messages", + max_tokens=2000, + response_model=PromptRepresentation, + json_mode=True, + ) + + assert isinstance(response, HonchoLLMCallResponse) + assert isinstance(response.content, PromptRepresentation) + assert len(response.content.explicit) >= 1 + assert response.finish_reasons == ["length"] + + async def test_completely_broken_json_falls_back_to_empty(self) -> None: + """Completely unrepairable JSON should fall back to empty PromptRepresentation.""" + mock_client = AsyncMock(spec=AsyncOpenAI) + mock_client.chat.completions.parse = _raise_length_error( + "this is not json at all just random text" + ) + + with patch.dict(CLIENTS, {"openai": mock_client}): + response = await honcho_llm_call_inner( + provider="openai", + model="test-model", + prompt="Analyze messages", + max_tokens=2000, + response_model=PromptRepresentation, + json_mode=True, + ) + + assert isinstance(response.content, PromptRepresentation) + assert response.content.explicit == [] + assert response.finish_reasons == ["length"] + + async def test_empty_content_falls_back_to_empty(self) -> None: + """Empty/null content should fall back to empty PromptRepresentation.""" + mock_client = AsyncMock(spec=AsyncOpenAI) + mock_client.chat.completions.parse = _raise_length_error("") + + with patch.dict(CLIENTS, {"openai": mock_client}): + response = await honcho_llm_call_inner( + provider="openai", + model="test-model", + prompt="Analyze messages", + max_tokens=2000, + response_model=PromptRepresentation, + json_mode=True, + ) + + assert isinstance(response.content, PromptRepresentation) + assert response.content.explicit == [] + + async def test_non_prompt_representation_reraises_on_unfixable(self) -> None: + """Non-PromptRepresentation with unrepairable JSON should raise ValidationError.""" + mock_client = AsyncMock(spec=AsyncOpenAI) + mock_client.chat.completions.parse = _raise_length_error("not json") + + with ( + patch.dict(CLIENTS, {"openai": mock_client}), + pytest.raises(ValidationError), + ): + await honcho_llm_call_inner( + provider="openai", + model="test-model", + prompt="Generate items", + max_tokens=2000, + response_model=SimpleModel, + json_mode=True, + ) + + async def test_token_counts_preserved(self) -> None: + """Token counts from the truncated completion should be preserved.""" + truncated_json = '{"explicit": [{"content": "fact one"}' + + mock_client = AsyncMock(spec=AsyncOpenAI) + mock_client.chat.completions.parse = _raise_length_error(truncated_json) + + with patch.dict(CLIENTS, {"openai": mock_client}): + response = await honcho_llm_call_inner( + provider="openai", + model="test-model", + prompt="Analyze messages", + max_tokens=2000, + response_model=PromptRepresentation, + json_mode=True, + ) + + assert response.input_tokens == 1000 + assert response.output_tokens == 2000 + + async def test_valid_json_with_length_finish_reason(self) -> None: + """Valid JSON despite length truncation should parse fine.""" + valid_json = json.dumps(VALID_REPR_JSON) + + mock_client = AsyncMock(spec=AsyncOpenAI) + mock_client.chat.completions.parse = _raise_length_error(valid_json) + + with patch.dict(CLIENTS, {"openai": mock_client}): + response = await honcho_llm_call_inner( + provider="openai", + model="test-model", + prompt="Analyze messages", + max_tokens=2000, + response_model=PromptRepresentation, + json_mode=True, + ) + + assert isinstance(response.content, PromptRepresentation) + assert len(response.content.explicit) == 2 + assert response.content.explicit[0].content == "hermes is 25 years old" + + +# --------------------------------------------------------------------------- +# Anthropic provider tests (JSON parse failure -> repair path) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestAnthropicJsonRepair: + """Tests that Anthropic response_model parse failures trigger JSON repair.""" + + async def test_truncated_anthropic_response_repaired(self) -> None: + """Truncated Anthropic JSON response should be repaired.""" + # Anthropic prefills "{" so the response text starts after that + # The code prepends "{" back: json_content = "{" + text_content + truncated_text = json.dumps(VALID_REPR_JSON)[ + 1:-2 + ] # Remove leading { and trailing }] + + mock_client = _make_anthropic_mock(truncated_text, stop_reason="max_tokens") + + with patch.dict(CLIENTS, {"anthropic": mock_client}): + response = await honcho_llm_call_inner( + provider="anthropic", + model="claude-3-sonnet", + prompt="Analyze messages", + max_tokens=2000, + response_model=PromptRepresentation, + json_mode=True, + ) + + assert isinstance(response.content, PromptRepresentation) + assert len(response.content.explicit) >= 1 + + async def test_broken_anthropic_response_falls_back_to_empty(self) -> None: + """Completely broken Anthropic JSON should fall back to empty PromptRepresentation.""" + mock_client = _make_anthropic_mock( + "random gibberish that is not json", stop_reason="max_tokens" + ) + + with patch.dict(CLIENTS, {"anthropic": mock_client}): + response = await honcho_llm_call_inner( + provider="anthropic", + model="claude-3-sonnet", + prompt="Analyze messages", + max_tokens=2000, + response_model=PromptRepresentation, + json_mode=True, + ) + + assert isinstance(response.content, PromptRepresentation) + assert response.content.explicit == [] + + async def test_non_prompt_representation_reraises(self) -> None: + """Non-PromptRepresentation with broken JSON should raise.""" + mock_client = _make_anthropic_mock("not json", stop_reason="max_tokens") + + with ( + patch.dict(CLIENTS, {"anthropic": mock_client}), + pytest.raises(ValidationError), + ): + await honcho_llm_call_inner( + provider="anthropic", + model="claude-3-sonnet", + prompt="Generate items", + max_tokens=2000, + response_model=SimpleModel, + json_mode=True, + ) + + +# --------------------------------------------------------------------------- +# Gemini provider tests (parsed=None or type mismatch -> repair path) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestGeminiJsonRepair: + """Tests that Gemini response_model parse failures trigger JSON repair.""" + + async def test_gemini_unparsed_response_repaired(self) -> None: + """Gemini returning text but no parsed object should repair from raw text.""" + from google import genai + + valid_text = json.dumps(VALID_REPR_JSON) + mock_client = _make_gemini_mock( + text=valid_text, parsed=None, finish_reason_name="MAX_TOKENS" + ) + + with ( + patch.dict(CLIENTS, {"gemini": mock_client}), + patch.object(genai.Client, "__instancecheck__", return_value=True), + ): + # We need the match statement to hit the genai.Client case + mock_client.__class__ = genai.Client # pyright: ignore[reportAttributeAccessIssue] + response = await honcho_llm_call_inner( + provider="gemini", + model="gemini-2.5-flash", + prompt="Analyze messages", + max_tokens=2000, + response_model=PromptRepresentation, + json_mode=True, + ) + + assert isinstance(response.content, PromptRepresentation) + assert len(response.content.explicit) == 2 + + async def test_gemini_broken_text_falls_back_to_empty(self) -> None: + """Gemini with broken text and no parsed content should fall back.""" + from google import genai + + mock_client = _make_gemini_mock( + text="broken json", parsed=None, finish_reason_name="MAX_TOKENS" + ) + mock_client.__class__ = genai.Client # pyright: ignore[reportAttributeAccessIssue] + + with patch.dict(CLIENTS, {"gemini": mock_client}): + response = await honcho_llm_call_inner( + provider="gemini", + model="gemini-2.5-flash", + prompt="Analyze messages", + max_tokens=2000, + response_model=PromptRepresentation, + json_mode=True, + ) + + assert isinstance(response.content, PromptRepresentation) + assert response.content.explicit == [] + + +# --------------------------------------------------------------------------- +# Gemini thinking budget tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestGeminiThinkingBudget: + """Tests that thinking_budget_tokens is passed to Gemini via ThinkingConfig.""" + + async def test_thinking_budget_passed_to_gemini(self) -> None: + """thinking_budget_tokens should be included in Gemini config.""" + from google import genai + + mock_client = _make_gemini_mock(text="Hello", parsed=None) + mock_client.__class__ = genai.Client # pyright: ignore[reportAttributeAccessIssue] + + with patch.dict(CLIENTS, {"gemini": mock_client}): + await honcho_llm_call_inner( + provider="gemini", + model="gemini-2.5-flash", + prompt="Think about this", + max_tokens=2000, + thinking_budget_tokens=4096, + ) + + # Verify generate_content was called with thinking_config + call_args = mock_client.aio.models.generate_content.call_args + config = call_args.kwargs.get("config") or call_args[1].get("config") + assert config is not None + assert "thinking_config" in config + assert config["thinking_config"]["thinking_budget"] == 4096 + + async def test_no_thinking_config_when_budget_is_none(self) -> None: + """When thinking_budget_tokens is None, thinking_config should not be set.""" + from google import genai + + mock_client = _make_gemini_mock(text="Hello", parsed=None) + mock_client.__class__ = genai.Client # pyright: ignore[reportAttributeAccessIssue] + + with patch.dict(CLIENTS, {"gemini": mock_client}): + await honcho_llm_call_inner( + provider="gemini", + model="gemini-2.5-flash", + prompt="No thinking needed", + max_tokens=2000, + ) + + call_args = mock_client.aio.models.generate_content.call_args + config = call_args.kwargs.get("config") or call_args[1].get("config") + if config: + assert "thinking_config" not in config diff --git a/tests/utils/test_summarizer.py b/tests/utils/test_summarizer.py index 3e8f8dc9..b842ced7 100644 --- a/tests/utils/test_summarizer.py +++ b/tests/utils/test_summarizer.py @@ -10,11 +10,14 @@ from unittest.mock import AsyncMock, patch import pytest -from src.utils.clients import HonchoLLMCallResponse +from src.config import settings +from src.llm import HonchoLLMCallResponse from src.utils.summarizer import ( Summary, SummaryType, _create_summary, # pyright: ignore[reportPrivateUsage] + create_long_summary, + create_short_summary, ) # Common test arguments for _create_summary @@ -217,3 +220,61 @@ class TestCreateSummary: assert is_fallback is True assert summary["content"] == "" assert summary["token_count"] == 0 + + +@pytest.mark.asyncio +class TestSummaryCallerMigration: + async def test_create_short_summary_uses_model_config(self): + mock_response = HonchoLLMCallResponse( + content="short summary", + input_tokens=10, + output_tokens=5, + finish_reasons=["STOP"], + ) + + with patch( + "src.utils.summarizer.honcho_llm_call", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_llm_call: + await create_short_summary( + formatted_messages=_FORMATTED_MESSAGES, + input_tokens=_INPUT_TOKENS, + previous_summary=None, + ) + + await_args = mock_llm_call.await_args + if await_args is None: + raise AssertionError("Expected summary LLM call") + kwargs = await_args.kwargs + expected_config = settings.SUMMARY.MODEL_CONFIG + assert "model_config" in kwargs + assert kwargs["model_config"].model == expected_config.model + assert "llm_settings" not in kwargs + + async def test_create_long_summary_uses_model_config(self): + mock_response = HonchoLLMCallResponse( + content="long summary", + input_tokens=10, + output_tokens=5, + finish_reasons=["STOP"], + ) + + with patch( + "src.utils.summarizer.honcho_llm_call", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_llm_call: + await create_long_summary( + formatted_messages=_FORMATTED_MESSAGES, + previous_summary=None, + ) + + await_args = mock_llm_call.await_args + if await_args is None: + raise AssertionError("Expected summary LLM call") + kwargs = await_args.kwargs + expected_config = settings.SUMMARY.MODEL_CONFIG + assert "model_config" in kwargs + assert kwargs["model_config"].model == expected_config.model + assert "llm_settings" not in kwargs diff --git a/tests/vector_store/test_turbopuffer.py b/tests/vector_store/test_turbopuffer.py new file mode 100644 index 00000000..73bf3c20 --- /dev/null +++ b/tests/vector_store/test_turbopuffer.py @@ -0,0 +1,81 @@ +"""Tests for TurbopufferVectorStore error handling on 5xx responses.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest +from turbopuffer import InternalServerError + +from src.config import settings +from src.exceptions import VectorStoreError +from src.vector_store import VectorRecord +from src.vector_store.turbopuffer import TurbopufferVectorStore + + +def _internal_server_error(status_code: int = 503) -> InternalServerError: + request = httpx.Request( + "POST", "https://api.turbopuffer.com/v2/namespaces/ns/write" + ) + response = httpx.Response(status_code, request=request) + return InternalServerError("turbopuffer unavailable", response=response, body=None) + + +@pytest.fixture +def store(monkeypatch: pytest.MonkeyPatch) -> TurbopufferVectorStore: + monkeypatch.setattr(settings.VECTOR_STORE, "TURBOPUFFER_API_KEY", "test-key") + monkeypatch.setattr(settings.VECTOR_STORE, "TURBOPUFFER_REGION", "gcp-us-east4") + return TurbopufferVectorStore() + + +@pytest.fixture +def record() -> VectorRecord: + return VectorRecord( + id="doc_1", embedding=[0.1, 0.2, 0.3, 0.4], metadata={"foo": "bar"} + ) + + +@pytest.mark.asyncio +async def test_upsert_many_raises_vector_store_error_on_5xx( + store: TurbopufferVectorStore, + record: VectorRecord, +) -> None: + namespace_mock = MagicMock() + namespace_mock.write = AsyncMock(side_effect=_internal_server_error(503)) + store._get_namespace = MagicMock(return_value=namespace_mock) # pyright: ignore[reportPrivateUsage] + + with pytest.raises(VectorStoreError) as excinfo: + await store.upsert_many("honcho.doc.test", [record]) + + assert "honcho.doc.test" in str(excinfo.value) + assert isinstance(excinfo.value.__cause__, InternalServerError) + namespace_mock.write.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_upsert_many_short_circuits_on_empty( + store: TurbopufferVectorStore, +) -> None: + namespace_mock = MagicMock() + namespace_mock.write = AsyncMock() + store._get_namespace = MagicMock(return_value=namespace_mock) # pyright: ignore[reportPrivateUsage] + + await store.upsert_many("honcho.doc.test", []) + + namespace_mock.write.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_upsert_many_succeeds_without_raising( + store: TurbopufferVectorStore, + record: VectorRecord, +) -> None: + namespace_mock = MagicMock() + namespace_mock.write = AsyncMock() + store._get_namespace = MagicMock(return_value=namespace_mock) # pyright: ignore[reportPrivateUsage] + + result = await store.upsert_many("honcho.doc.test", [record]) + + assert result is None + namespace_mock.write.assert_awaited_once() 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") diff --git a/uv.lock b/uv.lock index 29d84d26..c0bbd9db 100644 --- a/uv.lock +++ b/uv.lock @@ -1,17 +1,17 @@ version = 1 revision = 3 -requires-python = ">=3.10" +requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14'", "python_full_version == '3.13.*'", - "python_full_version >= '3.11' and python_full_version < '3.13'", - "python_full_version < '3.11'", + "python_full_version < '3.13'", ] [manifest] members = [ "honcho", "honcho-ai", + "honcho-cli", ] [[package]] @@ -30,7 +30,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, - { name = "async-timeout", marker = "python_full_version < '3.11'" }, { name = "attrs" }, { name = "frozenlist" }, { name = "multidict" }, @@ -39,23 +38,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950, upload-time = "2026-01-03T17:29:13.002Z" }, - { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099, upload-time = "2026-01-03T17:29:15.268Z" }, - { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072, upload-time = "2026-01-03T17:29:16.922Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588, upload-time = "2026-01-03T17:29:18.539Z" }, - { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334, upload-time = "2026-01-03T17:29:21.028Z" }, - { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656, upload-time = "2026-01-03T17:29:22.531Z" }, - { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625, upload-time = "2026-01-03T17:29:24.276Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604, upload-time = "2026-01-03T17:29:26.099Z" }, - { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370, upload-time = "2026-01-03T17:29:28.121Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023, upload-time = "2026-01-03T17:29:30.002Z" }, - { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680, upload-time = "2026-01-03T17:29:31.782Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407, upload-time = "2026-01-03T17:29:33.392Z" }, - { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047, upload-time = "2026-01-03T17:29:34.855Z" }, - { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264, upload-time = "2026-01-03T17:29:36.389Z" }, - { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275, upload-time = "2026-01-03T17:29:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053, upload-time = "2026-01-03T17:29:40.074Z" }, - { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687, upload-time = "2026-01-03T17:29:41.819Z" }, { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, @@ -163,7 +145,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } @@ -213,7 +194,6 @@ name = "anyio" version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -249,15 +229,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, -] - [[package]] name = "basedpyright" version = "1.38.1" @@ -330,18 +301,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, @@ -418,22 +377,6 @@ version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, - { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, - { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, - { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, - { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, - { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, - { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, - { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, - { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, - { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, - { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, @@ -540,20 +483,6 @@ version = "7.13.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, - { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, - { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, - { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, - { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, - { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, - { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, - { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, - { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, - { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, - { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, @@ -658,7 +587,6 @@ version = "46.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } wheels = [ @@ -773,18 +701,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -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 = "execnet" version = "2.1.2" @@ -801,7 +717,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "redis" }, { name = "sortedcontainers" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d8/44/c403963727d707e03f49a417712b0a23e853d33ae50729679040b6cfe281/fakeredis-2.34.0.tar.gz", hash = "sha256:72bc51a7ab39bedf5004f0cf1b5206822619c1be8c2657fd878d1f4250256c57", size = 177156, upload-time = "2026-02-16T15:56:34.318Z" } wheels = [ @@ -842,7 +757,6 @@ version = "0.0.23" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "rich-toolkit" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -896,20 +810,6 @@ version = "0.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/69/e7/f89d54fb04104114dd0552836dc2b47914f416cc0e200b409dd04a33de5e/fastar-0.8.0.tar.gz", hash = "sha256:f4d4d68dbf1c4c2808f0e730fac5843493fc849f70fe3ad3af60dfbaf68b9a12", size = 68524, upload-time = "2025-11-26T02:36:00.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/e2/51d9ee443aabcd5aa581d45b18b6198ced364b5cd97e5504c5d782ceb82c/fastar-0.8.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c9f930cff014cf79d396d0541bd9f3a3f170c9b5e45d10d634d98f9ed08788c3", size = 708536, upload-time = "2025-11-26T02:34:35.236Z" }, - { url = "https://files.pythonhosted.org/packages/07/2a/edfc6274768b8a3859a5ca4f8c29cb7f614d7f27d2378e2c88aa91cda54e/fastar-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07b70f712d20622346531a4b46bb332569bea621f61314c0b7e80903a16d14cf", size = 632235, upload-time = "2025-11-26T02:34:19.367Z" }, - { url = "https://files.pythonhosted.org/packages/ef/1e/3cfbaaec464caef196700ee2ffae1c03f94f7c5e2a85d0ec0ea9cdd1da81/fastar-0.8.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:330639db3bfba4c6d132421a2a4aeb81e7bea8ce9159cdb6e247fbc5fae97686", size = 871386, upload-time = "2025-11-26T02:33:47.613Z" }, - { url = "https://files.pythonhosted.org/packages/82/50/224a674ad541054179e4e6e0b54bb6e162f04f698a2512b42a8085fc6b6f/fastar-0.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98ea7ceb6231e48d7bb0d7dc13e946baa29c7f6873eaf4afb69725d6da349033", size = 764955, upload-time = "2025-11-26T02:32:44.279Z" }, - { url = "https://files.pythonhosted.org/packages/4d/5e/4608184aa57cb6a54f62c1eb3e5133ba8d461fc7f13193c0255effbec12a/fastar-0.8.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a90695a601a78bbca910fdf2efcdf3103c55d0de5a5c6e93556d707bf886250b", size = 765987, upload-time = "2025-11-26T02:32:59.701Z" }, - { url = "https://files.pythonhosted.org/packages/e0/53/6afd2b680dddfa10df9a16bbcf6cabfee0d92435d5c7e3f4cfe3b1712662/fastar-0.8.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d0bf655ff4c9320b0ca8a5b128063d5093c0c8c1645a2b5f7167143fd8531aa", size = 930900, upload-time = "2025-11-26T02:33:16.059Z" }, - { url = "https://files.pythonhosted.org/packages/ef/1e/b7a304bfcc1d06845cbfa4b464516f6fff9c8c6692f6ef80a3a86b04e199/fastar-0.8.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8df22cdd8d58e7689aa89b2e4a07e8e5fa4f88d2d9c2621f0e88a49be97ccea", size = 821523, upload-time = "2025-11-26T02:33:30.897Z" }, - { url = "https://files.pythonhosted.org/packages/1d/da/9ef8605c6d233cd6ca3a95f7f518ac22aa064903afe6afa57733bfb7c31b/fastar-0.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8a5e6ad722685128521c8fb44cf25bd38669650ba3a4b466b8903e5aa28e1a0", size = 821268, upload-time = "2025-11-26T02:34:04.003Z" }, - { url = "https://files.pythonhosted.org/packages/7e/22/ed37c78a6b4420de1677d82e79742787975c34847229c33dc376334c7283/fastar-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:31cd541231a2456e32104da891cf9962c3b40234d0465cbf9322a6bc8a1b05d5", size = 986286, upload-time = "2025-11-26T02:34:50.279Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a6/366b15f432d85d4089e6e4b52a09cc2a2bcf4d7a1f0771e3d3194deccb1e/fastar-0.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:175db2a98d67ced106468e8987975484f8bbbd5ad99201da823b38bafb565ed5", size = 1041921, upload-time = "2025-11-26T02:35:07.292Z" }, - { url = "https://files.pythonhosted.org/packages/f4/45/45f8e6991e3ce9f8aeefdc8d4c200daada41097a36808643d1703464c3e2/fastar-0.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ada877ab1c65197d772ce1b1c2e244d4799680d8b3f136a4308360f3d8661b23", size = 1047302, upload-time = "2025-11-26T02:35:24.995Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e2/a587796111a3cd4b78cd61ec3fc1252d8517d81f763f4164ed5680f84810/fastar-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:01084cb75f13ca6a8e80bd41584322523189f8e81b472053743d6e6c3062b5a6", size = 995141, upload-time = "2025-11-26T02:35:42.449Z" }, - { url = "https://files.pythonhosted.org/packages/89/c0/7a8ec86695b0b77168e220cf2af1aa30592f5ecdbd0ce6d641d29c4a8bae/fastar-0.8.0-cp310-cp310-win32.whl", hash = "sha256:ca639b9909805e44364ea13cca2682b487e74826e4ad75957115ec693228d6b6", size = 456544, upload-time = "2025-11-26T02:36:23.801Z" }, - { url = "https://files.pythonhosted.org/packages/be/a9/8da4deb840121c59deabd939ce2dca3d6beec85576f3743d1144441938b5/fastar-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:fbc0f2ed0f4add7fb58034c576584d44d7eaaf93dee721dfb26dbed6e222dbac", size = 490701, upload-time = "2025-11-26T02:36:09.625Z" }, { url = "https://files.pythonhosted.org/packages/cd/15/1c764530b81b266f6d27d78d49b6bef22a73b3300cd83a280bfd244908c5/fastar-0.8.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:cd9c0d3ebf7a0a6f642f771cf41b79f7c98d40a3072a8abe1174fbd9bd615bd3", size = 708427, upload-time = "2025-11-26T02:34:36.502Z" }, { url = "https://files.pythonhosted.org/packages/41/fc/75d42c008516543219e4293e4d8ac55da57a5c63147484f10468bd1bc24e/fastar-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2875a077340fe4f8099bd3ed8fa90d9595e1ac3cd62ae19ab690d5bf550eeb35", size = 631740, upload-time = "2025-11-26T02:34:20.718Z" }, { url = "https://files.pythonhosted.org/packages/50/8d/9632984f7824ed2210157dcebd8e9821ef6d4f2b28510d0516db6625ff9b/fastar-0.8.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a999263d9f87184bf2801833b2ecf105e03c0dd91cac78685673b70da564fd64", size = 871628, upload-time = "2025-11-26T02:33:49.279Z" }, @@ -985,18 +885,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/e2/dfa19a4b260b8ab3581b7484dcb80c09b25324f4daa6b6ae1c7640d1607a/fastar-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:187f61dc739afe45ac8e47ed7fd1adc45d52eac110cf27d579155720507d6fbe", size = 455767, upload-time = "2025-11-26T02:36:34.758Z" }, { url = "https://files.pythonhosted.org/packages/51/47/df65c72afc1297797b255f90c4778b5d6f1f0f80282a134d5ab610310ed9/fastar-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:40e9d763cf8bf85ce2fa256e010aa795c0fe3d3bd1326d5c3084e6ce7857127e", size = 489971, upload-time = "2025-11-26T02:36:22.081Z" }, { url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" }, - { url = "https://files.pythonhosted.org/packages/25/9f/6eaa810c240236eff2edf736cd50a17c97dbab1693cda4f7bcea09d13418/fastar-0.8.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2127cf2e80ffd49744a160201e0e2f55198af6c028a7b3f750026e0b1f1caa4e", size = 710544, upload-time = "2025-11-26T02:34:46.195Z" }, - { url = "https://files.pythonhosted.org/packages/1d/a5/58ff9e49a1cd5fbfc8f1238226cbf83b905376a391a6622cdd396b2cfa29/fastar-0.8.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ff85094f10003801339ac4fa9b20a3410c2d8f284d4cba2dc99de6e98c877812", size = 634020, upload-time = "2025-11-26T02:34:31.085Z" }, - { url = "https://files.pythonhosted.org/packages/80/94/f839257c6600a83fbdb5a7fcc06319599086137b25ba38ca3d2c0fe14562/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:3dbca235f0bd804cca6602fe055d3892bebf95fb802e6c6c7d872fb10f7abc6c", size = 871735, upload-time = "2025-11-26T02:34:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/eb/79/4124c54260f7ee5cb7034bfe499eff2f8512b052d54be4671e59d4f25a4f/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e54bfdee6c81a0005e147319e93d8797f442308032c92fa28d03ef8fda076", size = 766779, upload-time = "2025-11-26T02:32:55.109Z" }, - { url = "https://files.pythonhosted.org/packages/36/b6/043b263c4126bf6557c942d099503989af9c5c7ee5cca9a04e00f754816f/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a78e5221b94a80800930b7fd0d0e797ae73aadf7044c05ed46cb9bdf870f022", size = 766755, upload-time = "2025-11-26T02:33:11.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/ff/29a5dc06f2940439ebf98661ecc98d48d3f22fed8d6a2d5dc985d1e8da24/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:997092d31ff451de8d0568f6773f3517cb87dcd0bc76184edb65d7154390a6f8", size = 932732, upload-time = "2025-11-26T02:33:27.122Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e8/2218830f422b37aad52c24b53cb84b5d88bd6fd6ad411bd6689b1a32500d/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:558e8fcf8fe574541df5db14a46cd98bfbed14a811b7014a54f2b714c0cfac42", size = 822571, upload-time = "2025-11-26T02:33:42.986Z" }, - { url = "https://files.pythonhosted.org/packages/6e/fd/ba6dfeff77cddfe58d85c490b1735c002b81c0d6f826916a8b6c4f8818bc/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1d2a54f87e2908cc19e1a6ee249620174fbefc54a219aba1eaa6f31657683c3", size = 822440, upload-time = "2025-11-26T02:34:15.439Z" }, - { url = "https://files.pythonhosted.org/packages/a7/57/54d5740c84b35de0eb12975397ecc16785b5ad8bed2dbac38b8c8a7c1edd/fastar-0.8.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:ef94901537be277f9ec59db939eb817960496c6351afede5b102699b5098604d", size = 987424, upload-time = "2025-11-26T02:35:02.742Z" }, - { url = "https://files.pythonhosted.org/packages/ee/c7/18115927f16deb1ddffdbd4ae992e7e33064bc6defa2b92a147948f8bc0c/fastar-0.8.0-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:0afbb92f78bf29d5e9db76fb46cbabc429e49015cddf72ab9e761afbe88ac100", size = 1042675, upload-time = "2025-11-26T02:35:20.252Z" }, - { url = "https://files.pythonhosted.org/packages/d7/1a/ca884fc7973ec6d765e87af23a4dd25784fb0a36ac2df825f18c3630bbab/fastar-0.8.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:fb59c7925e7710ad178d9e1a3e65edf295d9a042a0cdcb673b4040949eb8ad0a", size = 1047098, upload-time = "2025-11-26T02:35:37.643Z" }, - { url = "https://files.pythonhosted.org/packages/44/ee/25cd645db749b206bb95e1512e57e75d56ccbbb8ec3536f52a7979deab6b/fastar-0.8.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e6c4d6329da568ec36b1347b0c09c4d27f9dfdeddf9f438ddb16799ecf170098", size = 997397, upload-time = "2025-11-26T02:35:56.215Z" }, { url = "https://files.pythonhosted.org/packages/98/6e/6c46aa7f8c8734e7f96ee5141acd3877667ce66f34eea10703aa7571d191/fastar-0.8.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:998e3fa4b555b63eb134e6758437ed739ad1652fdd2a61dfe1dacbfddc35fe66", size = 710662, upload-time = "2025-11-26T02:34:47.593Z" }, { url = "https://files.pythonhosted.org/packages/70/27/fd622442f2fbd4ff5459677987481ef1c60e077cb4e63a2ed4d8dce6f869/fastar-0.8.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5f83e60d845091f3a12bc37f412774264d161576eaf810ed8b43567eb934b7e5", size = 634049, upload-time = "2025-11-26T02:34:32.365Z" }, { url = "https://files.pythonhosted.org/packages/8f/ee/aa4d08aea25b5419a7277132e738ab1cd775f26aebddce11413b07e2fdff/fastar-0.8.0-pp311-pypy311_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:299672e1c74d8b73c61684fac9159cfc063d35f4b165996a88facb0e26862cb5", size = 872055, upload-time = "2025-11-26T02:34:01.377Z" }, @@ -1026,22 +914,6 @@ version = "1.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, - { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, - { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, - { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, - { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, - { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, - { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, - { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, - { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, - { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, - { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, - { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, @@ -1200,14 +1072,6 @@ version = "3.3.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, - { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, - { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, - { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, - { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, - { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, - { url = "https://files.pythonhosted.org/packages/ac/78/f93e840cbaef8becaf6adafbaf1319682a6c2d8c1c20224267a5c6c8c891/greenlet-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f", size = 230092, upload-time = "2026-02-20T20:17:09.379Z" }, { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, @@ -1254,23 +1118,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] -[[package]] -name = "groq" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3f/12/f4099a141677fcd2ed79dcc1fcec431e60c52e0e90c9c5d935f0ffaf8c0e/groq-1.0.0.tar.gz", hash = "sha256:66cb7bb729e6eb644daac7ce8efe945e99e4eb33657f733ee6f13059ef0c25a9", size = 146068, upload-time = "2025-12-17T23:34:23.115Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/88/3175759d2ef30406ea721f4d837bfa1ba4339fde3b81ba8c5640a96ed231/groq-1.0.0-py3-none-any.whl", hash = "sha256:6e22bf92ffad988f01d2d4df7729add66b8fd5dbfb2154b5bbf3af245b72c731", size = 138292, upload-time = "2025-12-17T23:34:21.957Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -1282,7 +1129,7 @@ wheels = [ [[package]] name = "honcho" -version = "3.0.3" +version = "3.0.6" source = { virtual = "." } dependencies = [ { name = "alembic" }, @@ -1292,7 +1139,6 @@ dependencies = [ { name = "fastapi-pagination" }, { name = "google-genai" }, { name = "greenlet" }, - { name = "groq" }, { name = "httpx" }, { name = "json-repair" }, { name = "lancedb" }, @@ -1310,8 +1156,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "redis" }, { name = "rich" }, - { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scikit-learn" }, { name = "sentry-sdk", extra = ["anthropic", "fastapi", "sqlalchemy"] }, { name = "sqlalchemy" }, { name = "tenacity" }, @@ -1335,8 +1180,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-xdist" }, { name = "ruff" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy" }, { name = "sqlalchemy-utils" }, ] @@ -1349,7 +1193,6 @@ requires-dist = [ { name = "fastapi-pagination", specifier = ">=0.14.2" }, { name = "google-genai", specifier = ">=1.32.0" }, { name = "greenlet", specifier = ">=3.0.3" }, - { name = "groq", specifier = ">=0.31.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "json-repair", specifier = ">=0.49.0" }, { name = "lancedb", specifier = ">=0.25.3" }, @@ -1397,7 +1240,7 @@ dev = [ [[package]] name = "honcho-ai" -version = "2.0.1" +version = "2.1.1" source = { editable = "sdks/python" } dependencies = [ { name = "httpx" }, @@ -1405,6 +1248,11 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] +[package.optional-dependencies] +cli = [ + { name = "honcho-cli" }, +] + [package.dev-dependencies] dev = [ { name = "ruff" }, @@ -1412,14 +1260,44 @@ dev = [ [package.metadata] requires-dist = [ + { name = "honcho-cli", marker = "extra == 'cli'", editable = "honcho-cli" }, { name = "httpx", specifier = ">=0.28.0,<1" }, { name = "pydantic", specifier = ">=2.0.0,<3" }, { name = "typing-extensions", marker = "python_full_version < '3.12'", specifier = ">=4.12.0" }, ] +provides-extras = ["cli"] [package.metadata.requires-dev] dev = [{ name = "ruff", specifier = ">=0.11.13" }] +[[package]] +name = "honcho-cli" +version = "0.1.0" +source = { editable = "honcho-cli" } +dependencies = [ + { name = "honcho-ai" }, + { name = "httpx" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "honcho-ai", editable = "sdks/python" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.14.0" }, + { name = "rich", specifier = ">=13.0.0" }, + { name = "typer", specifier = ">=0.15.0" }, +] +provides-extras = ["dev"] + [[package]] name = "httpcore" version = "1.0.9" @@ -1439,13 +1317,6 @@ version = "0.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" }, - { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" }, - { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" }, - { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" }, - { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" }, { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, @@ -1540,7 +1411,6 @@ dependencies = [ { name = "colorama" }, { name = "py" }, { name = "tabulate" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8b/22/74f7fcc96280eea46cf2bcbfa1354ac31de0e60a4be6f7966f12cef20893/interrogate-1.7.0.tar.gz", hash = "sha256:a320d6ec644dfd887cc58247a345054fc4d9f981100c45184470068f4b3719b0", size = 159636, upload-time = "2024-04-07T22:30:46.217Z" } wheels = [ @@ -1565,18 +1435,6 @@ version = "0.13.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/5a/41da76c5ea07bec1b0472b6b2fdb1b651074d504b19374d7e130e0cdfb25/jiter-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2ffc63785fd6c7977defe49b9824ae6ce2b2e2b77ce539bdaf006c26da06342e", size = 311164, upload-time = "2026-02-02T12:35:17.688Z" }, - { url = "https://files.pythonhosted.org/packages/40/cb/4a1bf994a3e869f0d39d10e11efb471b76d0ad70ecbfb591427a46c880c2/jiter-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a638816427006c1e3f0013eb66d391d7a3acda99a7b0cf091eff4497ccea33a", size = 320296, upload-time = "2026-02-02T12:35:19.828Z" }, - { url = "https://files.pythonhosted.org/packages/09/82/acd71ca9b50ecebadc3979c541cd717cce2fe2bc86236f4fa597565d8f1a/jiter-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19928b5d1ce0ff8c1ee1b9bdef3b5bfc19e8304f1b904e436caf30bc15dc6cf5", size = 352742, upload-time = "2026-02-02T12:35:21.258Z" }, - { url = "https://files.pythonhosted.org/packages/71/03/d1fc996f3aecfd42eb70922edecfb6dd26421c874503e241153ad41df94f/jiter-0.13.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:309549b778b949d731a2f0e1594a3f805716be704a73bf3ad9a807eed5eb5721", size = 363145, upload-time = "2026-02-02T12:35:24.653Z" }, - { url = "https://files.pythonhosted.org/packages/f1/61/a30492366378cc7a93088858f8991acd7d959759fe6138c12a4644e58e81/jiter-0.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcdabaea26cb04e25df3103ce47f97466627999260290349a88c8136ecae0060", size = 487683, upload-time = "2026-02-02T12:35:26.162Z" }, - { url = "https://files.pythonhosted.org/packages/20/4e/4223cffa9dbbbc96ed821c5aeb6bca510848c72c02086d1ed3f1da3d58a7/jiter-0.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a377af27b236abbf665a69b2bdd680e3b5a0bd2af825cd3b81245279a7606c", size = 373579, upload-time = "2026-02-02T12:35:27.582Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c9/b0489a01329ab07a83812d9ebcffe7820a38163c6d9e7da644f926ff877c/jiter-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe49d3ff6db74321f144dff9addd4a5874d3105ac5ba7c5b77fac099cfae31ae", size = 362904, upload-time = "2026-02-02T12:35:28.925Z" }, - { url = "https://files.pythonhosted.org/packages/05/af/53e561352a44afcba9a9bc67ee1d320b05a370aed8df54eafe714c4e454d/jiter-0.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2113c17c9a67071b0f820733c0893ed1d467b5fcf4414068169e5c2cabddb1e2", size = 392380, upload-time = "2026-02-02T12:35:30.385Z" }, - { url = "https://files.pythonhosted.org/packages/76/2a/dd805c3afb8ed5b326c5ae49e725d1b1255b9754b1b77dbecdc621b20773/jiter-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ab1185ca5c8b9491b55ebf6c1e8866b8f68258612899693e24a92c5fdb9455d5", size = 517939, upload-time = "2026-02-02T12:35:31.865Z" }, - { url = "https://files.pythonhosted.org/packages/20/2a/7b67d76f55b8fe14c937e7640389612f05f9a4145fc28ae128aaa5e62257/jiter-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9621ca242547edc16400981ca3231e0c91c0c4c1ab8573a596cd9bb3575d5c2b", size = 551696, upload-time = "2026-02-02T12:35:33.306Z" }, - { url = "https://files.pythonhosted.org/packages/85/9c/57cdd64dac8f4c6ab8f994fe0eb04dc9fd1db102856a4458fcf8a99dfa62/jiter-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a7637d92b1c9d7a771e8c56f445c7f84396d48f2e756e5978840ecba2fac0894", size = 204592, upload-time = "2026-02-02T12:35:34.58Z" }, - { url = "https://files.pythonhosted.org/packages/a7/38/f4f3ea5788b8a5bae7510a678cdc747eda0c45ffe534f9878ff37e7cf3b3/jiter-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c1b609e5cbd2f52bb74fb721515745b407df26d7b800458bd97cb3b972c29e7d", size = 206016, upload-time = "2026-02-02T12:35:36.435Z" }, { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, @@ -1717,8 +1575,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, { name = "lance-namespace" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, { name = "overrides", marker = "python_full_version < '3.12'" }, { name = "packaging" }, { name = "pyarrow" }, @@ -1785,17 +1642,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, @@ -1877,29 +1723,8 @@ wheels = [ name = "multidict" version = "6.7.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, - { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, - { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, - { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, - { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, - { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, - { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, - { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, - { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, - { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, - { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, - { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, - { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, @@ -2045,80 +1870,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/c4/7532325f968ecfc078e8a028e69a52e4c3f95fb800906bf6931ac1e89e2b/nodejs_wheel_binaries-24.13.1-py2.py3-none-win_arm64.whl", hash = "sha256:caec398cb9e94c560bacdcba56b3828df22a355749eb291f47431af88cbf26dc", size = 38881194, upload-time = "2026-02-12T17:31:00.214Z" }, ] -[[package]] -name = "numpy" -version = "2.2.6" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, - { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, - { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, - { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, - { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, - { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, - { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, - { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, - { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, - { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, - { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, - { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, - { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, - { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, - { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, - { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, - { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, - { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, - { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, - { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, - { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, - { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, - { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, - { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, - { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, - { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, - { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, - { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, - { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, - { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, - { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, - { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, - { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, -] - [[package]] name = "numpy" version = "2.4.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version >= '3.11' and python_full_version < '3.13'", -] sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" }, @@ -2301,19 +2056,6 @@ version = "3.11.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1a/a373746fa6d0e116dd9e54371a7b54622c44d12296d5d0f3ad5e3ff33490/orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174", size = 229140, upload-time = "2026-02-02T15:37:06.082Z" }, - { url = "https://files.pythonhosted.org/packages/52/a2/fa129e749d500f9b183e8a3446a193818a25f60261e9ce143ad61e975208/orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67", size = 128670, upload-time = "2026-02-02T15:37:08.002Z" }, - { url = "https://files.pythonhosted.org/packages/08/93/1e82011cd1e0bd051ef9d35bed1aa7fb4ea1f0a055dc2c841b46b43a9ebd/orjson-3.11.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043d3006b7d32c7e233b8cfb1f01c651013ea079e08dcef7189a29abd8befe11", size = 123832, upload-time = "2026-02-02T15:37:09.191Z" }, - { url = "https://files.pythonhosted.org/packages/fe/d8/a26b431ef962c7d55736674dddade876822f3e33223c1f47a36879350d04/orjson-3.11.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57036b27ac8a25d81112eb0cc9835cd4833c5b16e1467816adc0015f59e870dc", size = 129171, upload-time = "2026-02-02T15:37:11.112Z" }, - { url = "https://files.pythonhosted.org/packages/a7/19/f47819b84a580f490da260c3ee9ade214cf4cf78ac9ce8c1c758f80fdfc9/orjson-3.11.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:733ae23ada68b804b222c44affed76b39e30806d38660bf1eb200520d259cc16", size = 141967, upload-time = "2026-02-02T15:37:12.282Z" }, - { url = "https://files.pythonhosted.org/packages/5b/cd/37ece39a0777ba077fdcdbe4cccae3be8ed00290c14bf8afdc548befc260/orjson-3.11.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5fdfad2093bdd08245f2e204d977facd5f871c88c4a71230d5bcbd0e43bf6222", size = 130991, upload-time = "2026-02-02T15:37:13.465Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ed/f2b5d66aa9b6b5c02ff5f120efc7b38c7c4962b21e6be0f00fd99a5c348e/orjson-3.11.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cededd6738e1c153530793998e31c05086582b08315db48ab66649768f326baa", size = 133674, upload-time = "2026-02-02T15:37:14.694Z" }, - { url = "https://files.pythonhosted.org/packages/c4/6e/baa83e68d1aa09fa8c3e5b2c087d01d0a0bd45256de719ed7bc22c07052d/orjson-3.11.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14f440c7268c8f8633d1b3d443a434bd70cb15686117ea6beff8fdc8f5917a1e", size = 138722, upload-time = "2026-02-02T15:37:16.501Z" }, - { url = "https://files.pythonhosted.org/packages/0c/47/7f8ef4963b772cd56999b535e553f7eb5cd27e9dd6c049baee6f18bfa05d/orjson-3.11.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3a2479753bbb95b0ebcf7969f562cdb9668e6d12416a35b0dda79febf89cdea2", size = 409056, upload-time = "2026-02-02T15:37:17.895Z" }, - { url = "https://files.pythonhosted.org/packages/38/eb/2df104dd2244b3618f25325a656f85cc3277f74bbd91224752410a78f3c7/orjson-3.11.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:71924496986275a737f38e3f22b4e0878882b3f7a310d2ff4dc96e812789120c", size = 144196, upload-time = "2026-02-02T15:37:19.349Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2a/ee41de0aa3a6686598661eae2b4ebdff1340c65bfb17fcff8b87138aab21/orjson-3.11.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4a9eefdc70bf8bf9857f0290f973dec534ac84c35cd6a7f4083be43e7170a8f", size = 134979, upload-time = "2026-02-02T15:37:20.906Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fa/92fc5d3d402b87a8b28277a9ed35386218a6a5287c7fe5ee9b9f02c53fb2/orjson-3.11.7-cp310-cp310-win32.whl", hash = "sha256:ae9e0b37a834cef7ce8f99de6498f8fad4a2c0bf6bfc3d02abd8ed56aa15b2de", size = 127968, upload-time = "2026-02-02T15:37:23.178Z" }, - { url = "https://files.pythonhosted.org/packages/07/29/a576bf36d73d60df06904d3844a9df08e25d59eba64363aaf8ec2f9bff41/orjson-3.11.7-cp310-cp310-win_amd64.whl", hash = "sha256:d772afdb22555f0c58cfc741bdae44180122b3616faa1ecadb595cd526e4c993", size = 125128, upload-time = "2026-02-02T15:37:24.329Z" }, { url = "https://files.pythonhosted.org/packages/37/02/da6cb01fc6087048d7f61522c327edf4250f1683a58a839fdcc435746dd5/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c", size = 228664, upload-time = "2026-02-02T15:37:25.542Z" }, { url = "https://files.pythonhosted.org/packages/c1/c2/5885e7a5881dba9a9af51bc564e8967225a642b3e03d089289a35054e749/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b", size = 125344, upload-time = "2026-02-02T15:37:26.92Z" }, { url = "https://files.pythonhosted.org/packages/a4/1d/4e7688de0a92d1caf600dfd5fb70b4c5bfff51dfa61ac555072ef2d0d32a/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e", size = 128404, upload-time = "2026-02-02T15:37:28.108Z" }, @@ -2426,8 +2168,7 @@ name = "pgvector" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/25/6c/6d8b4b03b958c02fa8687ec6063c49d952a189f8c91ebbe51e877dfab8f7/pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a", size = 31354, upload-time = "2025-12-05T01:07:17.87Z" } wheels = [ @@ -2440,17 +2181,6 @@ version = "12.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, - { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, - { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, - { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, - { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, @@ -2581,21 +2311,6 @@ version = "0.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" }, - { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" }, - { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" }, - { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" }, - { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" }, - { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" }, - { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" }, - { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" }, - { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" }, - { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" }, { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, @@ -2727,17 +2442,6 @@ name = "psycopg-binary" version = "3.3.3" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/d8/a763308a41e2ecfb6256ba0877d340c2f2b124c8b2746401863d96fa2c7a/psycopg_binary-3.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b3385b58b2fe408a13d084c14b8dcf468cd36cbbe774408250facc128f9fa75c", size = 4609758, upload-time = "2026-02-18T16:46:33.132Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a9/f8a683e85400c1208685e7c895abc049dc13aa0b6ea989e6adf0a3681fe0/psycopg_binary-3.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1bef235a50a80f6aba05147002bc354559657cb6386dbd04d8e1c97d1d7cbe84", size = 4676740, upload-time = "2026-02-18T16:46:42.904Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7d/03512c4aaac8a58fc3b1221f38293aa517a1950d10ef8646c72c49addc7d/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:97c839717bf8c8df3f6d983a20949c4fb22e2a34ee172e3e427ede363feda27b", size = 5496335, upload-time = "2026-02-18T16:46:51.517Z" }, - { url = "https://files.pythonhosted.org/packages/8a/bc/23319b4b1c2c0b810d225e1b6f16efbb16150074fc0ea96bfcabdf59ee09/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:48e500cf1c0984dacf1f28ea482c3cdbb4c2288d51c336c04bc64198ab21fc51", size = 5172032, upload-time = "2026-02-18T16:47:00.878Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c8/6d61dc0a56654c558a37b2d9b2094e470aa12621305cc7935fd769122e32/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb36a08859b9432d94ea6b26ec41a2f98f83f14868c91321d0c1e11f672eeae7", size = 6763107, upload-time = "2026-02-18T16:47:11.784Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b5/e2a3c90aa1059f5b5f593379caad7be3cc3c2ce1ddfc7730e39854e174fe/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dde92cfde09293fb63b3f547919ba7d73bd2654573c03502b3263dd0218e44e", size = 5006494, upload-time = "2026-02-18T16:47:17.062Z" }, - { url = "https://files.pythonhosted.org/packages/5d/3e/bf126e0a1f864e191b7f3eeea667ee2ce13d582b036255fb8b12946d1f7a/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:78c9ce98caaf82ac8484d269791c1b403d7598633e0e4e2fa1097baae244e2f1", size = 4533850, upload-time = "2026-02-18T16:47:21.673Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d8/bb5e8d395deb945629aa0c65d12ab90ec3bfcbdf56be89e2a84d001864c9/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d593612758d0041cb13cb0003f7f8d3fabb7ad9319e651e78afae49b1cf5860e", size = 4223316, upload-time = "2026-02-18T16:47:25.82Z" }, - { url = "https://files.pythonhosted.org/packages/c2/70/33eef61b0f0fd41ebf93b9699f44067313a45016827f67b3c8cc41f0a7ab/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f24e8e17035200a465c178e9ea945527ad0738118694184c450f1192a452ff25", size = 3954515, upload-time = "2026-02-18T16:47:30.434Z" }, - { url = "https://files.pythonhosted.org/packages/ea/db/27c2b3b9698e713e83e11e8540daa27516f9e90390ec21a41091cb15fcaf/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e7b607f0e14f2a4cf7e78a05ebd13df6144acfba87cb90842e70d3f125d9f53f", size = 4260274, upload-time = "2026-02-18T16:47:36.128Z" }, - { url = "https://files.pythonhosted.org/packages/a1/3b/71e5d603059bf5474215f573a3e2d357a4e95672b26e04d41674400d4862/psycopg_binary-3.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:b27d3a23c79fa59557d2cc63a7e8bb4c7e022c018558eda36f9d7c4e6b99a6e0", size = 3557375, upload-time = "2026-02-18T16:47:42.799Z" }, { url = "https://files.pythonhosted.org/packages/be/c0/b389119dd754483d316805260f3e73cdcad97925839107cc7a296f6132b1/psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048", size = 4609740, upload-time = "2026-02-18T16:47:51.093Z" }, { url = "https://files.pythonhosted.org/packages/cf/e3/9976eef20f61840285174d360da4c820a311ab39d6b82fa09fbb545be825/psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181", size = 4676837, upload-time = "2026-02-18T16:47:55.523Z" }, { url = "https://files.pythonhosted.org/packages/9f/f2/d28ba2f7404fd7f68d41e8a11df86313bd646258244cb12a8dd83b868a97/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13", size = 5497070, upload-time = "2026-02-18T16:47:59.929Z" }, @@ -2814,13 +2518,6 @@ version = "23.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/a8/24e5dc6855f50a62936ceb004e6e9645e4219a8065f304145d7fb8a79d5d/pyarrow-23.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:3fab8f82571844eb3c460f90a75583801d14ca0cc32b1acc8c361650e006fd56", size = 34307390, upload-time = "2026-02-16T10:08:08.654Z" }, - { url = "https://files.pythonhosted.org/packages/bc/8e/4be5617b4aaae0287f621ad31c6036e5f63118cfca0dc57d42121ff49b51/pyarrow-23.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:3f91c038b95f71ddfc865f11d5876c42f343b4495535bd262c7b321b0b94507c", size = 35853761, upload-time = "2026-02-16T10:08:17.811Z" }, - { url = "https://files.pythonhosted.org/packages/2e/08/3e56a18819462210432ae37d10f5c8eed3828be1d6c751b6e6a2e93c286a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:d0744403adabef53c985a7f8a082b502a368510c40d184df349a0a8754533258", size = 44493116, upload-time = "2026-02-16T10:08:25.792Z" }, - { url = "https://files.pythonhosted.org/packages/f8/82/c40b68001dbec8a3faa4c08cd8c200798ac732d2854537c5449dc859f55a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c33b5bf406284fd0bba436ed6f6c3ebe8e311722b441d89397c54f871c6863a2", size = 47564532, upload-time = "2026-02-16T10:08:34.27Z" }, - { url = "https://files.pythonhosted.org/packages/20/bc/73f611989116b6f53347581b02177f9f620efdf3cd3f405d0e83cdf53a83/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ddf743e82f69dcd6dbbcb63628895d7161e04e56794ef80550ac6f3315eeb1d5", size = 48183685, upload-time = "2026-02-16T10:08:42.889Z" }, - { url = "https://files.pythonhosted.org/packages/b0/cc/6c6b3ecdae2a8c3aced99956187e8302fc954cc2cca2a37cf2111dad16ce/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e052a211c5ac9848ae15d5ec875ed0943c0221e2fcfe69eee80b604b4e703222", size = 50605582, upload-time = "2026-02-16T10:08:51.641Z" }, - { url = "https://files.pythonhosted.org/packages/8d/94/d359e708672878d7638a04a0448edf7c707f9e5606cee11e15aaa5c7535a/pyarrow-23.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:5abde149bb3ce524782d838eb67ac095cd3fd6090eba051130589793f1a7f76d", size = 27521148, upload-time = "2026-02-16T10:08:58.077Z" }, { url = "https://files.pythonhosted.org/packages/b0/41/8e6b6ef7e225d4ceead8459427a52afdc23379768f54dd3566014d7618c1/pyarrow-23.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6f0147ee9e0386f519c952cc670eb4a8b05caa594eeffe01af0e25f699e4e9bb", size = 34302230, upload-time = "2026-02-16T10:09:03.859Z" }, { url = "https://files.pythonhosted.org/packages/bf/4a/1472c00392f521fea03ae93408bf445cc7bfa1ab81683faf9bc188e36629/pyarrow-23.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:0ae6e17c828455b6265d590100c295193f93cc5675eb0af59e49dbd00d2de350", size = 35850050, upload-time = "2026-02-16T10:09:11.877Z" }, { url = "https://files.pythonhosted.org/packages/0c/b2/bd1f2f05ded56af7f54d702c8364c9c43cd6abb91b0e9933f3d77b4f4132/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:fed7020203e9ef273360b9e45be52a2a47d3103caf156a30ace5247ffb51bdbd", size = 44491918, upload-time = "2026-02-16T10:09:18.144Z" }, @@ -2892,25 +2589,6 @@ version = "1.4.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/aa/b8/4ed5c7ad5ec15b08d35cc79ace6145d5c1ae426e46435f4987379439dfea/pybase64-1.4.3.tar.gz", hash = "sha256:c2ed274c9e0ba9c8f9c4083cfe265e66dd679126cd9c2027965d807352f3f053", size = 137272, upload-time = "2025-12-06T13:27:04.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/47/16d7af6fae7803f4c691856bc0d8d433ccf30e106432e2ef7707ee19a38a/pybase64-1.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f63aa7f29139b8a05ce5f97cdb7fad63d29071e5bdc8a638a343311fe996112a", size = 38241, upload-time = "2025-12-06T13:22:27.396Z" }, - { url = "https://files.pythonhosted.org/packages/4d/3e/268beb8d2240ab55396af4d1b45d2494935982212549b92a5f5b57079bd3/pybase64-1.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f5943ec1ae87a8b4fe310905bb57205ea4330c75e2c628433a7d9dd52295b588", size = 31672, upload-time = "2025-12-06T13:22:28.854Z" }, - { url = "https://files.pythonhosted.org/packages/80/14/4365fa33222edcc46b6db4973f9e22bda82adfb6ab2a01afff591f1e41c8/pybase64-1.4.3-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5f2b8aef86f35cd5894c13681faf433a1fffc5b2e76544dcb5416a514a1a8347", size = 65978, upload-time = "2025-12-06T13:22:30.191Z" }, - { url = "https://files.pythonhosted.org/packages/1c/22/e89739d8bc9b96c68ead44b4eec42fe555683d9997e4ba65216d384920fc/pybase64-1.4.3-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6ec7e53dd09b0a8116ccf5c3265c7c7fce13c980747525be76902aef36a514a", size = 68903, upload-time = "2025-12-06T13:22:31.29Z" }, - { url = "https://files.pythonhosted.org/packages/77/e1/7e59a19f8999cdefe9eb0d56bfd701dd38263b0f6fb4a4d29fce165a1b36/pybase64-1.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7528604cd69c538e1dbaafded46e9e4915a2adcd6f2a60fcef6390d87ca922ea", size = 57516, upload-time = "2025-12-06T13:22:32.395Z" }, - { url = "https://files.pythonhosted.org/packages/42/ad/f47dc7e6fe32022b176868b88b671a32dab389718c8ca905cab79280aaaf/pybase64-1.4.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:4ec645f32b50593879031e09158f8681a1db9f5df0f72af86b3969a1c5d1fa2b", size = 54533, upload-time = "2025-12-06T13:22:33.457Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/7ab312b5a324833953b00e47b23eb4f83d45bd5c5c854b4b4e51b2a0cf5b/pybase64-1.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:634a000c5b3485ccc18bb9b244e0124f74b6fbc7f43eade815170237a7b34c64", size = 57187, upload-time = "2025-12-06T13:22:34.566Z" }, - { url = "https://files.pythonhosted.org/packages/2c/84/80acab1fcbaaae103e6b862ef5019192c8f2cd8758433595a202179a0d1d/pybase64-1.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:309ea32ad07639a485580af1be0ad447a434deb1924e76adced63ac2319cfe15", size = 57730, upload-time = "2025-12-06T13:22:35.581Z" }, - { url = "https://files.pythonhosted.org/packages/1f/24/84256d472400ea3163d7d69c44bb7e2e1027f0f1d4d20c47629a7dc4578e/pybase64-1.4.3-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:d10d517566b748d3f25f6ac7162af779360c1c6426ad5f962927ee205990d27c", size = 53036, upload-time = "2025-12-06T13:22:36.621Z" }, - { url = "https://files.pythonhosted.org/packages/a3/0f/33aecbed312ee0431798a73fa25e00dedbffdd91389ee23121fed397c550/pybase64-1.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a74cc0f4d835400857cc5c6d27ec854f7949491e07a04e6d66e2137812831f4c", size = 56321, upload-time = "2025-12-06T13:22:37.7Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1c/a341b050746658cbec8cab3c733aeb3ef52ce8f11e60d0d47adbdf729ebf/pybase64-1.4.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1b591d774ac09d5eb73c156a03277cb271438fbd8042bae4109ff3a827cd218c", size = 50114, upload-time = "2025-12-06T13:22:38.752Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d3/f7e6680ae6dc4ddff39112ad66e0fa6b2ec346e73881bafc08498c560bc0/pybase64-1.4.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5eb588d35a04302ef6157d17db62354a787ac6f8b1585dd0b90c33d63a97a550", size = 66570, upload-time = "2025-12-06T13:22:40.221Z" }, - { url = "https://files.pythonhosted.org/packages/4c/71/774748eecc7fe23869b7e5df028e3c4c2efa16b506b83ea3fa035ea95dc2/pybase64-1.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df8b122d5be2c96962231cc4831d9c2e1eae6736fb12850cec4356d8b06fe6f8", size = 55700, upload-time = "2025-12-06T13:22:41.289Z" }, - { url = "https://files.pythonhosted.org/packages/b3/91/dd15075bb2fe0086193e1cd4bad80a43652c38d8a572f9218d46ba721802/pybase64-1.4.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:31b7a85c661fc591bbcce82fb8adaebe2941e6a83b08444b0957b77380452a4b", size = 52491, upload-time = "2025-12-06T13:22:42.628Z" }, - { url = "https://files.pythonhosted.org/packages/7b/27/f357d63ea3774c937fc47160e040419ed528827aa3d4306d5ec9826259c0/pybase64-1.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e6d7beaae65979fef250e25e66cf81c68a8f81910bcda1a2f43297ab486a7e4e", size = 53957, upload-time = "2025-12-06T13:22:44.615Z" }, - { url = "https://files.pythonhosted.org/packages/b3/c3/243693771701a54e67ff5ccbf4c038344f429613f5643169a7befc51f007/pybase64-1.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4a6276bc3a3962d172a2b5aba544d89881c4037ea954517b86b00892c703d007", size = 68422, upload-time = "2025-12-06T13:22:45.641Z" }, - { url = "https://files.pythonhosted.org/packages/75/95/f987081bf6bc1d1eda3012dae1b06ad427732ef9933a632cb8b58f9917f8/pybase64-1.4.3-cp310-cp310-win32.whl", hash = "sha256:4bdd07ef017515204ee6eaab17e1ad05f83c0ccb5af8ae24a0fe6d9cb5bb0b7a", size = 33622, upload-time = "2025-12-06T13:22:47.348Z" }, - { url = "https://files.pythonhosted.org/packages/79/28/c169a769fe90128f16d394aad87b2096dd4bf2f035ae0927108a46b617df/pybase64-1.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:5db0b6bbda15110db2740c61970a8fda3bf9c93c3166a3f57f87c7865ed1125c", size = 35799, upload-time = "2025-12-06T13:22:48.731Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f2/bdbe6af0bd4f3fe5bc70e77ead7f7d523bb9d3ca3ad50ac42b9adbb9ca14/pybase64-1.4.3-cp310-cp310-win_arm64.whl", hash = "sha256:f96367dfc82598569aa02b1103ebd419298293e59e1151abda2b41728703284b", size = 31158, upload-time = "2025-12-06T13:22:50.021Z" }, { url = "https://files.pythonhosted.org/packages/2b/63/21e981e9d3f1f123e0b0ee2130112b1956cad9752309f574862c7ae77c08/pybase64-1.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70b0d4a4d54e216ce42c2655315378b8903933ecfa32fced453989a92b4317b2", size = 38237, upload-time = "2025-12-06T13:22:52.159Z" }, { url = "https://files.pythonhosted.org/packages/92/fb/3f448e139516404d2a3963915cc10dc9dde7d3a67de4edba2f827adfef17/pybase64-1.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8127f110cdee7a70e576c5c9c1d4e17e92e76c191869085efbc50419f4ae3c72", size = 31673, upload-time = "2025-12-06T13:22:53.241Z" }, { url = "https://files.pythonhosted.org/packages/3c/fb/bb06a5b9885e7d853ac1e801c4d8abfdb4c8506deee33e53d55aa6690e67/pybase64-1.4.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f9ef0388878bc15a084bd9bf73ec1b2b4ee513d11009b1506375e10a7aae5032", size = 68331, upload-time = "2025-12-06T13:22:54.197Z" }, @@ -3045,12 +2723,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/8f/43c3bb11ca9bacf81cb0b7a71500bb65b2eda6d5fe07433c09b543de97f3/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5c29a582b0ea3936d02bd6fe9bf674ab6059e6e45ab71c78404ab2c913224414", size = 43461, upload-time = "2025-12-06T13:26:28.906Z" }, { url = "https://files.pythonhosted.org/packages/2d/4c/2a5258329200be57497d3972b5308558c6de42e3749c6cc2aa1cbe34b25a/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6b664758c804fa919b4f1257aa8cf68e95db76fc331de5f70bfc3a34655afe1", size = 36058, upload-time = "2025-12-06T13:26:30.092Z" }, { url = "https://files.pythonhosted.org/packages/ea/6d/41faa414cde66ec023b0ca8402a8f11cb61731c3dc27c082909cbbd1f929/pybase64-1.4.3-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:f7537fa22ae56a0bf51e4b0ffc075926ad91c618e1416330939f7ef366b58e3b", size = 36231, upload-time = "2025-12-06T13:26:31.656Z" }, - { url = "https://files.pythonhosted.org/packages/2a/cf/6e712491bd665ea8633efb0b484121893ea838d8e830e06f39f2aae37e58/pybase64-1.4.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:94cf50c36bb2f8618982ee5a978c4beed9db97d35944fa96e8586dd953c7994a", size = 38007, upload-time = "2025-12-06T13:26:32.804Z" }, - { url = "https://files.pythonhosted.org/packages/38/c0/9272cae1c49176337dcdbd97511e2843faae1aaf5a5fb48569093c6cd4ce/pybase64-1.4.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:01bc3ff5ca1341685c6d2d945b035f442f7b9c3b068a5c6ee8408a41fda5754e", size = 31538, upload-time = "2025-12-06T13:26:34.001Z" }, - { url = "https://files.pythonhosted.org/packages/20/f2/17546f97befe429c73f622bbd869ceebb518c40fdb0dec4c4f98312e80a5/pybase64-1.4.3-pp310-pypy310_pp73-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:03d0aa3761a99034960496280c02aa063f856a3cc9b33771bc4eab0e4e72b5c2", size = 40682, upload-time = "2025-12-06T13:26:35.168Z" }, - { url = "https://files.pythonhosted.org/packages/92/a0/464b36d5dfb61f3da17858afaeaa876a9342d58e9f17803ce7f28b5de9e8/pybase64-1.4.3-pp310-pypy310_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7ca5b1ce768520acd6440280cdab35235b27ad2faacfcec064bc9c3377066ef1", size = 41306, upload-time = "2025-12-06T13:26:36.351Z" }, - { url = "https://files.pythonhosted.org/packages/07/c9/a748dfc0969a8d960ecf1e82c8a2a16046ffec22f8e7ece582aa3b1c6cf9/pybase64-1.4.3-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3caa1e2ddad1c50553ffaaa1c86b74b3f9fbd505bea9970326ab88fc68c4c184", size = 35452, upload-time = "2025-12-06T13:26:37.772Z" }, - { url = "https://files.pythonhosted.org/packages/95/b7/4d37bd3577d1aa6c732dc099087fe027c48873e223de3784b095e5653f8b/pybase64-1.4.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bd47076f736b27a8b0f9b30d93b6bb4f5af01b0dc8971f883ed3b75934f39a99", size = 36125, upload-time = "2025-12-06T13:26:39.78Z" }, { url = "https://files.pythonhosted.org/packages/b2/76/160dded493c00d3376d4ad0f38a2119c5345de4a6693419ad39c3565959b/pybase64-1.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:277de6e03cc9090fb359365c686a2a3036d23aee6cd20d45d22b8c89d1247f17", size = 37939, upload-time = "2025-12-06T13:26:41.014Z" }, { url = "https://files.pythonhosted.org/packages/b7/b8/a0f10be8d648d6f8f26e560d6e6955efa7df0ff1e009155717454d76f601/pybase64-1.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ab1dd8b1ed2d1d750260ed58ab40defaa5ba83f76a30e18b9ebd5646f6247ae5", size = 31466, upload-time = "2025-12-06T13:26:42.539Z" }, { url = "https://files.pythonhosted.org/packages/d3/22/832a2f9e76cdf39b52e01e40d8feeb6a04cf105494f2c3e3126d0149717f/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:bd4d2293de9fd212e294c136cec85892460b17d24e8c18a6ba18750928037750", size = 40681, upload-time = "2025-12-06T13:26:43.782Z" }, @@ -3097,19 +2769,6 @@ dependencies = [ ] 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" }, @@ -3188,14 +2847,6 @@ wheels = [ { 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" }, @@ -3286,12 +2937,10 @@ version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] 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 = [ @@ -3303,7 +2952,6 @@ name = "pytest-asyncio" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -3326,6 +2974,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "pytest-xdist" version = "3.8.0" @@ -3375,15 +3035,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, @@ -3451,23 +3102,6 @@ version = "2026.2.19" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ff/c0/d8079d4f6342e4cec5c3e7d7415b5cd3e633d5f4124f7a4626908dbe84c7/regex-2026.2.19.tar.gz", hash = "sha256:6fb8cb09b10e38f3ae17cc6dc04a1df77762bd0351b6ba9041438e7cc85ec310", size = 414973, upload-time = "2026-02-19T19:03:47.899Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/de/f10b4506acfd684de4e42b0aa56ccea1a778a18864da8f6d319a40591062/regex-2026.2.19-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f5a37a17d110f9d5357a43aa7e3507cb077bf3143d1c549a45c4649e90e40a70", size = 488369, upload-time = "2026-02-19T18:59:45.01Z" }, - { url = "https://files.pythonhosted.org/packages/8b/2f/b4eaef1f0b4d0bf2a73eaf07c08f6c13422918a4180c9211ce0521746d0c/regex-2026.2.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:676c4e6847a83a1d5732b4ed553881ad36f0a8133627bb695a89ecf3571499d3", size = 290743, upload-time = "2026-02-19T18:59:48.527Z" }, - { url = "https://files.pythonhosted.org/packages/76/7c/805413bd0a88d04688c0725c222cfb811bd54a2f571004c24199a1ae55d6/regex-2026.2.19-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82336faeecac33297cd42857c3b36f12b91810e3fdd276befdd128f73a2b43fa", size = 288652, upload-time = "2026-02-19T18:59:50.2Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/2c4cd530a878b1975398e76faef4285f11e7c9ccf1aaedfd528bfcc1f580/regex-2026.2.19-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52136f5b71f095cb74b736cc3a1b578030dada2e361ef2f07ca582240b703946", size = 781759, upload-time = "2026-02-19T18:59:51.836Z" }, - { url = "https://files.pythonhosted.org/packages/37/45/9608ab1b41f6740ff4076eabadde8e8b3f3400942b348ac41e8599ccc131/regex-2026.2.19-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4192464fe3e6cb0ef6751f7d3b16f886d8270d359ed1590dd555539d364f0ff7", size = 850947, upload-time = "2026-02-19T18:59:53.739Z" }, - { url = "https://files.pythonhosted.org/packages/90/3a/66471b6c4f7cac17e14bf5300e46661bba2b17ffb0871bd2759e837a6f82/regex-2026.2.19-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e561dd47a85d2660d3d3af4e6cb2da825cf20f121e577147963f875b83d32786", size = 898794, upload-time = "2026-02-19T18:59:55.993Z" }, - { url = "https://files.pythonhosted.org/packages/c2/d2/38c53929a5931f7398e5e49f5a5a3079cb2aba30119b4350608364cfad8c/regex-2026.2.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00ec994d7824bf01cd6c7d14c7a6a04d9aeaf7c42a2bc22d2359d715634d539b", size = 791922, upload-time = "2026-02-19T18:59:58.216Z" }, - { url = "https://files.pythonhosted.org/packages/8b/bd/b046e065630fa25059d9c195b7b5308ea94da45eee65d40879772500f74c/regex-2026.2.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2cb00aabd96b345d56a8c2bc328c8d6c4d29935061e05078bf1f02302e12abf5", size = 783345, upload-time = "2026-02-19T18:59:59.948Z" }, - { url = "https://files.pythonhosted.org/packages/d4/8f/045c643d2fa255a985e8f87d848e4be230b711a8935e4bdc58e60b8f7b84/regex-2026.2.19-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f374366ed35673ea81b86a8859c457d4fae6ba092b71024857e9e237410c7404", size = 768055, upload-time = "2026-02-19T19:00:01.65Z" }, - { url = "https://files.pythonhosted.org/packages/72/9f/ab7ae9f5447559562f1a788bbc85c0e526528c5e6c20542d18e4afc86aad/regex-2026.2.19-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9417fd853fcd00b7d55167e692966dd12d95ba1a88bf08a62002ccd85030790", size = 774955, upload-time = "2026-02-19T19:00:03.368Z" }, - { url = "https://files.pythonhosted.org/packages/37/5c/f16fc23c56f60b6f4ff194604a6e53bb8aec7b6e8e4a23a482dee8d77235/regex-2026.2.19-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:12e86a01594031abf892686fcb309b041bf3de3d13d99eb7e2b02a8f3c687df1", size = 846010, upload-time = "2026-02-19T19:00:05.079Z" }, - { url = "https://files.pythonhosted.org/packages/51/c8/6be4c854135d7c9f35d4deeafdaf124b039ecb4ffcaeb7ed0495ad2c97ca/regex-2026.2.19-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:79014115e6fdf18fd9b32e291d58181bf42d4298642beaa13fd73e69810e4cb6", size = 755938, upload-time = "2026-02-19T19:00:07.148Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8d/f683d49b9663a5324b95a328e69d397f6dade7cb84154eec116bf79fe150/regex-2026.2.19-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:31aefac2506967b7dd69af2c58eca3cc8b086d4110b66d6ac6e9026f0ee5b697", size = 835773, upload-time = "2026-02-19T19:00:08.939Z" }, - { url = "https://files.pythonhosted.org/packages/16/cd/619224b90da09f167fe4497c350a0d0b30edc539ee9244bf93e604c073c3/regex-2026.2.19-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:49cef7bb2a491f91a8869c7cdd90babf0a417047ab0bf923cd038ed2eab2ccb8", size = 780075, upload-time = "2026-02-19T19:00:10.838Z" }, - { url = "https://files.pythonhosted.org/packages/5b/88/19cfb0c262d6f9d722edef29157125418bf90eb3508186bf79335afeedae/regex-2026.2.19-cp310-cp310-win32.whl", hash = "sha256:3a039474986e7a314ace6efb9ce52f5da2bdb80ac4955358723d350ec85c32ad", size = 266004, upload-time = "2026-02-19T19:00:12.371Z" }, - { url = "https://files.pythonhosted.org/packages/82/af/5b487e0287ef72545d7ae92edecdacbe3d44e531cac24fda7de5598ba8dd/regex-2026.2.19-cp310-cp310-win_amd64.whl", hash = "sha256:5b81ff4f9cad99f90c807a00c5882fbcda86d8b3edd94e709fb531fc52cb3d25", size = 277895, upload-time = "2026-02-19T19:00:13.75Z" }, - { url = "https://files.pythonhosted.org/packages/4c/19/b6715a187ffca4d2979af92a46ce922445ba41f910bf187ccd666a2d52ef/regex-2026.2.19-cp310-cp310-win_arm64.whl", hash = "sha256:a032bc01a4bc73fc3cadba793fce28eb420da39338f47910c59ffcc11a5ba5ef", size = 270465, upload-time = "2026-02-19T19:00:15.127Z" }, { url = "https://files.pythonhosted.org/packages/6f/93/43f405a98f54cc59c786efb4fc0b644615ed2392fc89d57d30da11f35b5b/regex-2026.2.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:93b16a18cadb938f0f2306267161d57eb33081a861cee9ffcd71e60941eb5dfc", size = 488365, upload-time = "2026-02-19T19:00:17.857Z" }, { url = "https://files.pythonhosted.org/packages/66/46/da0efce22cd8f5ae28eeb25ac69703f49edcad3331ac22440776f4ea0867/regex-2026.2.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:78af1e499cab704131f6f4e2f155b7f54ce396ca2acb6ef21a49507e4752e0be", size = 290737, upload-time = "2026-02-19T19:00:19.869Z" }, { url = "https://files.pythonhosted.org/packages/fb/19/f735078448132c1c974974d30d5306337bc297fe6b6f126164bff72c1019/regex-2026.2.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eb20c11aa4c3793c9ad04c19a972078cdadb261b8429380364be28e867a843f2", size = 288654, upload-time = "2026-02-19T19:00:21.307Z" }, @@ -3614,20 +3248,6 @@ version = "0.7.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/7a/b970cd0138b0ece72eb28f086e933f9ed75b795716ad3de5ab22994b3b54/rignore-0.7.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f3c74a7e5ee77aea669c95fdb3933f2a6c7549893700082e759128a29cf67e45", size = 884999, upload-time = "2025-11-05T20:42:38.373Z" }, - { url = "https://files.pythonhosted.org/packages/ca/05/23faca29616d8966ada63fb0e13c214107811fa9a0aba2275e4c7ca63bd5/rignore-0.7.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b7202404958f5fe3474bac91f65350f0b1dde1a5e05089f2946549b7e91e79ec", size = 824824, upload-time = "2025-11-05T20:42:22.1Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/05a1e61f04cf2548524224f0b5f21ca19ea58f7273a863bac10846b8ff69/rignore-0.7.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bde7c5835fa3905bfb7e329a4f1d7eccb676de63da7a3f934ddd5c06df20597", size = 899121, upload-time = "2025-11-05T20:40:48.94Z" }, - { url = "https://files.pythonhosted.org/packages/ff/35/71518847e10bdbf359badad8800e4681757a01f4777b3c5e03dbde8a42d8/rignore-0.7.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:626c3d4ba03af266694d25101bc1d8d16eda49c5feb86cedfec31c614fceca7d", size = 873813, upload-time = "2025-11-05T20:41:04.71Z" }, - { url = "https://files.pythonhosted.org/packages/f6/c8/32ae405d3e7fd4d9f9b7838f2fcca0a5005bb87fa514b83f83fd81c0df22/rignore-0.7.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a43841e651e7a05a4274b9026cc408d1912e64016ede8cd4c145dae5d0635be", size = 1168019, upload-time = "2025-11-05T20:41:20.723Z" }, - { url = "https://files.pythonhosted.org/packages/25/98/013c955982bc5b4719bf9a5bea58be317eea28aa12bfd004025e3cd7c000/rignore-0.7.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7978c498dbf7f74d30cdb8859fe612167d8247f0acd377ae85180e34490725da", size = 942822, upload-time = "2025-11-05T20:41:36.99Z" }, - { url = "https://files.pythonhosted.org/packages/90/fb/9a3f3156c6ed30bcd597e63690353edac1fcffe9d382ad517722b56ac195/rignore-0.7.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d22f72ab695c07d2d96d2a645208daff17084441b5d58c07378c9dd6f9c4c87", size = 959820, upload-time = "2025-11-05T20:42:06.364Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b2/93bf609633021e9658acaff24cfb055d8cdaf7f5855d10ebb35307900dda/rignore-0.7.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d5bd8e1a91ed1a789b2cbe39eeea9204a6719d4f2cf443a9544b521a285a295f", size = 985050, upload-time = "2025-11-05T20:41:51.124Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/ec2d040469bdfd7b743df10f2201c5d285009a4263d506edbf7a06a090bb/rignore-0.7.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fc03efad5789365018e94ac4079f851a999bc154d1551c45179f7fcf45322", size = 1079164, upload-time = "2025-11-05T21:40:10.368Z" }, - { url = "https://files.pythonhosted.org/packages/df/26/4b635f4ea5baf4baa8ba8eee06163f6af6e76dfbe72deb57da34bb24b19d/rignore-0.7.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ce2617fe28c51367fd8abfd4eeea9e61664af63c17d4ea00353d8ef56dfb95fa", size = 1139028, upload-time = "2025-11-05T21:40:27.977Z" }, - { url = "https://files.pythonhosted.org/packages/6a/54/a3147ebd1e477b06eb24e2c2c56d951ae5faa9045b7b36d7892fec5080d9/rignore-0.7.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7c4ad2cee85068408e7819a38243043214e2c3047e9bd4c506f8de01c302709e", size = 1119024, upload-time = "2025-11-05T21:40:45.148Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f4/27475db769a57cff18fe7e7267b36e6cdb5b1281caa185ba544171106cba/rignore-0.7.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:02cd240bfd59ecc3907766f4839cbba20530a2e470abca09eaa82225e4d946fb", size = 1128531, upload-time = "2025-11-05T21:41:02.734Z" }, - { url = "https://files.pythonhosted.org/packages/97/32/6e782d3b352e4349fa0e90bf75b13cb7f11d8908b36d9e2b262224b65d9a/rignore-0.7.6-cp310-cp310-win32.whl", hash = "sha256:fe2bd8fa1ff555259df54c376abc73855cb02628a474a40d51b358c3a1ddc55b", size = 646817, upload-time = "2025-11-05T21:41:47.51Z" }, - { url = "https://files.pythonhosted.org/packages/c0/8a/53185c69abb3bb362e8a46b8089999f820bf15655629ff8395107633c8ab/rignore-0.7.6-cp310-cp310-win_amd64.whl", hash = "sha256:d80afd6071c78baf3765ec698841071b19e41c326f994cfa69b5a1df676f5d39", size = 727001, upload-time = "2025-11-05T21:41:32.778Z" }, { url = "https://files.pythonhosted.org/packages/25/41/b6e2be3069ef3b7f24e35d2911bd6deb83d20ed5642ad81d5a6d1c015473/rignore-0.7.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:40be8226e12d6653abbebaffaea2885f80374c1c8f76fe5ca9e0cadd120a272c", size = 885285, upload-time = "2025-11-05T20:42:39.763Z" }, { url = "https://files.pythonhosted.org/packages/52/66/ba7f561b6062402022887706a7f2b2c2e2e2a28f1e3839202b0a2f77e36d/rignore-0.7.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:182f4e5e4064d947c756819446a7d4cdede8e756b8c81cf9e509683fe38778d7", size = 823882, upload-time = "2025-11-05T20:42:23.488Z" }, { url = "https://files.pythonhosted.org/packages/f5/81/4087453df35a90b07370647b19017029324950c1b9137d54bf1f33843f17/rignore-0.7.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16b63047648a916a87be1e51bb5c009063f1b8b6f5afe4f04f875525507e63dc", size = 899362, upload-time = "2025-11-05T20:40:51.111Z" }, @@ -3703,18 +3323,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" }, { url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" }, { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, - { url = "https://files.pythonhosted.org/packages/85/12/62d690b4644c330d7ac0f739b7f078190ab4308faa909a60842d0e4af5b2/rignore-0.7.6-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3d3a523af1cd4ed2c0cba8d277a32d329b0c96ef9901fb7ca45c8cfaccf31a5", size = 887462, upload-time = "2025-11-05T20:42:50.804Z" }, - { url = "https://files.pythonhosted.org/packages/05/bc/6528a0e97ed2bd7a7c329183367d1ffbc5b9762ae8348d88dae72cc9d1f5/rignore-0.7.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:990853566e65184a506e1e2af2d15045afad3ebaebb8859cb85b882081915110", size = 826918, upload-time = "2025-11-05T20:42:33.689Z" }, - { url = "https://files.pythonhosted.org/packages/3e/2c/7d7bad116e09a04e9e1688c6f891fa2d4fd33f11b69ac0bd92419ddebeae/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cab9ff2e436ce7240d7ee301c8ef806ed77c1fd6b8a8239ff65f9bbbcb5b8a3", size = 900922, upload-time = "2025-11-05T20:41:00.361Z" }, - { url = "https://files.pythonhosted.org/packages/09/ba/e5ea89fbde8e37a90ce456e31c5e9d85512cef5ae38e0f4d2426eb776a19/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d1a6671b2082c13bfd9a5cf4ce64670f832a6d41470556112c4ab0b6519b2fc4", size = 876987, upload-time = "2025-11-05T20:41:16.219Z" }, - { url = "https://files.pythonhosted.org/packages/d0/fb/93d14193f0ec0c3d35b763f0a000e9780f63b2031f3d3756442c2152622d/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2468729b4c5295c199d084ab88a40afcb7c8b974276805105239c07855bbacee", size = 1171110, upload-time = "2025-11-05T20:41:32.631Z" }, - { url = "https://files.pythonhosted.org/packages/9e/46/08436312ff96ffa29cfa4e1a987efc37e094531db46ba5e9fda9bb792afd/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:775710777fd71e5fdf54df69cdc249996a1d6f447a2b5bfb86dbf033fddd9cf9", size = 943339, upload-time = "2025-11-05T20:41:47.128Z" }, - { url = "https://files.pythonhosted.org/packages/34/28/3b3c51328f505cfaf7e53f408f78a1e955d561135d02f9cb0341ea99f69a/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4565407f4a77f72cf9d91469e75d15d375f755f0a01236bb8aaa176278cc7085", size = 961680, upload-time = "2025-11-05T20:42:18.061Z" }, - { url = "https://files.pythonhosted.org/packages/5c/9e/cbff75c8676d4f4a90bd58a1581249d255c7305141b0868f0abc0324836b/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc44c33f8fb2d5c9da748de7a6e6653a78aa740655e7409895e94a247ffa97c8", size = 987045, upload-time = "2025-11-05T20:42:02.315Z" }, - { url = "https://files.pythonhosted.org/packages/8c/25/d802d1d369502a7ddb8816059e7c79d2d913e17df975b863418e0aca4d8a/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:8f32478f05540513c11923e8838afab9efef0131d66dca7f67f0e1bbd118af6a", size = 1080310, upload-time = "2025-11-05T21:40:23.184Z" }, - { url = "https://files.pythonhosted.org/packages/43/f0/250b785c2e473b1ab763eaf2be820934c2a5409a722e94b279dddac21c7d/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:1b63a3dd76225ea35b01dd6596aa90b275b5d0f71d6dc28fce6dd295d98614aa", size = 1140998, upload-time = "2025-11-05T21:40:40.603Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d6/bb42fd2a8bba6aea327962656e20621fd495523259db40cfb4c5f760f05c/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:fe6c41175c36554a4ef0994cd1b4dbd6d73156fca779066456b781707402048e", size = 1121178, upload-time = "2025-11-05T21:40:57.585Z" }, - { url = "https://files.pythonhosted.org/packages/97/f4/aeb548374129dce3dc191a4bb598c944d9ed663f467b9af830315d86059c/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9a0c6792406ae36f4e7664dc772da909451d46432ff8485774526232d4885063", size = 1130190, upload-time = "2025-11-05T21:41:16.403Z" }, { url = "https://files.pythonhosted.org/packages/82/78/a6250ff0c49a3cdb943910ada4116e708118e9b901c878cfae616c80a904/rignore-0.7.6-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a20b6fb61bcced9a83dfcca6599ad45182b06ba720cff7c8d891e5b78db5b65f", size = 886470, upload-time = "2025-11-05T20:42:52.314Z" }, { url = "https://files.pythonhosted.org/packages/35/af/c69c0c51b8f9f7914d95c4ea91c29a2ac067572048cae95dd6d2efdbe05d/rignore-0.7.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:392dcabfecbe176c9ebbcb40d85a5e86a5989559c4f988c2741da7daf1b5be25", size = 825976, upload-time = "2025-11-05T20:42:35.118Z" }, { url = "https://files.pythonhosted.org/packages/f1/d2/1b264f56132264ea609d3213ab603d6a27016b19559a1a1ede1a66a03dcd/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22baa462abdc36fdd5a5e2dae423107723351b85ff093762f9261148b9d0a04a", size = 899739, upload-time = "2025-11-05T20:41:01.518Z" }, @@ -3778,67 +3386,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, ] -[[package]] -name = "scikit-learn" -version = "1.7.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "joblib", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/3e/daed796fd69cce768b8788401cc464ea90b306fb196ae1ffed0b98182859/scikit_learn-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b33579c10a3081d076ab403df4a4190da4f4432d443521674637677dc91e61f", size = 9336221, upload-time = "2025-09-09T08:20:19.328Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ce/af9d99533b24c55ff4e18d9b7b4d9919bbc6cd8f22fe7a7be01519a347d5/scikit_learn-1.7.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c", size = 8653834, upload-time = "2025-09-09T08:20:22.073Z" }, - { url = "https://files.pythonhosted.org/packages/58/0e/8c2a03d518fb6bd0b6b0d4b114c63d5f1db01ff0f9925d8eb10960d01c01/scikit_learn-1.7.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7a58814265dfc52b3295b1900cfb5701589d30a8bb026c7540f1e9d3499d5ec8", size = 9660938, upload-time = "2025-09-09T08:20:24.327Z" }, - { url = "https://files.pythonhosted.org/packages/2b/75/4311605069b5d220e7cf5adabb38535bd96f0079313cdbb04b291479b22a/scikit_learn-1.7.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a847fea807e278f821a0406ca01e387f97653e284ecbd9750e3ee7c90347f18", size = 9477818, upload-time = "2025-09-09T08:20:26.845Z" }, - { url = "https://files.pythonhosted.org/packages/7f/9b/87961813c34adbca21a6b3f6b2bea344c43b30217a6d24cc437c6147f3e8/scikit_learn-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:ca250e6836d10e6f402436d6463d6c0e4d8e0234cfb6a9a47835bd392b852ce5", size = 8886969, upload-time = "2025-09-09T08:20:29.329Z" }, - { url = "https://files.pythonhosted.org/packages/43/83/564e141eef908a5863a54da8ca342a137f45a0bfb71d1d79704c9894c9d1/scikit_learn-1.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7509693451651cd7361d30ce4e86a1347493554f172b1c72a39300fa2aea79e", size = 9331967, upload-time = "2025-09-09T08:20:32.421Z" }, - { url = "https://files.pythonhosted.org/packages/18/d6/ba863a4171ac9d7314c4d3fc251f015704a2caeee41ced89f321c049ed83/scikit_learn-1.7.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0486c8f827c2e7b64837c731c8feff72c0bd2b998067a8a9cbc10643c31f0fe1", size = 8648645, upload-time = "2025-09-09T08:20:34.436Z" }, - { url = "https://files.pythonhosted.org/packages/ef/0e/97dbca66347b8cf0ea8b529e6bb9367e337ba2e8be0ef5c1a545232abfde/scikit_learn-1.7.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89877e19a80c7b11a2891a27c21c4894fb18e2c2e077815bcade10d34287b20d", size = 9715424, upload-time = "2025-09-09T08:20:36.776Z" }, - { url = "https://files.pythonhosted.org/packages/f7/32/1f3b22e3207e1d2c883a7e09abb956362e7d1bd2f14458c7de258a26ac15/scikit_learn-1.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8da8bf89d4d79aaec192d2bda62f9b56ae4e5b4ef93b6a56b5de4977e375c1f1", size = 9509234, upload-time = "2025-09-09T08:20:38.957Z" }, - { url = "https://files.pythonhosted.org/packages/9f/71/34ddbd21f1da67c7a768146968b4d0220ee6831e4bcbad3e03dd3eae88b6/scikit_learn-1.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:9b7ed8d58725030568523e937c43e56bc01cadb478fc43c042a9aca1dacb3ba1", size = 8894244, upload-time = "2025-09-09T08:20:41.166Z" }, - { url = "https://files.pythonhosted.org/packages/a7/aa/3996e2196075689afb9fce0410ebdb4a09099d7964d061d7213700204409/scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96", size = 9259818, upload-time = "2025-09-09T08:20:43.19Z" }, - { url = "https://files.pythonhosted.org/packages/43/5d/779320063e88af9c4a7c2cf463ff11c21ac9c8bd730c4a294b0000b666c9/scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476", size = 8636997, upload-time = "2025-09-09T08:20:45.468Z" }, - { url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" }, - { url = "https://files.pythonhosted.org/packages/82/70/8bf44b933837ba8494ca0fc9a9ab60f1c13b062ad0197f60a56e2fc4c43e/scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44", size = 9300296, upload-time = "2025-09-09T08:20:50.366Z" }, - { url = "https://files.pythonhosted.org/packages/c6/99/ed35197a158f1fdc2fe7c3680e9c70d0128f662e1fee4ed495f4b5e13db0/scikit_learn-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:6088aa475f0785e01bcf8529f55280a3d7d298679f50c0bb70a2364a82d0b290", size = 8731256, upload-time = "2025-09-09T08:20:52.627Z" }, - { url = "https://files.pythonhosted.org/packages/ae/93/a3038cb0293037fd335f77f31fe053b89c72f17b1c8908c576c29d953e84/scikit_learn-1.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b7dacaa05e5d76759fb071558a8b5130f4845166d88654a0f9bdf3eb57851b7", size = 9212382, upload-time = "2025-09-09T08:20:54.731Z" }, - { url = "https://files.pythonhosted.org/packages/40/dd/9a88879b0c1104259136146e4742026b52df8540c39fec21a6383f8292c7/scikit_learn-1.7.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:abebbd61ad9e1deed54cca45caea8ad5f79e1b93173dece40bb8e0c658dbe6fe", size = 8592042, upload-time = "2025-09-09T08:20:57.313Z" }, - { url = "https://files.pythonhosted.org/packages/46/af/c5e286471b7d10871b811b72ae794ac5fe2989c0a2df07f0ec723030f5f5/scikit_learn-1.7.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:502c18e39849c0ea1a5d681af1dbcf15f6cce601aebb657aabbfe84133c1907f", size = 9434180, upload-time = "2025-09-09T08:20:59.671Z" }, - { url = "https://files.pythonhosted.org/packages/f1/fd/df59faa53312d585023b2da27e866524ffb8faf87a68516c23896c718320/scikit_learn-1.7.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a4c328a71785382fe3fe676a9ecf2c86189249beff90bf85e22bdb7efaf9ae0", size = 9283660, upload-time = "2025-09-09T08:21:01.71Z" }, - { url = "https://files.pythonhosted.org/packages/a7/c7/03000262759d7b6f38c836ff9d512f438a70d8a8ddae68ee80de72dcfb63/scikit_learn-1.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:63a9afd6f7b229aad94618c01c252ce9e6fa97918c5ca19c9a17a087d819440c", size = 8702057, upload-time = "2025-09-09T08:21:04.234Z" }, - { url = "https://files.pythonhosted.org/packages/55/87/ef5eb1f267084532c8e4aef98a28b6ffe7425acbfd64b5e2f2e066bc29b3/scikit_learn-1.7.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9acb6c5e867447b4e1390930e3944a005e2cb115922e693c08a323421a6966e8", size = 9558731, upload-time = "2025-09-09T08:21:06.381Z" }, - { url = "https://files.pythonhosted.org/packages/93/f8/6c1e3fc14b10118068d7938878a9f3f4e6d7b74a8ddb1e5bed65159ccda8/scikit_learn-1.7.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:2a41e2a0ef45063e654152ec9d8bcfc39f7afce35b08902bfe290c2498a67a6a", size = 9038852, upload-time = "2025-09-09T08:21:08.628Z" }, - { url = "https://files.pythonhosted.org/packages/83/87/066cafc896ee540c34becf95d30375fe5cbe93c3b75a0ee9aa852cd60021/scikit_learn-1.7.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98335fb98509b73385b3ab2bd0639b1f610541d3988ee675c670371d6a87aa7c", size = 9527094, upload-time = "2025-09-09T08:21:11.486Z" }, - { url = "https://files.pythonhosted.org/packages/9c/2b/4903e1ccafa1f6453b1ab78413938c8800633988c838aa0be386cbb33072/scikit_learn-1.7.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191e5550980d45449126e23ed1d5e9e24b2c68329ee1f691a3987476e115e09c", size = 9367436, upload-time = "2025-09-09T08:21:13.602Z" }, - { url = "https://files.pythonhosted.org/packages/b5/aa/8444be3cfb10451617ff9d177b3c190288f4563e6c50ff02728be67ad094/scikit_learn-1.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:57dc4deb1d3762c75d685507fbd0bc17160144b2f2ba4ccea5dc285ab0d0e973", size = 9275749, upload-time = "2025-09-09T08:21:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/d9/82/dee5acf66837852e8e68df6d8d3a6cb22d3df997b733b032f513d95205b7/scikit_learn-1.7.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fa8f63940e29c82d1e67a45d5297bdebbcb585f5a5a50c4914cc2e852ab77f33", size = 9208906, upload-time = "2025-09-09T08:21:18.557Z" }, - { url = "https://files.pythonhosted.org/packages/3c/30/9029e54e17b87cb7d50d51a5926429c683d5b4c1732f0507a6c3bed9bf65/scikit_learn-1.7.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f95dc55b7902b91331fa4e5845dd5bde0580c9cd9612b1b2791b7e80c3d32615", size = 8627836, upload-time = "2025-09-09T08:21:20.695Z" }, - { url = "https://files.pythonhosted.org/packages/60/18/4a52c635c71b536879f4b971c2cedf32c35ee78f48367885ed8025d1f7ee/scikit_learn-1.7.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9656e4a53e54578ad10a434dc1f993330568cfee176dff07112b8785fb413106", size = 9426236, upload-time = "2025-09-09T08:21:22.645Z" }, - { url = "https://files.pythonhosted.org/packages/99/7e/290362f6ab582128c53445458a5befd471ed1ea37953d5bcf80604619250/scikit_learn-1.7.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96dc05a854add0e50d3f47a1ef21a10a595016da5b007c7d9cd9d0bffd1fcc61", size = 9312593, upload-time = "2025-09-09T08:21:24.65Z" }, - { url = "https://files.pythonhosted.org/packages/8e/87/24f541b6d62b1794939ae6422f8023703bbf6900378b2b34e0b4384dfefd/scikit_learn-1.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:bb24510ed3f9f61476181e4db51ce801e2ba37541def12dc9333b946fc7a9cf8", size = 8820007, upload-time = "2025-09-09T08:21:26.713Z" }, -] - [[package]] name = "scikit-learn" version = "1.8.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version >= '3.11' and python_full_version < '3.13'", -] dependencies = [ - { name = "joblib", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -3880,76 +3436,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, ] -[[package]] -name = "scipy" -version = "1.15.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, - { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, - { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, - { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, - { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, - { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, - { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, - { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, - { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, - { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, - { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, - { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, - { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, - { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, - { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, - { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, - { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, - { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, - { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, - { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, - { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, - { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, - { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, - { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, - { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, - { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, - { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, - { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, - { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, - { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, - { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, - { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, - { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, - { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, - { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, -] - [[package]] name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version >= '3.11' and python_full_version < '3.13'", -] dependencies = [ - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -4085,13 +3577,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/26/66ba59328dc25e523bfcb0f8db48bdebe2035e0159d600e1f01c0fc93967/sqlalchemy-2.0.46-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:895296687ad06dc9b11a024cf68e8d9d3943aa0b4964278d2553b86f1b267735", size = 2155051, upload-time = "2026-01-21T18:27:28.965Z" }, - { url = "https://files.pythonhosted.org/packages/21/cd/9336732941df972fbbfa394db9caa8bb0cf9fe03656ec728d12e9cbd6edc/sqlalchemy-2.0.46-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab65cb2885a9f80f979b85aa4e9c9165a31381ca322cbde7c638fe6eefd1ec39", size = 3234666, upload-time = "2026-01-21T18:32:28.72Z" }, - { url = "https://files.pythonhosted.org/packages/38/62/865ae8b739930ec433cd4123760bee7f8dafdc10abefd725a025604fb0de/sqlalchemy-2.0.46-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52fe29b3817bd191cc20bad564237c808967972c97fa683c04b28ec8979ae36f", size = 3232917, upload-time = "2026-01-21T18:44:54.064Z" }, - { url = "https://files.pythonhosted.org/packages/24/38/805904b911857f2b5e00fdea44e9570df62110f834378706939825579296/sqlalchemy-2.0.46-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:09168817d6c19954d3b7655da6ba87fcb3a62bb575fb396a81a8b6a9fadfe8b5", size = 3185790, upload-time = "2026-01-21T18:32:30.581Z" }, - { url = "https://files.pythonhosted.org/packages/69/4f/3260bb53aabd2d274856337456ea52f6a7eccf6cce208e558f870cec766b/sqlalchemy-2.0.46-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:be6c0466b4c25b44c5d82b0426b5501de3c424d7a3220e86cd32f319ba56798e", size = 3207206, upload-time = "2026-01-21T18:44:55.93Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b3/67c432d7f9d88bb1a61909b67e29f6354d59186c168fb5d381cf438d3b73/sqlalchemy-2.0.46-cp310-cp310-win32.whl", hash = "sha256:1bc3f601f0a818d27bfe139f6766487d9c88502062a2cd3a7ee6c342e81d5047", size = 2115296, upload-time = "2026-01-21T18:33:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/4a/8c/25fb284f570f9d48e6c240f0269a50cec9cf009a7e08be4c0aaaf0654972/sqlalchemy-2.0.46-cp310-cp310-win_amd64.whl", hash = "sha256:e0c05aff5c6b1bb5fb46a87e0f9d2f733f83ef6cbbbcd5c642b6c01678268061", size = 2138540, upload-time = "2026-01-21T18:33:14.22Z" }, { url = "https://files.pythonhosted.org/packages/69/ac/b42ad16800d0885105b59380ad69aad0cce5a65276e269ce2729a2343b6a/sqlalchemy-2.0.46-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:261c4b1f101b4a411154f1da2b76497d73abbfc42740029205d4d01fa1052684", size = 2154851, upload-time = "2026-01-21T18:27:30.54Z" }, { url = "https://files.pythonhosted.org/packages/a0/60/d8710068cb79f64d002ebed62a7263c00c8fd95f4ebd4b5be8f7ca93f2bc/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181903fe8c1b9082995325f1b2e84ac078b1189e2819380c2303a5f90e114a62", size = 3311241, upload-time = "2026-01-21T18:32:33.45Z" }, { url = "https://files.pythonhosted.org/packages/2b/0f/20c71487c7219ab3aa7421c7c62d93824c97c1460f2e8bb72404b0192d13/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:590be24e20e2424a4c3c1b0835e9405fa3d0af5823a1a9fc02e5dff56471515f", size = 3310741, upload-time = "2026-01-21T18:44:57.887Z" }, @@ -4193,13 +3678,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/b3/2cb7c17b6c4cf8ca983204255d3f1d95eda7213e247e6947a0ee2c747a2c/tiktoken-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3de02f5a491cfd179aec916eddb70331814bd6bf764075d39e21d5862e533970", size = 1051991, upload-time = "2025-10-06T20:21:34.098Z" }, - { url = "https://files.pythonhosted.org/packages/27/0f/df139f1df5f6167194ee5ab24634582ba9a1b62c6b996472b0277ec80f66/tiktoken-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b6cfb6d9b7b54d20af21a912bfe63a2727d9cfa8fbda642fd8322c70340aad16", size = 995798, upload-time = "2025-10-06T20:21:35.579Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5d/26a691f28ab220d5edc09b9b787399b130f24327ef824de15e5d85ef21aa/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:cde24cdb1b8a08368f709124f15b36ab5524aac5fa830cc3fdce9c03d4fb8030", size = 1129865, upload-time = "2025-10-06T20:21:36.675Z" }, - { url = "https://files.pythonhosted.org/packages/b2/94/443fab3d4e5ebecac895712abd3849b8da93b7b7dec61c7db5c9c7ebe40c/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6de0da39f605992649b9cfa6f84071e3f9ef2cec458d08c5feb1b6f0ff62e134", size = 1152856, upload-time = "2025-10-06T20:21:37.873Z" }, - { url = "https://files.pythonhosted.org/packages/54/35/388f941251b2521c70dd4c5958e598ea6d2c88e28445d2fb8189eecc1dfc/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6faa0534e0eefbcafaccb75927a4a380463a2eaa7e26000f0173b920e98b720a", size = 1195308, upload-time = "2025-10-06T20:21:39.577Z" }, - { url = "https://files.pythonhosted.org/packages/f8/00/c6681c7f833dd410576183715a530437a9873fa910265817081f65f9105f/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:82991e04fc860afb933efb63957affc7ad54f83e2216fe7d319007dab1ba5892", size = 1255697, upload-time = "2025-10-06T20:21:41.154Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d2/82e795a6a9bafa034bf26a58e68fe9a89eeaaa610d51dbeb22106ba04f0a/tiktoken-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:6fb2995b487c2e31acf0a9e17647e3b242235a20832642bb7a9d1a181c0c1bb1", size = 879375, upload-time = "2025-10-06T20:21:43.201Z" }, { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" }, { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" }, { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" }, @@ -4391,7 +3869,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } wheels = [ @@ -4415,12 +3892,6 @@ version = "0.22.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, - { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, - { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, - { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, @@ -4461,7 +3932,6 @@ dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ed/54/809199edc537dbace273495ac0884d13df26436e910a5ed4d0ec0a69806b/virtualenv-20.39.0.tar.gz", hash = "sha256:a15f0cebd00d50074fd336a169d53422436a12dfe15149efec7072cfe817df8b", size = 5869141, upload-time = "2026-02-23T18:09:13.349Z" } wheels = [ @@ -4477,18 +3947,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, - { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, - { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, - { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, - { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, - { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, - { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, - { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, - { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, @@ -4561,10 +4019,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, - { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, - { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, @@ -4577,17 +4031,6 @@ version = "15.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, - { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, - { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, - { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, - { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, - { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, @@ -4621,12 +4064,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, - { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, - { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, - { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] @@ -4636,16 +4073,6 @@ version = "1.17.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, - { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, - { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, - { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, - { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, - { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, - { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, - { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, @@ -4710,22 +4137,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517, upload-time = "2025-10-06T14:08:42.494Z" }, - { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495, upload-time = "2025-10-06T14:08:46.2Z" }, - { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400, upload-time = "2025-10-06T14:08:47.855Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545, upload-time = "2025-10-06T14:08:49.683Z" }, - { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598, upload-time = "2025-10-06T14:08:51.215Z" }, - { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893, upload-time = "2025-10-06T14:08:53.144Z" }, - { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240, upload-time = "2025-10-06T14:08:55.036Z" }, - { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965, upload-time = "2025-10-06T14:08:56.722Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026, upload-time = "2025-10-06T14:08:58.563Z" }, - { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637, upload-time = "2025-10-06T14:09:00.506Z" }, - { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082, upload-time = "2025-10-06T14:09:01.936Z" }, - { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811, upload-time = "2025-10-06T14:09:03.445Z" }, - { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223, upload-time = "2025-10-06T14:09:05.401Z" }, - { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118, upload-time = "2025-10-06T14:09:11.148Z" }, - { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852, upload-time = "2025-10-06T14:09:12.958Z" }, - { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012, upload-time = "2025-10-06T14:09:14.664Z" }, { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" },