diff --git a/.claude/skills/migrate-honcho-py/DETAILED-CHANGES.md b/.claude/skills/migrate-honcho-py/DETAILED-CHANGES.md new file mode 100644 index 00000000..624cdd7a --- /dev/null +++ b/.claude/skills/migrate-honcho-py/DETAILED-CHANGES.md @@ -0,0 +1,478 @@ +# Detailed API Changes + +## 1. Async Client Architecture (Major Change) + +The separate `AsyncHoncho`, `AsyncPeer`, and `AsyncSession` classes have been removed. Use the `.aio` accessor instead. + +### Before (v1.6.0) + +```python +from honcho import Honcho, AsyncHoncho, AsyncPeer, AsyncSession + +# Sync client +client = Honcho() + +# Async client - separate class +async_client = AsyncHoncho() +peer = await async_client.peer("user-123") +response = await peer.chat("query") +``` + +### After (v2.0.0) + +```python +from honcho import Honcho + +# Single client with .aio accessor for async operations +client = Honcho() + +# Sync operations +peer = client.peer("user-123") +response = peer.chat("query") + +# Async operations via .aio accessor +peer = await client.aio.peer("user-123") +response = await peer.aio.chat("query") + +# Async iteration +async for p in client.aio.peers(): + print(p.id) +``` + +**Migration steps:** + +1. Remove all `AsyncHoncho`, `AsyncPeer`, `AsyncSession` imports +2. Replace `AsyncHoncho()` with `Honcho()` and use `.aio` accessor +3. Replace `AsyncPeer` type hints with `Peer` +4. Replace `AsyncSession` type hints with `Session` +5. Access async methods via `.aio` property on instances + +--- + +## 2. Observations → Conclusions (Terminology Change) + +### Before (v1.6.0) + +```python +from honcho import Observation, ObservationScope, AsyncObservationScope + +# Access observations +scope = peer.observations +scope = peer.observations_of("other-peer") + +# List observations +obs_list = scope.list() + +# Query observations +results = scope.query("preferences") + +# Create observations +scope.create([{"content": "User likes dark mode", "session_id": "sess-1"}]) + +# Get representation from observations +rep = scope.get_representation() +``` + +### After (v2.0.0) + +```python +from honcho import Conclusion, ConclusionScope, ConclusionScopeAio + +# Access conclusions +scope = peer.conclusions +scope = peer.conclusions_of("other-peer") + +# List conclusions (now returns SyncPage, not list) +conclusions_page = scope.list() +for conclusion in conclusions_page: + print(conclusion.content) + +# Query conclusions +results = scope.query("preferences") + +# Create conclusions +scope.create([{"content": "User likes dark mode", "session_id": "sess-1"}]) + +# Get representation from conclusions +rep = scope.representation() # Returns str, not Representation object +``` + +--- + +## 3. Representation Type Change (Major Change) + +The `Representation` class has been removed. Representations are now simple strings. + +### Before (v1.6.0) + +```python +from honcho import Representation, ExplicitObservation, DeductiveObservation + +# Get working representation +rep: Representation = peer.working_rep() + +# Access explicit and deductive observations +for obs in rep.explicit: + print(obs.content, obs.created_at) + +for obs in rep.deductive: + print(obs.conclusion, obs.premises) + +# Check if empty +if rep.is_empty(): + print("No observations") + +# Merge representations +rep.merge_representation(other_rep) + +# Diff representations +diff = rep.diff_representation(other_rep) + +# String formatting +print(str(rep)) +print(rep.str_no_timestamps()) +print(rep.format_as_markdown()) +``` + +### After (v2.0.0) + +```python +# Get representation - now returns str directly +rep: str = peer.representation() + +# It's just a string now +print(rep) + +# Check if empty +if not rep: + print("No conclusions") +``` + +**Removed methods:** + +- `.explicit` property +- `.deductive` property +- `.is_empty()` +- `.merge_representation()` +- `.diff_representation()` +- `.str_no_timestamps()` +- `.format_as_markdown()` + +--- + +## 4. Configuration Parameter Rename + +All `config` parameters have been renamed to `configuration`, and configuration types are now strongly typed. + +### Before (v1.6.0) + +```python +# Creating resources with config +peer = client.peer("user-1", config={"observe_me": True}) +session = client.session("sess-1", config={"some_setting": True}) + +# Getting/setting config +config = peer.get_config() +peer.set_config({"observe_me": False}) + +config = session.get_config() +session.set_config({"some_setting": False}) + +config = client.get_config() +client.set_config({"workspace_setting": True}) + +# Message config parameter +msg = peer.message("Hello", config={"reasoning": {"enabled": True}}) +``` + +### After (v2.0.0) + +```python +from honcho.api_types import PeerConfig, SessionConfiguration, WorkspaceConfiguration + +# Creating resources with configuration (typed) +peer = client.peer("user-1", configuration=PeerConfig(observe_me=True)) +session = client.session("sess-1", configuration=SessionConfiguration()) + +# Getting/setting configuration (returns typed objects) +config: PeerConfig = peer.get_configuration() +peer.set_configuration(PeerConfig(observe_me=False)) + +config: SessionConfiguration = session.get_configuration() +session.set_configuration(SessionConfiguration()) + +config: WorkspaceConfiguration = client.get_configuration() +client.set_configuration(WorkspaceConfiguration()) + +# Message configuration parameter +msg = peer.message("Hello", configuration={"reasoning": {"enabled": True}}) +``` + +--- + +## 5. Streaming Chat API Change + +### Before (v1.6.0) + +```python +# Streaming via parameter +response = peer.chat("query", stream=True) +for chunk in response: + print(chunk, end="") + +final = response.get_final_response() +``` + +### After (v2.0.0) + +```python +# Streaming via separate method +stream = peer.chat_stream("query") +for chunk in stream: + print(chunk, end="") + +final = stream.get_final_response() + +# Non-streaming (no stream parameter needed) +response = peer.chat("query") +``` + +--- + +## 6. Deriver Status → Queue Status + +### Before (v1.6.0) + +```python +from honcho_core.types import DeriverStatus + +# Get status +status: DeriverStatus = client.get_deriver_status() +status = session.get_deriver_status() + +# Poll until complete +status = client.poll_deriver_status(timeout=300.0) +status = session.poll_deriver_status(timeout=300.0) + +# Access fields +print(status.pending_work_units) +print(status.in_progress_work_units) +``` + +### After (v2.0.0) + +```python +from honcho.api_types import QueueStatusResponse + +# Get status +status: QueueStatusResponse = client.queue_status() +status = session.queue_status() + +# Access fields (same as before) +print(status.pending_work_units) +print(status.in_progress_work_units) + +# poll_deriver_status has been removed - implement polling manually if needed: +import time + +def poll_until_complete(client, timeout=300.0): + start = time.time() + while time.time() - start < timeout: + status = client.queue_status() + if status.pending_work_units == 0 and status.in_progress_work_units == 0: + return status + time.sleep(1) + raise TimeoutError("Queue processing did not complete in time") +``` + +--- + +## 7. PeerContext Changes + +### Before (v1.6.0) + +```python +from honcho import PeerContext + +context: PeerContext = peer.get_context() + +# Access representation (was Representation object) +rep: Representation = context.representation +if rep: + print(rep.explicit) + print(rep.deductive) +``` + +### After (v2.0.0) + +```python +from honcho.api_types import PeerContextResponse + +context: PeerContextResponse = peer.context() + +# Access representation (now str) +rep: str | None = context.representation +if rep: + print(rep) +``` + +--- + +## 8. Card Method Return Type Change + +### Before (v1.6.0) + +```python +# card() returned str (joined with newlines) +card: str = peer.card() +print(card) # "line1\nline2\nline3" +``` + +### After (v2.0.0) + +```python +# card() returns list[str] | None +card: list[str] | None = peer.card() +if card: + print("\n".join(card)) # Join manually if needed +``` + +--- + +## 9. Message Update Location Change + +### Before (v1.6.0) + +```python +# Update message via client +updated = client.update_message( + message=msg, + metadata={"key": "value"}, + session="session-id" # Required if message is string ID +) +``` + +### After (v2.0.0) + +```python +# Update message via session +updated = session.update_message( + message=msg, + metadata={"key": "value"} +) +``` + +--- + +## 10. Removed: `core` Property + +### Before (v1.6.0) + +```python +# Access underlying Stainless-generated client +core_client = client.core +workspace = client.core.workspaces.get_or_create(id="custom-workspace") +``` + +### After (v2.0.0) + +```python +# The `core` property has been removed +# The SDK no longer uses a Stainless-generated client internally +# Use the SDK's public API directly +``` + +--- + +## 11. Environment Changes + +### Before (v1.6.0) + +```python +# Three environments available +client = Honcho(environment="local") +client = Honcho(environment="production") +client = Honcho(environment="demo") +``` + +### After (v2.0.0) + +```python +# Only two environments +client = Honcho(environment="local") +client = Honcho(environment="production") +# "demo" environment has been removed +``` + +--- + +## 12. Reasoning Level Parameter (New Feature) + +The chat method now supports a `reasoning_level` parameter: + +```python +# New in v2.0.0 +response = peer.chat( + "complex query", + reasoning_level="high" # "minimal", "low", "medium", "high", "max" +) + +stream = peer.chat_stream( + "complex query", + reasoning_level="max" +) +``` + +--- + +## 13. Import Changes Summary + +### Removed Imports + +```python +# These no longer exist in v2.0.0 +from honcho import AsyncHoncho # Use Honcho with .aio accessor +from honcho import AsyncPeer # Use Peer with .aio accessor +from honcho import AsyncSession # Use Session with .aio accessor +from honcho import Observation # Renamed to Conclusion +from honcho import ObservationScope # Renamed to ConclusionScope +from honcho import AsyncObservationScope # Renamed to ConclusionScopeAio +from honcho import Representation # Removed (now str) +from honcho import ExplicitObservation # Removed +from honcho import DeductiveObservation # Removed +from honcho import PeerContext # Use PeerContextResponse from api_types +``` + +### New Imports + +```python +from honcho import Conclusion, ConclusionScope +from honcho import ConclusionScopeAio +from honcho import HonchoAio, PeerAio, SessionAio # For type hints +from honcho import MessageCreateParams, Message + +# Typed configuration classes +from honcho.api_types import ( + PeerConfig, + SessionConfiguration, + WorkspaceConfiguration, + SessionPeerConfig, + QueueStatusResponse, + PeerContextResponse, +) +``` + +### Message Type Import Changes + +```python +# Before +from honcho_core.types.workspaces.sessions import MessageCreateParam +from honcho_core.types.workspaces.sessions.message import Message +from honcho.session import SessionPeerConfig + +# After +from honcho import Message, MessageCreateParams # Note: plural "Params" +from honcho.api_types import SessionPeerConfig +``` + +**Note:** `MessageCreateParam` (singular) is now `MessageCreateParams` (plural). diff --git a/.claude/skills/migrate-honcho-py/MIGRATION-CHECKLIST.md b/.claude/skills/migrate-honcho-py/MIGRATION-CHECKLIST.md new file mode 100644 index 00000000..7f0b9750 --- /dev/null +++ b/.claude/skills/migrate-honcho-py/MIGRATION-CHECKLIST.md @@ -0,0 +1,121 @@ +# Migration Checklist + +Use this checklist to track migration progress. Copy into your working notes and check off items as completed. + +## Dependencies + +- [ ] Update `honcho` package to v2.0.0 +- [ ] Remove any `honcho-core` imports + +## Async Architecture Changes + +- [ ] Remove `AsyncHoncho` imports → use `Honcho` with `.aio` accessor +- [ ] Remove `AsyncPeer` imports → use `Peer` with `.aio` accessor +- [ ] Remove `AsyncSession` imports → use `Session` with `.aio` accessor +- [ ] Update all async client usage to use `.aio` accessor pattern +- [ ] Update type hints: `AsyncPeer` → `Peer`, `AsyncSession` → `Session` + +## Terminology: Observations → Conclusions + +- [ ] Replace `Observation` import with `Conclusion` +- [ ] Replace `ObservationScope` import with `ConclusionScope` +- [ ] Replace `AsyncObservationScope` import with `ConclusionScopeAio` +- [ ] Replace `.observations` property with `.conclusions` +- [ ] Replace `.observations_of()` method with `.conclusions_of()` +- [ ] Replace `.get_representation()` with `.representation()` + +## Representation Changes + +- [ ] Remove `Representation` import (now returns `str`) +- [ ] Remove `ExplicitObservation` import +- [ ] Remove `DeductiveObservation` import +- [ ] Replace `working_rep()` with `representation()` +- [ ] Update type hints from `Representation` to `str` +- [ ] Remove `.explicit` property access +- [ ] Remove `.deductive` property access +- [ ] Replace `.is_empty()` checks with `not rep` +- [ ] Remove `.merge_representation()` calls +- [ ] Remove `.diff_representation()` calls +- [ ] Remove `.str_no_timestamps()` calls +- [ ] Remove `.format_as_markdown()` calls + +## Configuration Changes + +- [ ] Replace all `config=` parameters with `configuration=` +- [ ] Replace `.get_config()` with `.get_configuration()` +- [ ] Replace `.set_config()` with `.set_configuration()` +- [ ] Rename `.get_peer_config()` → `.get_peer_configuration()` +- [ ] Rename `.set_peer_config()` → `.set_peer_configuration()` +- [ ] Import typed config classes from `honcho.api_types` if needed: + - [ ] `PeerConfig` + - [ ] `SessionConfiguration` + - [ ] `WorkspaceConfiguration` + +## Method Renames + +### Peer Methods + +- [ ] `peer.working_rep()` → `peer.representation()` +- [ ] `peer.get_context()` → `peer.context()` +- [ ] `peer.get_sessions()` → `peer.sessions()` +- [ ] `peer.chat(stream=True)` → `peer.chat_stream()` + +### Session Methods + +- [ ] `session.get_context()` → `session.context()` +- [ ] `session.get_summaries()` → `session.summaries()` +- [ ] `session.get_messages()` → `session.messages()` +- [ ] `session.get_peers()` → `session.peers()` +- [ ] `session.get_peer_config()` → `session.get_peer_configuration()` +- [ ] `session.set_peer_config()` → `session.set_peer_configuration()` +- [ ] `session.working_rep()` → `session.representation()` +- [ ] `session.get_deriver_status()` → `session.queue_status()` +- [ ] Remove `session.poll_deriver_status()` calls + +### Client Methods + +- [ ] `client.get_peers()` → `client.peers()` +- [ ] `client.get_sessions()` → `client.sessions()` +- [ ] `client.get_workspaces()` → `client.workspaces()` +- [ ] `client.get_deriver_status()` → `client.queue_status()` +- [ ] Remove `client.poll_deriver_status()` calls +- [ ] Move `client.update_message()` → `session.update_message()` + +## Parameter Renames + +- [ ] `include_most_derived=` → `include_most_frequent=` +- [ ] `max_observations=` → `max_conclusions=` + +## Return Type Changes + +- [ ] Handle `card()` returning `list[str] | None` instead of `str` +- [ ] Handle `.list()` on conclusions returning `SyncPage` instead of `list` + +## Removed Features + +- [ ] Remove any usage of `client.core` property +- [ ] Remove usage of `"demo"` environment (only `"local"` and `"production"` remain) +- [ ] Implement custom polling if you were using `poll_deriver_status()` + +## Type Import Updates + +- [ ] Replace `PeerContext` import with `PeerContextResponse` from `honcho.api_types` +- [ ] Replace `DeriverStatus` import with `QueueStatusResponse` from `honcho.api_types` +- [ ] Replace `MessageCreateParam` with `MessageCreateParams` (plural) +- [ ] Move `SessionPeerConfig` import from `honcho.session` to `honcho.api_types` + +## Exception Handling (Optional) + +- [ ] Update exception handling to use new exception types if needed: + - `HonchoError`, `APIError`, `BadRequestError`, `AuthenticationError` + - `PermissionDeniedError`, `NotFoundError`, `ConflictError` + - `UnprocessableEntityError`, `RateLimitError`, `ServerError` + - `TimeoutError`, `ConnectionError` + +## Final Verification + +- [ ] Run type checker (mypy/pyright) with no errors +- [ ] Run tests +- [ ] Verify async operations work with `.aio` accessor +- [ ] Verify streaming functionality works with `chat_stream()` +- [ ] Verify configuration changes take effect diff --git a/.claude/skills/migrate-honcho-py/SKILL.md b/.claude/skills/migrate-honcho-py/SKILL.md new file mode 100644 index 00000000..1ed11c9c --- /dev/null +++ b/.claude/skills/migrate-honcho-py/SKILL.md @@ -0,0 +1,277 @@ +--- +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. +--- + +# Honcho Python SDK Migration (v1.6.0 → v2.0.0) + +## Overview + +This skill migrates code from `honcho` Python SDK v1.6.0 to v2.0.0 (required for Honcho 3.0.0). + +**Key breaking changes:** + +- `AsyncHoncho`/`AsyncPeer`/`AsyncSession` removed → use `.aio` accessor +- "Observation" → "Conclusion" terminology +- `Representation` class removed (returns `str` now) +- `get_config`/`set_config` → `get_configuration`/`set_configuration` +- Streaming via `chat_stream()` instead of `chat(stream=True)` +- `poll_deriver_status()` removed +- `.core` property removed + +## Quick Migration + +### 1. Update async architecture + +```python +# Before +from honcho import AsyncHoncho, AsyncPeer, AsyncSession + +async_client = AsyncHoncho() +peer = await async_client.peer("user-123") +response = await peer.chat("query") + +# After +from honcho import Honcho + +client = Honcho() +peer = await client.aio.peer("user-123") +response = await peer.aio.chat("query") + +# Async iteration +async for p in client.aio.peers(): + print(p.id) +``` + +### 2. Replace observations with conclusions + +```python +# Before +from honcho import Observation, ObservationScope, AsyncObservationScope + +scope = peer.observations +scope = peer.observations_of("other-peer") +rep = scope.get_representation() + +# After +from honcho import Conclusion, ConclusionScope, ConclusionScopeAio + +scope = peer.conclusions +scope = peer.conclusions_of("other-peer") +rep = scope.representation() # Returns str +``` + +### 3. Update representation handling + +```python +# Before +from honcho import Representation, ExplicitObservation, DeductiveObservation + +rep: Representation = peer.working_rep() +print(rep.explicit) +print(rep.deductive) +if rep.is_empty(): + print("No observations") + +# After +rep: str = peer.representation() +print(rep) # Just a string now +if not rep: + print("No conclusions") +``` + +### 4. Rename configuration methods + +```python +# Before +config = peer.get_config() +peer.set_config({"observe_me": False}) +session.get_config() +client.get_config() + +# After +from honcho.api_types import PeerConfig, SessionConfiguration, WorkspaceConfiguration + +config = peer.get_configuration() +peer.set_configuration(PeerConfig(observe_me=False)) +session.get_configuration() +client.get_configuration() +``` + +### 5. Update method names + +```python +# Before +peer.working_rep() +peer.get_context() +peer.get_sessions() +session.get_context() +session.get_summaries() +session.get_messages() +session.get_peers() +session.get_peer_config() +client.get_peers() +client.get_sessions() +client.get_workspaces() + +# After +peer.representation() +peer.context() +peer.sessions() +session.context() +session.summaries() +session.messages() +session.peers() +session.get_peer_configuration() +client.peers() +client.sessions() +client.workspaces() +``` + +### 6. Update streaming + +```python +# Before +response = peer.chat("query", stream=True) +for chunk in response: + print(chunk, end="") + +# After +stream = peer.chat_stream("query") +for chunk in stream: + print(chunk, end="") +``` + +### 7. Update queue status (formerly deriver) + +```python +# Before +from honcho_core.types import DeriverStatus + +status = client.get_deriver_status() +status = client.poll_deriver_status(timeout=300.0) # Removed! + +# After +from honcho.api_types import QueueStatusResponse + +status = client.queue_status() +# poll_deriver_status removed - implement polling manually if needed +``` + +### 8. Update representation parameters + +```python +# Before +rep = peer.working_rep( + include_most_derived=True, + max_observations=50 +) + +# After +rep = peer.representation( + include_most_frequent=True, + max_conclusions=50 +) +``` + +### 9. Move update_message to session + +```python +# Before +updated = client.update_message(message=msg, metadata={"key": "value"}, session="sess-id") + +# After +updated = session.update_message(message=msg, metadata={"key": "value"}) +``` + +### 10. Update card() return type + +```python +# Before +card: str = peer.card() # Returns str + +# After +card: list[str] | None = peer.card() # Returns list[str] | None +if card: + print("\n".join(card)) +``` + +## Quick Reference Table + +| v1.6.0 | v2.0.0 | +|--------|--------| +| `AsyncHoncho()` | `Honcho()` + `.aio` accessor | +| `AsyncPeer` | `Peer` + `.aio` accessor | +| `AsyncSession` | `Session` + `.aio` accessor | +| `Observation` | `Conclusion` | +| `ObservationScope` | `ConclusionScope` | +| `AsyncObservationScope` | `ConclusionScopeAio` | +| `Representation` | `str` | +| `.observations` | `.conclusions` | +| `.observations_of()` | `.conclusions_of()` | +| `.get_config()` | `.get_configuration()` | +| `.set_config()` | `.set_configuration()` | +| `.working_rep()` | `.representation()` | +| `.get_context()` | `.context()` | +| `.get_sessions()` | `.sessions()` | +| `.get_peers()` | `.peers()` | +| `.get_messages()` | `.messages()` | +| `.get_summaries()` | `.summaries()` | +| `.get_deriver_status()` | `.queue_status()` | +| `.poll_deriver_status()` | *(removed)* | +| `.get_peer_config()` | `.get_peer_configuration()` | +| `.set_peer_config()` | `.set_peer_configuration()` | +| `client.update_message()` | `session.update_message()` | +| `chat(stream=True)` | `chat_stream()` | +| `include_most_derived=` | `include_most_frequent=` | +| `max_observations=` | `max_conclusions=` | +| `config=` | `configuration=` | +| `PeerContext` | `PeerContextResponse` | +| `DeriverStatus` | `QueueStatusResponse` | +| `client.core` | *(removed)* | + +## Detailed Reference + +For comprehensive details on each change, see: + +- [DETAILED-CHANGES.md](DETAILED-CHANGES.md) - Full API change documentation +- [MIGRATION-CHECKLIST.md](MIGRATION-CHECKLIST.md) - Step-by-step checklist + +## New Exception Types + +```python +from honcho import ( + HonchoError, + APIError, + BadRequestError, + AuthenticationError, + PermissionDeniedError, + NotFoundError, + ConflictError, + UnprocessableEntityError, + RateLimitError, + ServerError, + TimeoutError, + ConnectionError, +) +``` + +## New Import Locations + +```python +# Configuration types +from honcho.api_types import ( + PeerConfig, + SessionConfiguration, + WorkspaceConfiguration, + SessionPeerConfig, + QueueStatusResponse, + PeerContextResponse, +) + +# Async type hints +from honcho import HonchoAio, PeerAio, SessionAio + +# Message types (note: Params is plural now) +from honcho import Message, MessageCreateParams +``` diff --git a/.claude/skills/migrate-honcho-ts/DETAILED-CHANGES.md b/.claude/skills/migrate-honcho-ts/DETAILED-CHANGES.md new file mode 100644 index 00000000..776b341b --- /dev/null +++ b/.claude/skills/migrate-honcho-ts/DETAILED-CHANGES.md @@ -0,0 +1,432 @@ +# Detailed API Changes + +## Client Changes + +### `.core` Property Removed + +The `.core` property (which exposed the raw `@honcho-ai/core` client) has been removed. Use `.http` for advanced HTTP access. + +```typescript +// Before +const workspace = await client.core.workspaces.getOrCreate({ id: 'my-workspace' }) + +// After - SDK handles workspace creation automatically +// For advanced usage: +const response = await client.http.post('/v3/workspaces', { body: { id: 'my-workspace' } }) +``` + +### Listing Methods Return Type Changes + +- `workspaces()` now returns `Page` instead of `string[]` +- `session.peers()` now returns `Peer[]` instead of `Page` + +```typescript +const workspacePage = await honcho.workspaces() +for (const id of workspacePage.items) { + console.log(id) +} +``` + +### `updateMessage()` Moved to Session + +```typescript +// Before +await honcho.updateMessage(message, { key: 'value' }, session) + +// After +await session.updateMessage(message, { key: 'value' }) +``` + +### `config` Option Renamed to `configuration` + +```typescript +// Before +const peer = await honcho.peer('user-id', { config: { observe_me: true } }) +const session = await honcho.session('session-id', { config: { ... } }) + +// After +const peer = await honcho.peer('user-id', { configuration: { observeMe: true } }) +const session = await honcho.session('session-id', { configuration: { reasoning: { enabled: true } } }) +``` + +--- + +## Peer Changes + +### Streaming API + +The `stream` option on `chat()` has been removed. Use `chatStream()` instead. + +```typescript +// Before +const stream = await peer.chat('Hello', { stream: true }) +for await (const chunk of stream) { + process.stdout.write(chunk) +} + +// After +const stream = await peer.chatStream('Hello') +for await (const chunk of stream) { + process.stdout.write(chunk) +} +``` + +Non-streaming `chat()` now only returns `string | null`: + +```typescript +const response = await peer.chat('Hello') // Returns string | null +``` + +### New `reasoningLevel` Option + +```typescript +const response = await peer.chat('Complex question', { + reasoningLevel: 'high' // 'minimal' | 'low' | 'medium' | 'high' | 'max' +}) +``` + +### `workingRep()` Renamed to `representation()` + +```typescript +// Before +const rep = await peer.workingRep(session, target, options) +console.log(rep.toString()) +console.log(rep.explicit) +console.log(rep.deductive) + +// After +const rep = await peer.representation({ + session, + target, + searchQuery: options?.searchQuery, + maxConclusions: options?.maxObservations, + includeMostFrequent: options?.includeMostDerived, +}) +console.log(rep) // Returns string directly +``` + +### `getContext()` Renamed to `context()` + +Options are now passed as a single object: + +```typescript +// Before +const ctx = await peer.getContext(target, options) + +// After +const ctx = await peer.context({ target, ...options }) +``` + +### `card()` Return Type Changed + +```typescript +// Before +const card = await peer.card(target) // Returns string + +// After +const card = await peer.card(target) // Returns string[] | null +``` + +### `message()` Options Changed + +```typescript +// Before +const msg = peer.message('Hello', { + metadata: { key: 'value' }, + configuration: { deriver: { enabled: true } }, + created_at: '2024-01-01T00:00:00Z' +}) +// Returns ValidatedMessageCreate with peer_id, created_at + +// After +const msg = peer.message('Hello', { + metadata: { key: 'value' }, + configuration: { reasoning: { enabled: true } }, + createdAt: '2024-01-01T00:00:00Z' +}) +// Returns MessageInput with peerId, createdAt +``` + +### `PeerContext.representation` Type Changed + +```typescript +// Before +const ctx = await peer.getContext() +if (ctx.representation) { + console.log(ctx.representation.explicit) // Representation object + console.log(ctx.representation.deductive) +} + +// After +const ctx = await peer.context() +if (ctx.representation) { + console.log(ctx.representation) // Now a string +} +``` + +--- + +## Session Changes + +### `getPeers()` Return Type Changed + +```typescript +// Before +const peers = await session.getPeers() // Returns Page + +// After +const peers = await session.peers() // Returns Peer[] +``` + +### `getContext()` Renamed to `context()` + +```typescript +// Before +const ctx = await session.getContext({ + summary: true, + peerTarget: user, + peerPerspective: assistant, + representationOptions: { + maxObservations: 50, + includeMostDerived: true + } +}) + +// After +const ctx = await session.context({ + summary: true, + peerTarget: user, + peerPerspective: assistant, + representationOptions: { + maxConclusions: 50, + includeMostFrequent: true + } +}) +``` + +### `SessionPeerConfig` Uses camelCase and Methods Renamed + +```typescript +// Before +await session.setPeerConfig(peer, { + observe_me: true, + observe_others: false +}) +const config = await session.peerConfig(peer) + +// After +await session.setPeerConfiguration(peer, { + observeMe: true, + observeOthers: false +}) +const config = await session.getPeerConfiguration(peer) +``` + +--- + +## Message Changes + +### Message Properties Use camelCase + +```typescript +// Before (from @honcho-ai/core) +message.peer_id +message.session_id +message.workspace_id +message.created_at +message.token_count + +// After +message.peerId +message.sessionId +message.workspaceId +message.createdAt +message.tokenCount +``` + +### MessageInput Type + +```typescript +// Before +interface ValidatedMessageCreate { + peer_id: string + content: string + metadata?: Record + configuration?: Record + created_at?: string +} + +// After +interface MessageInput { + peerId: string + content: string + metadata?: Record + configuration?: MessageConfiguration + createdAt?: string +} +``` + +--- + +## Streaming Changes + +### `DialecticStreamDelta` Removed + +```typescript +// Before +import { DialecticStreamDelta, DialecticStreamChunk } from '@honcho-ai/sdk' + +// After +import { DialecticStreamChunk, DialecticStreamResponse } from '@honcho-ai/sdk' +``` + +--- + +## Configuration Changes + +### Workspace Configuration + +Configurations are now strongly typed objects instead of `Record`. + +```typescript +// Before +await honcho.setConfig({ + deriver: { enabled: true }, + some_custom_key: 'value' +}) + +// After +await honcho.setConfiguration({ + reasoning: { + enabled: true, + customInstructions: 'Be concise' + }, + peerCard: { + use: true, + create: true + }, + summary: { + enabled: true, + messagesPerShortSummary: 20, + messagesPerLongSummary: 60 + }, + dream: { + enabled: true + } +}) +``` + +### Peer Configuration + +```typescript +// Before +await peer.setConfig({ observe_me: false }) + +// After +await peer.setConfiguration({ observeMe: false }) +``` + +### Message Configuration + +```typescript +// Before +peer.message('Hello', { + configuration: { + deriver: { enabled: true } + } +}) + +// After +peer.message('Hello', { + configuration: { + reasoning: { + enabled: true, + customInstructions: 'Focus on emotions' + } + } +}) +``` + +--- + +## Type Changes + +### Removed Exports + +- `Observation` (use `Conclusion`) +- `ObservationScope` (use `ConclusionScope`) +- `ObservationData`, `ObservationCreateParam`, `ObservationQueryParams` +- `Representation`, `RepresentationData`, `RepresentationOptions` (class removed) +- `ExplicitObservation`, `DeductiveObservation` +- `DialecticStreamDelta` +- `DeriverStatusOptions` (use `QueueStatusOptions`) +- `MessageCreate` (use `MessageInput`) +- `WorkingRepParams` + +### New Exports + +```typescript +import { + // Domain classes + Conclusion, + ConclusionScope, + ConclusionCreateParams, + + // Error types + HonchoError, + AuthenticationError, + BadRequestError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + ConflictError, + UnprocessableEntityError, + ServerError, + ConnectionError, + TimeoutError, + + // Message types + Message, + MessageInput, + + // Configuration types + WorkspaceConfig, + SessionConfig, + PeerConfig, + SessionPeerConfig, + MessageConfiguration, + ReasoningConfig, + PeerCardConfig, + SummaryConfig, + DreamConfig, + + // API response types + QueueStatus, + QueueStatusOptions, + RepresentationOptions, + ConclusionQueryParams, + ConclusionResponse, +} from '@honcho-ai/sdk' +``` + +### SummaryData Type Changed + +```typescript +// Before +interface SummaryData { + content: string + message_id: string + summary_type: string + created_at: string + token_count: number +} + +// After +interface SummaryData { + content: string + messageId: string + summaryType: string + createdAt: string + tokenCount: number +} +``` diff --git a/.claude/skills/migrate-honcho-ts/MIGRATION-CHECKLIST.md b/.claude/skills/migrate-honcho-ts/MIGRATION-CHECKLIST.md new file mode 100644 index 00000000..78fcc6bd --- /dev/null +++ b/.claude/skills/migrate-honcho-ts/MIGRATION-CHECKLIST.md @@ -0,0 +1,108 @@ +# Migration Checklist + +Use this checklist to track migration progress. Copy into your working notes and check off items as completed. + +## Dependencies + +- [ ] Remove `@honcho-ai/core` from dependencies +- [ ] Update `@honcho-ai/sdk` to v2.0.0 + +## Client-Level Changes + +- [ ] Replace all `.core` usages with `.http` or remove +- [ ] Rename `getConfig()` → `getConfiguration()` +- [ ] Rename `setConfig()` → `setConfiguration()` +- [ ] Rename `getPeers()` → `peers()` +- [ ] Rename `getSessions()` → `sessions()` +- [ ] Rename `getWorkspaces()` → `workspaces()` (returns `Page` now) +- [ ] Rename `getDeriverStatus()` → `queueStatus()` +- [ ] Remove `pollDeriverStatus()` calls entirely (no replacement—do not rely on queue being empty) +- [ ] Move `updateMessage()` calls from client to session + +## Peer-Level Changes + +- [ ] Replace `peer.chat(q, { stream: true })` with `peer.chatStream(q)` +- [ ] Rename `getSessions()` → `sessions()` +- [ ] Rename `getConfig()` → `getConfiguration()` +- [ ] Rename `setConfig()` → `setConfiguration()` +- [ ] Rename `peerConfig()` → `getPeerConfiguration()` +- [ ] Rename `setPeerConfig()` → `setPeerConfiguration()` +- [ ] Rename `workingRep()` → `representation()` (returns string now) +- [ ] Rename `getContext()` → `context()` +- [ ] Replace `observations` → `conclusions` +- [ ] Replace `observationsOf()` → `conclusionsOf()` +- [ ] Handle `card()` returning `string[] | null` instead of `string` + +## Session-Level Changes + +- [ ] Rename `getPeers()` → `peers()` (returns `Peer[]` now, not `Page`) +- [ ] Rename `getMessages()` → `messages()` +- [ ] Rename `getConfig()` → `getConfiguration()` +- [ ] Rename `setConfig()` → `setConfiguration()` +- [ ] Rename `getContext()` → `context()` +- [ ] Rename `getSummaries()` → `summaries()` +- [ ] Rename `getDeriverStatus()` → `queueStatus()` +- [ ] Remove `pollDeriverStatus()` calls entirely (no replacement—do not rely on queue being empty) +- [ ] Rename `workingRep()` → `representation()` (returns string now) + +## Terminology Changes + +- [ ] Rename `maxObservations` → `maxConclusions` +- [ ] Rename `includeMostDerived` → `includeMostFrequent` +- [ ] Rename `Observation` type → `Conclusion` +- [ ] Rename `ObservationScope` type → `ConclusionScope` + +## snake_case → camelCase + +- [ ] Update all `{ config: ... }` to `{ configuration: ... }` +- [ ] Update `observe_me` → `observeMe` +- [ ] Update `observe_others` → `observeOthers` +- [ ] Update `created_at` → `createdAt` +- [ ] Update message property access: + - [ ] `peer_id` → `peerId` + - [ ] `session_id` → `sessionId` + - [ ] `workspace_id` → `workspaceId` + - [ ] `created_at` → `createdAt` + - [ ] `token_count` → `tokenCount` +- [ ] Update summary property access: + - [ ] `message_id` → `messageId` + - [ ] `summary_type` → `summaryType` + +## Configuration Objects + +- [ ] Update workspace configuration to typed structure +- [ ] Update session configuration to typed structure +- [ ] Update peer configuration to typed structure +- [ ] Replace `deriver` config with `reasoning` config + +## Error Handling + +- [ ] Update error handling to use new error types if needed + +## Type Imports + +- [ ] Remove imports of deleted types: + - `Observation`, `ObservationScope`, `ObservationData` + - `Representation`, `RepresentationData` + - `ExplicitObservation`, `DeductiveObservation` + - `DialecticStreamDelta` + - `DeriverStatusOptions` + - `MessageCreate`, `ValidatedMessageCreate` + - `WorkingRepParams` +- [ ] Add imports of new types as needed: + - `Conclusion`, `ConclusionScope` + - `MessageInput` + - `QueueStatusOptions` + - Error types + +## Representation Handling + +- [ ] Remove usage of `Representation` class methods (`.explicit`, `.deductive`, `.isEmpty()`, `.diff()`) +- [ ] Handle representation as plain string + +## Final Verification + +- [ ] Run TypeScript compiler with no errors +- [ ] Run tests +- [ ] Verify streaming functionality works +- [ ] Verify configuration changes take effect diff --git a/.claude/skills/migrate-honcho-ts/SKILL.md b/.claude/skills/migrate-honcho-ts/SKILL.md new file mode 100644 index 00000000..5a45e14f --- /dev/null +++ b/.claude/skills/migrate-honcho-ts/SKILL.md @@ -0,0 +1,227 @@ +--- +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. +--- + +# Honcho TypeScript SDK Migration (v1.6.0 → v2.0.0) + +## Overview + +This skill migrates code from `@honcho-ai/sdk` v1.6.0 to v2.0.0 (required for Honcho 3.0.0). + +**Key breaking changes:** + +- `@honcho-ai/core` dependency removed +- "Observation" → "Conclusion" terminology +- "Deriver" → "Queue" terminology +- `getConfig`/`setConfig` → `getConfiguration`/`setConfiguration` +- `snake_case` → `camelCase` throughout +- Streaming via `chatStream()` instead of `chat({ stream: true })` +- `Representation` class removed (returns string now) + +## Quick Migration + +### 1. Update dependencies + +Remove `@honcho-ai/core` from package.json. The SDK now has its own HTTP client. + +### 2. Replace `.core` with `.http` + +```typescript +// Before +const workspace = await client.core.workspaces.getOrCreate({ id: 'my-workspace' }) + +// After +const response = await client.http.post('/v3/workspaces', { body: { id: 'my-workspace' } }) +``` + +### 3. Rename configuration methods + +```typescript +// Before +await honcho.getConfig() +await honcho.setConfig({ key: 'value' }) +await peer.getConfig() +await session.getConfig() + +// After +await honcho.getConfiguration() +await honcho.setConfiguration({ reasoning: { enabled: true } }) +await peer.getConfiguration() +await session.getConfiguration() +``` + +### 4. Rename listing methods + +```typescript +// Before +const peers = await honcho.getPeers() +const sessions = await honcho.getSessions() +const workspaces = await honcho.getWorkspaces() // string[] + +// After +const peers = await honcho.peers() +const sessions = await honcho.sessions() +const workspaces = await honcho.workspaces() // Page +``` + +### 5. Update streaming + +```typescript +// Before +const stream = await peer.chat('Hello', { stream: true }) + +// After +const stream = await peer.chatStream('Hello') +``` + +### 6. Update observations → conclusions + +```typescript +// Before +peer.observations +peer.observationsOf('bob') +maxObservations: 50 +includeMostDerived: true + +// After +peer.conclusions +peer.conclusionsOf('bob') +maxConclusions: 50 +includeMostFrequent: true +``` + +### 7. Update queue status methods + +```typescript +// Before +await honcho.getDeriverStatus({ observer: peer }) +await honcho.pollDeriverStatus({ timeoutMs: 60000 }) // REMOVE - see note below + +// After +await honcho.queueStatus({ observer: peer }) +// pollDeriverStatus() has no replacement - see note below +``` + +**Important:** `pollDeriverStatus()` and its polling pattern have been removed entirely. Do not rely on the queue ever being empty. The queue is a continuous processing system—new messages may arrive at any time, and waiting for "completion" is not a valid pattern. If your code previously polled for queue completion, redesign it to work without that assumption. + +### 8. Convert snake_case to camelCase + +```typescript +// Before +message.peer_id +message.session_id +message.created_at +message.token_count +{ observe_me: true, observe_others: false } +{ created_at: '2024-01-01' } + +// After +message.peerId +message.sessionId +message.createdAt +message.tokenCount +{ observeMe: true, observeOthers: false } +{ createdAt: '2024-01-01' } +``` + +### 9. Update representation calls + +```typescript +// Before +const rep = await peer.workingRep(session, target, options) +console.log(rep.explicit) // ExplicitObservation[] +console.log(rep.deductive) // DeductiveObservation[] + +// After +const rep = await peer.representation({ session, target, ...options }) +console.log(rep) // string +``` + +### 10. Move updateMessage to session + +```typescript +// Before +await honcho.updateMessage(message, metadata, session) + +// After +await session.updateMessage(message, metadata) +``` + +## Quick Reference Table + +| v1.6.0 | v2.0.0 | +|--------|--------| +| `client.core` | `client.http` | +| `getConfig()` | `getConfiguration()` | +| `setConfig()` | `setConfiguration()` | +| `getPeers()` | `peers()` | +| `getSessions()` | `sessions()` | +| `getWorkspaces()` | `workspaces()` | +| `getDeriverStatus()` | `queueStatus()` | +| `pollDeriverStatus()` | *Removed - do not poll* | +| `peer.chat(q, { stream: true })` | `peer.chatStream(q)` | +| `peer.workingRep()` | `peer.representation()` | +| `peer.getContext()` | `peer.context()` | +| `peer.observations` | `peer.conclusions` | +| `peer.observationsOf()` | `peer.conclusionsOf()` | +| `session.getPeers()` | `session.peers()` | +| `session.getMessages()` | `session.messages()` | +| `session.getSummaries()` | `session.summaries()` | +| `session.getContext()` | `session.context()` | +| `session.workingRep()` | `session.representation()` | +| `session.peerConfig()` | `session.getPeerConfiguration()` | +| `session.setPeerConfig()` | `session.setPeerConfiguration()` | +| `{ timeoutMs: 60000 }` | `{ timeout: 60 }` | +| `{ maxObservations: 50 }` | `{ maxConclusions: 50 }` | +| `{ includeMostDerived }` | `{ includeMostFrequent }` | +| `{ config: ... }` | `{ configuration: ... }` | +| `message.peer_id` | `message.peerId` | +| `message.created_at` | `message.createdAt` | +| `Observation` | `Conclusion` | +| `ObservationScope` | `ConclusionScope` | + +## Detailed Reference + +For comprehensive details on each change, see: + +- [DETAILED-CHANGES.md](DETAILED-CHANGES.md) - Full API change documentation +- [MIGRATION-CHECKLIST.md](MIGRATION-CHECKLIST.md) - Step-by-step checklist + +## New Error Types + +```typescript +import { + HonchoError, + AuthenticationError, + BadRequestError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + ConflictError, + UnprocessableEntityError, + ServerError, + ConnectionError, + TimeoutError +} from '@honcho-ai/sdk' +``` + +## New Configuration Types + +Configurations are now strongly typed: + +```typescript +await honcho.setConfiguration({ + reasoning: { + enabled: true, + customInstructions: 'Be concise' + }, + peerCard: { use: true, create: true }, + summary: { + enabled: true, + messagesPerShortSummary: 20, + messagesPerLongSummary: 60 + }, + dream: { enabled: true } +}) +``` diff --git a/.env.template b/.env.template index 781f71b3..8593b237 100644 --- a/.env.template +++ b/.env.template @@ -85,7 +85,7 @@ LLM_ANTHROPIC_API_KEY=your-anthropic-api-key-here # Global LLM settings # LLM_DEFAULT_MAX_TOKENS=2500 # LLM_EMBEDDING_PROVIDER=openai -# LLM_MAX_TOOL_OUTPUT_CHARS=30000 # Max chars for tool output (~7500 tokens) +# 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 # ============================================================================= @@ -121,43 +121,47 @@ LLM_ANTHROPIC_API_KEY=your-anthropic-api-key-here # DIALECTIC_MAX_OUTPUT_TOKENS=8192 # DIALECTIC_MAX_INPUT_TOKENS=100000 # DIALECTIC_HISTORY_TOKEN_LIMIT=8192 -# DIALECTIC_SESSION_HISTORY_MAX_TOKENS=16384 +# DIALECTIC_SESSION_HISTORY_MAX_TOKENS=4096 # Per-level settings (reasoning_level parameter in API) -# Each level can have its own provider, model, thinking budget, and tool iterations +# 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 -# DIALECTIC_LEVELS__minimal__MAX_TOOL_ITERATIONS=5 -# DIALECTIC_LEVELS__minimal__TOOL_CHOICE=any +# 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-3-flash-preview +# DIALECTIC_LEVELS__low__MODEL=gemini-2.5-flash-lite # DIALECTIC_LEVELS__low__THINKING_BUDGET_TOKENS=0 # DIALECTIC_LEVELS__low__MAX_TOOL_ITERATIONS=5 -# DIALECTIC_LEVELS__low__TOOL_CHOICE=any +# DIALECTIC_LEVELS__low__MAX_OUTPUT_TOKENS=8192 # Optional: override global default # Medium level # DIALECTIC_LEVELS__medium__PROVIDER=anthropic # DIALECTIC_LEVELS__medium__MODEL=claude-haiku-4-5 # DIALECTIC_LEVELS__medium__THINKING_BUDGET_TOKENS=1024 -# DIALECTIC_LEVELS__medium__MAX_TOOL_ITERATIONS=4 +# 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-opus-4-5 -# DIALECTIC_LEVELS__high__THINKING_BUDGET_TOKENS=0 +# DIALECTIC_LEVELS__high__MODEL=claude-haiku-4-5 +# DIALECTIC_LEVELS__high__THINKING_BUDGET_TOKENS=1024 # 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-opus-4-5 +# DIALECTIC_LEVELS__max__MODEL=claude-haiku-4-5 # DIALECTIC_LEVELS__max__THINKING_BUDGET_TOKENS=2048 # DIALECTIC_LEVELS__max__MAX_TOOL_ITERATIONS=10 +# DIALECTIC_LEVELS__max__MAX_OUTPUT_TOKENS=8192 # Optional: override global default # Optional backup per level (must set both or neither): # DIALECTIC_LEVELS__max__BACKUP_PROVIDER=google # DIALECTIC_LEVELS__max__BACKUP_MODEL=gemini-2.5-pro diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index fc3df8b1..00dfba9f 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -38,7 +38,6 @@ jobs: runs-on: ubuntu-latest outputs: python: ${{ steps.filter.outputs.python }} - typescript: ${{ steps.filter.outputs.typescript }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -50,8 +49,6 @@ jobs: - 'pyproject.toml' - 'uv.lock' - 'migrations/**' - - '.github/workflows/unittest.yml' - typescript: - 'sdks/typescript/**' - '.github/workflows/unittest.yml' @@ -90,6 +87,13 @@ jobs: with: python-version-file: "pyproject.toml" + - name: Install bun + uses: oven-sh/setup-bun@v2 + + - name: Install TypeScript SDK dependencies + run: bun install + working-directory: sdks/typescript + - name: Install the project run: uv sync --all-extras --dev @@ -130,37 +134,11 @@ jobs: SUMMARY_PROVIDER: openai SUMMARY_MODEL: test - test-typescript: - needs: changes - if: ${{ needs.changes.outputs.typescript == 'true' }} - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: latest - - - name: Install TypeScript SDK dependencies - run: | - cd sdks/typescript - bun install - - - name: Run TypeScript SDK tests - run: | - cd sdks/typescript - bun run test - env: - HONCHO_API_KEY: test-key - HONCHO_BASE_URL: http://localhost:8000 - # Status check for branch protection rules # This job always runs and reports success only if all required jobs pass test-status: runs-on: ubuntu-latest - needs: [changes, test-python, test-typescript] + needs: [changes, test-python] if: always() steps: - name: Check test results @@ -169,8 +147,4 @@ jobs: echo "Python tests failed or were cancelled" exit 1 fi - if [[ "${{ needs.changes.outputs.typescript }}" == "true" && "${{ needs.test-typescript.result }}" != "success" && "${{ needs.test-typescript.result }}" != "skipped" ]]; then - echo "TypeScript tests failed or were cancelled" - exit 1 - fi echo "All required tests passed!" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index adf296be..51428930 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -99,10 +99,10 @@ repos: stages: [pre-push] pass_filenames: false - # # TypeScript build/test with bun + # TypeScript build with bun (tests run via pytest) - id: typescript-check - name: TypeScript build and test - entry: bash -c 'if [ -f "sdks/typescript/package.json" ]; then cd sdks/typescript && bun run build && bun run test; fi' + name: TypeScript build + entry: bash -c 'if [ -f "sdks/typescript/package.json" ]; then cd sdks/typescript && bun run build; fi' language: system files: ^sdks/typescript/.*\.(js|ts|jsx|tsx|json)$ stages: [pre-push] diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c012374..2bb1c9c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ 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/). +## [3.0.0] - 2026-01-19 + +### Added + +- Agentic Dreamer for intelligent memory consolidation using LLM agents +- Agentic Dialectic for query answering using LLM agents with tool use +- Reasoning levels configuration for dialectic (`minimal`, `low`, `medium`, `high`, `max`) +- Prometheus token tracking for deriver and dialectic operations +- n8n integration + +### Changed + +- API route renaming for consistency +- Dreamer and dialectic now respect peer card configuration settings +- Observations renamed to Conclusions across API and SDKs + +### Fixed + +- Dream scheduling bugs +- Summary creation when start_message_id > end_message_id +- Cashews upgrade to prevent NoScriptError + +### Removed + +- Peer card configuration from message configuration; peer cards no longer created/updated in deriver process + ## [2.5.1] - 2025-12-15 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 5c77bdd0..f71650f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,27 @@ All API routes follow the pattern: `/v1/{resource}/{id}/{action}` - Typechecking: `uv run basedpyright` - Format code: `uv run ruff format src/` +### SDK Testing + +#### TypeScript SDK + +**🚨 DO NOT RUN `bun test` DIRECTLY. IT WILL NOT WORK. 🚨** + +The TypeScript SDK tests require a running Honcho server with database and Redis. Running `bun test` alone will fail immediately because there's no server. The tests are orchestrated via pytest which handles all the infrastructure setup. + +**The ONLY way to run TypeScript SDK tests:** + +```bash +# From the monorepo root (not from sdks/typescript/) +uv run pytest tests/ -k typescript +``` + +**To type-check the TypeScript SDK (this is fine to run directly):** + +```bash +cd sdks/typescript && bun run tsc --noEmit +``` + ### Code Style - Follow isort conventions with absolute imports preferred diff --git a/README.md b/README.md index d9e551e4..d9684333 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ --- -![Static Badge](https://img.shields.io/badge/Version-2.5.1-blue) +![Static Badge](https://img.shields.io/badge/Version-3.0.0-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) @@ -127,22 +127,14 @@ The Honcho project is split between several repositories with this one hosting the core service logic. This is implemented as a FastAPI server/API to store data about an application's state. -There are also client sdks in implemented in the `sdks/` directory with support -for Python and TypeScript. These SDKs wrap core SDKs that are generated using -[Stainless](https://www.stainlessapi.com/). +There are also client SDKs implemented in the `sdks/` directory with support +for Python and TypeScript. - [Python](https://pypi.org/project/honcho-ai/) - [TypeScript](https://www.npmjs.com/package/@honcho-ai/sdk) -We recommend using the official client SDKs instead of the core ones for better -developer experience, however for any custom use cases you can still access the -core SDKs in their own repos: - -- [Honcho Core Python](https://github.com/plastic-labs/honcho-python-core) -- [Honcho Core TypeScript](https://github.com/plastic-labs/honcho-node-core) - Examples on how to use the SDK are located within each SDK folder and in the -[SDK Reference](https://docs.honcho.dev/v2/documentation/tutorial/SDK) +[SDK Reference](https://docs.honcho.dev/v3/documentation/tutorial/SDK) There are also documented examples of how to use the core SDKs in the [API Reference](https://docs.honcho.dev/api-reference/introduction) section of diff --git a/config.toml.example b/config.toml.example index 87d10f6e..b54985b9 100644 --- a/config.toml.example +++ b/config.toml.example @@ -52,7 +52,7 @@ PROFILES_SAMPLE_RATE = 0.1 [llm] DEFAULT_MAX_TOKENS = 2500 EMBEDDING_PROVIDER = "openai" -MAX_TOOL_OUTPUT_CHARS = 30000 # Max chars for tool output (~7500 tokens) +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 @@ -96,41 +96,44 @@ ENABLED = true MAX_OUTPUT_TOKENS = 8192 MAX_INPUT_TOKENS = 100000 HISTORY_TOKEN_LIMIT = 8192 -SESSION_HISTORY_MAX_TOKENS = 16384 +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 = 5 -TOOL_CHOICE = "any" +MAX_TOOL_ITERATIONS = 1 +MAX_OUTPUT_TOKENS = 250 [dialectic.levels.low] PROVIDER = "google" -MODEL = "gemini-3-flash-preview" +MODEL = "gemini-2.5-flash-lite" THINKING_BUDGET_TOKENS = 0 MAX_TOOL_ITERATIONS = 5 -TOOL_CHOICE = "any" +# MAX_OUTPUT_TOKENS = 8192 # Optional: override global default [dialectic.levels.medium] PROVIDER = "anthropic" MODEL = "claude-haiku-4-5" THINKING_BUDGET_TOKENS = 1024 -MAX_TOOL_ITERATIONS = 4 -# TOOL_CHOICE = "any" # Optional: None/auto lets model decide +MAX_TOOL_ITERATIONS = 2 +# MAX_OUTPUT_TOKENS = 8192 # Optional: override global default [dialectic.levels.high] PROVIDER = "anthropic" -MODEL = "claude-opus-4-5" -THINKING_BUDGET_TOKENS = 0 +MODEL = "claude-haiku-4-5" +THINKING_BUDGET_TOKENS = 1024 MAX_TOOL_ITERATIONS = 4 +# MAX_OUTPUT_TOKENS = 8192 # Optional: override global default [dialectic.levels.max] PROVIDER = "anthropic" -MODEL = "claude-opus-4-5" +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" diff --git a/docs/SKILL.md b/docs/SKILL.md index 32a8181b..3e828d6a 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -32,7 +32,7 @@ Use Glob and Grep to find: After exploring the codebase, use the **AskUserQuestion** tool to clarify integration requirements. Ask these questions (adapt based on what you learned in Phase 1): -**Question Set 1 - Entities & Peers** +#### Question Set 1 - Entities & Peers Ask about which entities should be Honcho peers: @@ -41,7 +41,7 @@ Ask about which entities should be Honcho peers: - options based on what you found (e.g., "End users only", "Users + AI assistant", "Users + multiple AI agents", "All participants including third-party services") - Include a follow-up if they have multiple AI agents: should any AI peers be observed? -**Question Set 2 - Integration Pattern** +#### Question Set 2 - Integration Pattern Ask how they want to use Honcho context: @@ -53,7 +53,7 @@ Ask how they want to use Honcho context: - "get_context()" - "Include conversation history and representations in prompt" - "Multiple patterns" - "Combine approaches for different use cases" -**Question Set 3 - Session Structure** +#### Question Set 3 - Session Structure Ask about conversation structure: @@ -61,7 +61,7 @@ Ask about conversation structure: - question: "How should conversations map to Honcho sessions?" - options based on their app (e.g., "One session per chat thread", "One session per user", "Multiple users per session (group chat)", "Custom session logic") -**Question Set 4 - Specific Queries (if using pre-fetch pattern)** +#### Question Set 4 - Specific Queries (if using pre-fetch pattern) If they chose pre-fetch, ask what context matters: @@ -449,4 +449,4 @@ When integrating Honcho into an existing codebase: - Documentation: - Latest SDK versions: -- API Reference: +- API Reference: diff --git a/docs/changelog/compatibility-guide.mdx b/docs/changelog/compatibility-guide.mdx index 98490721..93349ccc 100644 --- a/docs/changelog/compatibility-guide.mdx +++ b/docs/changelog/compatibility-guide.mdx @@ -8,23 +8,23 @@ This guide helps you understand which versions of Honcho's API are compatible wi ## Version Compatibility -### Honcho API v2.5.1 (Current) +### Honcho API v3.0.0 (Current) - **Compatible Version:** v1.6.0 + **Compatible Version:** v2.0.0 Install with: ```bash - npm install @honcho-ai/sdk@1.6.0 + npm install @honcho-ai/sdk@2.0.0 ``` - **Compatible Version:** v1.6.0 + **Compatible Version:** v2.0.0 Install with: ```bash - pip install honcho-ai==1.6.0 + pip install honcho-ai==2.0.0 ``` @@ -34,7 +34,8 @@ This guide helps you understand which versions of Honcho's API are compatible wi | Honcho API Version | TypeScript SDK | Python SDK | |-------------------|---------------|------------| -| v2.5.1 (Current) | v1.6.0 | v1.6.0 | +| v3.0.0 (Current) | v2.0.0 | v2.0.0 | +| v2.5.1 | v1.6.0 | v1.6.0 | | v2.5.0 | v1.6.0 | v1.6.0 | | v2.4.3 | v1.5.0 | v1.5.0 | | v2.4.2 | v1.5.0 | v1.5.0 | diff --git a/docs/changelog/introduction.mdx b/docs/changelog/introduction.mdx index 09c15174..99414bd7 100644 --- a/docs/changelog/introduction.mdx +++ b/docs/changelog/introduction.mdx @@ -27,7 +27,13 @@ Welcome to the Honcho changelog! This section documents all notable changes to t ### Honcho API and SDK Changelogs - + + ### Changed + + - Major version release + + + ### Fixed - Backwards compatibility for `message_ids` field in documents to handle legacy tuple format @@ -142,7 +148,7 @@ Welcome to the Honcho changelog! This section documents all notable changes to t ### Added - - Get peer cards endpoint (`GET /v2/peers/{peer_id}/peer-card`) for retrieving targeted peer context information + - Get peer cards endpoint (`GET /v3/peers/{peer_id}/peer-card`) for retrieving targeted peer context information ### Changed @@ -406,7 +412,12 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [Python SDK](https://pypi.org/project/honcho-ai/) - + + ### Changed + + - Major version release + + ### Added - metadata and configuration fields to Workspace, Peer, Session, and Message objects @@ -493,7 +504,12 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [TypeScript SDK](https://www.npmjs.com/package/@honcho-ai/sdk) - + + ### Changed + + - Major version release + + ### Added - metadata and configuration fields to Workspace, Peer, Session, and Message objects diff --git a/docs/docs.json b/docs/docs.json index 3ad377a4..a0135eeb 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -5,7 +5,7 @@ "redirects": [ { "source": "/", - "destination": "/v2/documentation/introduction/overview" + "destination": "/v3/documentation/introduction/overview" } ], "colors": { @@ -15,14 +15,21 @@ }, "favicon": "/favicon.svg", "contextual": { - "options": ["copy", "view", "chatgpt", "claude"] + "options": [ + "copy", + "view", + "chatgpt", + "claude" + ] }, "navigation": { "versions": [ { "version": "v2.5.1", "api": { - "openapi": ["openapi.json"] + "openapi": [ + "openapi.json" + ] }, "tabs": [ { @@ -69,11 +76,15 @@ "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", @@ -86,7 +97,10 @@ }, { "group": "Application Interfaces", - "pages": ["v2/guides/discord", "v2/guides/telegram"] + "pages": [ + "v2/guides/discord", + "v2/guides/telegram" + ] } ] }, @@ -95,7 +109,9 @@ "groups": [ { "group": "API Documentation", - "pages": ["v2/api-reference/introduction"] + "pages": [ + "v2/api-reference/introduction" + ] }, { "group": "workspaces", @@ -209,9 +225,11 @@ ] }, { - "version": "v2.6.0-alpha", + "version": "v3.0.0", "api": { - "openapi": ["openapi.json"] + "openapi": [ + "openapi.json" + ] }, "tabs": [ { @@ -220,35 +238,35 @@ { "group": "Introduction", "pages": [ - "v2.6.0-alpha/documentation/introduction/overview", - "v2.6.0-alpha/documentation/introduction/quickstart", - "v2.6.0-alpha/documentation/introduction/vibecoding" + "v3/documentation/introduction/overview", + "v3/documentation/introduction/quickstart", + "v3/documentation/introduction/vibecoding" ] }, { "group": "Core Concepts", "pages": [ - "v2.6.0-alpha/documentation/core-concepts/architecture", - "v2.6.0-alpha/documentation/core-concepts/reasoning", - "v2.6.0-alpha/documentation/core-concepts/representation" + "v3/documentation/core-concepts/architecture", + "v3/documentation/core-concepts/reasoning", + "v3/documentation/core-concepts/representation" ] }, { "group": "Features", "pages": [ - "v2.6.0-alpha/documentation/features/get-context", - "v2.6.0-alpha/documentation/features/chat", + "v3/documentation/features/get-context", + "v3/documentation/features/chat", { "group": "Advanced", "pages": [ - "v2.6.0-alpha/documentation/features/advanced/overview", - "v2.6.0-alpha/documentation/features/advanced/queue-status", - "v2.6.0-alpha/documentation/features/advanced/reasoning-configuration", - "v2.6.0-alpha/documentation/features/advanced/representation-scopes", - "v2.6.0-alpha/documentation/features/advanced/summarizer", - "v2.6.0-alpha/documentation/features/advanced/search", - "v2.6.0-alpha/documentation/features/advanced/using-filters", - "v2.6.0-alpha/documentation/features/advanced/streaming-response" + "v3/documentation/features/advanced/overview", + "v3/documentation/features/advanced/queue-status", + "v3/documentation/features/advanced/reasoning-configuration", + "v3/documentation/features/advanced/representation-scopes", + "v3/documentation/features/advanced/summarizer", + "v3/documentation/features/advanced/search", + "v3/documentation/features/advanced/using-filters", + "v3/documentation/features/advanced/streaming-response" ] } ] @@ -256,8 +274,8 @@ { "group": "Reference", "pages": [ - "v2.6.0-alpha/documentation/reference/platform", - "v2.6.0-alpha/documentation/reference/sdk" + "v3/documentation/reference/platform", + "v3/documentation/reference/sdk" ] } ] @@ -268,28 +286,30 @@ { "group": "Overview", "pages": [ - "v2.6.0-alpha/guides/overview", - "v2.6.0-alpha/guides/file-uploads", - "v2.6.0-alpha/guides/storing-data" + "v3/guides/overview", + "v3/guides/file-uploads", + "v3/guides/storing-data" ] }, { "group": "Integrations", "pages": [ - "v2.6.0-alpha/guides/integrations/crewai", - "v2.6.0-alpha/guides/integrations/langgraph", - "v2.6.0-alpha/guides/integrations/mcp" + "v3/guides/integrations/crewai", + "v3/guides/integrations/langgraph", + "v3/guides/integrations/mcp" ] }, { "group": "Migrations", - "pages": ["v2.6.0-alpha/guides/migrations/mem0"] + "pages": [ + "v3/guides/migrations/mem0" + ] }, { "group": "Chatbots", "pages": [ - "v2.6.0-alpha/guides/discord", - "v2.6.0-alpha/guides/telegram" + "v3/guides/discord", + "v3/guides/telegram" ] } ] @@ -300,15 +320,15 @@ { "group": "Self-Hosting", "pages": [ - "v2.6.0-alpha/contributing/self-hosting", - "v2.6.0-alpha/contributing/configuration" + "v3/contributing/self-hosting", + "v3/contributing/configuration" ] }, { "group": "Contributing", "pages": [ - "v2.6.0-alpha/contributing/guidelines", - "v2.6.0-alpha/contributing/license" + "v3/contributing/guidelines", + "v3/contributing/license" ] } ] @@ -318,87 +338,89 @@ "groups": [ { "group": "API Documentation", - "pages": ["v2.6.0-alpha/api-reference/introduction"] + "pages": [ + "v3/api-reference/introduction" + ] }, { "group": "workspaces", "pages": [ - "v2.6.0-alpha/api-reference/endpoint/workspaces/get-or-create-workspace", - "v2.6.0-alpha/api-reference/endpoint/workspaces/get-all-workspaces", - "v2.6.0-alpha/api-reference/endpoint/workspaces/update-workspace", - "v2.6.0-alpha/api-reference/endpoint/workspaces/delete-workspace", - "v2.6.0-alpha/api-reference/endpoint/workspaces/search-workspace", - "v2.6.0-alpha/api-reference/endpoint/workspaces/get-deriver-status", - "v2.6.0-alpha/api-reference/endpoint/workspaces/trigger-dream" + "v3/api-reference/endpoint/workspaces/get-or-create-workspace", + "v3/api-reference/endpoint/workspaces/get-all-workspaces", + "v3/api-reference/endpoint/workspaces/update-workspace", + "v3/api-reference/endpoint/workspaces/delete-workspace", + "v3/api-reference/endpoint/workspaces/search-workspace", + "v3/api-reference/endpoint/workspaces/get-deriver-status", + "v3/api-reference/endpoint/workspaces/trigger-dream" ] }, { "group": "peers", "pages": [ - "v2.6.0-alpha/api-reference/endpoint/peers/get-peers", - "v2.6.0-alpha/api-reference/endpoint/peers/get-or-create-peer", - "v2.6.0-alpha/api-reference/endpoint/peers/update-peer", - "v2.6.0-alpha/api-reference/endpoint/peers/get-sessions-for-peer", - "v2.6.0-alpha/api-reference/endpoint/peers/chat", - "v2.6.0-alpha/api-reference/endpoint/peers/get-working-representation", - "v2.6.0-alpha/api-reference/endpoint/peers/get-peer-card", - "v2.6.0-alpha/api-reference/endpoint/peers/set-peer-card", - "v2.6.0-alpha/api-reference/endpoint/peers/get-peer-context", - "v2.6.0-alpha/api-reference/endpoint/peers/search-peer" + "v3/api-reference/endpoint/peers/get-peers", + "v3/api-reference/endpoint/peers/get-or-create-peer", + "v3/api-reference/endpoint/peers/update-peer", + "v3/api-reference/endpoint/peers/get-sessions-for-peer", + "v3/api-reference/endpoint/peers/chat", + "v3/api-reference/endpoint/peers/get-working-representation", + "v3/api-reference/endpoint/peers/get-peer-card", + "v3/api-reference/endpoint/peers/set-peer-card", + "v3/api-reference/endpoint/peers/get-peer-context", + "v3/api-reference/endpoint/peers/search-peer" ] }, { "group": "sessions", "pages": [ - "v2.6.0-alpha/api-reference/endpoint/sessions/get-or-create-session", - "v2.6.0-alpha/api-reference/endpoint/sessions/get-sessions", - "v2.6.0-alpha/api-reference/endpoint/sessions/update-session", - "v2.6.0-alpha/api-reference/endpoint/sessions/delete-session", - "v2.6.0-alpha/api-reference/endpoint/sessions/clone-session", - "v2.6.0-alpha/api-reference/endpoint/sessions/get-session-peers", - "v2.6.0-alpha/api-reference/endpoint/sessions/set-session-peers", - "v2.6.0-alpha/api-reference/endpoint/sessions/add-peers-to-session", - "v2.6.0-alpha/api-reference/endpoint/sessions/remove-peers-from-session", - "v2.6.0-alpha/api-reference/endpoint/sessions/get-peer-config", - "v2.6.0-alpha/api-reference/endpoint/sessions/set-peer-config", - "v2.6.0-alpha/api-reference/endpoint/sessions/get-session-context", - "v2.6.0-alpha/api-reference/endpoint/sessions/get-session-summaries", - "v2.6.0-alpha/api-reference/endpoint/sessions/search-session" + "v3/api-reference/endpoint/sessions/get-or-create-session", + "v3/api-reference/endpoint/sessions/get-sessions", + "v3/api-reference/endpoint/sessions/update-session", + "v3/api-reference/endpoint/sessions/delete-session", + "v3/api-reference/endpoint/sessions/clone-session", + "v3/api-reference/endpoint/sessions/get-session-peers", + "v3/api-reference/endpoint/sessions/set-session-peers", + "v3/api-reference/endpoint/sessions/add-peers-to-session", + "v3/api-reference/endpoint/sessions/remove-peers-from-session", + "v3/api-reference/endpoint/sessions/get-peer-config", + "v3/api-reference/endpoint/sessions/set-peer-config", + "v3/api-reference/endpoint/sessions/get-session-context", + "v3/api-reference/endpoint/sessions/get-session-summaries", + "v3/api-reference/endpoint/sessions/search-session" ] }, { "group": "messages", "pages": [ - "v2.6.0-alpha/api-reference/endpoint/messages/create-messages-for-session", - "v2.6.0-alpha/api-reference/endpoint/messages/get-messages", - "v2.6.0-alpha/api-reference/endpoint/messages/get-message", - "v2.6.0-alpha/api-reference/endpoint/messages/update-message", - "v2.6.0-alpha/api-reference/endpoint/messages/create-messages-with-file" + "v3/api-reference/endpoint/messages/create-messages-for-session", + "v3/api-reference/endpoint/messages/get-messages", + "v3/api-reference/endpoint/messages/get-message", + "v3/api-reference/endpoint/messages/update-message", + "v3/api-reference/endpoint/messages/create-messages-with-file" ] }, { "group": "observations", "pages": [ - "v2.6.0-alpha/api-reference/endpoint/observations/create-observations", - "v2.6.0-alpha/api-reference/endpoint/observations/list-observations", - "v2.6.0-alpha/api-reference/endpoint/observations/query-observations", - "v2.6.0-alpha/api-reference/endpoint/observations/delete-observation" + "v3/api-reference/endpoint/observations/create-observations", + "v3/api-reference/endpoint/observations/list-observations", + "v3/api-reference/endpoint/observations/query-observations", + "v3/api-reference/endpoint/observations/delete-observation" ] }, { "group": "webhooks", "pages": [ - "v2.6.0-alpha/api-reference/endpoint/webhooks/list-webhook-endpoints", - "v2.6.0-alpha/api-reference/endpoint/webhooks/get-or-create-webhook-endpoint", - "v2.6.0-alpha/api-reference/endpoint/webhooks/delete-webhook-endpoint", - "v2.6.0-alpha/api-reference/endpoint/webhooks/test-emit" + "v3/api-reference/endpoint/webhooks/list-webhook-endpoints", + "v3/api-reference/endpoint/webhooks/get-or-create-webhook-endpoint", + "v3/api-reference/endpoint/webhooks/delete-webhook-endpoint", + "v3/api-reference/endpoint/webhooks/test-emit" ] }, { "group": "miscellaneous", "pages": [ - "v2.6.0-alpha/api-reference/endpoint/keys/create-key", - "v2.6.0-alpha/api-reference/endpoint/metrics" + "v3/api-reference/endpoint/keys/create-key", + "v3/api-reference/endpoint/metrics" ] } ] @@ -420,7 +442,9 @@ { "version": "v1.1.0", "api": { - "openapi": ["openapi.json"] + "openapi": [ + "openapi.json" + ] }, "tabs": [ { @@ -450,15 +474,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" + ] } ] }, @@ -467,7 +499,9 @@ "groups": [ { "group": "API Documentation", - "pages": ["v1/api-reference/introduction"] + "pages": [ + "v1/api-reference/introduction" + ] }, { "group": "apps", @@ -515,7 +549,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/package.json b/docs/package.json index 6576471d..1e7259a0 100644 --- a/docs/package.json +++ b/docs/package.json @@ -5,7 +5,7 @@ "main": ".pnp.js", "scripts": { "dev": "mint dev", - "openapi": "npx @mintlify/scraping openapi-file v2/openapi.json -o v2/api-reference/endpoint", + "openapi": "npx @mintlify/scraping openapi-file v3/openapi.json -o v3/api-reference/endpoint", "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/keys/create-key.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/keys/create-key.mdx deleted file mode 100644 index eb133652..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/keys/create-key.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/keys ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/messages/create-messages-for-session.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/messages/create-messages-for-session.mdx deleted file mode 100644 index 59f2a1ad..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/messages/create-messages-for-session.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id}/messages/ ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/messages/create-messages-with-file.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/messages/create-messages-with-file.mdx deleted file mode 100644 index f87b8243..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/messages/create-messages-with-file.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id}/messages/upload ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/messages/get-message.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/messages/get-message.mdx deleted file mode 100644 index db9c62ef..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/messages/get-message.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: get /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id}/messages/{message_id} ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/messages/get-messages.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/messages/get-messages.mdx deleted file mode 100644 index eb5b5097..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/messages/get-messages.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id}/messages/list ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/messages/update-message.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/messages/update-message.mdx deleted file mode 100644 index 3cb2db81..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/messages/update-message.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: put /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id}/messages/{message_id} ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/observations/create-observations.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/observations/create-observations.mdx deleted file mode 100644 index 569e7190..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/observations/create-observations.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/observations ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/observations/delete-observation.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/observations/delete-observation.mdx deleted file mode 100644 index 69c9ddd3..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/observations/delete-observation.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: delete /v2.6.0-alpha/workspaces/{workspace_id}/observations/{observation_id} ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/observations/list-observations.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/observations/list-observations.mdx deleted file mode 100644 index 1c4bef72..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/observations/list-observations.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/observations/list ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/observations/query-observations.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/observations/query-observations.mdx deleted file mode 100644 index 00c5c6f8..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/observations/query-observations.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/observations/query ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/peers/chat.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/peers/chat.mdx deleted file mode 100644 index b7f8047e..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/peers/chat.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/peers/{peer_id}/chat ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-or-create-peer.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-or-create-peer.mdx deleted file mode 100644 index 9964c2aa..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-or-create-peer.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/peers ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-peer-card.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-peer-card.mdx deleted file mode 100644 index 5ced4d68..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-peer-card.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: get /v2.6.0-alpha/workspaces/{workspace_id}/peers/{peer_id}/card ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-peer-context.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-peer-context.mdx deleted file mode 100644 index 3e2c9508..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-peer-context.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: get /v2.6.0-alpha/workspaces/{workspace_id}/peers/{peer_id}/context ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-peers.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-peers.mdx deleted file mode 100644 index 7b885554..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-peers.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/peers/list ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-sessions-for-peer.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-sessions-for-peer.mdx deleted file mode 100644 index 93ff788d..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-sessions-for-peer.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/peers/{peer_id}/sessions ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-working-representation.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-working-representation.mdx deleted file mode 100644 index b77e17de..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/peers/get-working-representation.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/peers/{peer_id}/representation ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/peers/search-peer.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/peers/search-peer.mdx deleted file mode 100644 index 0053845c..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/peers/search-peer.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/peers/{peer_id}/search ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/peers/set-peer-card.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/peers/set-peer-card.mdx deleted file mode 100644 index ab484cf3..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/peers/set-peer-card.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: put /v2.6.0-alpha/workspaces/{workspace_id}/peers/{peer_id}/card ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/peers/update-peer.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/peers/update-peer.mdx deleted file mode 100644 index 7bc1b2a5..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/peers/update-peer.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: put /v2.6.0-alpha/workspaces/{workspace_id}/peers/{peer_id} ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/add-peers-to-session.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/sessions/add-peers-to-session.mdx deleted file mode 100644 index e04fe792..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/add-peers-to-session.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id}/peers ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/clone-session.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/sessions/clone-session.mdx deleted file mode 100644 index 016cb1de..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/clone-session.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: get /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id}/clone ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/delete-session.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/sessions/delete-session.mdx deleted file mode 100644 index c4348148..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/delete-session.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: delete /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id} ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-or-create-session.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-or-create-session.mdx deleted file mode 100644 index b3aab9d8..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-or-create-session.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/sessions ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-peer-config.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-peer-config.mdx deleted file mode 100644 index 33438219..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-peer-config.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: get /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-session-context.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-session-context.mdx deleted file mode 100644 index 4e0444f5..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-session-context.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: get /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id}/context ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-session-peers.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-session-peers.mdx deleted file mode 100644 index fa85d340..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-session-peers.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: get /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id}/peers ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-session-summaries.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-session-summaries.mdx deleted file mode 100644 index 77208467..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-session-summaries.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: get /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id}/summaries ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-sessions.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-sessions.mdx deleted file mode 100644 index 671f02b3..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/get-sessions.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/sessions/list ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/remove-peers-from-session.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/sessions/remove-peers-from-session.mdx deleted file mode 100644 index c08aa744..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/remove-peers-from-session.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: delete /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id}/peers ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/search-session.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/sessions/search-session.mdx deleted file mode 100644 index b246354d..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/search-session.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id}/search ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/set-peer-config.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/sessions/set-peer-config.mdx deleted file mode 100644 index 39b6199b..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/set-peer-config.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/set-session-peers.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/sessions/set-session-peers.mdx deleted file mode 100644 index aa8b3f8d..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/set-session-peers.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: put /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id}/peers ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/update-session.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/sessions/update-session.mdx deleted file mode 100644 index d2f51dd3..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/sessions/update-session.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: put /v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id} ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/webhooks/delete-webhook-endpoint.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/webhooks/delete-webhook-endpoint.mdx deleted file mode 100644 index 44a48097..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/webhooks/delete-webhook-endpoint.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: delete /v2.6.0-alpha/workspaces/{workspace_id}/webhooks/{endpoint_id} ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/webhooks/get-or-create-webhook-endpoint.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/webhooks/get-or-create-webhook-endpoint.mdx deleted file mode 100644 index 3303c4dd..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/webhooks/get-or-create-webhook-endpoint.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/webhooks ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/webhooks/list-webhook-endpoints.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/webhooks/list-webhook-endpoints.mdx deleted file mode 100644 index 4f0288c2..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/webhooks/list-webhook-endpoints.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: get /v2.6.0-alpha/workspaces/{workspace_id}/webhooks ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/webhooks/test-emit.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/webhooks/test-emit.mdx deleted file mode 100644 index e12c94f3..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/webhooks/test-emit.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: get /v2.6.0-alpha/workspaces/{workspace_id}/webhooks/test ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/delete-workspace.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/delete-workspace.mdx deleted file mode 100644 index 01d7774f..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/delete-workspace.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: delete /v2.6.0-alpha/workspaces/{workspace_id} ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/get-all-workspaces.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/get-all-workspaces.mdx deleted file mode 100644 index dbfa1d98..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/get-all-workspaces.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/list ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/get-deriver-status.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/get-deriver-status.mdx deleted file mode 100644 index b5460068..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/get-deriver-status.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: get /v2.6.0-alpha/workspaces/{workspace_id}/deriver/status ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/get-or-create-workspace.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/get-or-create-workspace.mdx deleted file mode 100644 index 85246932..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/get-or-create-workspace.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/search-workspace.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/search-workspace.mdx deleted file mode 100644 index 9fa73978..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/search-workspace.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/search ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/trigger-dream.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/trigger-dream.mdx deleted file mode 100644 index d238dc7f..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/trigger-dream.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: post /v2.6.0-alpha/workspaces/{workspace_id}/trigger_dream ---- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/update-workspace.mdx b/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/update-workspace.mdx deleted file mode 100644 index 0ab685e7..00000000 --- a/docs/v2.6.0-alpha/api-reference/endpoint/workspaces/update-workspace.mdx +++ /dev/null @@ -1,3 +0,0 @@ ---- -openapi: put /v2.6.0-alpha/workspaces/{workspace_id} ---- diff --git a/docs/v2.6.0-alpha/documentation/features/advanced/overview.mdx b/docs/v2.6.0-alpha/documentation/features/advanced/overview.mdx deleted file mode 100644 index 916334a6..00000000 --- a/docs/v2.6.0-alpha/documentation/features/advanced/overview.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: "Advanced Features" -icon: "brain" -description: "Advanced configuration and monitoring options for Honcho" -sidebarTitle: "Overview" ---- - -Advanced features give you fine-grained control over Honcho's behavior and implementation. - -## Configuration & Monitoring - -- [Queue Status](/v2.6.0-alpha/documentation/features/advanced/queue-status) - Monitor background processing and reasoning tasks -- [Configuration](/v2.6.0-alpha/documentation/features/advanced/toggle-reasoning) - Configure reasoning models and behavior -- [Summarizer](/v2.6.0-alpha/documentation/features/advanced/summarizer) - Automatic session summarization - -## Querying & Filtering - -- [Search](/v2.6.0-alpha/documentation/features/advanced/search) - Search across peers, sessions, and messages -- [Filters](/v2.6.0-alpha/documentation/features/advanced/using-filters) - Filter queries with advanced parameters -- [Streaming Responses](/v2.6.0-alpha/documentation/features/advanced/streaming-response) - Stream dialectic responses in real-time diff --git a/docs/v2.6.0-alpha/README.md b/docs/v3/README.md similarity index 100% rename from docs/v2.6.0-alpha/README.md rename to docs/v3/README.md diff --git a/docs/v3/api-reference/endpoint/keys/create-key.mdx b/docs/v3/api-reference/endpoint/keys/create-key.mdx new file mode 100644 index 00000000..484229c9 --- /dev/null +++ b/docs/v3/api-reference/endpoint/keys/create-key.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/keys +--- diff --git a/docs/v3/api-reference/endpoint/messages/create-messages-for-session.mdx b/docs/v3/api-reference/endpoint/messages/create-messages-for-session.mdx new file mode 100644 index 00000000..9a579dc4 --- /dev/null +++ b/docs/v3/api-reference/endpoint/messages/create-messages-for-session.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/sessions/{session_id}/messages/ +--- diff --git a/docs/v3/api-reference/endpoint/messages/create-messages-with-file.mdx b/docs/v3/api-reference/endpoint/messages/create-messages-with-file.mdx new file mode 100644 index 00000000..cdc5cf22 --- /dev/null +++ b/docs/v3/api-reference/endpoint/messages/create-messages-with-file.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/sessions/{session_id}/messages/upload +--- diff --git a/docs/v3/api-reference/endpoint/messages/get-message.mdx b/docs/v3/api-reference/endpoint/messages/get-message.mdx new file mode 100644 index 00000000..07c32a85 --- /dev/null +++ b/docs/v3/api-reference/endpoint/messages/get-message.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v3/workspaces/{workspace_id}/sessions/{session_id}/messages/{message_id} +--- diff --git a/docs/v3/api-reference/endpoint/messages/get-messages.mdx b/docs/v3/api-reference/endpoint/messages/get-messages.mdx new file mode 100644 index 00000000..e1528f29 --- /dev/null +++ b/docs/v3/api-reference/endpoint/messages/get-messages.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/sessions/{session_id}/messages/list +--- diff --git a/docs/v3/api-reference/endpoint/messages/update-message.mdx b/docs/v3/api-reference/endpoint/messages/update-message.mdx new file mode 100644 index 00000000..76a973d4 --- /dev/null +++ b/docs/v3/api-reference/endpoint/messages/update-message.mdx @@ -0,0 +1,3 @@ +--- +openapi: put /v3/workspaces/{workspace_id}/sessions/{session_id}/messages/{message_id} +--- diff --git a/docs/v2.6.0-alpha/api-reference/endpoint/metrics.mdx b/docs/v3/api-reference/endpoint/metrics.mdx similarity index 100% rename from docs/v2.6.0-alpha/api-reference/endpoint/metrics.mdx rename to docs/v3/api-reference/endpoint/metrics.mdx diff --git a/docs/v3/api-reference/endpoint/observations/create-observations.mdx b/docs/v3/api-reference/endpoint/observations/create-observations.mdx new file mode 100644 index 00000000..408eddbd --- /dev/null +++ b/docs/v3/api-reference/endpoint/observations/create-observations.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/observations +--- diff --git a/docs/v3/api-reference/endpoint/observations/delete-observation.mdx b/docs/v3/api-reference/endpoint/observations/delete-observation.mdx new file mode 100644 index 00000000..653f73cb --- /dev/null +++ b/docs/v3/api-reference/endpoint/observations/delete-observation.mdx @@ -0,0 +1,3 @@ +--- +openapi: delete /v3/workspaces/{workspace_id}/observations/{observation_id} +--- diff --git a/docs/v3/api-reference/endpoint/observations/list-observations.mdx b/docs/v3/api-reference/endpoint/observations/list-observations.mdx new file mode 100644 index 00000000..6d1ffd0e --- /dev/null +++ b/docs/v3/api-reference/endpoint/observations/list-observations.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/observations/list +--- diff --git a/docs/v3/api-reference/endpoint/observations/query-observations.mdx b/docs/v3/api-reference/endpoint/observations/query-observations.mdx new file mode 100644 index 00000000..a9625753 --- /dev/null +++ b/docs/v3/api-reference/endpoint/observations/query-observations.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/observations/query +--- diff --git a/docs/v3/api-reference/endpoint/peers/chat.mdx b/docs/v3/api-reference/endpoint/peers/chat.mdx new file mode 100644 index 00000000..0baf4add --- /dev/null +++ b/docs/v3/api-reference/endpoint/peers/chat.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/peers/{peer_id}/chat +--- diff --git a/docs/v3/api-reference/endpoint/peers/get-or-create-peer.mdx b/docs/v3/api-reference/endpoint/peers/get-or-create-peer.mdx new file mode 100644 index 00000000..01a03204 --- /dev/null +++ b/docs/v3/api-reference/endpoint/peers/get-or-create-peer.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/peers +--- diff --git a/docs/v3/api-reference/endpoint/peers/get-peer-card.mdx b/docs/v3/api-reference/endpoint/peers/get-peer-card.mdx new file mode 100644 index 00000000..125c757e --- /dev/null +++ b/docs/v3/api-reference/endpoint/peers/get-peer-card.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v3/workspaces/{workspace_id}/peers/{peer_id}/card +--- diff --git a/docs/v3/api-reference/endpoint/peers/get-peer-context.mdx b/docs/v3/api-reference/endpoint/peers/get-peer-context.mdx new file mode 100644 index 00000000..b634b689 --- /dev/null +++ b/docs/v3/api-reference/endpoint/peers/get-peer-context.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v3/workspaces/{workspace_id}/peers/{peer_id}/context +--- diff --git a/docs/v3/api-reference/endpoint/peers/get-peers.mdx b/docs/v3/api-reference/endpoint/peers/get-peers.mdx new file mode 100644 index 00000000..ddc542b5 --- /dev/null +++ b/docs/v3/api-reference/endpoint/peers/get-peers.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/peers/list +--- diff --git a/docs/v3/api-reference/endpoint/peers/get-sessions-for-peer.mdx b/docs/v3/api-reference/endpoint/peers/get-sessions-for-peer.mdx new file mode 100644 index 00000000..5a9d57cd --- /dev/null +++ b/docs/v3/api-reference/endpoint/peers/get-sessions-for-peer.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/peers/{peer_id}/sessions +--- diff --git a/docs/v3/api-reference/endpoint/peers/get-working-representation.mdx b/docs/v3/api-reference/endpoint/peers/get-working-representation.mdx new file mode 100644 index 00000000..41e8b3bf --- /dev/null +++ b/docs/v3/api-reference/endpoint/peers/get-working-representation.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/peers/{peer_id}/representation +--- diff --git a/docs/v3/api-reference/endpoint/peers/search-peer.mdx b/docs/v3/api-reference/endpoint/peers/search-peer.mdx new file mode 100644 index 00000000..15841e4a --- /dev/null +++ b/docs/v3/api-reference/endpoint/peers/search-peer.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/peers/{peer_id}/search +--- diff --git a/docs/v3/api-reference/endpoint/peers/set-peer-card.mdx b/docs/v3/api-reference/endpoint/peers/set-peer-card.mdx new file mode 100644 index 00000000..43b7e05b --- /dev/null +++ b/docs/v3/api-reference/endpoint/peers/set-peer-card.mdx @@ -0,0 +1,3 @@ +--- +openapi: put /v3/workspaces/{workspace_id}/peers/{peer_id}/card +--- diff --git a/docs/v3/api-reference/endpoint/peers/update-peer.mdx b/docs/v3/api-reference/endpoint/peers/update-peer.mdx new file mode 100644 index 00000000..d7abd739 --- /dev/null +++ b/docs/v3/api-reference/endpoint/peers/update-peer.mdx @@ -0,0 +1,3 @@ +--- +openapi: put /v3/workspaces/{workspace_id}/peers/{peer_id} +--- diff --git a/docs/v3/api-reference/endpoint/sessions/add-peers-to-session.mdx b/docs/v3/api-reference/endpoint/sessions/add-peers-to-session.mdx new file mode 100644 index 00000000..58dd98c8 --- /dev/null +++ b/docs/v3/api-reference/endpoint/sessions/add-peers-to-session.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/sessions/{session_id}/peers +--- diff --git a/docs/v3/api-reference/endpoint/sessions/clone-session.mdx b/docs/v3/api-reference/endpoint/sessions/clone-session.mdx new file mode 100644 index 00000000..fc20d81c --- /dev/null +++ b/docs/v3/api-reference/endpoint/sessions/clone-session.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v3/workspaces/{workspace_id}/sessions/{session_id}/clone +--- diff --git a/docs/v3/api-reference/endpoint/sessions/delete-session.mdx b/docs/v3/api-reference/endpoint/sessions/delete-session.mdx new file mode 100644 index 00000000..551220c7 --- /dev/null +++ b/docs/v3/api-reference/endpoint/sessions/delete-session.mdx @@ -0,0 +1,3 @@ +--- +openapi: delete /v3/workspaces/{workspace_id}/sessions/{session_id} +--- diff --git a/docs/v3/api-reference/endpoint/sessions/get-or-create-session.mdx b/docs/v3/api-reference/endpoint/sessions/get-or-create-session.mdx new file mode 100644 index 00000000..ffe5ee8f --- /dev/null +++ b/docs/v3/api-reference/endpoint/sessions/get-or-create-session.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/sessions +--- diff --git a/docs/v3/api-reference/endpoint/sessions/get-peer-config.mdx b/docs/v3/api-reference/endpoint/sessions/get-peer-config.mdx new file mode 100644 index 00000000..1108f1bc --- /dev/null +++ b/docs/v3/api-reference/endpoint/sessions/get-peer-config.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v3/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config +--- diff --git a/docs/v3/api-reference/endpoint/sessions/get-session-context.mdx b/docs/v3/api-reference/endpoint/sessions/get-session-context.mdx new file mode 100644 index 00000000..438f8abf --- /dev/null +++ b/docs/v3/api-reference/endpoint/sessions/get-session-context.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v3/workspaces/{workspace_id}/sessions/{session_id}/context +--- diff --git a/docs/v3/api-reference/endpoint/sessions/get-session-peers.mdx b/docs/v3/api-reference/endpoint/sessions/get-session-peers.mdx new file mode 100644 index 00000000..3cbb48c7 --- /dev/null +++ b/docs/v3/api-reference/endpoint/sessions/get-session-peers.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v3/workspaces/{workspace_id}/sessions/{session_id}/peers +--- diff --git a/docs/v3/api-reference/endpoint/sessions/get-session-summaries.mdx b/docs/v3/api-reference/endpoint/sessions/get-session-summaries.mdx new file mode 100644 index 00000000..ab27f3dc --- /dev/null +++ b/docs/v3/api-reference/endpoint/sessions/get-session-summaries.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v3/workspaces/{workspace_id}/sessions/{session_id}/summaries +--- diff --git a/docs/v3/api-reference/endpoint/sessions/get-sessions.mdx b/docs/v3/api-reference/endpoint/sessions/get-sessions.mdx new file mode 100644 index 00000000..1c291150 --- /dev/null +++ b/docs/v3/api-reference/endpoint/sessions/get-sessions.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/sessions/list +--- diff --git a/docs/v3/api-reference/endpoint/sessions/remove-peers-from-session.mdx b/docs/v3/api-reference/endpoint/sessions/remove-peers-from-session.mdx new file mode 100644 index 00000000..0862b1d2 --- /dev/null +++ b/docs/v3/api-reference/endpoint/sessions/remove-peers-from-session.mdx @@ -0,0 +1,3 @@ +--- +openapi: delete /v3/workspaces/{workspace_id}/sessions/{session_id}/peers +--- diff --git a/docs/v3/api-reference/endpoint/sessions/search-session.mdx b/docs/v3/api-reference/endpoint/sessions/search-session.mdx new file mode 100644 index 00000000..3f772764 --- /dev/null +++ b/docs/v3/api-reference/endpoint/sessions/search-session.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/sessions/{session_id}/search +--- diff --git a/docs/v3/api-reference/endpoint/sessions/set-peer-config.mdx b/docs/v3/api-reference/endpoint/sessions/set-peer-config.mdx new file mode 100644 index 00000000..7591bf85 --- /dev/null +++ b/docs/v3/api-reference/endpoint/sessions/set-peer-config.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config +--- diff --git a/docs/v3/api-reference/endpoint/sessions/set-session-peers.mdx b/docs/v3/api-reference/endpoint/sessions/set-session-peers.mdx new file mode 100644 index 00000000..20867efd --- /dev/null +++ b/docs/v3/api-reference/endpoint/sessions/set-session-peers.mdx @@ -0,0 +1,3 @@ +--- +openapi: put /v3/workspaces/{workspace_id}/sessions/{session_id}/peers +--- diff --git a/docs/v3/api-reference/endpoint/sessions/update-session.mdx b/docs/v3/api-reference/endpoint/sessions/update-session.mdx new file mode 100644 index 00000000..605dc47b --- /dev/null +++ b/docs/v3/api-reference/endpoint/sessions/update-session.mdx @@ -0,0 +1,3 @@ +--- +openapi: put /v3/workspaces/{workspace_id}/sessions/{session_id} +--- diff --git a/docs/v3/api-reference/endpoint/webhooks/delete-webhook-endpoint.mdx b/docs/v3/api-reference/endpoint/webhooks/delete-webhook-endpoint.mdx new file mode 100644 index 00000000..d5c84126 --- /dev/null +++ b/docs/v3/api-reference/endpoint/webhooks/delete-webhook-endpoint.mdx @@ -0,0 +1,3 @@ +--- +openapi: delete /v3/workspaces/{workspace_id}/webhooks/{endpoint_id} +--- diff --git a/docs/v3/api-reference/endpoint/webhooks/get-or-create-webhook-endpoint.mdx b/docs/v3/api-reference/endpoint/webhooks/get-or-create-webhook-endpoint.mdx new file mode 100644 index 00000000..7069921b --- /dev/null +++ b/docs/v3/api-reference/endpoint/webhooks/get-or-create-webhook-endpoint.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/webhooks +--- diff --git a/docs/v3/api-reference/endpoint/webhooks/list-webhook-endpoints.mdx b/docs/v3/api-reference/endpoint/webhooks/list-webhook-endpoints.mdx new file mode 100644 index 00000000..ff2672ad --- /dev/null +++ b/docs/v3/api-reference/endpoint/webhooks/list-webhook-endpoints.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v3/workspaces/{workspace_id}/webhooks +--- diff --git a/docs/v3/api-reference/endpoint/webhooks/test-emit.mdx b/docs/v3/api-reference/endpoint/webhooks/test-emit.mdx new file mode 100644 index 00000000..f0401874 --- /dev/null +++ b/docs/v3/api-reference/endpoint/webhooks/test-emit.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v3/workspaces/{workspace_id}/webhooks/test +--- diff --git a/docs/v3/api-reference/endpoint/workspaces/delete-workspace.mdx b/docs/v3/api-reference/endpoint/workspaces/delete-workspace.mdx new file mode 100644 index 00000000..2b6dd05e --- /dev/null +++ b/docs/v3/api-reference/endpoint/workspaces/delete-workspace.mdx @@ -0,0 +1,3 @@ +--- +openapi: delete /v3/workspaces/{workspace_id} +--- diff --git a/docs/v3/api-reference/endpoint/workspaces/get-all-workspaces.mdx b/docs/v3/api-reference/endpoint/workspaces/get-all-workspaces.mdx new file mode 100644 index 00000000..bbd4eda7 --- /dev/null +++ b/docs/v3/api-reference/endpoint/workspaces/get-all-workspaces.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/list +--- diff --git a/docs/v3/api-reference/endpoint/workspaces/get-deriver-status.mdx b/docs/v3/api-reference/endpoint/workspaces/get-deriver-status.mdx new file mode 100644 index 00000000..b8364804 --- /dev/null +++ b/docs/v3/api-reference/endpoint/workspaces/get-deriver-status.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v3/workspaces/{workspace_id}/deriver/status +--- diff --git a/docs/v3/api-reference/endpoint/workspaces/get-or-create-workspace.mdx b/docs/v3/api-reference/endpoint/workspaces/get-or-create-workspace.mdx new file mode 100644 index 00000000..8a8803ec --- /dev/null +++ b/docs/v3/api-reference/endpoint/workspaces/get-or-create-workspace.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces +--- diff --git a/docs/v3/api-reference/endpoint/workspaces/search-workspace.mdx b/docs/v3/api-reference/endpoint/workspaces/search-workspace.mdx new file mode 100644 index 00000000..bbf5a920 --- /dev/null +++ b/docs/v3/api-reference/endpoint/workspaces/search-workspace.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/search +--- diff --git a/docs/v3/api-reference/endpoint/workspaces/trigger-dream.mdx b/docs/v3/api-reference/endpoint/workspaces/trigger-dream.mdx new file mode 100644 index 00000000..c5b2645c --- /dev/null +++ b/docs/v3/api-reference/endpoint/workspaces/trigger-dream.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/schedule_dream +--- diff --git a/docs/v3/api-reference/endpoint/workspaces/update-workspace.mdx b/docs/v3/api-reference/endpoint/workspaces/update-workspace.mdx new file mode 100644 index 00000000..96f52d79 --- /dev/null +++ b/docs/v3/api-reference/endpoint/workspaces/update-workspace.mdx @@ -0,0 +1,3 @@ +--- +openapi: put /v3/workspaces/{workspace_id} +--- diff --git a/docs/v2.6.0-alpha/api-reference/introduction.mdx b/docs/v3/api-reference/introduction.mdx similarity index 92% rename from docs/v2.6.0-alpha/api-reference/introduction.mdx rename to docs/v3/api-reference/introduction.mdx index 5234167e..a2e49a08 100644 --- a/docs/v2.6.0-alpha/api-reference/introduction.mdx +++ b/docs/v3/api-reference/introduction.mdx @@ -5,7 +5,7 @@ title: 'Introduction' This section documents all available API endpoints in the Honcho Server. Each endpoint provides CRUD operations for our core primitives. For information about these primitives, see -[Architecture](/v2.6.0-alpha/documentation/core-concepts/architecture). +[Architecture](/v3/documentation/core-concepts/architecture). We strongly recommend using our official SDKs instead of calling these APIs directly. The SDKs provide better error handling, type safety, and developer experience. diff --git a/docs/v2.6.0-alpha/contributing/configuration.mdx b/docs/v3/contributing/configuration.mdx similarity index 59% rename from docs/v2.6.0-alpha/contributing/configuration.mdx rename to docs/v3/contributing/configuration.mdx index b668c4c2..bac2a73e 100644 --- a/docs/v2.6.0-alpha/contributing/configuration.mdx +++ b/docs/v3/contributing/configuration.mdx @@ -46,18 +46,20 @@ cp config.toml.example config.toml Then modify the values as needed. The TOML file is organized into sections: -- `[app]` - Application-level settings (log level, session limits, embedding settings, Langfuse integration, local metrics collection) +- `[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, OpenAI-compatible endpoints) and general LLM settings -- `[dialectic]` - Dialectic API configuration (provider, model, query generation settings, semantic search parameters, context window size) +- `[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 (provider, model, token 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) +- `[dream]` - Dream processing configuration (enable/disable, thresholds, idle timeouts, dream types, LLM settings, surprisal sampling) - `[webhook]` - Webhook configuration (webhook secret, workspace limits) -- `[metrics]` - Metrics collection settings (enable/disable metrics, namespace) +- `[otel]` - OpenTelemetry settings for push-based metrics via OTLP +- `[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) ### Using Environment Variables @@ -66,14 +68,17 @@ All configuration values can be overridden using environment variables. The envi - `{SECTION}_{KEY}` for nested settings - Just `{KEY}` for app-level settings +- `{SECTION}__{NESTED}__{KEY}` for deeply nested settings (double underscore) Examples: - `DB_CONNECTION_URI` → `[db].CONNECTION_URI` - `DB_POOL_SIZE` → `[db].POOL_SIZE` - `AUTH_JWT_SECRET` → `[auth].JWT_SECRET` -- `DIALECTIC_MODEL` → `[dialectic].MODEL` +- `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` ### Configuration Priority @@ -123,11 +128,15 @@ LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR, CRITICAL 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:** @@ -139,6 +148,9 @@ 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 @@ -159,13 +171,15 @@ DB_CONNECTION_URI=postgresql+psycopg://honcho_user:secure_password@db.example.co ```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 -DB_POOL_RECYCLE=300 -DB_POOL_PRE_PING=true -DB_SQL_DEBUG=false -DB_TRACING=false +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:** @@ -218,8 +232,10 @@ CACHE_ENABLED=false # Set to true to enable caching # Redis connection CACHE_URL=redis://localhost:6379/0?suppress=true -# Cache namespace and TTL -CACHE_NAMESPACE=honcho # Prefix for all cache keys +# 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 @@ -254,6 +270,10 @@ 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 ``` ### General LLM Settings @@ -263,7 +283,11 @@ LLM_OPENAI_COMPATIBLE_BASE_URL=https://your-openai-compatible-endpoint.com LLM_DEFAULT_MAX_TOKENS=2500 # Embedding provider (used when EMBED_MESSAGES=true) -LLM_EMBEDDING_PROVIDER=openai # Options: openai, gemini +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 ``` ### Feature-Specific Model Configuration @@ -272,24 +296,65 @@ Different features can use different providers and models: **Dialectic API:** -The Dialectic API provides theory-of-mind informed responses by integrating long-term facts with current context. +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: ```bash -# Main dialectic model (default: Anthropic) -DIALECTIC_PROVIDER=anthropic -DIALECTIC_MODEL=claude-sonnet-4-20250514 -DIALECTIC_MAX_OUTPUT_TOKENS=2500 -DIALECTIC_THINKING_BUDGET_TOKENS=1024 # Only used with Anthropic provider -DIALECTIC_CONTEXT_WINDOW_SIZE=100000 # Maximum context window tokens +# 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 +``` -# Query generation for dialectic searches -DIALECTIC_PERFORM_QUERY_GENERATION=false # Enable query generation for semantic search -DIALECTIC_QUERY_GENERATION_PROVIDER=groq -DIALECTIC_QUERY_GENERATION_MODEL=llama-3.1-8b-instant +**Per-Level Configuration:** -# Semantic search settings -DIALECTIC_SEMANTIC_SEARCH_TOP_K=10 # Number of results to retrieve -DIALECTIC_SEMANTIC_SEARCH_MAX_DISTANCE=0.85 # Maximum distance for relevance +Each reasoning level (minimal, low, medium, high, max) 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" + +[dialectic.levels.low] +PROVIDER = "google" +MODEL = "gemini-2.5-flash-lite" +THINKING_BUDGET_TOKENS = 0 +MAX_TOOL_ITERATIONS = 5 +TOOL_CHOICE = "any" + +[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 +# Backup provider (optional, must set both or neither) +# BACKUP_PROVIDER = "google" +# BACKUP_MODEL = "gemini-2.5-pro" +``` + +**Environment variables for nested dialectic levels:** +```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__MAX_TOOL_ITERATIONS=1 ``` **Deriver (Theory of Mind):** @@ -297,12 +362,20 @@ DIALECTIC_SEMANTIC_SEARCH_MAX_DISTANCE=0.85 # Maximum distance for relevance The Deriver is a background processing system that 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=10000 -DERIVER_THINKING_BUDGET_TOKENS=1024 # Only used with Anthropic provider +DERIVER_MAX_OUTPUT_TOKENS=4096 +DERIVER_THINKING_BUDGET_TOKENS=1024 DERIVER_MAX_INPUT_TOKENS=23000 # Maximum input tokens for deriver +DERIVER_TEMPERATURE= # Optional temperature override (unset by default) + +# Backup provider (optional, must set both or neither) +# DERIVER_BACKUP_PROVIDER=anthropic +# DERIVER_BACKUP_MODEL=claude-haiku-4-5 # Worker settings DERIVER_WORKERS=1 # Number of background worker processes @@ -312,9 +385,13 @@ DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5 # Timeout for stale sessions # Queue management DERIVER_QUEUE_ERROR_RETENTION_SECONDS=2592000 # Keep errored items for 30 days -# Working representation settings -DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=50 # Max observations stored -DERIVER_REPRESENTATION_BATCH_MAX_TOKENS=4096 # Max tokens per batch +# Document settings +DERIVER_DEDUPLICATE=true # Deduplicate documents when creating + +# 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) ``` **Peer Card:** @@ -324,11 +401,6 @@ Peer cards are short, structured summaries of peer identity and characteristics. ```bash # Enable/disable peer card generation PEER_CARD_ENABLED=true - -# LLM settings for peer card generation -PEER_CARD_PROVIDER=openai -PEER_CARD_MODEL=gpt-5-nano-2025-08-07 -PEER_CARD_MAX_OUTPUT_TOKENS=4000 # Includes thinking tokens for GPT-5 models ``` **Summary Generation:** @@ -340,11 +412,15 @@ Session summaries provide compressed context for long conversations. Honcho crea SUMMARY_ENABLED=true # LLM settings for summary generation -SUMMARY_PROVIDER=openai -SUMMARY_MODEL=gpt-4o-mini-2024-07-18 +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 # Only used with Anthropic provider +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 @@ -354,10 +430,8 @@ SUMMARY_MESSAGES_PER_LONG_SUMMARY=60 # Create long summary every N messages ### Default Provider Usage By default, Honcho uses: -- **Anthropic** (Claude) for dialectic API responses -- **Groq** for query generation (fast, cost-effective) -- **Google** (Gemini) for theory of mind derivation -- **OpenAI** (GPT) for peer cards and summarization +- **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`) You only need to set the API keys for the providers you plan to use. All providers are configurable per feature. @@ -382,9 +456,44 @@ DREAM_MIN_HOURS_BETWEEN_DREAMS=8 # Minimum hours between dreams for a peer DREAM_ENABLED_TYPES=["omni"] # Currently supported: omni # LLM settings for dream processing -DREAM_PROVIDER=openai -DREAM_MODEL=gpt-4o-mini-2024-07-18 -DREAM_MAX_OUTPUT_TOKENS=2000 +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 + +# 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 +``` + +**Surprisal-Based Sampling (Advanced):** + +The dream system includes an optional surprisal-based sampling subsystem for identifying unusual or surprising observations: + +```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__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__MIN_HIGH_SURPRISAL_FOR_REPLACE=10 + +# Observation level filtering +DREAM_SURPRISAL__INCLUDE_LEVELS=["explicit", "deductive"] ``` ### Webhook Configuration @@ -400,21 +509,92 @@ WEBHOOK_SECRET=your-webhook-signing-secret WEBHOOK_MAX_WORKSPACE_LIMIT=10 ``` -### Metrics Collection +### Vector Store Configuration -Enable metrics collection for monitoring Honcho performance and usage. +Honcho supports multiple vector store backends for storing embeddings. -**Metrics Settings:** +**Vector Store Settings:** ```bash -# Enable/disable metrics collection -METRICS_ENABLED=false +# Vector store type +VECTOR_STORE_TYPE=pgvector # Options: pgvector, turbopuffer, lancedb -# Namespace for metrics (used in metric names) -METRICS_NAMESPACE=honcho +# 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) +VECTOR_STORE_TURBOPUFFER_API_KEY=your-turbopuffer-api-key +VECTOR_STORE_TURBOPUFFER_REGION=us-east-1 + +# LanceDB-specific settings (local embedded mode) +VECTOR_STORE_LANCEDB_PATH=./lancedb_data ``` ## Monitoring Configuration +### OpenTelemetry (Push-based Metrics) + +Honcho supports push-based metrics via OpenTelemetry Protocol (OTLP) to any compatible backend (Mimir, Grafana Cloud, etc.). + +**OpenTelemetry Settings:** +```bash +# Enable/disable OTel metrics +OTEL_ENABLED=false + +# OTLP HTTP endpoint for metrics +# For Mimir: /otlp/v1/metrics +# For Grafana Cloud: https://otlp-gateway-.grafana.net/otlp/v1/metrics +OTEL_ENDPOINT=https://mimir.example.com/otlp/v1/metrics + +# Optional auth headers (JSON format in env var) +OTEL_HEADERS='{"X-Scope-OrgID": "honcho"}' + +# Export interval in milliseconds (default: 60 seconds) +OTEL_EXPORT_INTERVAL_MILLIS=60000 + +# Service identification +OTEL_SERVICE_NAME=honcho +OTEL_SERVICE_NAMESPACE=honcho # Inherits from app.NAMESPACE if not set +``` + +### CloudEvents Telemetry (Analytics) + +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 Settings:** @@ -442,6 +622,7 @@ SENTRY_PROFILES_SAMPLE_RATE=0.1 # 10% of transactions profiled LOG_LEVEL = "DEBUG" SESSION_OBSERVERS_LIMIT = 10 EMBED_MESSAGES = false +NAMESPACE = "honcho-dev" [db] CONNECTION_URI = "postgresql+psycopg://postgres:postgres@localhost:5432/honcho_dev" @@ -453,38 +634,72 @@ USE_AUTH = false [cache] ENABLED = false -[dialectic] -PROVIDER = "anthropic" -MODEL = "claude-sonnet-4-20250514" -PERFORM_QUERY_GENERATION = false -MAX_OUTPUT_TOKENS = 2500 - [deriver] +ENABLED = true WORKERS = 1 PROVIDER = "google" MODEL = "gemini-2.5-flash-lite" [peer_card] ENABLED = true -PROVIDER = "openai" -MODEL = "gpt-5-nano-2025-08-07" + +[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 = "openai" -MODEL = "gpt-4o-mini-2024-07-18" +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] +[otel] ENABLED = false +[telemetry] +ENABLED = false + +[vector_store] +TYPE = "pgvector" + [sentry] ENABLED = false ``` @@ -511,6 +726,7 @@ LLM_GEMINI_API_KEY=your-dev-gemini-key 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" @@ -525,40 +741,72 @@ ENABLED = true URL = "redis://redis:6379/0" DEFAULT_TTL_SECONDS = 300 -[dialectic] -PROVIDER = "anthropic" -MODEL = "claude-sonnet-4-20250514" -PERFORM_QUERY_GENERATION = false -MAX_OUTPUT_TOKENS = 2500 - [deriver] +ENABLED = true WORKERS = 4 PROVIDER = "google" MODEL = "gemini-2.5-flash-lite" [peer_card] ENABLED = true -PROVIDER = "openai" -MODEL = "gpt-5-nano-2025-08-07" + +[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 = "openai" -MODEL = "gpt-4o-mini-2024-07-18" +PROVIDER = "google" +MODEL = "gemini-2.5-flash" MAX_TOKENS_SHORT = 1000 MAX_TOKENS_LONG = 4000 [dream] ENABLED = true -PROVIDER = "openai" -MODEL = "gpt-4o-mini-2024-07-18" +PROVIDER = "anthropic" +MODEL = "claude-sonnet-4-20250514" [webhook] MAX_WORKSPACE_LIMIT = 10 -[metrics] +[otel] ENABLED = true +[telemetry] +ENABLED = true + +[vector_store] +TYPE = "pgvector" + [sentry] ENABLED = true ENVIRONMENT = "production" @@ -590,6 +838,8 @@ LLM_GROQ_API_KEY=your-prod-groq-key WEBHOOK_SECRET=your-webhook-signing-secret # Monitoring +OTEL_ENDPOINT=https://mimir.example.com/otlp/v1/metrics +TELEMETRY_ENDPOINT=https://telemetry.honcho.dev/v1/events SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id SENTRY_ENVIRONMENT=production ``` @@ -635,4 +885,13 @@ uv run alembic revision --autogenerate -m "Description of changes" - Check `DERIVER_STALE_SESSION_TIMEOUT_MINUTES` for session cleanup - Monitor background processing logs +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. diff --git a/docs/v2.6.0-alpha/contributing/guidelines.mdx b/docs/v3/contributing/guidelines.mdx similarity index 100% rename from docs/v2.6.0-alpha/contributing/guidelines.mdx rename to docs/v3/contributing/guidelines.mdx diff --git a/docs/v2.6.0-alpha/contributing/license.mdx b/docs/v3/contributing/license.mdx similarity index 100% rename from docs/v2.6.0-alpha/contributing/license.mdx rename to docs/v3/contributing/license.mdx diff --git a/docs/v2.6.0-alpha/contributing/self-hosting.mdx b/docs/v3/contributing/self-hosting.mdx similarity index 97% rename from docs/v2.6.0-alpha/contributing/self-hosting.mdx rename to docs/v3/contributing/self-hosting.mdx index 88fca48e..b6f47e94 100644 --- a/docs/v2.6.0-alpha/contributing/self-hosting.mdx +++ b/docs/v3/contributing/self-hosting.mdx @@ -276,8 +276,8 @@ const client = new Honcho({ ### Next Steps -- **Explore the API**: Check out the [API Reference](/v2.6.0-alpha/api-reference/introduction) -- **Try the SDKs**: See our [guides](/v2.6.0-alpha/guides) for examples +- **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) diff --git a/docs/v2.6.0-alpha/documentation/core-concepts/architecture.mdx b/docs/v3/documentation/core-concepts/architecture.mdx similarity index 89% rename from docs/v2.6.0-alpha/documentation/core-concepts/architecture.mdx rename to docs/v3/documentation/core-concepts/architecture.mdx index 35ec932a..429fcb0a 100644 --- a/docs/v2.6.0-alpha/documentation/core-concepts/architecture.mdx +++ b/docs/v3/documentation/core-concepts/architecture.mdx @@ -5,7 +5,7 @@ icon: "sitemap" sidebarTitle: "Architecture" --- -Honcho is memory infrastructure that continuously [*reasons*](/v2.6.0-alpha/documentation/core-concepts/reasoning) about data to build rich representations of peers (users, agents, or any entity) over time. This document explains the data model, system components, and how data flows through Honcho. +Honcho is memory infrastructure that continuously [*reasons*](/v3/documentation/core-concepts/reasoning) about data to build rich representations of peers (users, agents, or any entity) over time. This document explains the data model, system components, and how data flows through Honcho. ## Data Model @@ -40,7 +40,7 @@ Authentication is scoped to the workspace level, and configuration settings can ### Peers -Peers are the most important entity in Honcho--everything revolves around building and maintaining their [*representations*](/v2.6.0-alpha/documentation/core-concepts/representation). A peer represents any individual user, agent, or entity in a workspace. Treating humans and agents the same way lets you build arbitrary combinations for multi-agent or group chat scenarios. +Peers are the most important entity in Honcho--everything revolves around building and maintaining their [*representations*](/v3/documentation/core-concepts/representation). A peer represents any individual user, agent, or entity in a workspace. Treating humans and agents the same way lets you build arbitrary combinations for multi-agent or group chat scenarios. Each peer has a unique identifier within a workspace and is a container for reasoning across all their sessions. This cross-session context means conclusions drawn about a peer in one session can inform interactions in completely different sessions. Peers can be configured to control whether Honcho reasons about them. @@ -90,13 +90,13 @@ Honcho's architecture follows a few core principles. Everything revolves around Sign up for the Honcho platform and start building - + Get started with your first integration - + Learn how Honcho reasons about messages to build memory - + Understand what peer representations are and how they work diff --git a/docs/v2.6.0-alpha/documentation/core-concepts/reasoning.mdx b/docs/v3/documentation/core-concepts/reasoning.mdx similarity index 96% rename from docs/v2.6.0-alpha/documentation/core-concepts/reasoning.mdx rename to docs/v3/documentation/core-concepts/reasoning.mdx index 38aec7b5..2ea6b1d8 100644 --- a/docs/v2.6.0-alpha/documentation/core-concepts/reasoning.mdx +++ b/docs/v3/documentation/core-concepts/reasoning.mdx @@ -84,13 +84,13 @@ Without exhaustive reasoning, you're stuck with surface-level retrieval or someo Sign up for the Honcho platform and start building - + Get started with your first integration - + See how reasoning fits into Honcho's overall architecture - + Learn how reasoning produces peer representations diff --git a/docs/v2.6.0-alpha/documentation/core-concepts/representation.mdx b/docs/v3/documentation/core-concepts/representation.mdx similarity index 92% rename from docs/v2.6.0-alpha/documentation/core-concepts/representation.mdx rename to docs/v3/documentation/core-concepts/representation.mdx index 5d9efb83..08425029 100644 --- a/docs/v2.6.0-alpha/documentation/core-concepts/representation.mdx +++ b/docs/v3/documentation/core-concepts/representation.mdx @@ -10,7 +10,7 @@ When you write messages to Honcho, the reasoning models extract premises, draw c ## What's in a Representation? -A peer representation is made up of several types of artifacts that Honcho generates through [*reasoning*](/v2.6.0-alpha/documentation/core-concepts/reasoning): +A peer representation is made up of several types of artifacts that Honcho generates through [*reasoning*](/v3/documentation/core-concepts/reasoning): **Conclusions** are insights derived through formal logic. Deductive conclusions are things Honcho can be certain about based on extracted premises. Inductive conclusions identify patterns across multiple messages. Abductive conclusions infer the simplest explanations for observed behavior. For example, if a user frequently mentions work deadlines and rarely mentions hobbies, Honcho might inductively conclude they're time-constrained or career-focused. @@ -25,7 +25,7 @@ These enable continuous improvement. Each new message refines conclusions, updat Honcho can build different representations based on what each peer observes. This enables sophisticated multi-peer scenarios where understanding is relative to what was actually witnessed. -There are two observation modes controlled by [configuration](/v2.6.0-alpha/documentation/features/advanced/configuration): +There are two observation modes controlled by [configuration](/v3/documentation/features/advanced/configuration): **Honcho observing peers** (`observe_me`): When enabled (default), Honcho forms a representation of the peer based on all messages they've sent across all sessions. This is Honcho's understanding of that peer, built from everything they've said and done in your system. Set `observe_me: false` if you don't want Honcho to reason about that peer at all. @@ -54,13 +54,13 @@ Humans reconstruct the past from imperfect recollections, then act on those reco Sign up for the Honcho platform and start building - + See representations in action with a working example - + Understand how representations fit into Honcho's architecture - + Learn how to query representations with natural language diff --git a/docs/v3/documentation/features/advanced/overview.mdx b/docs/v3/documentation/features/advanced/overview.mdx new file mode 100644 index 00000000..00a92cc2 --- /dev/null +++ b/docs/v3/documentation/features/advanced/overview.mdx @@ -0,0 +1,20 @@ +--- +title: "Advanced Features" +icon: "brain" +description: "Advanced configuration and monitoring options for Honcho" +sidebarTitle: "Overview" +--- + +Advanced features give you fine-grained control over Honcho's behavior and implementation. + +## Configuration & Monitoring + +- [Queue Status](/v3/documentation/features/advanced/queue-status) - Monitor background processing and reasoning tasks +- [Configuration](/v3/documentation/features/advanced/toggle-reasoning) - Configure reasoning models and behavior +- [Summarizer](/v3/documentation/features/advanced/summarizer) - Automatic session summarization + +## Querying & Filtering + +- [Search](/v3/documentation/features/advanced/search) - Search across peers, sessions, and messages +- [Filters](/v3/documentation/features/advanced/using-filters) - Filter queries with advanced parameters +- [Streaming Responses](/v3/documentation/features/advanced/streaming-response) - Stream dialectic responses in real-time diff --git a/docs/v2.6.0-alpha/documentation/features/advanced/queue-status.mdx b/docs/v3/documentation/features/advanced/queue-status.mdx similarity index 80% rename from docs/v2.6.0-alpha/documentation/features/advanced/queue-status.mdx rename to docs/v3/documentation/features/advanced/queue-status.mdx index 8a3e00c5..6c4598cd 100644 --- a/docs/v2.6.0-alpha/documentation/features/advanced/queue-status.mdx +++ b/docs/v3/documentation/features/advanced/queue-status.mdx @@ -4,7 +4,7 @@ description: Learn how to check the status of Honcho's reasoning icon: "lines-leaning" --- -Whenever messages are stored in Honcho, background processes kick off to [reason](/v2.6.0-alpha/documentation/core-concepts/reasoning) about the conversation and generate insights. +Whenever messages are stored in Honcho, background processes kick off to [reason](/v3/documentation/core-concepts/reasoning) about the conversation and generate insights. Reasoning is an asynchronous process and will not immediately generate insights for the latest message you've sent. This is @@ -17,8 +17,7 @@ several utilities to check the status of the queue. from honcho import Honcho honcho = Honcho() -status = honcho.get_queue_status() -honcho.poll_queue_status() +status = honcho.queue_status() ``` ```typescript typescript @@ -26,8 +25,7 @@ import { Honcho } from '@honcho-ai/sdk'; const honcho = new Honcho({}); -const status = await honcho.getQueueStatus(); -await honcho.pollQueueStatus(); +const status = await honcho.queueStatus(); ``` @@ -76,12 +74,12 @@ work_units will be processed in parallel - If local representations are turned in a Session then a message will generate an additional work unit for every peer that has `observe_others=True` -The `get_queue_status` and `poll_queue_status` methods can take additional +The `queue_status` method can take additional parameters to scope the status to a specific work unit: ```python Python -def get_queue_status( +def queue_status( self, observer_id: str | None = None, sender_id: str | None = None, @@ -103,15 +101,18 @@ export const QueueStatusOptionsSchema = z.object({ ``` -Additionally, there are queue status and polling queue status methods -available on the session objects in each of the SDKs. +Additionally, there are queue status methods available on the session objects in each of the SDKs. + + +**Do not wait for the queue to be empty.** The queue is a continuous processing system—new messages may arrive at any time, and "completion" is not a meaningful state. Design your application to work without assuming the queue will ever be fully drained. Use `queueStatus()` for observability and debugging, not for synchronization. + Below are the function signatures for the session level queue status method: ```python python @validate_call - def get_queue_status( + def queue_status( self, observer_id: str | None = None, sender_id: str | None = None, @@ -119,7 +120,7 @@ Below are the function signatures for the session level queue status method: ``` ```typescript TypeScript -async getQueueStatus( +async queueStatus( options?: Omit ): Promise<{ totalWorkUnits: number diff --git a/docs/v2.6.0-alpha/documentation/features/advanced/reasoning-configuration.mdx b/docs/v3/documentation/features/advanced/reasoning-configuration.mdx similarity index 91% rename from docs/v2.6.0-alpha/documentation/features/advanced/reasoning-configuration.mdx rename to docs/v3/documentation/features/advanced/reasoning-configuration.mdx index 50d20f2c..8642254a 100644 --- a/docs/v2.6.0-alpha/documentation/features/advanced/reasoning-configuration.mdx +++ b/docs/v3/documentation/features/advanced/reasoning-configuration.mdx @@ -130,7 +130,7 @@ Controls the "dreaming" process that consolidates and refines representations. A ```python Python # Disable dreams for a workspace -honcho.set_config({ +honcho.set_configuration({ "dream": { "enabled": False } @@ -138,7 +138,7 @@ honcho.set_config({ ``` ```typescript TypeScript // Disable dreams for a workspace -await honcho.setConfig({ +await honcho.setConfiguration({ dream: { enabled: false } @@ -157,7 +157,7 @@ You may therefore disable observation of a peer by setting the `observe_me` flag If the peer has a session-level configuration, it will override this configuration. If the flag is not set, or is set to `true`, the peer will be observed. -For session-level observation controls and local representations (where peers build separate models of each other), see [Representation Scopes](/v2.6.0-alpha/documentation/features/advanced/representation-scopes). +For session-level observation controls and local representations (where peers build separate models of each other), see [Representation Scopes](/v3/documentation/features/advanced/representation-scopes). @@ -168,13 +168,13 @@ from honcho import Honcho honcho = Honcho() # Create peer with configuration -peer = honcho.peer("my-peer", config={"observe_me": False}) +peer = honcho.peer("my-peer", configuration={"observe_me": False}) # Change peer's configuration -peer.set_config({"observe_me": True}) +peer.set_configuration({"observe_me": True}) # Note: creating the same peer again will also replace the configuration -peer = honcho.peer("my-peer", config={"observe_me": False}) +peer = honcho.peer("my-peer", configuration={"observe_me": False}) ``` ```typescript TypeScript import { Honcho } from "@honcho-ai/sdk"; @@ -184,13 +184,13 @@ import { Honcho } from "@honcho-ai/sdk"; const honcho = new Honcho({}); // Create peer with configuration - const peer = await honcho.peer("my-peer", { config: { observe_me: false } }); + const peer = await honcho.peer("my-peer", { configuration: { observeMe: false } }); // Change peer's configuration - await peer.setConfig({ observe_me: true }); + await peer.setConfiguration({ observeMe: true }); // Note: creating the same peer again will also replace the configuration - await honcho.peer("my-peer", { config: { observe_me: false } }); + await honcho.peer("my-peer", { configuration: { observeMe: false } }); })(); ``` @@ -207,12 +207,12 @@ from honcho import Honcho honcho = Honcho() # Create session with reasoning disabled -session = honcho.session("my-session", config={ +session = honcho.session("my-session", configuration={ "reasoning": {"enabled": False} }) # Create session with custom summary settings -session = honcho.session("detailed-session", config={ +session = honcho.session("detailed-session", configuration={ "summary": { "messages_per_short_summary": 10, "messages_per_long_summary": 30 @@ -228,12 +228,12 @@ import { Honcho } from "@honcho-ai/sdk"; // Create session with reasoning disabled const session = await honcho.session("my-session", { - config: { reasoning: { enabled: false } } + configuration: { reasoning: { enabled: false } } }); // Create session with custom summary settings const detailedSession = await honcho.session("detailed-session", { - config: { + configuration: { summary: { messages_per_short_summary: 10, messages_per_long_summary: 30 @@ -258,14 +258,14 @@ user = honcho.peer("user") # Create a message that skips the reasoning process session.add_messages([ - user.message("This message won't be analyzed", config={ + user.message("This message won't be analyzed", configuration={ "reasoning": {"enabled": False} }) ]) # Create a message with custom peer card settings session.add_messages([ - user.message("Use existing card but don't update it", config={ + user.message("Use existing card but don't update it", configuration={ "peer_card": {"use": True, "create": False} }) ]) diff --git a/docs/v2.6.0-alpha/documentation/features/advanced/representation-scopes.mdx b/docs/v3/documentation/features/advanced/representation-scopes.mdx similarity index 98% rename from docs/v2.6.0-alpha/documentation/features/advanced/representation-scopes.mdx rename to docs/v3/documentation/features/advanced/representation-scopes.mdx index 8512e87e..7ca7204e 100644 --- a/docs/v2.6.0-alpha/documentation/features/advanced/representation-scopes.mdx +++ b/docs/v3/documentation/features/advanced/representation-scopes.mdx @@ -128,7 +128,7 @@ const charlie = await honcho.peer("charlie"); await session.addPeers([alice, bob, charlie]); -await session.setPeerConfig(alice, { observe_others: true }); +await session.setPeerConfig(alice, { observeOthers: true }); await session.addMessages([ bob.message("I had pancakes for breakfast."), @@ -137,7 +137,7 @@ await session.addMessages([ const session2 = await honcho.session("game-session-2"); await session2.addPeers([alice, charlie]); -await session2.setPeerConfig(alice, { observe_others: true }); +await session2.setPeerConfig(alice, { observeOthers: true }); await session2.addMessages([ charlie.message("I didn't have breakfast. I lied to Bob.") diff --git a/docs/v2.6.0-alpha/documentation/features/advanced/search.mdx b/docs/v3/documentation/features/advanced/search.mdx similarity index 96% rename from docs/v2.6.0-alpha/documentation/features/advanced/search.mdx rename to docs/v3/documentation/features/advanced/search.mdx index c82b3bd7..90f155a2 100644 --- a/docs/v2.6.0-alpha/documentation/features/advanced/search.mdx +++ b/docs/v3/documentation/features/advanced/search.mdx @@ -144,9 +144,9 @@ results = my_peer.search("budget planning", filters={"session_id": my_session.id ```typescript TypeScript (async () => { - const my_peer = await honcho.peer("my-peer"); - const my_session = await honcho.session("team-meeting-jan"); - const results = await my_peer.search("budget planning", { filters: { session_id: my_session.id } }); + const myPeer = await honcho.peer("my-peer"); + const mySession = await honcho.session("team-meeting-jan"); + const results = await myPeer.search("budget planning", { filters: { session_id: mySession.id } }); })(); ``` diff --git a/docs/v2.6.0-alpha/documentation/features/advanced/streaming-response.mdx b/docs/v3/documentation/features/advanced/streaming-response.mdx similarity index 98% rename from docs/v2.6.0-alpha/documentation/features/advanced/streaming-response.mdx rename to docs/v3/documentation/features/advanced/streaming-response.mdx index 44c88b5b..2f5b2a32 100644 --- a/docs/v2.6.0-alpha/documentation/features/advanced/streaming-response.mdx +++ b/docs/v3/documentation/features/advanced/streaming-response.mdx @@ -18,7 +18,7 @@ Streaming is particularly useful for: ## Streaming with the Chat Endpoint -One of the primary use cases for streaming in Honcho is with the [chat endpoint](/v2.6.0-alpha/documentation/features/chat). This allows you to stream the AI's reasoning about a user in real-time. +One of the primary use cases for streaming in Honcho is with the [chat endpoint](/v3/documentation/features/chat). This allows you to stream the AI's reasoning about a user in real-time. ### Prerequisites diff --git a/docs/v2.6.0-alpha/documentation/features/advanced/summarizer.mdx b/docs/v3/documentation/features/advanced/summarizer.mdx similarity index 98% rename from docs/v2.6.0-alpha/documentation/features/advanced/summarizer.mdx rename to docs/v3/documentation/features/advanced/summarizer.mdx index b23492ee..4d220af8 100644 --- a/docs/v2.6.0-alpha/documentation/features/advanced/summarizer.mdx +++ b/docs/v3/documentation/features/advanced/summarizer.mdx @@ -27,7 +27,7 @@ It's important to keep in mind that summary tasks run in the background and are ### Retrieving Summaries -Summaries are retrieved from the session by the [`get_context`](/v2.6.0-alpha/documentation/features/get-context) method. This method has two parameters: +Summaries are retrieved from the session by the [`get_context`](/v3/documentation/features/get-context) method. This method has two parameters: * `summary`: A boolean indicating whether to include the summary in the return type. The default is true. * `tokens`: An integer indicating the maximum number of tokens to use for the context. **If not provided, `get_context` will retrieve as many tokens as are required to create exhaustive conversation coverage.** diff --git a/docs/v2.6.0-alpha/documentation/features/advanced/using-filters.mdx b/docs/v3/documentation/features/advanced/using-filters.mdx similarity index 93% rename from docs/v2.6.0-alpha/documentation/features/advanced/using-filters.mdx rename to docs/v3/documentation/features/advanced/using-filters.mdx index 6c5143d0..e1f623f1 100644 --- a/docs/v2.6.0-alpha/documentation/features/advanced/using-filters.mdx +++ b/docs/v3/documentation/features/advanced/using-filters.mdx @@ -45,7 +45,7 @@ import { Honcho } from "@honcho-ai/sdk"; // Simple peer filter const peers = await honcho.getPeers({ - filters: { peerId: "alice" } + filters: { peer_id: "alice" } }); // Simple session filter with metadata @@ -58,8 +58,8 @@ import { Honcho } from "@honcho-ai/sdk"; // Simple message filter const messages = await honcho.getMessages({ filters: { - sessionId: "support-chat-1", - peerId: "alice" + session_id: "support-chat-1", + peer_id: "alice" } }); })(); @@ -89,8 +89,8 @@ messages = honcho.get_messages(filters={ const messages = await honcho.getMessages({ filters: { AND: [ - { sessionId: "chat-1" }, - { createdAt: { gte: "2024-01-01" } } + { session_id: "chat-1" }, + { created_at: { gte: "2024-01-01" } } ] } }); @@ -128,8 +128,8 @@ sessions = honcho.get_sessions(filters={ const messages = await session.getMessages({ filters: { OR: [ - { peerId: "alice" }, - { peerId: "bob" } + { peer_id: "alice" }, + { peer_id: "bob" } ] } }); @@ -175,7 +175,7 @@ sessions = honcho.get_sessions(filters={ const peers = await honcho.getPeers({ filters: { NOT: [ - { peerId: "alice" } + { peer_id: "alice" } ] } }); @@ -224,8 +224,8 @@ messages = session.get_messages(filters={ AND: [ { OR: [ - { peerId: "alice" }, - { peerId: "bob" } + { peer_id: "alice" }, + { peer_id: "bob" } ] }, { @@ -275,14 +275,14 @@ sessions = honcho.get_sessions(filters={ // Find sessions created after a specific date const sessions = await honcho.getSessions({ filters: { - createdAt: { gte: "2024-01-01" } + created_at: { gte: "2024-01-01" } } }); // Find messages within a date range const messages = await session.getMessages({ filters: { - createdAt: { + created_at: { gte: "2024-01-01", lte: "2024-12-31" } @@ -290,7 +290,7 @@ sessions = honcho.get_sessions(filters={ }); // Metadata numeric comparisons - const sessions = await honcho.getSessions({ + const filteredSessions = await honcho.getSessions({ filters: { metadata: { score: { gt: 8.5 }, @@ -331,7 +331,7 @@ peers = honcho.get_peers(filters={ // Find messages from specific peers in a session const messages = await session.getMessages({ filters: { - peerId: { in: ["alice", "bob", "charlie"] } + peer_id: { in: ["alice", "bob", "charlie"] } } }); @@ -444,7 +444,7 @@ messages = session.get_messages(filters={ filters: { metadata: { score: { gte: 4.0, lte: 5.0 }, - createdBy: { ne: "system" }, + created_by: { ne: "system" }, tags: { contains: "important" } } } @@ -494,19 +494,19 @@ sessions = honcho.get_sessions(filters={ // Find all sessions with any peer_id (essentially all sessions) const sessions = await honcho.getSessions({ filters: { - peerId: "*" + peer_id: "*" } }); // Wildcard in lists - matches everything const messages = await session.getMessages({ filters: { - peerId: { in: ["alice", "bob", "*"] } + peer_id: { in: ["alice", "bob", "*"] } } }); // Metadata wildcards - const sessions = await honcho.getSessions({ + const filteredSessions = await honcho.getSessions({ filters: { metadata: { type: "*", // Any type @@ -594,17 +594,17 @@ messages = session.get_messages(filters={ filters: { AND: [ { content: { icontains: "error" } }, - { createdAt: { gte: weekAgo } }, + { created_at: { gte: weekAgo } }, { metadata: { level: { in: ["error", "critical"] } } } ] } }); // Find messages in specific sessions with sentiment analysis - const messages = await session.getMessages({ + const sentimentMessages = await session.getMessages({ filters: { AND: [ - { sessionId: { in: ["support-1", "support-2", "support-3"] } }, + { session_id: { in: ["support-1", "support-2", "support-3"] } }, { metadata: { sentiment: "negative" } }, { metadata: { confidence: { gte: 0.7 } } } ] @@ -646,7 +646,7 @@ except FilterError as e: // Invalid filter - unsupported operator const messages = await session.getMessages({ filters: { - createdAt: { invalidOperator: "2024-01-01" } + created_at: { invalid_operator: "2024-01-01" } } }); } catch (error) { @@ -660,7 +660,7 @@ except FilterError as e: // Invalid column name const sessions = await honcho.getSessions({ filters: { - nonexistentField: "value" + nonexistent_field: "value" } }); } catch (error) { diff --git a/docs/v2.6.0-alpha/documentation/features/chat.mdx b/docs/v3/documentation/features/chat.mdx similarity index 91% rename from docs/v2.6.0-alpha/documentation/features/chat.mdx rename to docs/v3/documentation/features/chat.mdx index b983846d..56dad4ab 100644 --- a/docs/v2.6.0-alpha/documentation/features/chat.mdx +++ b/docs/v3/documentation/features/chat.mdx @@ -23,7 +23,7 @@ query = "What is the user's favorite way of completing the task?" answer = peer.chat(query) print(answer) -# "Based on observations, the user prefers using keyboard shortcuts..." +# "Based on conclusions, the user prefers using keyboard shortcuts..." ``` ```typescript TypeScript @@ -37,7 +37,7 @@ const query = "What is the user's favorite way of completing the task?"; const answer = await peer.chat(query); console.log(answer); -// "Based on observations, the user prefers using keyboard shortcuts..." +// "Based on conclusions, the user prefers using keyboard shortcuts..." ``` @@ -172,7 +172,7 @@ When you call `peer.chat(query)`: 3. Combines them with segments of source messages, if needed, to gather more context 4. Synthesizes them into a coherent natural language response to your query -Honcho [reasoning](/v2.6.0-alpha/documentation/core-concepts/reasoning) runs continuously in the background, processing new messages and updating representations. The chat endpoint always has access to Honcho's latest conclusions about the peer. +Honcho [reasoning](/v3/documentation/core-concepts/reasoning) runs continuously in the background, processing new messages and updating representations. The chat endpoint always has access to Honcho's latest conclusions about the peer. ## Best Practices @@ -188,4 +188,4 @@ Don't just use chat for LLM prompts - use it to drive application logic, routing ### Combine with get_context() Use `get_context()` for conversation context and `peer.chat()` for specific insights. They complement each other. -For more ideas on using the chat endpoint, see our [guides](/v2.6.0-alpha/guides/overview). +For more ideas on using the chat endpoint, see our [guides](/v3/guides/overview). diff --git a/docs/v2.6.0-alpha/documentation/features/get-context.mdx b/docs/v3/documentation/features/get-context.mdx similarity index 96% rename from docs/v2.6.0-alpha/documentation/features/get-context.mdx rename to docs/v3/documentation/features/get-context.mdx index 67aa5428..2e27a1e3 100644 --- a/docs/v2.6.0-alpha/documentation/features/get-context.mdx +++ b/docs/v3/documentation/features/get-context.mdx @@ -99,7 +99,7 @@ context = session.get_context(summary=False, tokens=2000) ### Peer Representation in Context -You can include a peer's [representation](/v2.6.0-alpha/documentation/core-concepts/representation) and peer card in the context by specifying `peer_target`. This is useful for providing the LLM with knowledge about a specific peer. +You can include a peer's [representation](/v3/documentation/core-concepts/representation) and peer card in the context by specifying `peer_target`. This is useful for providing the LLM with knowledge about a specific peer. ```python Python @@ -179,25 +179,25 @@ context = session.get_context( ### Session-Scoped Representations -Use `limit_to_session` to only include observations from the current session: +Use `limit_to_session` to only include conclusions from the current session: ```python Python -# Get context limited to this session's observations only +# Get context limited to this session's conclusions only context = session.get_context( tokens=2000, peer_target="user-123", - limit_to_session=True # Only observations from this session + limit_to_session=True # Only conclusions from this session ) ``` ```typescript TypeScript (async () => { - // Get context limited to this session's observations only + // Get context limited to this session's conclusions only const context = await session.getContext({ tokens: 2000, peerTarget: "user-123", - limitToSession: true // Only observations from this session + limitToSession: true // Only conclusions from this session }); })(); ``` @@ -370,7 +370,7 @@ import { Honcho } from "@honcho-ai/sdk"; ]); // Get context for LLM - const messages = await session.getContext({ tokens: 2000 }).toOpenAI(assistant); + const messages = (await session.getContext({ tokens: 2000 })).toOpenAI(assistant); // Add new user message and get AI response const response = await openai.chat.completions.create({ diff --git a/docs/v2.6.0-alpha/documentation/introduction/overview.mdx b/docs/v3/documentation/introduction/overview.mdx similarity index 88% rename from docs/v2.6.0-alpha/documentation/introduction/overview.mdx rename to docs/v3/documentation/introduction/overview.mdx index 76ccafff..f3b631b7 100644 --- a/docs/v2.6.0-alpha/documentation/introduction/overview.mdx +++ b/docs/v3/documentation/introduction/overview.mdx @@ -14,7 +14,7 @@ Honcho has defined the Pareto Frontier of Agent Memory. Watch the [video](https: Sign up and start building with Honcho - + Build your first stateful agent in minutes @@ -67,7 +67,7 @@ Honcho has four storage primitives that work together: - **Sessions** - Interaction threads between peers with temporal boundaries - **Messages** - Units of data that trigger reasoning (conversations, events, activity, documents, and more) -When you write messages to Honcho, they're stored and processed in the background. Custom reasoning models perform formal logical [*reasoning*](/v2.6.0-alpha/documentation/core-concepts/reasoning) to generate conclusions about each peer. These conclusions are stored as [*representations*](/v2.6.0-alpha/documentation/core-concepts/representation) that you can query to provide rich context for your agents. +When you write messages to Honcho, they're stored and processed in the background. Custom reasoning models perform formal logical [*reasoning*](/v3/documentation/core-concepts/reasoning) to generate conclusions about each peer. These conclusions are stored as [*representations*](/v3/documentation/core-concepts/representation) that you can query to provide rich context for your agents. ![Honcho Architecture](/images/architecture.png) @@ -91,13 +91,13 @@ Welcome to Honcho. We're excited to have you at the frontier of AI with us 🫡. Sign up for the Honcho platform and get your API key - + Build your first stateful agent in minutes - + Deep dive into how Honcho's primitives fit together - + Learn how Honcho reasons about data to build memory diff --git a/docs/v2.6.0-alpha/documentation/introduction/quickstart.mdx b/docs/v3/documentation/introduction/quickstart.mdx similarity index 87% rename from docs/v2.6.0-alpha/documentation/introduction/quickstart.mdx rename to docs/v3/documentation/introduction/quickstart.mdx index 3314d051..96eb6acd 100644 --- a/docs/v2.6.0-alpha/documentation/introduction/quickstart.mdx +++ b/docs/v3/documentation/introduction/quickstart.mdx @@ -49,7 +49,7 @@ The Honcho client is the main entry point for interacting with Honcho's API. It from honcho import Honcho # Initialize client -honcho = Honcho(workspace="first-honcho-test", api_key=HONCHO_API_KEY) +honcho = Honcho(workspace_id="first-honcho-test", api_key=HONCHO_API_KEY) ``` @@ -57,7 +57,7 @@ honcho = Honcho(workspace="first-honcho-test", api_key=HONCHO_API_KEY) import { Honcho } from '@honcho-ai/sdk'; // Initialize client -const honcho = new Honcho({ workspace = "first-honcho-test", apiKey = HONCHO_API_KEY }); +const honcho = new Honcho({ workspaceId: "first-honcho-test", apiKey: HONCHO_API_KEY }); ``` @@ -230,7 +230,7 @@ user.chat("What should I know about this user? 3 sentences max").then((response) -Honcho needs a short amount of time to process messages you write to it. There are several utilities to [check the status](/v2.6.0-alpha/documentation/features/advanced/queue-status) of the queue. Honcho also offers numerous ways to query reasoning to fit latency needs: see the [Get Context](/v2.6.0-alpha/documentation/features/get-context) page. +Honcho needs a short amount of time to process messages you write to it. There are several utilities to [check the status](/v3/documentation/features/advanced/queue-status) of the queue. Honcho also offers numerous ways to query reasoning to fit latency needs: see the [Get Context](/v3/documentation/features/get-context) page. The response will look something like this: @@ -301,19 +301,6 @@ for session_data in conversation_data["sessions"]: session.add_messages(messages) -# Wait for Honcho to process the conversation history -def wait_for_processing(): - status = honcho.get_deriver_status() - while status.pending_work_units > 0 or status.in_progress_work_units > 0: - time.sleep(1) - status = honcho.poll_deriver_status() - -print("Processing conversation history...") -start_time = time.time() -wait_for_processing() -elapsed = int(time.time() - start_time) -print(f"Done in {elapsed}s! Querying user insights...\n") - # Query insights about the user based on conversation history response = user.chat("What should I know about this user? 3 sentences max") print(response) @@ -362,20 +349,6 @@ for (const sessionData of conversationData.sessions) { await session.addMessages(messages); } -// Wait for Honcho to process the conversation history -async function waitForProcessing() { - let status = await honcho.getDeriverStatus(); - while (status.pendingWorkUnits > 0 || status.inProgressWorkUnits > 0) { - await new Promise(resolve => setTimeout(resolve, 1000)); - status = await honcho.pollDeriverStatus(); - } -} - -console.log("Processing conversation history..."); -const startTime = Date.now(); -await waitForProcessing(); -const elapsed = Math.floor((Date.now() - startTime) / 1000); -console.log(`Done in ${elapsed}s! Querying user insights...\n`); // Query insights about the user based on conversation history const response = await user.chat("What should I know about this user? 3 sentences max"); @@ -389,16 +362,16 @@ console.log(response); From here, you can explore how to use Honcho's features in your own applications: - + Learn how to fetch the right context for your agent's next response - + Deep dive into how Honcho's primitives fit together - + Query representations with natural language - + Integration patterns and advanced use cases diff --git a/docs/v2.6.0-alpha/documentation/introduction/vibecoding.mdx b/docs/v3/documentation/introduction/vibecoding.mdx similarity index 93% rename from docs/v2.6.0-alpha/documentation/introduction/vibecoding.mdx rename to docs/v3/documentation/introduction/vibecoding.mdx index be4b8a85..40a2d0c2 100644 --- a/docs/v2.6.0-alpha/documentation/introduction/vibecoding.mdx +++ b/docs/v3/documentation/introduction/vibecoding.mdx @@ -58,9 +58,9 @@ I want to start building with Honcho - an open source memory library for buildin **Documentation:** - Main docs: https://docs.honcho.dev -- API Reference: https://docs.honcho.dev/v2.6.0-alpha/api-reference/introduction -- Quickstart: https://docs.honcho.dev/v2.6.0-alpha/documentation/introduction/quickstart -- Architecture: https://docs.honcho.dev/v2.6.0-alpha/documentation/core-concepts/architecture +- API Reference: https://docs.honcho.dev/v3/api-reference/introduction +- Quickstart: https://docs.honcho.dev/v3/documentation/introduction/quickstart +- Architecture: https://docs.honcho.dev/v3/documentation/core-concepts/architecture **Code & Examples:** - Core repo: https://github.com/plastic-labs/honcho diff --git a/docs/v2.6.0-alpha/documentation/reference/platform.mdx b/docs/v3/documentation/reference/platform.mdx similarity index 92% rename from docs/v2.6.0-alpha/documentation/reference/platform.mdx rename to docs/v3/documentation/reference/platform.mdx index 88a14c84..11df5884 100644 --- a/docs/v2.6.0-alpha/documentation/reference/platform.mdx +++ b/docs/v3/documentation/reference/platform.mdx @@ -93,7 +93,7 @@ Expand the `Peers` list from the `Workspace` dashboard to see a detailed view of Peer Dashboard -Click into any peer to navigate to their respective utilities page. Next to the `Peer` name you can edit the [Peer Configuration](/v2.6.0-alpha/documentation/features/advanced/reasoning-configuration), and in the tabs below, explore all utilities for the `Peer`. +Click into any peer to navigate to their respective utilities page. Next to the `Peer` name you can edit the [Peer Configuration](/v3/documentation/features/advanced/reasoning-configuration), and in the tabs below, explore all utilities for the `Peer`. Peer Management Dashboard @@ -108,7 +108,7 @@ Utilities include: - **Session logs** view which `Sessions` the `Peer` is active -- **Peer configuration and metadata management** including [Session-Peer Configuration](/v2.6.0-alpha/documentation/features/advanced/reasoning-configuration#session-configuration) +- **Peer configuration and metadata management** including [Session-Peer Configuration](/v3/documentation/features/advanced/reasoning-configuration#session-configuration) Peer Management Dashboard @@ -153,9 +153,9 @@ The [Members](https://app.honcho.dev/members) page provides organization adminis ## Go Further -View the [Architecture](/v2.6.0-alpha/documentation/core-concepts/architecture) to see how Honcho works under the hood. +View the [Architecture](/v3/documentation/core-concepts/architecture) to see how Honcho works under the hood. -Dive into our [API Reference](/v2.6.0-alpha/api-reference) to explore all available endpoints. +Dive into our [API Reference](/v3/api-reference) to explore all available endpoints. ## Next Steps @@ -166,10 +166,10 @@ Dive into our [API Reference](/v2.6.0-alpha/api-reference) to explore all availa Connect with 1000+ developers building with Honcho - + View our guidelines and explore the codebase - + See Honcho in action with real examples diff --git a/docs/v2.6.0-alpha/documentation/reference/sdk.mdx b/docs/v3/documentation/reference/sdk.mdx similarity index 82% rename from docs/v2.6.0-alpha/documentation/reference/sdk.mdx rename to docs/v3/documentation/reference/sdk.mdx index 07fb91a4..a520fc0a 100644 --- a/docs/v2.6.0-alpha/documentation/reference/sdk.mdx +++ b/docs/v3/documentation/reference/sdk.mdx @@ -286,9 +286,9 @@ context = alice.get_context(target="bob") # What alice knows about bob # Get working representation with semantic search rep = alice.get_representation(search_query="preferences", search_top_k=10) -# Access observations -self_observations = alice.observations.list() # Self-observations -bob_observations = alice.observations_of("bob").list() # Observations of bob +# Access conclusions +self_conclusions = alice.conclusions.list() # Self-conclusions +bob_conclusions = alice.conclusions_of("bob").list() # Conclusions of bob ``` ```typescript TypeScript @@ -340,9 +340,9 @@ const rep = await alice.getRepresentation(undefined, undefined, { searchTopK: 10 }); -// Access observations -const selfObs = await alice.observations.list(); // Self-observations -const bobObs = await alice.observationsOf("bob").list(); // Observations of bob +// Access conclusions +const selfConclusions = await alice.conclusions.list(); // Self-conclusions +const bobConclusions = await alice.conclusionsOf("bob").list(); // Conclusions of bob ``` @@ -391,190 +391,101 @@ const searchedContext = await alice.getContext("bob", { ``` -### Observations +### Conclusions -Peers can access their observations (facts derived from messages) through the `observations` property and `observations_of()` method: +Peers can access their conclusions (facts derived from messages) through the `conclusions` property and `conclusions_of()` method: ```python Python -# Access self-observations (what honcho knows about alice) -self_obs = alice.observations +# Access self-conclusions (what honcho knows about alice) +self_conclusions = alice.conclusions -# List self-observations -obs_list = self_obs.list() +# List self-conclusions +conclusions_list = self_conclusions.list() -# Search self-observations semantically -results = self_obs.query("food preferences") +# Search self-conclusions semantically +results = self_conclusions.query("food preferences") -# Delete an observation -self_obs.delete("observation-id") +# Delete a conclusion +self_conclusions.delete("conclusion-id") -# Access observations of another peer (what alice knows about bob) -bob_obs = alice.observations_of("bob") -bob_obs_list = bob_obs.list() -bob_search = bob_obs.query("work history") +# Access conclusions of another peer (what alice knows about bob) +bob_conclusions = alice.conclusions_of("bob") +bob_conclusions_list = bob_conclusions.list() +bob_search = bob_conclusions.query("work history") ``` ```typescript TypeScript -// Access self-observations (what honcho knows about alice) -const selfObs = alice.observations; +// Access self-conclusions (what honcho knows about alice) +const selfConclusions = alice.conclusions; -// List self-observations -const obsList = await selfObs.list(); +// List self-conclusions +const conclusionsList = await selfConclusions.list(); -// Search self-observations semantically -const results = await selfObs.query("food preferences"); +// Search self-conclusions semantically +const results = await selfConclusions.query("food preferences"); -// Delete an observation -await selfObs.delete("observation-id"); +// Delete a conclusion +await selfConclusions.delete("conclusion-id"); -// Access observations of another peer (what alice knows about bob) -const bobObs = alice.observationsOf("bob"); -const bobObsList = await bobObs.list(); -const bobSearch = await bobObs.query("work history"); +// Access conclusions of another peer (what alice knows about bob) +const bobConclusions = alice.conclusionsOf("bob"); +const bobConclusionsList = await bobConclusions.list(); +const bobSearch = await bobConclusions.query("work history"); ``` -### Peer Context +#### Creating Conclusions Manually -The `get_context()` method on peers retrieves both the working representation and peer card in a single API call: +You can also create conclusions directly, which is useful for importing data or adding explicit facts: ```python Python -# Get peer's own context -context = alice.get_context() -print(context.representation) # Working representation -print(context.peer_card) # Peer card as list of strings +# Create conclusions for what alice knows about bob +bob_conclusions = alice.conclusions_of("bob") -# Get context about another peer (what alice knows about bob) -bob_context = alice.get_context(target="bob") - -# Get context with semantic search -context = alice.get_context( - target="bob", - search_query="work preferences", - search_top_k=10, - search_max_distance=0.8, - include_most_frequent=True, - max_conclusions=50 -) -``` - -```typescript TypeScript -// Get peer's own context -const context = await alice.getContext(); -console.log(context.representation); // Working representation -console.log(context.peerCard); // Peer card as array of strings - -// Get context about another peer (what alice knows about bob) -const bobContext = await alice.getContext("bob"); - -// Get context with semantic search -const searchedContext = await alice.getContext("bob", { - searchQuery: "work preferences", - searchTopK: 10, - searchMaxDistance: 0.8, - includeMostFrequent: true, - maxConclusions: 50 -}); -``` - - -### Observations - -Peers can access their observations (facts derived from messages) through the `observations` property and `observations_of()` method: - - -```python Python -# Access self-observations (what honcho knows about alice) -self_obs = alice.observations - -# List self-observations -obs_list = self_obs.list() - -# Search self-observations semantically -results = self_obs.query("food preferences") - -# Delete an observation -self_obs.delete("observation-id") - -# Access observations of another peer (what alice knows about bob) -bob_obs = alice.observations_of("bob") -bob_obs_list = bob_obs.list() -bob_search = bob_obs.query("work history") -``` - -```typescript TypeScript -// Access self-observations (what honcho knows about alice) -const selfObs = alice.observations; - -// List self-observations -const obsList = await selfObs.list(); - -// Search self-observations semantically -const results = await selfObs.query("food preferences"); - -// Delete an observation -await selfObs.delete("observation-id"); - -// Access observations of another peer (what alice knows about bob) -const bobObs = alice.observationsOf("bob"); -const bobObsList = await bobObs.list(); -const bobSearch = await bobObs.query("work history"); -``` - - -#### Creating Observations Manually - -You can also create observations directly, which is useful for importing data or adding explicit facts: - - -```python Python -# Create observations for what alice knows about bob -bob_obs = alice.observations_of("bob") - -# Create a single observation -created = bob_obs.create([ +# Create a single conclusion +created = bob_conclusions.create([ {"content": "User prefers dark mode", "session_id": "session-1"} ]) -# Create multiple observations in batch -created = bob_obs.create([ +# Create multiple conclusions in batch +created = bob_conclusions.create([ {"content": "User prefers dark mode", "session_id": "session-1"}, {"content": "User works late at night", "session_id": "session-1"}, {"content": "User enjoys programming", "session_id": "session-1"}, ]) -# Returns list of created Observation objects with IDs -for obs in created: - print(f"Created observation: {obs.id} - {obs.content}") +# Returns list of created Conclusion objects with IDs +for conclusion in created: + print(f"Created conclusion: {conclusion.id} - {conclusion.content}") ``` ```typescript TypeScript -// Create observations for what alice knows about bob -const bobObs = alice.observationsOf("bob"); +// Create conclusions for what alice knows about bob +const bobConclusions = alice.conclusionsOf("bob"); -// Create a single observation -const created = await bobObs.create([ +// Create a single conclusion +const created = await bobConclusions.create([ { content: "User prefers dark mode", sessionId: "session-1" } ]); -// Create multiple observations in batch -const batchCreated = await bobObs.create([ +// Create multiple conclusions in batch +const batchCreated = await bobConclusions.create([ { content: "User prefers dark mode", sessionId: "session-1" }, { content: "User works late at night", sessionId: "session-1" }, { content: "User enjoys programming", sessionId: "session-1" }, ]); -// Returns array of created Observation objects with IDs -for (const obs of batchCreated) { - console.log(`Created observation: ${obs.id} - ${obs.content}`); +// Returns array of created Conclusion objects with IDs +for (const conclusion of batchCreated) { + console.log(`Created conclusion: ${conclusion.id} - ${conclusion.content}`); } ``` -Manually created observations are marked as "explicit" and are treated the same as system-derived observations. Each observation must be tied to a session and the content length is validated against the embedding token limit. +Manually created conclusions are marked as "explicit" and are treated the same as system-derived conclusions. Each conclusion must be tied to a session and the content length is validated against the embedding token limit. ### Session @@ -627,7 +538,7 @@ context = session.get_context( search_top_k=10, search_max_distance=0.8, include_most_frequent=True, - max_observations=25 + max_conclusions=25 ) # Search session content @@ -706,7 +617,7 @@ const richContext = await session.getContext({ searchTopK: 10, searchMaxDistance: 0.8, includeMostDerived: true, - maxObservations: 25 + maxConclusions: 25 }); // Search session content @@ -944,7 +855,7 @@ const response = await openai.chat.completions.create({ When creating messages, you can optionally specify a custom `created_at` timestamp instead of using the server's current time: ```bash -curl -X POST "https://api.honcho.dev/v2.6.0-alpha/workspaces/{workspace_id}/sessions/{session_id}/messages" \ +curl -X POST "https://api.honcho.dev/v3/workspaces/{workspace_id}/sessions/{session_id}/messages" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ @@ -968,7 +879,7 @@ If `created_at` is not provided, messages will use the server's current timestam ### Metadata and Filtering -See [Using Filters](/v2.6.0-alpha/guides/using-filters) for more examples on how to use filters. +See [Using Filters](/v3/guides/using-filters) for more examples on how to use filters. ```python Python diff --git a/docs/v2.6.0-alpha/documentation/reference/storage.mdx b/docs/v3/documentation/reference/storage.mdx similarity index 100% rename from docs/v2.6.0-alpha/documentation/reference/storage.mdx rename to docs/v3/documentation/reference/storage.mdx diff --git a/docs/v2.6.0-alpha/documentation/scratch/honcho-memory/advanced-retrieval/get-context.mdx b/docs/v3/documentation/scratch/honcho-memory/advanced-retrieval/get-context.mdx similarity index 78% rename from docs/v2.6.0-alpha/documentation/scratch/honcho-memory/advanced-retrieval/get-context.mdx rename to docs/v3/documentation/scratch/honcho-memory/advanced-retrieval/get-context.mdx index c30dcf51..d16ba4ea 100644 --- a/docs/v2.6.0-alpha/documentation/scratch/honcho-memory/advanced-retrieval/get-context.mdx +++ b/docs/v3/documentation/scratch/honcho-memory/advanced-retrieval/get-context.mdx @@ -25,7 +25,7 @@ Unlike basic message retrieval, memory-enhanced context: Automatically manages context to fit within your specified token budget: ```python -context = session.get_context(max_tokens=2000) +context = session.get_context(tokens=2000) ``` ### Multi-Layered Context @@ -43,7 +43,7 @@ Fine-tune what context is included: ```python context = session.get_context( - max_tokens=2000, + tokens=2000, include_summaries=True, include_representation=True, peer_id="peer_123" # Get representation for specific peer @@ -58,7 +58,7 @@ Provide your agent with rich context for personalized responses: ```python # Get optimized context -context = session.get_context(max_tokens=1500) +context = session.get_context(tokens=1500) # Use in your LLM prompt response = llm.generate( @@ -87,10 +87,10 @@ Adjust context size based on task complexity: ```python # More context for complex tasks -detailed_context = session.get_context(max_tokens=4000) +detailed_context = session.get_context(tokens=4000) # Minimal context for simple queries -quick_context = session.get_context(max_tokens=500) +quick_context = session.get_context(tokens=500) ``` ## How It Works @@ -111,7 +111,7 @@ Leave room in your model's context window: ```python # For a 8K context model -context = session.get_context(max_tokens=2000) # Leaves room for prompt + response +context = session.get_context(tokens=2000) # Leaves room for prompt + response ``` ### Representation Updates @@ -119,12 +119,11 @@ context = session.get_context(max_tokens=2000) # Leaves room for prompt + respo Ensure representations are current: ```python -# Check if representation is being generated -status = workspace.get_deriver_status(session_id=session.id) +# Check queue status for observability +status = honcho.queue_status(session_id=session.id) -# Wait for processing if needed -if status.pending > 0: - time.sleep(1) # Or implement proper polling +# Note: Don't wait for the queue to be empty—it's a continuous system. +# The context endpoint will work with whatever reasoning is available. ``` ### Caching Strategies @@ -133,7 +132,7 @@ Context can be cached for repeated queries: ```python # Cache context for multiple agent calls -cached_context = session.get_context(max_tokens=2000) +cached_context = session.get_context(tokens=2000) # Reuse for multiple related queries for query in user_queries: @@ -150,13 +149,13 @@ for query in user_queries: ## Related Features - + Learn about basic context retrieval - + Understand session summarization - + Chat with Honcho for insights diff --git a/docs/v2.6.0-alpha/documentation/scratch/honcho-memory/quickstart.mdx b/docs/v3/documentation/scratch/honcho-memory/quickstart.mdx similarity index 96% rename from docs/v2.6.0-alpha/documentation/scratch/honcho-memory/quickstart.mdx rename to docs/v3/documentation/scratch/honcho-memory/quickstart.mdx index 228d0861..a8953fdc 100644 --- a/docs/v2.6.0-alpha/documentation/scratch/honcho-memory/quickstart.mdx +++ b/docs/v3/documentation/scratch/honcho-memory/quickstart.mdx @@ -237,19 +237,19 @@ and Bob. We: As soon as you save a message in Honcho, it will start to reason about it to pull out insights and develop a profile of the user. This is the default -behavior and can be toggled off via [the configuration](/v2.6.0-alpha/documentation/core-concepts/configuration). +behavior and can be toggled off via [the configuration](/v3/documentation/core-concepts/configuration). ## Next Steps + href="/v3/documentation/core-concepts/architecture"> Learn about the data primitives in Honcho and how they work together Sign up for Managed Honcho and get started building agents now. - + Check out spellbooks to see different examples apps built with Honcho diff --git a/docs/v2.6.0-alpha/documentation/scratch/local-vs-global.mdx b/docs/v3/documentation/scratch/local-vs-global.mdx similarity index 96% rename from docs/v2.6.0-alpha/documentation/scratch/local-vs-global.mdx rename to docs/v3/documentation/scratch/local-vs-global.mdx index c9036cdd..29674d57 100644 --- a/docs/v2.6.0-alpha/documentation/scratch/local-vs-global.mdx +++ b/docs/v3/documentation/scratch/local-vs-global.mdx @@ -53,7 +53,7 @@ This feature is illustrated in the graphic below: We can enable local representation for a `Peer` by setting `observe_others=True`. This is shown in the [Configure -Reasoning](/v2.6.0-alpha/documentation/core-concepts/configuration) page. +Reasoning](/v3/documentation/core-concepts/configuration) page. Now if we used Bob's local representation of Alice then Bob would only get insights on what they've seen Alice say to them. diff --git a/docs/v2.6.0-alpha/guides/discord.mdx b/docs/v3/guides/discord.mdx similarity index 100% rename from docs/v2.6.0-alpha/guides/discord.mdx rename to docs/v3/guides/discord.mdx diff --git a/docs/v2.6.0-alpha/guides/file-uploads.mdx b/docs/v3/guides/file-uploads.mdx similarity index 100% rename from docs/v2.6.0-alpha/guides/file-uploads.mdx rename to docs/v3/guides/file-uploads.mdx diff --git a/docs/v2.6.0-alpha/guides/integrations/crewai.mdx b/docs/v3/guides/integrations/crewai.mdx similarity index 94% rename from docs/v2.6.0-alpha/guides/integrations/crewai.mdx rename to docs/v3/guides/integrations/crewai.mdx index d6cb34a2..57fda576 100644 --- a/docs/v2.6.0-alpha/guides/integrations/crewai.mdx +++ b/docs/v3/guides/integrations/crewai.mdx @@ -53,7 +53,7 @@ This tutorial uses the Honcho demo server at https://demo.honcho.dev which runs The `honcho_crewai` package provides `HonchoStorage`, a storage provider that implements CrewAI's `Storage` interface using Honcho's session-based memory. -Before proceeding, it's important to understand Honcho's core concepts (`Peers` and `Sessions`). Review the [Honcho Architecture](/v2.6.0-alpha/documentation/core-concepts/architecture) to familiarize yourself with these primitives. +Before proceeding, it's important to understand Honcho's core concepts (`Peers` and `Sessions`). Review the [Honcho Architecture](/v3/documentation/core-concepts/architecture) to familiarize yourself with these primitives. `HonchoStorage` implements CrewAI's `Storage` interface using Honcho's `peer` and `session` primitives. @@ -97,7 +97,7 @@ results = storage.search("query", filters={ }) ``` -For the full filter syntax including logical operators (AND, OR, NOT), comparison operators, and metadata filtering, see the [Using Filters](https://docs.honcho.dev/v2.6.0-alpha/documentation/core-concepts/features/using-filters) documentation. +For the full filter syntax including logical operators (AND, OR, NOT), comparison operators, and metadata filtering, see the [Using Filters](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters) documentation. For comprehensive details about CrewAI's memory system, see the [official CrewAI Memory documentation](https://docs.crewai.com/en/concepts/memory). @@ -279,16 +279,16 @@ Now that you have a working CrewAI integration with Honcho, you can: ## Related Resources - + Understand Honcho's peer-based model and core primitives - + Learn about retrieving and formatting conversation context - + Query `peer` representations for deeper understanding - + Build stateful agents with LangGraph and Honcho diff --git a/docs/v2.6.0-alpha/guides/integrations/langgraph.mdx b/docs/v3/guides/integrations/langgraph.mdx similarity index 94% rename from docs/v2.6.0-alpha/guides/integrations/langgraph.mdx rename to docs/v3/guides/integrations/langgraph.mdx index 3eba2565..5427e24f 100644 --- a/docs/v2.6.0-alpha/guides/integrations/langgraph.mdx +++ b/docs/v3/guides/integrations/langgraph.mdx @@ -109,7 +109,7 @@ const llm = new OpenAI({ Define your state schema to pass data through the graph. The state stores Honcho objects directly along with the current user message and assistant response. -Before proceeding, it's important to understand Honcho's core concepts (`Peers` and `Sessions`). Review the [Honcho Architecture](/v2.6.0-alpha/documentation/core-concepts/architecture) to familiarize yourself with these primitives. +Before proceeding, it's important to understand Honcho's core concepts (`Peers` and `Sessions`). Review the [Honcho Architecture](/v3/documentation/core-concepts/architecture) to familiarize yourself with these primitives. @@ -227,7 +227,7 @@ const graph = new StateGraph(StateAnnotation) ### Understanding get_context() -The [`get_context()`](/v2.6.0-alpha/documentation/core-concepts/features/get-context) method retrieves comprehensive conversation context and formats it for your LLM. It automatically: +The [`get_context()`](/v3/documentation/core-concepts/features/get-context) method retrieves comprehensive conversation context and formats it for your LLM. It automatically: - **Manages conversation history** - Tracks all messages and determines what's relevant - **Respects token limits** - Stays within context window constraints without manual counting @@ -238,8 +238,8 @@ The `SessionContext` object always includes fields for messages, summaries, `pee **Using `peer_target` for Context:** -- **Without `peer_perspective`**: Returns Honcho's omniscient view of `peer_target` (all observations and context) -- **With `peer_perspective`**: Returns what `peer_perspective` knows about `peer_target` (perspective-based observations and context) +- **Without `peer_perspective`**: Returns Honcho's omniscient view of `peer_target` (all conclusions and context) +- **With `peer_perspective`**: Returns what `peer_perspective` knows about `peer_target` (perspective-based conclusions and context) That's it. Call `session.get_context().to_openai(assistant)` and you get properly formatted context tailored for your assistant. @@ -248,7 +248,7 @@ That's it. Call `session.get_context().to_openai(assistant)` and you get properl -For more details on all available parameters, see [`get_context() documentation`](/v2.6.0-alpha/documentation/core-concepts/features/get-context) +For more details on all available parameters, see [`get_context() documentation`](/v3/documentation/core-concepts/features/get-context) ## Chat Loop @@ -354,10 +354,10 @@ Now that you have a working LangGraph integration with Honcho, you can: ## Related Resources - + Learn more about retrieving and formatting conversation context - + Use Honcho in Claude Desktop with MCP diff --git a/docs/v2.6.0-alpha/guides/integrations/mcp.mdx b/docs/v3/guides/integrations/mcp.mdx similarity index 100% rename from docs/v2.6.0-alpha/guides/integrations/mcp.mdx rename to docs/v3/guides/integrations/mcp.mdx diff --git a/docs/v2.6.0-alpha/guides/migrations/mem0.mdx b/docs/v3/guides/migrations/mem0.mdx similarity index 88% rename from docs/v2.6.0-alpha/guides/migrations/mem0.mdx rename to docs/v3/guides/migrations/mem0.mdx index 8eb544ee..77022854 100644 --- a/docs/v2.6.0-alpha/guides/migrations/mem0.mdx +++ b/docs/v3/guides/migrations/mem0.mdx @@ -27,7 +27,7 @@ We would love to support the transfer and cost—just [book a call!](https://cal For the best results, we recommend importing your raw messages directly into Honcho. This gives Honcho the full context to build rich, accurate representations and enables features like session summaries. -However, if you'd like to get started quickly, you can migrate your existing Mem0 memories directly as **observations**. +However, if you'd like to get started quickly, you can migrate your existing Mem0 memories directly as **conclusions**. Get your API key at [app.honcho.dev/api-keys](https://app.honcho.dev/api-keys). New accounts start with $100 credits. @@ -49,18 +49,18 @@ user = honcho.peer("user123") session = honcho.session("imported") session.add_peers([user]) -# Import memories directly as observations -observations = [] +# Import memories directly as conclusions +conclusions = [] for memory in memories['results']: content = memory.get("memory") or memory.get("messages", [{}])[0].get("content", "") if content: - observations.append({"content": content, "session_id": "imported"}) + conclusions.append({"content": content, "session_id": "imported"}) -# Batch create observations (up to 100 at a time) -if observations: - user.observations.create(observations) +# Batch create conclusions (up to 100 at a time) +if conclusions: + user.conclusions.create(conclusions) -print(f"Migrated {len(observations)} memories as observations!") +print(f"Migrated {len(conclusions)} memories as conclusions!") ``` ```typescript TypeScript @@ -78,24 +78,24 @@ const user = await honcho.peer("user123"); const session = await honcho.session("imported"); await session.addPeers([user]); -// Import memories directly as observations -const observations = memories.results +// Import memories directly as conclusions +const conclusions = memories.results .map(memory => ({ content: memory.memory || memory.messages?.[0]?.content || "", - session_id: "imported" + sessionId: "imported" })) - .filter(obs => obs.content); + .filter(c => c.content); -// Batch create observations (up to 100 at a time) -if (observations.length > 0) { - await user.observations.create(observations); +// Batch create conclusions (up to 100 at a time) +if (conclusions.length > 0) { + await user.conclusions.create(conclusions); } -console.log(`Migrated ${observations.length} memories as observations!`); +console.log(`Migrated ${conclusions.length} memories as conclusions!`); ``` -That's it! The user's Mem0 memories are now searchable in Honcho as observations. For richer representations with deductive reasoning and session summaries, consider importing your raw messages as described in the [Step-by-Step Migration](#step-by-step-migration) section. +That's it! The user's Mem0 memories are now searchable in Honcho as conclusions. For richer representations with deductive reasoning and session summaries, consider importing your raw messages as described in the [Step-by-Step Migration](#step-by-step-migration) section. For more details on replacing Mem0 API calls with Honcho equivalents go to [API Comparison](#api-comparison). @@ -249,11 +249,11 @@ Reference the [API Comparison](#api-comparison) to replace your Mem0 API calls w | **Initialize** | `MemoryClient(api_key=...)` | `Honcho(api_key=...)` | | | **Identity** | `user_id` string param | `peer = honcho.peer("id")` | Peers can be users or AI agents | | **Add messages** | `client.add(messages, user_id=...)` | `session.add_messages([peer.message(...)])` | Session-scoped, triggers reasoning | -| **Add observations** | | `peer.observations.create([...])` | Direct observation or "memory" import, no processing | -| **Search** | `client.search(query, filters={"user_id": ...})` | `peer.search(query)` or `peer.observations.query(...)` | Scoped to peer or session | -| **List all** | `client.get_all(filters={"user_id": ...})` | `session.get_messages()` or `peer.observations.list()` | Messages or observations | +| **Add conclusions** | | `peer.conclusions.create([...])` | Direct conclusion or "memory" import, no processing | +| **Search** | `client.search(query, filters={"user_id": ...})` | `peer.search(query)` or `peer.conclusions.query(...)` | Scoped to peer or session | +| **List all** | `client.get_all(filters={"user_id": ...})` | `session.get_messages()` or `peer.conclusions.list()` | Messages or conclusions | | **Update** | `client.update(memory_id, data=...)` | `honcho.update_message(message, metadata=...)` | Metadata updates only | -| **Delete** | `client.delete(memory_id)` | `peer.observations.delete(id)` or `session.delete()` | Observation or session-level | +| **Delete** | `client.delete(memory_id)` | `peer.conclusions.delete(id)` or `session.delete()` | Conclusion or session-level | ### Honcho-Only Capabilities diff --git a/docs/v2.6.0-alpha/guides/overview.mdx b/docs/v3/guides/overview.mdx similarity index 69% rename from docs/v2.6.0-alpha/guides/overview.mdx rename to docs/v3/guides/overview.mdx index 1944c7d6..9b39510f 100644 --- a/docs/v2.6.0-alpha/guides/overview.mdx +++ b/docs/v3/guides/overview.mdx @@ -5,7 +5,7 @@ description: 'Helpful guides and design patterns for building with Honcho' icon: 'hat-wizard' --- - Before you start a guide, follow [Quickstart](/v2.6.0-alpha/documentation/introduction/quickstart) to get up and running with Honcho in your language of choice. + Before you start a guide, follow [Quickstart](/v3/documentation/introduction/quickstart) to get up and running with Honcho in your language of choice. 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. @@ -16,10 +16,10 @@ Each guide focuses on a specific use case with practical examples. The goal is t Quick integration guides to get up and running: - + Get Honcho running with a single prompt in Claude Code - + Add persistent memory and theory of mind to your LangGraph agents @@ -28,10 +28,10 @@ Quick integration guides to get up and running: Ready-to-use integration patterns for popular platforms: - + Build a Discord bot that remembers users across conversations - + Create a Telegram bot with persistent user understanding diff --git a/docs/v2.6.0-alpha/guides/storing-data.mdx b/docs/v3/guides/storing-data.mdx similarity index 95% rename from docs/v2.6.0-alpha/guides/storing-data.mdx rename to docs/v3/guides/storing-data.mdx index 4d49e744..961b9e1f 100644 --- a/docs/v2.6.0-alpha/guides/storing-data.mdx +++ b/docs/v3/guides/storing-data.mdx @@ -42,7 +42,7 @@ Once a `Message` is saved in Honcho, it will kick off a background task that looks at the new data to generate insights about the `Peer` that sent the `Message` This is the default behavior of Honcho and can be turned off by [configuring the -Peer or Session](/v2.6.0-alpha/documentation/core-concepts/configuration) +Peer or Session](/v3/documentation/core-concepts/configuration) This pattern of having a Peer, Session, and Messages is highly flexible and works for many different use cases and agent setups. Some use cases may only diff --git a/docs/v2.6.0-alpha/guides/telegram.mdx b/docs/v3/guides/telegram.mdx similarity index 100% rename from docs/v2.6.0-alpha/guides/telegram.mdx rename to docs/v3/guides/telegram.mdx diff --git a/docs/v2.6.0-alpha/migrations/from-mem0.mdx b/docs/v3/migrations/from-mem0.mdx similarity index 88% rename from docs/v2.6.0-alpha/migrations/from-mem0.mdx rename to docs/v3/migrations/from-mem0.mdx index 8eb544ee..77022854 100644 --- a/docs/v2.6.0-alpha/migrations/from-mem0.mdx +++ b/docs/v3/migrations/from-mem0.mdx @@ -27,7 +27,7 @@ We would love to support the transfer and cost—just [book a call!](https://cal For the best results, we recommend importing your raw messages directly into Honcho. This gives Honcho the full context to build rich, accurate representations and enables features like session summaries. -However, if you'd like to get started quickly, you can migrate your existing Mem0 memories directly as **observations**. +However, if you'd like to get started quickly, you can migrate your existing Mem0 memories directly as **conclusions**. Get your API key at [app.honcho.dev/api-keys](https://app.honcho.dev/api-keys). New accounts start with $100 credits. @@ -49,18 +49,18 @@ user = honcho.peer("user123") session = honcho.session("imported") session.add_peers([user]) -# Import memories directly as observations -observations = [] +# Import memories directly as conclusions +conclusions = [] for memory in memories['results']: content = memory.get("memory") or memory.get("messages", [{}])[0].get("content", "") if content: - observations.append({"content": content, "session_id": "imported"}) + conclusions.append({"content": content, "session_id": "imported"}) -# Batch create observations (up to 100 at a time) -if observations: - user.observations.create(observations) +# Batch create conclusions (up to 100 at a time) +if conclusions: + user.conclusions.create(conclusions) -print(f"Migrated {len(observations)} memories as observations!") +print(f"Migrated {len(conclusions)} memories as conclusions!") ``` ```typescript TypeScript @@ -78,24 +78,24 @@ const user = await honcho.peer("user123"); const session = await honcho.session("imported"); await session.addPeers([user]); -// Import memories directly as observations -const observations = memories.results +// Import memories directly as conclusions +const conclusions = memories.results .map(memory => ({ content: memory.memory || memory.messages?.[0]?.content || "", - session_id: "imported" + sessionId: "imported" })) - .filter(obs => obs.content); + .filter(c => c.content); -// Batch create observations (up to 100 at a time) -if (observations.length > 0) { - await user.observations.create(observations); +// Batch create conclusions (up to 100 at a time) +if (conclusions.length > 0) { + await user.conclusions.create(conclusions); } -console.log(`Migrated ${observations.length} memories as observations!`); +console.log(`Migrated ${conclusions.length} memories as conclusions!`); ``` -That's it! The user's Mem0 memories are now searchable in Honcho as observations. For richer representations with deductive reasoning and session summaries, consider importing your raw messages as described in the [Step-by-Step Migration](#step-by-step-migration) section. +That's it! The user's Mem0 memories are now searchable in Honcho as conclusions. For richer representations with deductive reasoning and session summaries, consider importing your raw messages as described in the [Step-by-Step Migration](#step-by-step-migration) section. For more details on replacing Mem0 API calls with Honcho equivalents go to [API Comparison](#api-comparison). @@ -249,11 +249,11 @@ Reference the [API Comparison](#api-comparison) to replace your Mem0 API calls w | **Initialize** | `MemoryClient(api_key=...)` | `Honcho(api_key=...)` | | | **Identity** | `user_id` string param | `peer = honcho.peer("id")` | Peers can be users or AI agents | | **Add messages** | `client.add(messages, user_id=...)` | `session.add_messages([peer.message(...)])` | Session-scoped, triggers reasoning | -| **Add observations** | | `peer.observations.create([...])` | Direct observation or "memory" import, no processing | -| **Search** | `client.search(query, filters={"user_id": ...})` | `peer.search(query)` or `peer.observations.query(...)` | Scoped to peer or session | -| **List all** | `client.get_all(filters={"user_id": ...})` | `session.get_messages()` or `peer.observations.list()` | Messages or observations | +| **Add conclusions** | | `peer.conclusions.create([...])` | Direct conclusion or "memory" import, no processing | +| **Search** | `client.search(query, filters={"user_id": ...})` | `peer.search(query)` or `peer.conclusions.query(...)` | Scoped to peer or session | +| **List all** | `client.get_all(filters={"user_id": ...})` | `session.get_messages()` or `peer.conclusions.list()` | Messages or conclusions | | **Update** | `client.update(memory_id, data=...)` | `honcho.update_message(message, metadata=...)` | Metadata updates only | -| **Delete** | `client.delete(memory_id)` | `peer.observations.delete(id)` or `session.delete()` | Observation or session-level | +| **Delete** | `client.delete(memory_id)` | `peer.conclusions.delete(id)` or `session.delete()` | Conclusion or session-level | ### Honcho-Only Capabilities diff --git a/docs/v2.6.0-alpha/openapi.json b/docs/v3/openapi.json similarity index 60% rename from docs/v2.6.0-alpha/openapi.json rename to docs/v3/openapi.json index bca1b8e9..355b6b42 100644 --- a/docs/v2.6.0-alpha/openapi.json +++ b/docs/v3/openapi.json @@ -3,32 +3,38 @@ "info": { "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", + "description": "Honcho is a platform for giving agents user-centric memory and social cognition.", "contact": { "name": "Plastic Labs", "url": "https://honcho.dev/", "email": "hello@plasticlabs.ai" }, - "version": "2.5.0" + "license": { + "name": "GNU Affero General Public License v3.0", + "identifier": "AGPL-3.0-only", + "url": "https://github.com/plastic-labs/honcho/blob/main/LICENSE" + }, + "version": "3.0.0" }, "servers": [ - { - "url": "http://localhost:8000", - "description": "Local Development Server" - }, - { "url": "https://demo.honcho.dev", "description": "Demo Server" }, { "url": "https://api.honcho.dev", "description": "Production SaaS Platform" + }, + { + "url": "http://localhost:8000", + "description": "Local Development Server" } ], "paths": { - "/v2/workspaces": { + "/v3/workspaces": { "post": { - "tags": ["workspaces"], + "tags": [ + "workspaces" + ], "summary": "Get Or Create Workspace", "description": "Get a Workspace by ID.\n\nIf workspace_id is provided as a query parameter, it uses that (must match JWT workspace_id).\nOtherwise, it uses the workspace_id from the JWT.", - "operationId": "get_or_create_workspace_v2_workspaces_post", + "operationId": "get_or_create_workspace_v3_workspaces_post", "requestBody": { "content": { "application/json": { @@ -45,7 +51,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Workspace" } + "schema": { + "$ref": "#/components/schemas/Workspace" + } } } }, @@ -53,21 +61,35 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } }, - "security": [{ "HTTPBearer": [] }] + "security": [ + { + "HTTPBearer": [] + }, + {} + ] } }, - "/v2/workspaces/list": { + "/v3/workspaces/list": { "post": { - "tags": ["workspaces"], + "tags": [ + "workspaces" + ], "summary": "Get All Workspaces", - "description": "Get all Workspaces", - "operationId": "get_all_workspaces_v2_workspaces_list_post", - "security": [{ "HTTPBearer": [] }], + "description": "Get all Workspaces, paginated with optional filters.", + "operationId": "get_all_workspaces_v3_workspaces_list_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "page", @@ -102,8 +124,12 @@ "application/json": { "schema": { "anyOf": [ - { "$ref": "#/components/schemas/WorkspaceGet" }, - { "type": "null" } + { + "$ref": "#/components/schemas/WorkspaceGet" + }, + { + "type": "null" + } ], "description": "Filtering and pagination options for the workspaces list", "title": "Options" @@ -116,7 +142,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Page_Workspace_" } + "schema": { + "$ref": "#/components/schemas/Page_Workspace_" + } } } }, @@ -124,20 +152,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}": { + "/v3/workspaces/{workspace_id}": { "put": { - "tags": ["workspaces"], + "tags": [ + "workspaces" + ], "summary": "Update Workspace", - "description": "Update a Workspace", - "operationId": "update_workspace_v2_workspaces__workspace_id__put", - "security": [{ "HTTPBearer": [] }], + "description": "Update Workspace metadata and/or configuration.", + "operationId": "update_workspace_v3_workspaces__workspace_id__put", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -145,10 +182,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace to update", "title": "Workspace Id" - }, - "description": "ID of the workspace to update" + } } ], "requestBody": { @@ -167,7 +202,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Workspace" } + "schema": { + "$ref": "#/components/schemas/Workspace" + } } } }, @@ -175,18 +212,27 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } }, "delete": { - "tags": ["workspaces"], + "tags": [ + "workspaces" + ], "summary": "Delete Workspace", - "description": "Delete a Workspace", - "operationId": "delete_workspace_v2_workspaces__workspace_id__delete", - "security": [{ "HTTPBearer": [] }], + "description": "Delete a Workspace. This will permanently delete all sessions, peers, messages, and conclusions\nassociated with the workspace.\n\nThis action cannot be undone.", + "operationId": "delete_workspace_v3_workspaces__workspace_id__delete", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -194,39 +240,41 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace to delete", "title": "Workspace Id" - }, - "description": "ID of the workspace to delete" + } } ], "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Workspace" } - } - } + "204": { + "description": "Successful Response" }, "422": { "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/search": { + "/v3/workspaces/{workspace_id}/search": { "post": { - "tags": ["workspaces"], + "tags": [ + "workspaces" + ], "summary": "Search Workspace", - "description": "Search a Workspace", - "operationId": "search_workspace_v2_workspaces__workspace_id__search_post", - "security": [{ "HTTPBearer": [] }], + "description": "Search messages in a Workspace using optional filters. Use `limit` to control the number of\nresults returned.", + "operationId": "search_workspace_v3_workspaces__workspace_id__search_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -234,10 +282,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace to search", "title": "Workspace Id" - }, - "description": "ID of the workspace to search" + } } ], "requestBody": { @@ -246,7 +292,7 @@ "application/json": { "schema": { "$ref": "#/components/schemas/MessageSearchOptions", - "description": "Message search parameters " + "description": "Message search parameters" } } } @@ -258,8 +304,10 @@ "application/json": { "schema": { "type": "array", - "items": { "$ref": "#/components/schemas/Message" }, - "title": "Response Search Workspace V2 Workspaces Workspace Id Search Post" + "items": { + "$ref": "#/components/schemas/Message" + }, + "title": "Response Search Workspace V3 Workspaces Workspace Id Search Post" } } } @@ -268,20 +316,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/deriver/status": { + "/v3/workspaces/{workspace_id}/queue/status": { "get": { - "tags": ["workspaces"], - "summary": "Get Deriver Status", - "description": "Get the deriver processing status, optionally scoped to an observer, sender, and/or session", - "operationId": "get_deriver_status_v2_workspaces__workspace_id__deriver_status_get", - "security": [{ "HTTPBearer": [] }], + "tags": [ + "workspaces" + ], + "summary": "Get Queue Status", + "description": "Get the processing queue status for a Workspace, optionally scoped to an observer, sender,\nand/or session.", + "operationId": "get_queue_status_v3_workspaces__workspace_id__queue_status_get", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -289,17 +346,22 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "observer_id", "in": "query", "required": false, "schema": { - "anyOf": [{ "type": "string" }, { "type": "null" }], + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "description": "Optional observer ID to filter by", "title": "Observer Id" }, @@ -310,7 +372,14 @@ "in": "query", "required": false, "schema": { - "anyOf": [{ "type": "string" }, { "type": "null" }], + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "description": "Optional sender ID to filter by", "title": "Sender Id" }, @@ -321,7 +390,14 @@ "in": "query", "required": false, "schema": { - "anyOf": [{ "type": "string" }, { "type": "null" }], + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "description": "Optional session ID to filter by", "title": "Session Id" }, @@ -333,7 +409,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/DeriverStatus" } + "schema": { + "$ref": "#/components/schemas/QueueStatus" + } } } }, @@ -341,20 +419,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/trigger_dream": { + "/v3/workspaces/{workspace_id}/schedule_dream": { "post": { - "tags": ["workspaces"], - "summary": "Trigger Dream", - "description": "Manually trigger a dream task immediately for a specific collection.\n\nThis endpoint bypasses all automatic dream conditions (document threshold,\nminimum hours between dreams) and executes the dream task immediately without delay.", - "operationId": "trigger_dream_v2_workspaces__workspace_id__trigger_dream_post", - "security": [{ "HTTPBearer": [] }], + "tags": [ + "workspaces" + ], + "summary": "Schedule Dream", + "description": "Manually schedule a dream task for a specific collection.\n\nThis endpoint bypasses all automatic dream conditions (document threshold,\nminimum hours between dreams) and schedules the dream task for a future execution.\n\nCurrently this endpoint only supports scheduling immediate dreams. In the future,\nusers may pass a cron-style expression to schedule dreams at specific times.", + "operationId": "schedule_dream_v3_workspaces__workspace_id__schedule_dream_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -362,10 +449,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } } ], "requestBody": { @@ -373,32 +458,43 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TriggerDreamRequest", - "description": "Dream trigger parameters" + "$ref": "#/components/schemas/ScheduleDreamRequest", + "description": "Dream scheduling parameters" } } } }, "responses": { - "204": { "description": "Successful Response" }, + "204": { + "description": "Successful Response" + }, "422": { "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/peers/list": { + "/v3/workspaces/{workspace_id}/peers/list": { "post": { - "tags": ["peers"], + "tags": [ + "peers" + ], "summary": "Get Peers", - "description": "Get All Peers for a Workspace", - "operationId": "get_peers_v2_workspaces__workspace_id__peers_list_post", - "security": [{ "HTTPBearer": [] }], + "description": "Get all Peers for a Workspace, paginated with optional filters.", + "operationId": "get_peers_v3_workspaces__workspace_id__peers_list_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -406,10 +502,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "page", @@ -444,8 +538,12 @@ "application/json": { "schema": { "anyOf": [ - { "$ref": "#/components/schemas/PeerGet" }, - { "type": "null" } + { + "$ref": "#/components/schemas/PeerGet" + }, + { + "type": "null" + } ], "description": "Filtering options for the peers list", "title": "Options" @@ -458,7 +556,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Page_Peer_" } + "schema": { + "$ref": "#/components/schemas/Page_Peer_" + } } } }, @@ -466,20 +566,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/peers": { + "/v3/workspaces/{workspace_id}/peers": { "post": { - "tags": ["peers"], + "tags": [ + "peers" + ], "summary": "Get Or Create Peer", - "description": "Get a Peer by ID\n\nIf peer_id is provided as a query parameter, it uses that (must match JWT workspace_id).\nOtherwise, it uses the peer_id from the JWT.", - "operationId": "get_or_create_peer_v2_workspaces__workspace_id__peers_post", - "security": [{ "HTTPBearer": [] }], + "description": "Get a Peer by ID or create a new Peer with the given ID.\n\nIf peer_id is provided as a query parameter, it uses that (must match JWT workspace_id).\nOtherwise, it uses the peer_id from the JWT.", + "operationId": "get_or_create_peer_v3_workspaces__workspace_id__peers_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -487,10 +596,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } } ], "requestBody": { @@ -509,7 +616,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Peer" } + "schema": { + "$ref": "#/components/schemas/Peer" + } } } }, @@ -517,20 +626,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/peers/{peer_id}": { + "/v3/workspaces/{workspace_id}/peers/{peer_id}": { "put": { - "tags": ["peers"], + "tags": [ + "peers" + ], "summary": "Update Peer", - "description": "Update a Peer's name and/or metadata", - "operationId": "update_peer_v2_workspaces__workspace_id__peers__peer_id__put", - "security": [{ "HTTPBearer": [] }], + "description": "Update a Peer's metadata and/or configuration.", + "operationId": "update_peer_v3_workspaces__workspace_id__peers__peer_id__put", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -538,10 +656,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "peer_id", @@ -549,10 +665,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the peer to update", "title": "Peer Id" - }, - "description": "ID of the peer to update" + } } ], "requestBody": { @@ -571,7 +685,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Peer" } + "schema": { + "$ref": "#/components/schemas/Peer" + } } } }, @@ -579,20 +695,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/peers/{peer_id}/sessions": { + "/v3/workspaces/{workspace_id}/peers/{peer_id}/sessions": { "post": { - "tags": ["peers"], + "tags": [ + "peers" + ], "summary": "Get Sessions For Peer", - "description": "Get All Sessions for a Peer", - "operationId": "get_sessions_for_peer_v2_workspaces__workspace_id__peers__peer_id__sessions_post", - "security": [{ "HTTPBearer": [] }], + "description": "Get all Sessions for a Peer, paginated with optional filters.", + "operationId": "get_sessions_for_peer_v3_workspaces__workspace_id__peers__peer_id__sessions_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -600,10 +725,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "peer_id", @@ -611,10 +734,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the peer", "title": "Peer Id" - }, - "description": "ID of the peer" + } }, { "name": "page", @@ -649,8 +770,12 @@ "application/json": { "schema": { "anyOf": [ - { "$ref": "#/components/schemas/SessionGet" }, - { "type": "null" } + { + "$ref": "#/components/schemas/SessionGet" + }, + { + "type": "null" + } ], "description": "Filtering options for the sessions list", "title": "Options" @@ -663,7 +788,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Page_Session_" } + "schema": { + "$ref": "#/components/schemas/Page_Session_" + } } } }, @@ -671,19 +798,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/peers/{peer_id}/chat": { + "/v3/workspaces/{workspace_id}/peers/{peer_id}/chat": { "post": { - "tags": ["peers"], - "summary": "Chat", - "operationId": "chat_v2_workspaces__workspace_id__peers__peer_id__chat_post", - "security": [{ "HTTPBearer": [] }], + "tags": [ + "peers" + ], + "summary": "Query a Peer's representation using natural language", + "description": "Query a Peer's representation using natural language. Performs agentic search and reasoning to comprehensively\nanswer the query based on all latent knowledge gathered about the peer from their messages and conclusions.", + "operationId": "chat_v3_workspaces__workspace_id__peers__peer_id__chat_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -691,10 +828,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "peer_id", @@ -702,10 +837,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the peer", "title": "Peer Id" - }, - "description": "ID of the peer" + } } ], "requestBody": { @@ -713,22 +846,33 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DialecticOptions", - "description": "Dialectic Endpoint Parameters" + "$ref": "#/components/schemas/DialecticOptions" } } } }, "responses": { "200": { - "description": "Response to a question informed by Honcho's User Representation", + "description": "Successful Response", "content": { "application/json": { "schema": { "properties": { - "content": { "title": "Content", "type": "string" } + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Content" + } }, - "required": ["content"], + "required": [ + "content" + ], "title": "DialecticResponse", "type": "object" } @@ -740,20 +884,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/peers/{peer_id}/representation": { + "/v3/workspaces/{workspace_id}/peers/{peer_id}/representation": { "post": { - "tags": ["peers"], - "summary": "Get Working Representation", - "description": "Get a peer's working representation for a session.\n\nIf a session_id is provided in the body, we get the working representation of the peer in that session.\nIf a target is provided, we get the representation of the target from the perspective of the peer.\nIf no target is provided, we get the omniscient Honcho representation of the peer.", - "operationId": "get_working_representation_v2_workspaces__workspace_id__peers__peer_id__representation_post", - "security": [{ "HTTPBearer": [] }], + "tags": [ + "peers" + ], + "summary": "Get Representation", + "description": "Get a curated subset of a Peer's Representation. A Representation is always a subset of the total\nknowledge about the Peer. The subset can be scoped and filtered in various ways.\n\n\nIf a session_id is provided in the body, we get the Representation of the Peer scoped to that Session.\nIf a target is provided, we get the Representation of the target from the perspective of the Peer.\nIf no target is provided, we get the omniscient Honcho Representation of the Peer.", + "operationId": "get_representation_v3_workspaces__workspace_id__peers__peer_id__representation_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -761,10 +914,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "peer_id", @@ -772,10 +923,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the peer", "title": "Peer Id" - }, - "description": "ID of the peer" + } } ], "requestBody": { @@ -795,9 +944,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Get Working Representation V2 Workspaces Workspace Id Peers Peer Id Representation Post" + "$ref": "#/components/schemas/RepresentationResponse" } } } @@ -806,20 +953,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/peers/{peer_id}/card": { + "/v3/workspaces/{workspace_id}/peers/{peer_id}/card": { "get": { - "tags": ["peers"], + "tags": [ + "peers" + ], "summary": "Get Peer Card", "description": "Get a peer card for a specific peer relationship.\n\nReturns the peer card that the observer peer has for the target peer if it exists.\nIf no target is specified, returns the observer's own peer card.", - "operationId": "get_peer_card_v2_workspaces__workspace_id__peers__peer_id__card_get", - "security": [{ "HTTPBearer": [] }], + "operationId": "get_peer_card_v3_workspaces__workspace_id__peers__peer_id__card_get", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -827,10 +983,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "peer_id", @@ -848,11 +1002,18 @@ "in": "query", "required": false, "schema": { - "anyOf": [{ "type": "string" }, { "type": "null" }], - "description": "The peer whose card to retrieve. If not provided, returns the observer's own card", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional target peer to retrieve a card for, from the observer's perspective. If not provided, returns the observer's own card", "title": "Target" }, - "description": "The peer whose card to retrieve. If not provided, returns the observer's own card" + "description": "Optional target peer to retrieve a card for, from the observer's perspective. If not provided, returns the observer's own card" } ], "responses": { @@ -860,7 +1021,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/PeerCardResponse" } + "schema": { + "$ref": "#/components/schemas/PeerCardResponse" + } } } }, @@ -868,18 +1031,27 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } }, "put": { - "tags": ["peers"], + "tags": [ + "peers" + ], "summary": "Set Peer Card", "description": "Set a peer card for a specific peer relationship.\n\nSets the peer card that the observer peer has for the target peer.\nIf no target is specified, sets the observer's own peer card.", - "operationId": "set_peer_card_v2_workspaces__workspace_id__peers__peer_id__card_put", - "security": [{ "HTTPBearer": [] }], + "operationId": "set_peer_card_v3_workspaces__workspace_id__peers__peer_id__card_put", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -887,10 +1059,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "peer_id", @@ -908,11 +1078,18 @@ "in": "query", "required": false, "schema": { - "anyOf": [{ "type": "string" }, { "type": "null" }], - "description": "The peer whose card to set. If not provided, sets the observer's own card", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional target peer to set a card for, from the observer's perspective. If not provided, sets the observer's own card", "title": "Target" }, - "description": "The peer whose card to set. If not provided, sets the observer's own card" + "description": "Optional target peer to set a card for, from the observer's perspective. If not provided, sets the observer's own card" } ], "requestBody": { @@ -931,7 +1108,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/PeerCardResponse" } + "schema": { + "$ref": "#/components/schemas/PeerCardResponse" + } } } }, @@ -939,20 +1118,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/peers/{peer_id}/context": { + "/v3/workspaces/{workspace_id}/peers/{peer_id}/context": { "get": { - "tags": ["peers"], + "tags": [ + "peers" + ], "summary": "Get Peer Context", - "description": "Get context for a peer, including their representation and peer card.\n\nThis endpoint returns the working representation and peer card for a peer.\nIf a target is specified, returns the context for the target from the\nobserver peer's perspective. If no target is specified, returns the\npeer's own context (self-observation).\n\nThis is useful for getting all the context needed about a peer without\nmaking multiple API calls.", - "operationId": "get_peer_context_v2_workspaces__workspace_id__peers__peer_id__context_get", - "security": [{ "HTTPBearer": [] }], + "description": "Get context for a peer, including their representation and peer card.\n\nThis endpoint returns a curated subset of the representation and peer card for a peer.\nIf a target is specified, returns the context for the target from the\nobserver peer's perspective. If no target is specified, returns the\npeer's own context (self-observation).\n\nThis is useful for getting all the context needed about a peer without\nmaking multiple API calls.", + "operationId": "get_peer_context_v3_workspaces__workspace_id__peers__peer_id__context_get", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -960,10 +1148,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "peer_id", @@ -971,28 +1157,42 @@ "required": true, "schema": { "type": "string", - "description": "ID of the peer (observer)", + "description": "ID of the observer peer", "title": "Peer Id" }, - "description": "ID of the peer (observer)" + "description": "ID of the observer peer" }, { "name": "target", "in": "query", "required": false, "schema": { - "anyOf": [{ "type": "string" }, { "type": "null" }], - "description": "The target peer to get context for. If not provided, returns the peer's own context (self-observation)", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional target peer to get context for, from the observer's perspective. If not provided, returns the observer's own context (self-observation)", "title": "Target" }, - "description": "The target peer to get context for. If not provided, returns the peer's own context (self-observation)" + "description": "Optional target peer to get context for, from the observer's perspective. If not provided, returns the observer's own context (self-observation)" }, { "name": "search_query", "in": "query", "required": false, "schema": { - "anyOf": [{ "type": "string" }, { "type": "null" }], + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "description": "Optional query to curate the representation around semantic search results", "title": "Search Query" }, @@ -1004,13 +1204,19 @@ "required": false, "schema": { "anyOf": [ - { "type": "integer", "maximum": 100, "minimum": 1 }, - { "type": "null" } + { + "type": "integer", + "maximum": 100, + "minimum": 1 + }, + { + "type": "null" + } ], - "description": "Only used if `search_query` is provided. Number of semantic-search-retrieved observations to include", + "description": "Only used if `search_query` is provided. Number of semantic-search-retrieved conclusions to include", "title": "Search Top K" }, - "description": "Only used if `search_query` is provided. Number of semantic-search-retrieved observations to include" + "description": "Only used if `search_query` is provided. Number of semantic-search-retrieved conclusions to include" }, { "name": "search_max_distance", @@ -1018,39 +1224,51 @@ "required": false, "schema": { "anyOf": [ - { "type": "number", "maximum": 1.0, "minimum": 0.0 }, - { "type": "null" } + { + "type": "number", + "maximum": 1.0, + "minimum": 0.0 + }, + { + "type": "null" + } ], - "description": "Only used if `search_query` is provided. Maximum distance for semantically relevant observations", + "description": "Only used if `search_query` is provided. Maximum distance for semantically relevant conclusions", "title": "Search Max Distance" }, - "description": "Only used if `search_query` is provided. Maximum distance for semantically relevant observations" + "description": "Only used if `search_query` is provided. Maximum distance for semantically relevant conclusions" }, { - "name": "include_most_derived", + "name": "include_most_frequent", "in": "query", "required": false, "schema": { "type": "boolean", - "description": "Whether to include the most derived observations in the representation", + "description": "Whether to include the most frequent conclusions in the representation", "default": true, - "title": "Include Most Derived" + "title": "Include Most Frequent" }, - "description": "Whether to include the most derived observations in the representation" + "description": "Whether to include the most frequent conclusions in the representation" }, { - "name": "max_observations", + "name": "max_conclusions", "in": "query", "required": false, "schema": { "anyOf": [ - { "type": "integer", "maximum": 100, "minimum": 1 }, - { "type": "null" } + { + "type": "integer", + "maximum": 100, + "minimum": 1 + }, + { + "type": "null" + } ], - "description": "Maximum number of observations to include in the representation", - "title": "Max Observations" + "description": "Maximum number of conclusions to include in the representation", + "title": "Max Conclusions" }, - "description": "Maximum number of observations to include in the representation" + "description": "Maximum number of conclusions to include in the representation" } ], "responses": { @@ -1058,7 +1276,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/PeerContext" } + "schema": { + "$ref": "#/components/schemas/PeerContext" + } } } }, @@ -1066,20 +1286,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/peers/{peer_id}/search": { + "/v3/workspaces/{workspace_id}/peers/{peer_id}/search": { "post": { - "tags": ["peers"], + "tags": [ + "peers" + ], "summary": "Search Peer", - "description": "Search a Peer", - "operationId": "search_peer_v2_workspaces__workspace_id__peers__peer_id__search_post", - "security": [{ "HTTPBearer": [] }], + "description": "Search a Peer's messages, optionally filtered by various criteria.", + "operationId": "search_peer_v3_workspaces__workspace_id__peers__peer_id__search_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -1087,10 +1316,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "peer_id", @@ -1098,10 +1325,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the peer", "title": "Peer Id" - }, - "description": "ID of the peer" + } } ], "requestBody": { @@ -1110,7 +1335,7 @@ "application/json": { "schema": { "$ref": "#/components/schemas/MessageSearchOptions", - "description": "Message search parameters " + "description": "Message search parameters. Use `limit` to control the number of results returned." } } } @@ -1122,8 +1347,10 @@ "application/json": { "schema": { "type": "array", - "items": { "$ref": "#/components/schemas/Message" }, - "title": "Response Search Peer V2 Workspaces Workspace Id Peers Peer Id Search Post" + "items": { + "$ref": "#/components/schemas/Message" + }, + "title": "Response Search Peer V3 Workspaces Workspace Id Peers Peer Id Search Post" } } } @@ -1132,71 +1359,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/sessions": { + "/v3/workspaces/{workspace_id}/sessions/list": { "post": { - "tags": ["sessions"], - "summary": "Get Or Create Session", - "description": "Get a specific session in a workspace.\n\nIf session_id is provided as a query parameter, it verifies the session is in the workspace.\nOtherwise, it uses the session_id from the JWT for verification.", - "operationId": "get_or_create_session_v2_workspaces__workspace_id__sessions_post", - "security": [{ "HTTPBearer": [] }], - "parameters": [ - { - "name": "workspace_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "description": "ID of the workspace", - "title": "Workspace Id" - }, - "description": "ID of the workspace" - } + "tags": [ + "sessions" ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionCreate", - "description": "Session creation parameters" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Session" } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } - } - } - } - } - } - }, - "/v2/workspaces/{workspace_id}/sessions/list": { - "post": { - "tags": ["sessions"], "summary": "Get Sessions", - "description": "Get All Sessions in a Workspace", - "operationId": "get_sessions_v2_workspaces__workspace_id__sessions_list_post", - "security": [{ "HTTPBearer": [] }], + "description": "Get all Sessions for a Workspace, paginated with optional filters.", + "operationId": "get_sessions_v3_workspaces__workspace_id__sessions_list_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -1204,10 +1389,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "page", @@ -1242,8 +1425,12 @@ "application/json": { "schema": { "anyOf": [ - { "$ref": "#/components/schemas/SessionGet" }, - { "type": "null" } + { + "$ref": "#/components/schemas/SessionGet" + }, + { + "type": "null" + } ], "description": "Filtering and pagination options for the sessions list", "title": "Options" @@ -1256,7 +1443,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Page_Session_" } + "schema": { + "$ref": "#/components/schemas/Page_Session_" + } } } }, @@ -1264,20 +1453,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/sessions/{session_id}": { - "put": { - "tags": ["sessions"], - "summary": "Update Session", - "description": "Update the metadata of a Session", - "operationId": "update_session_v2_workspaces__workspace_id__sessions__session_id__put", - "security": [{ "HTTPBearer": [] }], + "/v3/workspaces/{workspace_id}/sessions": { + "post": { + "tags": [ + "sessions" + ], + "summary": "Get Or Create Session", + "description": "Get a Session by ID or create a new Session with the given ID.\n\nIf Session ID is provided as a parameter, it verifies the Session is in the Workspace.\nOtherwise, it uses the session_id from the JWT for verification.", + "operationId": "get_or_create_session_v3_workspaces__workspace_id__sessions_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -1285,10 +1483,68 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionCreate", + "description": "Session creation parameters" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v3/workspaces/{workspace_id}/sessions/{session_id}": { + "put": { + "tags": [ + "sessions" + ], + "summary": "Update Session", + "description": "Update a Session's metadata and/or configuration.", + "operationId": "update_session_v3_workspaces__workspace_id__sessions__session_id__put", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Workspace Id" + } }, { "name": "session_id", @@ -1296,10 +1552,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the session to update", "title": "Session Id" - }, - "description": "ID of the session to update" + } } ], "requestBody": { @@ -1318,7 +1572,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Session" } + "schema": { + "$ref": "#/components/schemas/Session" + } } } }, @@ -1326,18 +1582,27 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } }, "delete": { - "tags": ["sessions"], + "tags": [ + "sessions" + ], "summary": "Delete Session", - "description": "Delete a session and all associated data.\n\nThe session is marked as inactive immediately and returns 202 Accepted. The actual\ndeletion of all related data (messages, embeddings, documents, etc.) happens\nasynchronously via the queue with retry support.\n\nThis action cannot be undone.", - "operationId": "delete_session_v2_workspaces__workspace_id__sessions__session_id__delete", - "security": [{ "HTTPBearer": [] }], + "description": "Delete a Session and all associated messages.\n\nThe Session is marked as inactive immediately and returns 202 Accepted. The actual\ndeletion of all related data happens asynchronously via the queue with retry support.\n\nThis action cannot be undone.", + "operationId": "delete_session_v3_workspaces__workspace_id__sessions__session_id__delete", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -1345,10 +1610,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "session_id", @@ -1356,35 +1619,46 @@ "required": true, "schema": { "type": "string", - "description": "ID of the session to delete", "title": "Session Id" - }, - "description": "ID of the session to delete" + } } ], "responses": { "202": { "description": "Successful Response", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, "422": { "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/sessions/{session_id}/clone": { - "get": { - "tags": ["sessions"], + "/v3/workspaces/{workspace_id}/sessions/{session_id}/clone": { + "post": { + "tags": [ + "sessions" + ], "summary": "Clone Session", - "description": "Clone a session, optionally up to a specific message", - "operationId": "clone_session_v2_workspaces__workspace_id__sessions__session_id__clone_get", - "security": [{ "HTTPBearer": [] }], + "description": "Clone a Session, optionally up to a specific message ID.", + "operationId": "clone_session_v3_workspaces__workspace_id__sessions__session_id__clone_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -1392,10 +1666,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "session_id", @@ -1403,17 +1675,22 @@ "required": true, "schema": { "type": "string", - "description": "ID of the session to clone", "title": "Session Id" - }, - "description": "ID of the session to clone" + } }, { "name": "message_id", "in": "query", "required": false, "schema": { - "anyOf": [{ "type": "string" }, { "type": "null" }], + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "description": "Message ID to cut off the clone at", "title": "Message Id" }, @@ -1421,11 +1698,13 @@ } ], "responses": { - "200": { + "201": { "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Session" } + "schema": { + "$ref": "#/components/schemas/Session" + } } } }, @@ -1433,20 +1712,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/sessions/{session_id}/peers": { + "/v3/workspaces/{workspace_id}/sessions/{session_id}/peers": { "post": { - "tags": ["sessions"], + "tags": [ + "sessions" + ], "summary": "Add Peers To Session", - "description": "Add peers to a session", - "operationId": "add_peers_to_session_v2_workspaces__workspace_id__sessions__session_id__peers_post", - "security": [{ "HTTPBearer": [] }], + "description": "Add Peers to a Session. If a Peer does not yet exist, it will be created automatically.", + "operationId": "add_peers_to_session_v3_workspaces__workspace_id__sessions__session_id__peers_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -1454,10 +1742,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "session_id", @@ -1465,10 +1751,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the session", "title": "Session Id" - }, - "description": "ID of the session" + } } ], "requestBody": { @@ -1480,7 +1764,7 @@ "additionalProperties": { "$ref": "#/components/schemas/SessionPeerConfig" }, - "description": "List of peer IDs to add to the session", + "description": "List of peer IDs (with session-level configuration) to add to the session", "title": "Peers" } } @@ -1491,7 +1775,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Session" } + "schema": { + "$ref": "#/components/schemas/Session" + } } } }, @@ -1499,18 +1785,27 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } }, "put": { - "tags": ["sessions"], + "tags": [ + "sessions" + ], "summary": "Set Session Peers", - "description": "Set the peers in a session", - "operationId": "set_session_peers_v2_workspaces__workspace_id__sessions__session_id__peers_put", - "security": [{ "HTTPBearer": [] }], + "description": "Set the Peers in a Session. If a Peer does not yet exist, it will be created automatically.\n\nThis will fully replace the current set of Peers in the Session.", + "operationId": "set_session_peers_v3_workspaces__workspace_id__sessions__session_id__peers_put", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -1518,10 +1813,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "session_id", @@ -1529,10 +1822,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the session", "title": "Session Id" - }, - "description": "ID of the session" + } } ], "requestBody": { @@ -1544,7 +1835,7 @@ "additionalProperties": { "$ref": "#/components/schemas/SessionPeerConfig" }, - "description": "List of peer IDs to set for the session", + "description": "List of peer IDs (with session-level configuration) to set for the session", "title": "Peers" } } @@ -1555,7 +1846,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Session" } + "schema": { + "$ref": "#/components/schemas/Session" + } } } }, @@ -1563,18 +1856,27 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } }, "delete": { - "tags": ["sessions"], + "tags": [ + "sessions" + ], "summary": "Remove Peers From Session", - "description": "Remove peers from a session", - "operationId": "remove_peers_from_session_v2_workspaces__workspace_id__sessions__session_id__peers_delete", - "security": [{ "HTTPBearer": [] }], + "description": "Remove Peers by ID from a Session.", + "operationId": "remove_peers_from_session_v3_workspaces__workspace_id__sessions__session_id__peers_delete", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -1582,10 +1884,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "session_id", @@ -1593,10 +1893,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the session", "title": "Session Id" - }, - "description": "ID of the session" + } } ], "requestBody": { @@ -1605,7 +1903,9 @@ "application/json": { "schema": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "List of peer IDs to remove from the session", "title": "Peers" } @@ -1617,7 +1917,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Session" } + "schema": { + "$ref": "#/components/schemas/Session" + } } } }, @@ -1625,18 +1927,27 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } }, "get": { - "tags": ["sessions"], + "tags": [ + "sessions" + ], "summary": "Get Session Peers", - "description": "Get peers from a session", - "operationId": "get_session_peers_v2_workspaces__workspace_id__sessions__session_id__peers_get", - "security": [{ "HTTPBearer": [] }], + "description": "Get all Peers in a Session. Results are paginated.", + "operationId": "get_session_peers_v3_workspaces__workspace_id__sessions__session_id__peers_get", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -1644,10 +1955,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "session_id", @@ -1655,10 +1964,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the session", "title": "Session Id" - }, - "description": "ID of the session" + } }, { "name": "page", @@ -1693,7 +2000,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Page_Peer_" } + "schema": { + "$ref": "#/components/schemas/Page_Peer_" + } } } }, @@ -1701,20 +2010,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config": { + "/v3/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config": { "get": { - "tags": ["sessions"], + "tags": [ + "sessions" + ], "summary": "Get Peer Config", - "description": "Get the configuration for a peer in a session", - "operationId": "get_peer_config_v2_workspaces__workspace_id__sessions__session_id__peers__peer_id__config_get", - "security": [{ "HTTPBearer": [] }], + "description": "Get the configuration for a Peer in a Session.", + "operationId": "get_peer_config_v3_workspaces__workspace_id__sessions__session_id__peers__peer_id__config_get", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -1722,10 +2040,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "session_id", @@ -1733,10 +2049,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the session", "title": "Session Id" - }, - "description": "ID of the session" + } }, { "name": "peer_id", @@ -1744,10 +2058,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the peer", "title": "Peer Id" - }, - "description": "ID of the peer" + } } ], "responses": { @@ -1755,7 +2067,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/SessionPeerConfig" } + "schema": { + "$ref": "#/components/schemas/SessionPeerConfig" + } } } }, @@ -1763,18 +2077,27 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } }, - "post": { - "tags": ["sessions"], + "put": { + "tags": [ + "sessions" + ], "summary": "Set Peer Config", - "description": "Set the configuration for a peer in a session", - "operationId": "set_peer_config_v2_workspaces__workspace_id__sessions__session_id__peers__peer_id__config_post", - "security": [{ "HTTPBearer": [] }], + "description": "Set the configuration for a Peer in a Session.", + "operationId": "set_peer_config_v3_workspaces__workspace_id__sessions__session_id__peers__peer_id__config_put", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -1782,10 +2105,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "session_id", @@ -1793,10 +2114,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the session", "title": "Session Id" - }, - "description": "ID of the session" + } }, { "name": "peer_id", @@ -1804,10 +2123,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the peer", "title": "Peer Id" - }, - "description": "ID of the peer" + } } ], "requestBody": { @@ -1816,34 +2133,42 @@ "application/json": { "schema": { "$ref": "#/components/schemas/SessionPeerConfig", - "description": "Peer configuration" + "description": "New peer configuration" } } } }, "responses": { - "200": { - "description": "Successful Response", - "content": { "application/json": { "schema": {} } } + "204": { + "description": "Successful Response" }, "422": { "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/sessions/{session_id}/context": { + "/v3/workspaces/{workspace_id}/sessions/{session_id}/context": { "get": { - "tags": ["sessions"], + "tags": [ + "sessions" + ], "summary": "Get Session Context", - "description": "Produce a context object from the session. The caller provides an optional token limit which the entire context must fit into.\nIf not provided, the context will be exhaustive (within configured max tokens). To do this, we allocate 40% of the token limit\nto the summary, and 60% to recent messages -- as many as can fit. Note that the summary will usually take up less space than\nthis. If the caller does not want a summary, we allocate all the tokens to recent messages.", - "operationId": "get_session_context_v2_workspaces__workspace_id__sessions__session_id__context_get", - "security": [{ "HTTPBearer": [] }], + "description": "Produce a context object from the Session. The caller provides an optional token limit which the entire context must fit into.\nIf not provided, the context will be exhaustive (within configured max tokens). To do this, we allocate 40% of the token limit\nto the summary, and 60% to recent messages -- as many as can fit. Note that the summary will usually take up less space than\nthis. If the caller does not want a summary, we allocate all the tokens to recent messages.", + "operationId": "get_session_context_v3_workspaces__workspace_id__sessions__session_id__context_get", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -1851,10 +2176,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "session_id", @@ -1862,10 +2185,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the session", "title": "Session Id" - }, - "description": "ID of the session" + } }, { "name": "tokens", @@ -1873,8 +2194,13 @@ "required": false, "schema": { "anyOf": [ - { "type": "integer", "maximum": 100000 }, - { "type": "null" } + { + "type": "integer", + "maximum": 100000 + }, + { + "type": "null" + } ], "description": "Number of tokens to use for the context. Includes summary if set to true. Includes representation and peer card if they are included in the response. If not provided, the context will be exhaustive (within 100000 tokens)", "title": "Tokens" @@ -1886,11 +2212,18 @@ "in": "query", "required": false, "schema": { - "anyOf": [{ "type": "string" }, { "type": "null" }], - "description": "The most recent message, used to fetch semantically relevant observations", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The most recent message, used to fetch semantically relevant conclusions", "title": "Last Message" }, - "description": "The most recent message, used to fetch semantically relevant observations" + "description": "The most recent message, used to fetch semantically relevant conclusions" }, { "name": "summary", @@ -1909,7 +2242,14 @@ "in": "query", "required": false, "schema": { - "anyOf": [{ "type": "string" }, { "type": "null" }], + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "description": "The target of the perspective. If given without `peer_perspective`, will get the Honcho-level representation and peer card for this peer. If given with `peer_perspective`, will get the representation and card for this peer *from the perspective of that peer*.", "title": "Peer Target" }, @@ -1920,7 +2260,14 @@ "in": "query", "required": false, "schema": { - "anyOf": [{ "type": "string" }, { "type": "null" }], + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "description": "A peer to get context for. If given, response will attempt to include representation and card from the perspective of that peer. Must be provided with `peer_target`.", "title": "Peer Perspective" }, @@ -1944,13 +2291,19 @@ "required": false, "schema": { "anyOf": [ - { "type": "integer", "maximum": 100, "minimum": 1 }, - { "type": "null" } + { + "type": "integer", + "maximum": 100, + "minimum": 1 + }, + { + "type": "null" + } ], - "description": "Only used if `last_message` is provided. The number of semantic-search-retrieved observations to include in the representation", + "description": "Only used if `last_message` is provided. The number of semantic-search-retrieved conclusions to include in the representation", "title": "Search Top K" }, - "description": "Only used if `last_message` is provided. The number of semantic-search-retrieved observations to include in the representation" + "description": "Only used if `last_message` is provided. The number of semantic-search-retrieved conclusions to include in the representation" }, { "name": "search_max_distance", @@ -1958,39 +2311,51 @@ "required": false, "schema": { "anyOf": [ - { "type": "number", "maximum": 1.0, "minimum": 0.0 }, - { "type": "null" } + { + "type": "number", + "maximum": 1.0, + "minimum": 0.0 + }, + { + "type": "null" + } ], - "description": "Only used if `last_message` is provided. The maximum distance to search for semantically relevant observations", + "description": "Only used if `last_message` is provided. The maximum distance to search for semantically relevant conclusions", "title": "Search Max Distance" }, - "description": "Only used if `last_message` is provided. The maximum distance to search for semantically relevant observations" + "description": "Only used if `last_message` is provided. The maximum distance to search for semantically relevant conclusions" }, { - "name": "include_most_derived", + "name": "include_most_frequent", "in": "query", "required": false, "schema": { "type": "boolean", - "description": "Only used if `last_message` is provided. Whether to include the most derived observations in the representation", + "description": "Only used if `last_message` is provided. Whether to include the most frequent conclusions in the representation", "default": false, - "title": "Include Most Derived" + "title": "Include Most Frequent" }, - "description": "Only used if `last_message` is provided. Whether to include the most derived observations in the representation" + "description": "Only used if `last_message` is provided. Whether to include the most frequent conclusions in the representation" }, { - "name": "max_observations", + "name": "max_conclusions", "in": "query", "required": false, "schema": { "anyOf": [ - { "type": "integer", "maximum": 100, "minimum": 1 }, - { "type": "null" } + { + "type": "integer", + "maximum": 100, + "minimum": 1 + }, + { + "type": "null" + } ], - "description": "Only used if `last_message` is provided. The maximum number of observations to include in the representation", - "title": "Max Observations" + "description": "Only used if `last_message` is provided. The maximum number of conclusions to include in the representation", + "title": "Max Conclusions" }, - "description": "Only used if `last_message` is provided. The maximum number of observations to include in the representation" + "description": "Only used if `last_message` is provided. The maximum number of conclusions to include in the representation" } ], "responses": { @@ -1998,7 +2363,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/SessionContext" } + "schema": { + "$ref": "#/components/schemas/SessionContext" + } } } }, @@ -2006,20 +2373,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/sessions/{session_id}/summaries": { + "/v3/workspaces/{workspace_id}/sessions/{session_id}/summaries": { "get": { - "tags": ["sessions"], + "tags": [ + "sessions" + ], "summary": "Get Session Summaries", - "description": "Get available summaries for a session.\n\nReturns both short and long summaries if available, including metadata like\nthe message ID they cover up to, creation timestamp, and token count.", - "operationId": "get_session_summaries_v2_workspaces__workspace_id__sessions__session_id__summaries_get", - "security": [{ "HTTPBearer": [] }], + "description": "Get available summaries for a Session.\n\nReturns both short and long summaries if available, including metadata like\nthe message ID they cover up to, creation timestamp, and token count.", + "operationId": "get_session_summaries_v3_workspaces__workspace_id__sessions__session_id__summaries_get", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -2027,10 +2403,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "session_id", @@ -2038,10 +2412,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the session", "title": "Session Id" - }, - "description": "ID of the session" + } } ], "responses": { @@ -2049,7 +2421,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/SessionSummaries" } + "schema": { + "$ref": "#/components/schemas/SessionSummaries" + } } } }, @@ -2057,20 +2431,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/sessions/{session_id}/search": { + "/v3/workspaces/{workspace_id}/sessions/{session_id}/search": { "post": { - "tags": ["sessions"], + "tags": [ + "sessions" + ], "summary": "Search Session", - "description": "Search a Session", - "operationId": "search_session_v2_workspaces__workspace_id__sessions__session_id__search_post", - "security": [{ "HTTPBearer": [] }], + "description": "Search a Session with optional filters. Use `limit` to control the number of results returned.", + "operationId": "search_session_v3_workspaces__workspace_id__sessions__session_id__search_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -2078,10 +2461,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "session_id", @@ -2089,10 +2470,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the session", "title": "Session Id" - }, - "description": "ID of the session" + } } ], "requestBody": { @@ -2113,8 +2492,10 @@ "application/json": { "schema": { "type": "array", - "items": { "$ref": "#/components/schemas/Message" }, - "title": "Response Search Session V2 Workspaces Workspace Id Sessions Session Id Search Post" + "items": { + "$ref": "#/components/schemas/Message" + }, + "title": "Response Search Session V3 Workspaces Workspace Id Sessions Session Id Search Post" } } } @@ -2123,51 +2504,70 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/sessions/{session_id}/messages/": { + "/v3/workspaces/{workspace_id}/sessions/{session_id}/messages": { "post": { - "tags": ["messages"], + "tags": [ + "messages" + ], "summary": "Create Messages For Session", "description": "Add new message(s) to a session.", - "operationId": "create_messages_for_session_v2_workspaces__workspace_id__sessions__session_id__messages__post", - "security": [{ "HTTPBearer": [] }], + "operationId": "create_messages_for_session_v3_workspaces__workspace_id__sessions__session_id__messages_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", "in": "path", "required": true, - "schema": { "type": "string", "title": "Workspace Id" } + "schema": { + "type": "string", + "title": "Workspace Id" + } }, { "name": "session_id", "in": "path", "required": true, - "schema": { "type": "string", "title": "Session Id" } + "schema": { + "type": "string", + "title": "Session Id" + } } ], "requestBody": { "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/MessageBatchCreate" } + "schema": { + "$ref": "#/components/schemas/MessageBatchCreate" + } } } }, "responses": { - "200": { + "201": { "description": "Successful Response", "content": { "application/json": { "schema": { "type": "array", - "items": { "$ref": "#/components/schemas/Message" }, - "title": "Response Create Messages For Session V2 Workspaces Workspace Id Sessions Session Id Messages Post" + "items": { + "$ref": "#/components/schemas/Message" + }, + "title": "Response Create Messages For Session V3 Workspaces Workspace Id Sessions Session Id Messages Post" } } } @@ -2176,32 +2576,47 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/sessions/{session_id}/messages/upload": { + "/v3/workspaces/{workspace_id}/sessions/{session_id}/messages/upload": { "post": { - "tags": ["messages"], + "tags": [ + "messages" + ], "summary": "Create Messages With File", "description": "Create messages from uploaded files. Files are converted to text and split into multiple messages.", - "operationId": "create_messages_with_file_v2_workspaces__workspace_id__sessions__session_id__messages_upload_post", - "security": [{ "HTTPBearer": [] }], + "operationId": "create_messages_with_file_v3_workspaces__workspace_id__sessions__session_id__messages_upload_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", "in": "path", "required": true, - "schema": { "type": "string", "title": "Workspace Id" } + "schema": { + "type": "string", + "title": "Workspace Id" + } }, { "name": "session_id", "in": "path", "required": true, - "schema": { "type": "string", "title": "Session Id" } + "schema": { + "type": "string", + "title": "Session Id" + } } ], "requestBody": { @@ -2209,20 +2624,22 @@ "content": { "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Body_create_messages_with_file_v2_workspaces__workspace_id__sessions__session_id__messages_upload_post" + "$ref": "#/components/schemas/Body_create_messages_with_file_v3_workspaces__workspace_id__sessions__session_id__messages_upload_post" } } } }, "responses": { - "200": { + "201": { "description": "Successful Response", "content": { "application/json": { "schema": { "type": "array", - "items": { "$ref": "#/components/schemas/Message" }, - "title": "Response Create Messages With File V2 Workspaces Workspace Id Sessions Session Id Messages Upload Post" + "items": { + "$ref": "#/components/schemas/Message" + }, + "title": "Response Create Messages With File V3 Workspaces Workspace Id Sessions Session Id Messages Upload Post" } } } @@ -2231,20 +2648,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/sessions/{session_id}/messages/list": { + "/v3/workspaces/{workspace_id}/sessions/{session_id}/messages/list": { "post": { - "tags": ["messages"], + "tags": [ + "messages" + ], "summary": "Get Messages", - "description": "Get all messages for a session", - "operationId": "get_messages_v2_workspaces__workspace_id__sessions__session_id__messages_list_post", - "security": [{ "HTTPBearer": [] }], + "description": "Get all messages for a Session with optional filters. Results are paginated.", + "operationId": "get_messages_v3_workspaces__workspace_id__sessions__session_id__messages_list_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -2252,10 +2678,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "session_id", @@ -2263,17 +2687,22 @@ "required": true, "schema": { "type": "string", - "description": "ID of the session", "title": "Session Id" - }, - "description": "ID of the session" + } }, { "name": "reverse", "in": "query", "required": false, "schema": { - "anyOf": [{ "type": "boolean" }, { "type": "null" }], + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], "description": "Whether to reverse the order of results", "default": false, "title": "Reverse" @@ -2313,10 +2742,14 @@ "application/json": { "schema": { "anyOf": [ - { "$ref": "#/components/schemas/MessageGet" }, - { "type": "null" } + { + "$ref": "#/components/schemas/MessageGet" + }, + { + "type": "null" + } ], - "description": "Filtering options for the messages list", + "description": "Filtering options for the message list", "title": "Options" } } @@ -2327,7 +2760,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Page_Message_" } + "schema": { + "$ref": "#/components/schemas/Page_Message_" + } } } }, @@ -2335,20 +2770,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/sessions/{session_id}/messages/{message_id}": { + "/v3/workspaces/{workspace_id}/sessions/{session_id}/messages/{message_id}": { "get": { - "tags": ["messages"], + "tags": [ + "messages" + ], "summary": "Get Message", - "description": "Get a Message by ID", - "operationId": "get_message_v2_workspaces__workspace_id__sessions__session_id__messages__message_id__get", - "security": [{ "HTTPBearer": [] }], + "description": "Get a single message by ID from a Session.", + "operationId": "get_message_v3_workspaces__workspace_id__sessions__session_id__messages__message_id__get", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -2356,10 +2800,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "session_id", @@ -2367,10 +2809,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the session", "title": "Session Id" - }, - "description": "ID of the session" + } }, { "name": "message_id", @@ -2378,10 +2818,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the message to retrieve", "title": "Message Id" - }, - "description": "ID of the message to retrieve" + } } ], "responses": { @@ -2389,7 +2827,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Message" } + "schema": { + "$ref": "#/components/schemas/Message" + } } } }, @@ -2397,18 +2837,27 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } }, "put": { - "tags": ["messages"], + "tags": [ + "messages" + ], "summary": "Update Message", - "description": "Update the metadata of a Message", - "operationId": "update_message_v2_workspaces__workspace_id__sessions__session_id__messages__message_id__put", - "security": [{ "HTTPBearer": [] }], + "description": "Update the metadata of a message.\n\nThis will overwrite any existing metadata for the message.", + "operationId": "update_message_v3_workspaces__workspace_id__sessions__session_id__messages__message_id__put", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -2416,10 +2865,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { "name": "session_id", @@ -2427,10 +2874,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the session", "title": "Session Id" - }, - "description": "ID of the session" + } }, { "name": "message_id", @@ -2438,10 +2883,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the message to update", "title": "Message Id" - }, - "description": "ID of the message to update" + } } ], "requestBody": { @@ -2455,66 +2898,13 @@ } } }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Message" } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } - } - } - } - } - } - }, - "/v2/workspaces/{workspace_id}/observations": { - "post": { - "tags": ["observations"], - "summary": "Create Observations", - "description": "Create one or more observations.\n\nCreates observations (theory-of-mind facts) for the specified observer/observed peer pairs.\nEach observation must reference existing peers and a session within the workspace.\nEmbeddings are automatically generated for semantic search.\n\nMaximum of 100 observations per request.", - "operationId": "create_observations_v2_workspaces__workspace_id__observations_post", - "security": [{ "HTTPBearer": [] }], - "parameters": [ - { - "name": "workspace_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "description": "ID of the workspace", - "title": "Workspace Id" - }, - "description": "ID of the workspace" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObservationBatchCreate", - "description": "Batch of observations to create" - } - } - } - }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "type": "array", - "items": { "$ref": "#/components/schemas/Observation" }, - "title": "Response Create Observations V2 Workspaces Workspace Id Observations Post" + "$ref": "#/components/schemas/Message" } } } @@ -2523,20 +2913,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/observations/list": { + "/v3/workspaces/{workspace_id}/conclusions": { "post": { - "tags": ["observations"], - "summary": "List Observations", - "description": "List all observations using custom filters. Observations are listed by recency unless `reverse` is set to `true`.\n\nObservations can be filtered by session_id, observer_id and observed_id using the filters parameter.", - "operationId": "list_observations_v2_workspaces__workspace_id__observations_list_post", - "security": [{ "HTTPBearer": [] }], + "tags": [ + "conclusions" + ], + "summary": "Create Conclusions", + "description": "Create one or more Conclusions.\n\nConclusions are logical certainties derived from interactions between Peers. They form the basis of a Peer's Representation.", + "operationId": "create_conclusions_v3_workspaces__workspace_id__conclusions_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -2544,17 +2943,86 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConclusionBatchCreate", + "description": "Batch of Conclusions to create" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Conclusion" + }, + "title": "Response Create Conclusions V3 Workspaces Workspace Id Conclusions Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v3/workspaces/{workspace_id}/conclusions/list": { + "post": { + "tags": [ + "conclusions" + ], + "summary": "List Conclusions", + "description": "List Conclusions using optional filters, ordered by recency unless `reverse` is true. Results are paginated.", + "operationId": "list_conclusions_v3_workspaces__workspace_id__conclusions_list_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Workspace Id" + } }, { "name": "reverse", "in": "query", "required": false, "schema": { - "anyOf": [{ "type": "boolean" }, { "type": "null" }], + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], "description": "Whether to reverse the order of results", "default": false, "title": "Reverse" @@ -2594,10 +3062,14 @@ "application/json": { "schema": { "anyOf": [ - { "$ref": "#/components/schemas/ObservationGet" }, - { "type": "null" } + { + "$ref": "#/components/schemas/ConclusionGet" + }, + { + "type": "null" + } ], - "description": "Filtering options for the observations list", + "description": "Filtering options for the Conclusions list", "title": "Options" } } @@ -2608,7 +3080,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Page_Observation_" } + "schema": { + "$ref": "#/components/schemas/Page_Conclusion_" + } } } }, @@ -2616,20 +3090,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/observations/query": { + "/v3/workspaces/{workspace_id}/conclusions/query": { "post": { - "tags": ["observations"], - "summary": "Query Observations", - "description": "Query observations using semantic search.\n\nPerforms vector similarity search on observations to find semantically relevant results.\nObserver and observed are required for semantic search and must be provided in filters.", - "operationId": "query_observations_v2_workspaces__workspace_id__observations_query_post", - "security": [{ "HTTPBearer": [] }], + "tags": [ + "conclusions" + ], + "summary": "Query Conclusions", + "description": "Query Conclusions using semantic search. Use `top_k` to control the number of results returned.", + "operationId": "query_conclusions_v3_workspaces__workspace_id__conclusions_query_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -2637,10 +3120,8 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } } ], "requestBody": { @@ -2648,8 +3129,8 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ObservationQuery", - "description": "Semantic search parameters for observations" + "$ref": "#/components/schemas/ConclusionQuery", + "description": "Semantic search parameters for Conclusions" } } } @@ -2661,8 +3142,10 @@ "application/json": { "schema": { "type": "array", - "items": { "$ref": "#/components/schemas/Observation" }, - "title": "Response Query Observations V2 Workspaces Workspace Id Observations Query Post" + "items": { + "$ref": "#/components/schemas/Conclusion" + }, + "title": "Response Query Conclusions V3 Workspaces Workspace Id Conclusions Query Post" } } } @@ -2671,20 +3154,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/observations/{observation_id}": { + "/v3/workspaces/{workspace_id}/conclusions/{conclusion_id}": { "delete": { - "tags": ["observations"], - "summary": "Delete Observation", - "description": "Delete a specific observation.\n\nThis permanently deletes the observation (document) from the theory-of-mind system.\nThis action cannot be undone.", - "operationId": "delete_observation_v2_workspaces__workspace_id__observations__observation_id__delete", - "security": [{ "HTTPBearer": [] }], + "tags": [ + "conclusions" + ], + "summary": "Delete Conclusion", + "description": "Delete a single Conclusion by ID.\n\nThis action cannot be undone.", + "operationId": "delete_conclusion_v3_workspaces__workspace_id__conclusions__conclusion_id__delete", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -2692,53 +3184,64 @@ "required": true, "schema": { "type": "string", - "description": "ID of the workspace", "title": "Workspace Id" - }, - "description": "ID of the workspace" + } }, { - "name": "observation_id", + "name": "conclusion_id", "in": "path", "required": true, "schema": { "type": "string", - "description": "ID of the observation to delete", - "title": "Observation Id" - }, - "description": "ID of the observation to delete" + "title": "Conclusion Id" + } } ], "responses": { - "200": { - "description": "Successful Response", - "content": { "application/json": { "schema": {} } } + "204": { + "description": "Successful Response" }, "422": { "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/keys": { + "/v3/keys": { "post": { - "tags": ["keys"], + "tags": [ + "keys" + ], "summary": "Create Key", "description": "Create a new Key", - "operationId": "create_key_v2_keys_post", - "security": [{ "HTTPBearer": [] }], + "operationId": "create_key_v3_keys_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", "in": "query", "required": false, "schema": { - "anyOf": [{ "type": "string" }, { "type": "null" }], + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "description": "ID of the workspace to scope the key to", "title": "Workspace Id" }, @@ -2749,7 +3252,14 @@ "in": "query", "required": false, "schema": { - "anyOf": [{ "type": "string" }, { "type": "null" }], + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "description": "ID of the peer to scope the key to", "title": "Peer Id" }, @@ -2760,7 +3270,14 @@ "in": "query", "required": false, "schema": { - "anyOf": [{ "type": "string" }, { "type": "null" }], + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "description": "ID of the session to scope the key to", "title": "Session Id" }, @@ -2772,8 +3289,13 @@ "required": false, "schema": { "anyOf": [ - { "type": "string", "format": "date-time" }, - { "type": "null" } + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } ], "title": "Expires At" } @@ -2782,26 +3304,39 @@ "responses": { "200": { "description": "Successful Response", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, "422": { "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/webhooks": { + "/v3/workspaces/{workspace_id}/webhooks": { "post": { - "tags": ["webhooks"], + "tags": [ + "webhooks" + ], "summary": "Get Or Create Webhook Endpoint", "description": "Get or create a webhook endpoint URL.", - "operationId": "get_or_create_webhook_endpoint_v2_workspaces__workspace_id__webhooks_post", - "security": [{ "HTTPBearer": [] }], + "operationId": "get_or_create_webhook_endpoint_v3_workspaces__workspace_id__webhooks_post", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -2831,7 +3366,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/WebhookEndpoint" } + "schema": { + "$ref": "#/components/schemas/WebhookEndpoint" + } } } }, @@ -2839,18 +3376,27 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } }, "get": { - "tags": ["webhooks"], + "tags": [ + "webhooks" + ], "summary": "List Webhook Endpoints", "description": "List all webhook endpoints, optionally filtered by workspace.", - "operationId": "list_webhook_endpoints_v2_workspaces__workspace_id__webhooks_get", - "security": [{ "HTTPBearer": [] }], + "operationId": "list_webhook_endpoints_v3_workspaces__workspace_id__webhooks_get", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -2906,20 +3452,29 @@ "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/webhooks/{endpoint_id}": { + "/v3/workspaces/{workspace_id}/webhooks/{endpoint_id}": { "delete": { - "tags": ["webhooks"], + "tags": [ + "webhooks" + ], "summary": "Delete Webhook Endpoint", "description": "Delete a specific webhook endpoint.", - "operationId": "delete_webhook_endpoint_v2_workspaces__workspace_id__webhooks__endpoint_id__delete", - "security": [{ "HTTPBearer": [] }], + "operationId": "delete_webhook_endpoint_v3_workspaces__workspace_id__webhooks__endpoint_id__delete", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -2945,28 +3500,36 @@ } ], "responses": { - "200": { - "description": "Successful Response", - "content": { "application/json": { "schema": {} } } + "204": { + "description": "Successful Response" }, "422": { "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/v2/workspaces/{workspace_id}/webhooks/test": { + "/v3/workspaces/{workspace_id}/webhooks/test": { "get": { - "tags": ["webhooks"], + "tags": [ + "webhooks" + ], "summary": "Test Emit", "description": "Test publishing a webhook event.", - "operationId": "test_emit_v2_workspaces__workspace_id__webhooks_test_get", - "security": [{ "HTTPBearer": [] }], + "operationId": "test_emit_v3_workspaces__workspace_id__webhooks_test_get", + "security": [ + { + "HTTPBearer": [] + }, + {} + ], "parameters": [ { "name": "workspace_id", @@ -2983,13 +3546,19 @@ "responses": { "200": { "description": "Successful Response", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, "422": { "description": "Validation Error", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } @@ -3004,7 +3573,11 @@ "responses": { "200": { "description": "Successful Response", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } } } } @@ -3012,74 +3585,1094 @@ }, "components": { "schemas": { - "Body_create_messages_with_file_v2_workspaces__workspace_id__sessions__session_id__messages_upload_post": { + "Body_create_messages_with_file_v3_workspaces__workspace_id__sessions__session_id__messages_upload_post": { "properties": { - "file": { "type": "string", "format": "binary", "title": "File" }, - "peer_id": { "type": "string", "title": "Peer Id" }, + "file": { + "type": "string", + "format": "binary", + "title": "File" + }, + "peer_id": { + "type": "string", + "title": "Peer Id" + }, "metadata": { - "anyOf": [{ "type": "string" }, { "type": "null" }], + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Metadata" }, "configuration": { - "anyOf": [{ "type": "string" }, { "type": "null" }], + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Configuration" }, "created_at": { - "anyOf": [{ "type": "string" }, { "type": "null" }], + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Created At" } }, "type": "object", - "required": ["file", "peer_id"], - "title": "Body_create_messages_with_file_v2_workspaces__workspace_id__sessions__session_id__messages_upload_post" + "required": [ + "file", + "peer_id" + ], + "title": "Body_create_messages_with_file_v3_workspaces__workspace_id__sessions__session_id__messages_upload_post" }, - "DeductiveObservation": { + "Conclusion": { "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "content": { + "type": "string", + "title": "Content" + }, + "observer_id": { + "type": "string", + "title": "Observer Id", + "description": "The peer who made the conclusion" + }, + "observed_id": { + "type": "string", + "title": "Observed Id", + "description": "The peer the conclusion is about" + }, + "session_id": { + "type": "string", + "title": "Session Id" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "content", + "observer_id", + "observed_id", + "session_id", + "created_at" + ], + "title": "Conclusion", + "description": "Conclusion response - external view of a document." + }, + "ConclusionBatchCreate": { + "properties": { + "conclusions": { + "items": { + "$ref": "#/components/schemas/ConclusionCreate" + }, + "type": "array", + "maxItems": 100, + "minItems": 1, + "title": "Conclusions" + } + }, + "type": "object", + "required": [ + "conclusions" + ], + "title": "ConclusionBatchCreate", + "description": "Schema for batch conclusion creation with a max of 100 conclusions." + }, + "ConclusionCreate": { + "properties": { + "content": { + "type": "string", + "maxLength": 65535, + "minLength": 1, + "title": "Content" + }, + "observer_id": { + "type": "string", + "title": "Observer Id", + "description": "The peer making the conclusion" + }, + "observed_id": { + "type": "string", + "title": "Observed Id", + "description": "The peer the conclusion is about" + }, + "session_id": { + "type": "string", + "title": "Session Id", + "description": "The session this conclusion relates to" + } + }, + "type": "object", + "required": [ + "content", + "observer_id", + "observed_id", + "session_id" + ], + "title": "ConclusionCreate", + "description": "Schema for creating a single conclusion." + }, + "ConclusionGet": { + "properties": { + "filters": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Filters" + } + }, + "type": "object", + "title": "ConclusionGet", + "description": "Schema for listing conclusions with optional filters." + }, + "ConclusionQuery": { + "properties": { + "query": { + "type": "string", + "title": "Query", + "description": "Semantic search query" + }, + "top_k": { + "type": "integer", + "maximum": 100.0, + "minimum": 1.0, + "title": "Top K", + "description": "Number of results to return", + "default": 10 + }, + "distance": { + "anyOf": [ + { + "type": "number", + "maximum": 1.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Distance", + "description": "Maximum cosine distance threshold for results" + }, + "filters": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Filters", + "description": "Additional filters to apply" + } + }, + "type": "object", + "required": [ + "query" + ], + "title": "ConclusionQuery", + "description": "Query parameters for semantic search of conclusions." + }, + "DialecticOptions": { + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Session Id", + "description": "ID of the session to scope the representation to" + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target", + "description": "Optional peer to get the representation for, from the perspective of this peer" + }, + "query": { + "type": "string", + "maxLength": 10000, + "minLength": 1, + "title": "Query", + "description": "Dialectic API Prompt" + }, + "stream": { + "type": "boolean", + "title": "Stream", + "default": false + }, + "reasoning_level": { + "type": "string", + "enum": [ + "minimal", + "low", + "medium", + "high", + "max" + ], + "title": "Reasoning Level", + "description": "Level of reasoning to apply: minimal, low, medium, high, or max", + "default": "low" + } + }, + "type": "object", + "required": [ + "query" + ], + "title": "DialecticOptions" + }, + "DreamConfiguration": { + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enabled", + "description": "Whether to enable dream functionality. If reasoning is disabled, dreams will also be disabled and this setting will be ignored." + } + }, + "type": "object", + "title": "DreamConfiguration" + }, + "DreamType": { + "type": "string", + "enum": [ + "omni" + ], + "title": "DreamType", + "description": "Types of dreams that can be triggered." + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "Message": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "content": { + "type": "string", + "title": "Content" + }, + "peer_id": { + "type": "string", + "title": "Peer Id" + }, + "session_id": { + "type": "string", + "title": "Session Id" + }, + "metadata": { + "additionalProperties": true, + "type": "object", + "title": "Metadata" + }, "created_at": { "type": "string", "format": "date-time", "title": "Created At" }, - "message_ids": { - "items": { "type": "integer" }, - "type": "array", - "title": "Message Ids" - }, - "session_name": { "type": "string", "title": "Session Name" }, - "premises": { - "items": { "type": "string" }, - "type": "array", - "title": "Premises", - "description": "Supporting premises or evidence for this conclusion" - }, - "conclusion": { + "workspace_id": { "type": "string", - "title": "Conclusion", - "description": "The deductive conclusion" - } - }, - "type": "object", - "required": ["created_at", "message_ids", "session_name", "conclusion"], - "title": "DeductiveObservation", - "description": "Deductive observation with multiple premises and one conclusion, plus metadata." - }, - "DeriverConfiguration": { - "properties": { - "enabled": { - "anyOf": [{ "type": "boolean" }, { "type": "null" }], - "title": "Enabled", - "description": "Whether to enable deriver functionality." + "title": "Workspace Id" }, - "custom_instructions": { - "anyOf": [{ "type": "string" }, { "type": "null" }], - "title": "Custom Instructions", - "description": "TODO: currently unused. Custom instructions to use for the deriver on this workspace/session/message." + "token_count": { + "type": "integer", + "title": "Token Count" } }, "type": "object", - "title": "DeriverConfiguration" + "required": [ + "id", + "content", + "peer_id", + "session_id", + "created_at", + "workspace_id", + "token_count" + ], + "title": "Message" }, - "DeriverStatus": { + "MessageBatchCreate": { + "properties": { + "messages": { + "items": { + "$ref": "#/components/schemas/MessageCreate" + }, + "type": "array", + "maxItems": 100, + "minItems": 1, + "title": "Messages" + } + }, + "type": "object", + "required": [ + "messages" + ], + "title": "MessageBatchCreate", + "description": "Schema for batch message creation with a max of 100 messages" + }, + "MessageConfiguration": { + "properties": { + "reasoning": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReasoningConfiguration" + }, + { + "type": "null" + } + ], + "description": "Configuration for reasoning functionality." + } + }, + "type": "object", + "title": "MessageConfiguration", + "description": "The set of options that can be in a message DB-level configuration dictionary.\n\nAll fields are optional. Message-level configuration overrides all other configurations." + }, + "MessageCreate": { + "properties": { + "content": { + "type": "string", + "maxLength": 25000, + "minLength": 0, + "title": "Content" + }, + "peer_id": { + "type": "string", + "title": "Peer Id" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "configuration": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageConfiguration" + }, + { + "type": "null" + } + ] + }, + "created_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Created At" + } + }, + "type": "object", + "required": [ + "content", + "peer_id" + ], + "title": "MessageCreate" + }, + "MessageGet": { + "properties": { + "filters": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Filters" + } + }, + "type": "object", + "title": "MessageGet" + }, + "MessageSearchOptions": { + "properties": { + "query": { + "type": "string", + "title": "Query", + "description": "Search query" + }, + "filters": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Filters", + "description": "Filters to scope the search" + }, + "limit": { + "type": "integer", + "maximum": 100.0, + "minimum": 1.0, + "title": "Limit", + "description": "Number of results to return", + "default": 10 + } + }, + "type": "object", + "required": [ + "query" + ], + "title": "MessageSearchOptions" + }, + "MessageUpdate": { + "properties": { + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + } + }, + "type": "object", + "title": "MessageUpdate" + }, + "Page_Conclusion_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Conclusion" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total" + }, + "page": { + "type": "integer", + "minimum": 1.0, + "title": "Page" + }, + "size": { + "type": "integer", + "minimum": 1.0, + "title": "Size" + }, + "pages": { + "type": "integer", + "minimum": 0.0, + "title": "Pages" + } + }, + "type": "object", + "required": [ + "items", + "page", + "size" + ], + "title": "Page[Conclusion]" + }, + "Page_Message_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Message" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total" + }, + "page": { + "type": "integer", + "minimum": 1.0, + "title": "Page" + }, + "size": { + "type": "integer", + "minimum": 1.0, + "title": "Size" + }, + "pages": { + "type": "integer", + "minimum": 0.0, + "title": "Pages" + } + }, + "type": "object", + "required": [ + "items", + "page", + "size" + ], + "title": "Page[Message]" + }, + "Page_Peer_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Peer" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total" + }, + "page": { + "type": "integer", + "minimum": 1.0, + "title": "Page" + }, + "size": { + "type": "integer", + "minimum": 1.0, + "title": "Size" + }, + "pages": { + "type": "integer", + "minimum": 0.0, + "title": "Pages" + } + }, + "type": "object", + "required": [ + "items", + "page", + "size" + ], + "title": "Page[Peer]" + }, + "Page_Session_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Session" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total" + }, + "page": { + "type": "integer", + "minimum": 1.0, + "title": "Page" + }, + "size": { + "type": "integer", + "minimum": 1.0, + "title": "Size" + }, + "pages": { + "type": "integer", + "minimum": 0.0, + "title": "Pages" + } + }, + "type": "object", + "required": [ + "items", + "page", + "size" + ], + "title": "Page[Session]" + }, + "Page_WebhookEndpoint_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/WebhookEndpoint" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total" + }, + "page": { + "type": "integer", + "minimum": 1.0, + "title": "Page" + }, + "size": { + "type": "integer", + "minimum": 1.0, + "title": "Size" + }, + "pages": { + "type": "integer", + "minimum": 0.0, + "title": "Pages" + } + }, + "type": "object", + "required": [ + "items", + "page", + "size" + ], + "title": "Page[WebhookEndpoint]" + }, + "Page_Workspace_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Workspace" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total" + }, + "page": { + "type": "integer", + "minimum": 1.0, + "title": "Page" + }, + "size": { + "type": "integer", + "minimum": 1.0, + "title": "Size" + }, + "pages": { + "type": "integer", + "minimum": 0.0, + "title": "Pages" + } + }, + "type": "object", + "required": [ + "items", + "page", + "size" + ], + "title": "Page[Workspace]" + }, + "Peer": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "workspace_id": { + "type": "string", + "title": "Workspace Id" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "metadata": { + "additionalProperties": true, + "type": "object", + "title": "Metadata" + }, + "configuration": { + "additionalProperties": true, + "type": "object", + "title": "Configuration" + } + }, + "type": "object", + "required": [ + "id", + "workspace_id", + "created_at" + ], + "title": "Peer" + }, + "PeerCardConfiguration": { + "properties": { + "use": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Use", + "description": "Whether to use peer card related to this peer during reasoning process." + }, + "create": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Create", + "description": "Whether to generate peer card based on content." + } + }, + "type": "object", + "title": "PeerCardConfiguration" + }, + "PeerCardResponse": { + "properties": { + "peer_card": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Peer Card", + "description": "The peer card content, or None if not found" + } + }, + "type": "object", + "title": "PeerCardResponse" + }, + "PeerCardSet": { + "properties": { + "peer_card": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Peer Card", + "description": "The peer card content to set" + } + }, + "type": "object", + "required": [ + "peer_card" + ], + "title": "PeerCardSet" + }, + "PeerContext": { + "properties": { + "peer_id": { + "type": "string", + "title": "Peer Id", + "description": "The ID of the peer" + }, + "target_id": { + "type": "string", + "title": "Target Id", + "description": "The ID of the target peer being observed" + }, + "representation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Representation", + "description": "A curated subset of the representation of the target peer from the observer's perspective" + }, + "peer_card": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Peer Card", + "description": "The peer card for the target peer from the observer's perspective" + } + }, + "type": "object", + "required": [ + "peer_id", + "target_id" + ], + "title": "PeerContext", + "description": "Context for a peer, including representation and peer card." + }, + "PeerCreate": { + "properties": { + "id": { + "type": "string", + "maxLength": 100, + "minLength": 1, + "pattern": "^[a-zA-Z0-9_-]+$", + "title": "Id" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "configuration": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Configuration" + } + }, + "type": "object", + "required": [ + "id" + ], + "title": "PeerCreate" + }, + "PeerGet": { + "properties": { + "filters": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Filters" + } + }, + "type": "object", + "title": "PeerGet" + }, + "PeerRepresentationGet": { + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Session Id", + "description": "Optional session ID within which to scope the representation" + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target", + "description": "Optional peer ID to get the representation for, from the perspective of this peer" + }, + "search_query": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Search Query", + "description": "Optional input to curate the representation around semantic search results" + }, + "search_top_k": { + "anyOf": [ + { + "type": "integer", + "maximum": 100.0, + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Search Top K", + "description": "Only used if `search_query` is provided. Number of semantic-search-retrieved conclusions to include in the representation" + }, + "search_max_distance": { + "anyOf": [ + { + "type": "number", + "maximum": 1.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Search Max Distance", + "description": "Only used if `search_query` is provided. Maximum distance to search for semantically relevant conclusions" + }, + "include_most_frequent": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Include Most Frequent", + "description": "Only used if `search_query` is provided. Whether to include the most frequent conclusions in the representation" + }, + "max_conclusions": { + "anyOf": [ + { + "type": "integer", + "maximum": 100.0, + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Max Conclusions", + "description": "Only used if `search_query` is provided. Maximum number of conclusions to include in the representation", + "default": 25 + } + }, + "type": "object", + "title": "PeerRepresentationGet" + }, + "PeerUpdate": { + "properties": { + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "configuration": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Configuration" + } + }, + "type": "object", + "title": "PeerUpdate" + }, + "QueueStatus": { "properties": { "total_work_units": { "type": "integer", @@ -3105,11 +4698,13 @@ "anyOf": [ { "additionalProperties": { - "$ref": "#/components/schemas/SessionDeriverStatus" + "$ref": "#/components/schemas/SessionQueueStatus" }, "type": "object" }, - { "type": "null" } + { + "type": "null" + } ], "title": "Sessions", "description": "Per-session status when not filtered by session" @@ -3122,844 +4717,103 @@ "in_progress_work_units", "pending_work_units" ], - "title": "DeriverStatus" + "title": "QueueStatus", + "description": "Aggregated processing queue status." }, - "DialecticOptions": { - "properties": { - "session_id": { - "anyOf": [{ "type": "string" }, { "type": "null" }], - "title": "Session Id", - "description": "ID of the session to scope the representation to" - }, - "target": { - "anyOf": [{ "type": "string" }, { "type": "null" }], - "title": "Target", - "description": "Optional peer to get the representation for, from the perspective of this peer" - }, - "query": { - "type": "string", - "maxLength": 10000, - "minLength": 1, - "title": "Query", - "description": "Dialectic API Prompt" - }, - "stream": { "type": "boolean", "title": "Stream", "default": false } - }, - "type": "object", - "required": ["query"], - "title": "DialecticOptions" - }, - "DreamConfiguration": { + "ReasoningConfiguration": { "properties": { "enabled": { - "anyOf": [{ "type": "boolean" }, { "type": "null" }], + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], "title": "Enabled", - "description": "Whether to enable dream functionality. If deriver is disabled, dreams will also be disabled and this setting will be ignored." + "description": "Whether to enable reasoning functionality." + }, + "custom_instructions": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Custom Instructions", + "description": "TODO: currently unused. Custom instructions to use for the reasoning system on this workspace/session/message." } }, "type": "object", - "title": "DreamConfiguration" + "title": "ReasoningConfiguration" }, - "DreamType": { - "type": "string", - "enum": ["consolidate", "agent"], - "title": "DreamType", - "description": "Types of dreams that can be triggered." - }, - "ExplicitObservation": { + "RepresentationResponse": { "properties": { - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - }, - "message_ids": { - "items": { "type": "integer" }, - "type": "array", - "title": "Message Ids" - }, - "session_name": { "type": "string", "title": "Session Name" }, - "content": { - "type": "string", - "title": "Content", - "description": "The explicit observation" - } - }, - "type": "object", - "required": ["created_at", "message_ids", "session_name", "content"], - "title": "ExplicitObservation", - "description": "Explicit observation with content and metadata." - }, - "HTTPValidationError": { - "properties": { - "detail": { - "items": { "$ref": "#/components/schemas/ValidationError" }, - "type": "array", - "title": "Detail" - } - }, - "type": "object", - "title": "HTTPValidationError" - }, - "Message": { - "properties": { - "id": { "type": "string", "title": "Id" }, - "content": { "type": "string", "title": "Content" }, - "peer_id": { "type": "string", "title": "Peer Id" }, - "session_id": { "type": "string", "title": "Session Id" }, - "metadata": { - "additionalProperties": true, - "type": "object", - "title": "Metadata" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - }, - "workspace_id": { "type": "string", "title": "Workspace Id" }, - "token_count": { "type": "integer", "title": "Token Count" } - }, - "type": "object", - "required": [ - "id", - "content", - "peer_id", - "session_id", - "created_at", - "workspace_id", - "token_count" - ], - "title": "Message" - }, - "MessageBatchCreate": { - "properties": { - "messages": { - "items": { "$ref": "#/components/schemas/MessageCreate" }, - "type": "array", - "maxItems": 100, - "minItems": 1, - "title": "Messages" - } - }, - "type": "object", - "required": ["messages"], - "title": "MessageBatchCreate", - "description": "Schema for batch message creation with a max of 100 messages" - }, - "MessageConfiguration": { - "properties": { - "deriver": { - "anyOf": [ - { "$ref": "#/components/schemas/DeriverConfiguration" }, - { "type": "null" } - ], - "description": "Configuration for deriver functionality." - }, - "peer_card": { - "anyOf": [ - { "$ref": "#/components/schemas/PeerCardConfiguration" }, - { "type": "null" } - ], - "description": "Configuration for peer card functionality. If deriver is disabled, peer cards will also be disabled and these settings will be ignored." - } - }, - "type": "object", - "title": "MessageConfiguration", - "description": "The set of options that can be in a message DB-level configuration dictionary.\n\nAll fields are optional. Message-level configuration overrides all other configurations." - }, - "MessageCreate": { - "properties": { - "content": { - "type": "string", - "maxLength": 25000, - "minLength": 0, - "title": "Content" - }, - "peer_id": { "type": "string", "title": "Peer Id" }, - "metadata": { - "anyOf": [ - { "additionalProperties": true, "type": "object" }, - { "type": "null" } - ], - "title": "Metadata" - }, - "configuration": { - "anyOf": [ - { "$ref": "#/components/schemas/MessageConfiguration" }, - { "type": "null" } - ] - }, - "created_at": { - "anyOf": [ - { "type": "string", "format": "date-time" }, - { "type": "null" } - ], - "title": "Created At" - } - }, - "type": "object", - "required": ["content", "peer_id"], - "title": "MessageCreate" - }, - "MessageGet": { - "properties": { - "filters": { - "anyOf": [ - { "additionalProperties": true, "type": "object" }, - { "type": "null" } - ], - "title": "Filters" - } - }, - "type": "object", - "title": "MessageGet" - }, - "MessageSearchOptions": { - "properties": { - "query": { - "type": "string", - "title": "Query", - "description": "Search query" - }, - "filters": { - "anyOf": [ - { "additionalProperties": true, "type": "object" }, - { "type": "null" } - ], - "title": "Filters", - "description": "Filters to scope the search" - }, - "limit": { - "type": "integer", - "maximum": 100.0, - "minimum": 1.0, - "title": "Limit", - "description": "Number of results to return", - "default": 10 - } - }, - "type": "object", - "required": ["query"], - "title": "MessageSearchOptions" - }, - "MessageUpdate": { - "properties": { - "metadata": { - "anyOf": [ - { "additionalProperties": true, "type": "object" }, - { "type": "null" } - ], - "title": "Metadata" - } - }, - "type": "object", - "title": "MessageUpdate" - }, - "Observation": { - "properties": { - "id": { "type": "string", "title": "Id" }, - "content": { "type": "string", "title": "Content" }, - "observer_id": { - "type": "string", - "title": "Observer Id", - "description": "The peer who made the observation" - }, - "observed_id": { - "type": "string", - "title": "Observed Id", - "description": "The peer being observed" - }, - "session_id": { "type": "string", "title": "Session Id" }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - } - }, - "type": "object", - "required": [ - "id", - "content", - "observer_id", - "observed_id", - "session_id", - "created_at" - ], - "title": "Observation", - "description": "Observation response - external view of a document" - }, - "ObservationBatchCreate": { - "properties": { - "observations": { - "items": { "$ref": "#/components/schemas/ObservationCreate" }, - "type": "array", - "maxItems": 100, - "minItems": 1, - "title": "Observations" - } - }, - "type": "object", - "required": ["observations"], - "title": "ObservationBatchCreate", - "description": "Schema for batch observation creation with a max of 100 observations" - }, - "ObservationCreate": { - "properties": { - "content": { - "type": "string", - "maxLength": 65535, - "minLength": 1, - "title": "Content" - }, - "observer_id": { - "type": "string", - "title": "Observer Id", - "description": "The peer making the observation" - }, - "observed_id": { - "type": "string", - "title": "Observed Id", - "description": "The peer being observed" - }, - "session_id": { - "type": "string", - "title": "Session Id", - "description": "The session this observation relates to" - } - }, - "type": "object", - "required": ["content", "observer_id", "observed_id", "session_id"], - "title": "ObservationCreate", - "description": "Schema for creating a single observation" - }, - "ObservationGet": { - "properties": { - "filters": { - "anyOf": [ - { "additionalProperties": true, "type": "object" }, - { "type": "null" } - ], - "title": "Filters" - } - }, - "type": "object", - "title": "ObservationGet", - "description": "Schema for listing observations with optional filters" - }, - "ObservationQuery": { - "properties": { - "query": { - "type": "string", - "title": "Query", - "description": "Semantic search query" - }, - "top_k": { - "type": "integer", - "maximum": 100.0, - "minimum": 1.0, - "title": "Top K", - "description": "Number of results to return", - "default": 10 - }, - "distance": { - "anyOf": [ - { "type": "number", "maximum": 1.0, "minimum": 0.0 }, - { "type": "null" } - ], - "title": "Distance", - "description": "Maximum cosine distance threshold for results" - }, - "filters": { - "anyOf": [ - { "additionalProperties": true, "type": "object" }, - { "type": "null" } - ], - "title": "Filters", - "description": "Additional filters to apply" - } - }, - "type": "object", - "required": ["query"], - "title": "ObservationQuery", - "description": "Query parameters for semantic search of observations" - }, - "Page_Message_": { - "properties": { - "items": { - "items": { "$ref": "#/components/schemas/Message" }, - "type": "array", - "title": "Items" - }, - "total": { - "anyOf": [ - { "type": "integer", "minimum": 0.0 }, - { "type": "null" } - ], - "title": "Total" - }, - "page": { - "anyOf": [ - { "type": "integer", "minimum": 1.0 }, - { "type": "null" } - ], - "title": "Page" - }, - "size": { - "anyOf": [ - { "type": "integer", "minimum": 1.0 }, - { "type": "null" } - ], - "title": "Size" - }, - "pages": { - "anyOf": [ - { "type": "integer", "minimum": 0.0 }, - { "type": "null" } - ], - "title": "Pages" - } - }, - "type": "object", - "required": ["items", "page", "size"], - "title": "Page[Message]" - }, - "Page_Observation_": { - "properties": { - "items": { - "items": { "$ref": "#/components/schemas/Observation" }, - "type": "array", - "title": "Items" - }, - "total": { - "anyOf": [ - { "type": "integer", "minimum": 0.0 }, - { "type": "null" } - ], - "title": "Total" - }, - "page": { - "anyOf": [ - { "type": "integer", "minimum": 1.0 }, - { "type": "null" } - ], - "title": "Page" - }, - "size": { - "anyOf": [ - { "type": "integer", "minimum": 1.0 }, - { "type": "null" } - ], - "title": "Size" - }, - "pages": { - "anyOf": [ - { "type": "integer", "minimum": 0.0 }, - { "type": "null" } - ], - "title": "Pages" - } - }, - "type": "object", - "required": ["items", "page", "size"], - "title": "Page[Observation]" - }, - "Page_Peer_": { - "properties": { - "items": { - "items": { "$ref": "#/components/schemas/Peer" }, - "type": "array", - "title": "Items" - }, - "total": { - "anyOf": [ - { "type": "integer", "minimum": 0.0 }, - { "type": "null" } - ], - "title": "Total" - }, - "page": { - "anyOf": [ - { "type": "integer", "minimum": 1.0 }, - { "type": "null" } - ], - "title": "Page" - }, - "size": { - "anyOf": [ - { "type": "integer", "minimum": 1.0 }, - { "type": "null" } - ], - "title": "Size" - }, - "pages": { - "anyOf": [ - { "type": "integer", "minimum": 0.0 }, - { "type": "null" } - ], - "title": "Pages" - } - }, - "type": "object", - "required": ["items", "page", "size"], - "title": "Page[Peer]" - }, - "Page_Session_": { - "properties": { - "items": { - "items": { "$ref": "#/components/schemas/Session" }, - "type": "array", - "title": "Items" - }, - "total": { - "anyOf": [ - { "type": "integer", "minimum": 0.0 }, - { "type": "null" } - ], - "title": "Total" - }, - "page": { - "anyOf": [ - { "type": "integer", "minimum": 1.0 }, - { "type": "null" } - ], - "title": "Page" - }, - "size": { - "anyOf": [ - { "type": "integer", "minimum": 1.0 }, - { "type": "null" } - ], - "title": "Size" - }, - "pages": { - "anyOf": [ - { "type": "integer", "minimum": 0.0 }, - { "type": "null" } - ], - "title": "Pages" - } - }, - "type": "object", - "required": ["items", "page", "size"], - "title": "Page[Session]" - }, - "Page_WebhookEndpoint_": { - "properties": { - "items": { - "items": { "$ref": "#/components/schemas/WebhookEndpoint" }, - "type": "array", - "title": "Items" - }, - "total": { - "anyOf": [ - { "type": "integer", "minimum": 0.0 }, - { "type": "null" } - ], - "title": "Total" - }, - "page": { - "anyOf": [ - { "type": "integer", "minimum": 1.0 }, - { "type": "null" } - ], - "title": "Page" - }, - "size": { - "anyOf": [ - { "type": "integer", "minimum": 1.0 }, - { "type": "null" } - ], - "title": "Size" - }, - "pages": { - "anyOf": [ - { "type": "integer", "minimum": 0.0 }, - { "type": "null" } - ], - "title": "Pages" - } - }, - "type": "object", - "required": ["items", "page", "size"], - "title": "Page[WebhookEndpoint]" - }, - "Page_Workspace_": { - "properties": { - "items": { - "items": { "$ref": "#/components/schemas/Workspace" }, - "type": "array", - "title": "Items" - }, - "total": { - "anyOf": [ - { "type": "integer", "minimum": 0.0 }, - { "type": "null" } - ], - "title": "Total" - }, - "page": { - "anyOf": [ - { "type": "integer", "minimum": 1.0 }, - { "type": "null" } - ], - "title": "Page" - }, - "size": { - "anyOf": [ - { "type": "integer", "minimum": 1.0 }, - { "type": "null" } - ], - "title": "Size" - }, - "pages": { - "anyOf": [ - { "type": "integer", "minimum": 0.0 }, - { "type": "null" } - ], - "title": "Pages" - } - }, - "type": "object", - "required": ["items", "page", "size"], - "title": "Page[Workspace]" - }, - "Peer": { - "properties": { - "id": { "type": "string", "title": "Id" }, - "workspace_id": { "type": "string", "title": "Workspace Id" }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - }, - "metadata": { - "additionalProperties": true, - "type": "object", - "title": "Metadata" - }, - "configuration": { - "additionalProperties": true, - "type": "object", - "title": "Configuration" - } - }, - "type": "object", - "required": ["id", "workspace_id", "created_at"], - "title": "Peer" - }, - "PeerCardConfiguration": { - "properties": { - "use": { - "anyOf": [{ "type": "boolean" }, { "type": "null" }], - "title": "Use", - "description": "Whether to use peer card related to this peer during deriver process." - }, - "create": { - "anyOf": [{ "type": "boolean" }, { "type": "null" }], - "title": "Create", - "description": "Whether to generate peer card based on content." - } - }, - "type": "object", - "title": "PeerCardConfiguration" - }, - "PeerCardResponse": { - "properties": { - "peer_card": { - "anyOf": [ - { "items": { "type": "string" }, "type": "array" }, - { "type": "null" } - ], - "title": "Peer Card", - "description": "The peer card content, or None if not found" - } - }, - "type": "object", - "title": "PeerCardResponse" - }, - "PeerCardSet": { - "properties": { - "peer_card": { - "items": { "type": "string" }, - "type": "array", - "title": "Peer Card", - "description": "The peer card content to set" - } - }, - "type": "object", - "required": ["peer_card"], - "title": "PeerCardSet" - }, - "PeerContext": { - "properties": { - "peer_id": { - "type": "string", - "title": "Peer Id", - "description": "The ID of the peer" - }, - "target_id": { - "type": "string", - "title": "Target Id", - "description": "The ID of the target peer being observed" - }, "representation": { - "anyOf": [ - { "$ref": "#/components/schemas/Representation" }, - { "type": "null" } - ], - "description": "The working representation of the target peer from the observer's perspective" - }, - "peer_card": { - "anyOf": [ - { "items": { "type": "string" }, "type": "array" }, - { "type": "null" } - ], - "title": "Peer Card", - "description": "The peer card for the target peer from the observer's perspective" - } - }, - "type": "object", - "required": ["peer_id", "target_id"], - "title": "PeerContext", - "description": "Context for a peer, including representation and peer card." - }, - "PeerCreate": { - "properties": { - "id": { "type": "string", - "maxLength": 100, - "minLength": 1, - "pattern": "^[a-zA-Z0-9_-]+$", - "title": "Id" - }, - "metadata": { - "anyOf": [ - { "additionalProperties": true, "type": "object" }, - { "type": "null" } - ], - "title": "Metadata" - }, - "configuration": { - "anyOf": [ - { "additionalProperties": true, "type": "object" }, - { "type": "null" } - ], - "title": "Configuration" + "title": "Representation" } }, "type": "object", - "required": ["id"], - "title": "PeerCreate" + "required": [ + "representation" + ], + "title": "RepresentationResponse" }, - "PeerGet": { + "ScheduleDreamRequest": { "properties": { - "filters": { + "observer": { + "type": "string", + "title": "Observer", + "description": "Observer peer name" + }, + "observed": { "anyOf": [ - { "additionalProperties": true, "type": "object" }, - { "type": "null" } + { + "type": "string" + }, + { + "type": "null" + } ], - "title": "Filters" - } - }, - "type": "object", - "title": "PeerGet" - }, - "PeerRepresentationGet": { - "properties": { + "title": "Observed", + "description": "Observed peer name (defaults to observer if not specified)" + }, + "dream_type": { + "$ref": "#/components/schemas/DreamType", + "description": "Type of dream to schedule" + }, "session_id": { - "anyOf": [{ "type": "string" }, { "type": "null" }], + "type": "string", "title": "Session Id", - "description": "Get the working representation within this session" - }, - "target": { - "anyOf": [{ "type": "string" }, { "type": "null" }], - "title": "Target", - "description": "Optional peer ID to get the representation for, from the perspective of this peer" - }, - "search_query": { - "anyOf": [{ "type": "string" }, { "type": "null" }], - "title": "Search Query", - "description": "Optional input to curate the representation around semantic search results" - }, - "search_top_k": { - "anyOf": [ - { "type": "integer", "maximum": 100.0, "minimum": 1.0 }, - { "type": "null" } - ], - "title": "Search Top K", - "description": "Only used if `search_query` is provided. Number of semantic-search-retrieved observations to include in the representation" - }, - "search_max_distance": { - "anyOf": [ - { "type": "number", "maximum": 1.0, "minimum": 0.0 }, - { "type": "null" } - ], - "title": "Search Max Distance", - "description": "Only used if `search_query` is provided. Maximum distance to search for semantically relevant observations" - }, - "include_most_derived": { - "anyOf": [{ "type": "boolean" }, { "type": "null" }], - "title": "Include Most Derived", - "description": "Only used if `search_query` is provided. Whether to include the most derived observations in the representation" - }, - "max_observations": { - "anyOf": [ - { "type": "integer", "maximum": 100.0, "minimum": 1.0 }, - { "type": "null" } - ], - "title": "Max Observations", - "description": "Only used if `search_query` is provided. Maximum number of observations to include in the representation", - "default": 25 + "description": "Session ID to scope the dream to" } }, "type": "object", - "title": "PeerRepresentationGet" - }, - "PeerUpdate": { - "properties": { - "metadata": { - "anyOf": [ - { "additionalProperties": true, "type": "object" }, - { "type": "null" } - ], - "title": "Metadata" - }, - "configuration": { - "anyOf": [ - { "additionalProperties": true, "type": "object" }, - { "type": "null" } - ], - "title": "Configuration" - } - }, - "type": "object", - "title": "PeerUpdate" - }, - "Representation": { - "properties": { - "explicit": { - "items": { "$ref": "#/components/schemas/ExplicitObservation" }, - "type": "array", - "title": "Explicit", - "description": "Facts LITERALLY stated by the user - direct quotes or clear paraphrases only, no interpretation or inference. Example: ['The user is 25 years old', 'The user has a dog']" - }, - "deductive": { - "items": { "$ref": "#/components/schemas/DeductiveObservation" }, - "type": "array", - "title": "Deductive", - "description": "Conclusions that MUST be true given explicit facts and premises - strict logical necessities. Each deduction should have premises and a single conclusion." - } - }, - "type": "object", - "title": "Representation", - "description": "A Representation is a traversable and diffable map of observations.\nAt the base, we have a list of explicit observations, derived from a peer's messages.\n\nFrom there, deductive observations can be made by establishing logical relationships between explicit observations.\n\nIn the future, we can add more levels of reasoning on top of these.\n\nAll of a peer's observations are stored as documents in a collection. These documents can be queried in various ways\nto produce this Representation object.\n\nAdditionally, a \"working representation\" is a version of this data structure representing the most recent observations\nwithin a single session.\n\nA representation can have a maximum number of observations, which is applied individually to each level of reasoning.\nIf a maximum is set, observations are added and removed in FIFO order." + "required": [ + "observer", + "dream_type", + "session_id" + ], + "title": "ScheduleDreamRequest" }, "Session": { "properties": { - "id": { "type": "string", "title": "Id" }, - "is_active": { "type": "boolean", "title": "Is Active" }, - "workspace_id": { "type": "string", "title": "Workspace Id" }, + "id": { + "type": "string", + "title": "Id" + }, + "is_active": { + "type": "boolean", + "title": "Is Active" + }, + "workspace_id": { + "type": "string", + "title": "Workspace Id" + }, "metadata": { "additionalProperties": true, "type": "object", @@ -3977,38 +4831,59 @@ } }, "type": "object", - "required": ["id", "is_active", "workspace_id", "created_at"], + "required": [ + "id", + "is_active", + "workspace_id", + "created_at" + ], "title": "Session" }, "SessionConfiguration": { "properties": { - "deriver": { + "reasoning": { "anyOf": [ - { "$ref": "#/components/schemas/DeriverConfiguration" }, - { "type": "null" } + { + "$ref": "#/components/schemas/ReasoningConfiguration" + }, + { + "type": "null" + } ], - "description": "Configuration for deriver functionality." + "description": "Configuration for reasoning functionality." }, "peer_card": { "anyOf": [ - { "$ref": "#/components/schemas/PeerCardConfiguration" }, - { "type": "null" } + { + "$ref": "#/components/schemas/PeerCardConfiguration" + }, + { + "type": "null" + } ], - "description": "Configuration for peer card functionality. If deriver is disabled, peer cards will also be disabled and these settings will be ignored." + "description": "Configuration for peer card functionality. If reasoning is disabled, peer cards will also be disabled and these settings will be ignored." }, "summary": { "anyOf": [ - { "$ref": "#/components/schemas/SummaryConfiguration" }, - { "type": "null" } + { + "$ref": "#/components/schemas/SummaryConfiguration" + }, + { + "type": "null" + } ], "description": "Configuration for summary functionality." }, "dream": { "anyOf": [ - { "$ref": "#/components/schemas/DreamConfiguration" }, - { "type": "null" } + { + "$ref": "#/components/schemas/DreamConfiguration" + }, + { + "type": "null" + } ], - "description": "Configuration for dream functionality. If deriver is disabled, dreams will also be disabled and these settings will be ignored." + "description": "Configuration for dream functionality. If reasoning is disabled, dreams will also be disabled and these settings will be ignored." } }, "additionalProperties": true, @@ -4018,37 +4893,61 @@ }, "SessionContext": { "properties": { - "id": { "type": "string", "title": "Id" }, + "id": { + "type": "string", + "title": "Id" + }, "messages": { - "items": { "$ref": "#/components/schemas/Message" }, + "items": { + "$ref": "#/components/schemas/Message" + }, "type": "array", "title": "Messages" }, "summary": { "anyOf": [ - { "$ref": "#/components/schemas/Summary" }, - { "type": "null" } + { + "$ref": "#/components/schemas/Summary" + }, + { + "type": "null" + } ], "description": "The summary if available" }, "peer_representation": { "anyOf": [ - { "$ref": "#/components/schemas/Representation" }, - { "type": "null" } + { + "type": "string" + }, + { + "type": "null" + } ], - "description": "The peer representation, if context is requested from a specific perspective" + "title": "Peer Representation", + "description": "A curated subset of a peer representation, if context is requested from a specific perspective" }, "peer_card": { "anyOf": [ - { "items": { "type": "string" }, "type": "array" }, - { "type": "null" } + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } ], "title": "Peer Card", "description": "The peer card, if context is requested from a specific perspective" } }, "type": "object", - "required": ["id", "messages"], + "required": [ + "id", + "messages" + ], "title": "SessionContext" }, "SessionCreate": { @@ -4062,8 +4961,13 @@ }, "metadata": { "anyOf": [ - { "additionalProperties": true, "type": "object" }, - { "type": "null" } + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } ], "title": "Metadata" }, @@ -4075,25 +4979,88 @@ }, "type": "object" }, - { "type": "null" } + { + "type": "null" + } ], "title": "Peers" }, "configuration": { "anyOf": [ - { "$ref": "#/components/schemas/SessionConfiguration" }, - { "type": "null" } + { + "$ref": "#/components/schemas/SessionConfiguration" + }, + { + "type": "null" + } ] } }, "type": "object", - "required": ["id"], + "required": [ + "id" + ], "title": "SessionCreate" }, - "SessionDeriverStatus": { + "SessionGet": { + "properties": { + "filters": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Filters" + } + }, + "type": "object", + "title": "SessionGet" + }, + "SessionPeerConfig": { + "properties": { + "observe_me": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Observe Me", + "description": "Whether Honcho will use reasoning to form a representation of this peer" + }, + "observe_others": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Observe Others", + "description": "Whether this peer should form a session-level theory-of-mind representation of other peers in the session" + } + }, + "type": "object", + "title": "SessionPeerConfig" + }, + "SessionQueueStatus": { "properties": { "session_id": { - "anyOf": [{ "type": "string" }, { "type": "null" }], + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Session Id", "description": "Session ID if filtered by session" }, @@ -4125,72 +5092,66 @@ "in_progress_work_units", "pending_work_units" ], - "title": "SessionDeriverStatus" - }, - "SessionGet": { - "properties": { - "filters": { - "anyOf": [ - { "additionalProperties": true, "type": "object" }, - { "type": "null" } - ], - "title": "Filters" - } - }, - "type": "object", - "title": "SessionGet" - }, - "SessionPeerConfig": { - "properties": { - "observe_me": { - "anyOf": [{ "type": "boolean" }, { "type": "null" }], - "title": "Observe Me", - "description": "Whether honcho should form a global theory-of-mind representation of this peer" - }, - "observe_others": { - "anyOf": [{ "type": "boolean" }, { "type": "null" }], - "title": "Observe Others", - "description": "Whether this peer should form a session-level theory-of-mind representation of other peers in the session" - } - }, - "type": "object", - "title": "SessionPeerConfig" + "title": "SessionQueueStatus", + "description": "Status for a specific session within the processing queue." }, "SessionSummaries": { "properties": { - "id": { "type": "string", "title": "Id" }, + "id": { + "type": "string", + "title": "Id" + }, "short_summary": { "anyOf": [ - { "$ref": "#/components/schemas/Summary" }, - { "type": "null" } + { + "$ref": "#/components/schemas/Summary" + }, + { + "type": "null" + } ], "description": "The short summary if available" }, "long_summary": { "anyOf": [ - { "$ref": "#/components/schemas/Summary" }, - { "type": "null" } + { + "$ref": "#/components/schemas/Summary" + }, + { + "type": "null" + } ], "description": "The long summary if available" } }, "type": "object", - "required": ["id"], + "required": [ + "id" + ], "title": "SessionSummaries" }, "SessionUpdate": { "properties": { "metadata": { "anyOf": [ - { "additionalProperties": true, "type": "object" }, - { "type": "null" } + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } ], "title": "Metadata" }, "configuration": { "anyOf": [ - { "$ref": "#/components/schemas/SessionConfiguration" }, - { "type": "null" } + { + "$ref": "#/components/schemas/SessionConfiguration" + }, + { + "type": "null" + } ] } }, @@ -4238,22 +5199,39 @@ "SummaryConfiguration": { "properties": { "enabled": { - "anyOf": [{ "type": "boolean" }, { "type": "null" }], + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], "title": "Enabled", "description": "Whether to enable summary functionality." }, "messages_per_short_summary": { "anyOf": [ - { "type": "integer", "minimum": 10.0 }, - { "type": "null" } + { + "type": "integer", + "minimum": 10.0 + }, + { + "type": "null" + } ], "title": "Messages Per Short Summary", "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": { "anyOf": [ - { "type": "integer", "minimum": 20.0 }, - { "type": "null" } + { + "type": "integer", + "minimum": 20.0 + }, + { + "type": "null" + } ], "title": "Messages Per Long Summary", "description": "Number of messages per long summary. Must be positive, greater than or equal to 20, and greater than messages_per_short_summary." @@ -4262,49 +5240,60 @@ "type": "object", "title": "SummaryConfiguration" }, - "TriggerDreamRequest": { - "properties": { - "observer": { - "type": "string", - "title": "Observer", - "description": "Observer peer name" - }, - "observed": { - "anyOf": [{ "type": "string" }, { "type": "null" }], - "title": "Observed", - "description": "Observed peer name (defaults to observer if not specified)" - }, - "dream_type": { - "$ref": "#/components/schemas/DreamType", - "description": "Type of dream to trigger" - } - }, - "type": "object", - "required": ["observer", "dream_type"], - "title": "TriggerDreamRequest" - }, "ValidationError": { "properties": { "loc": { - "items": { "anyOf": [{ "type": "string" }, { "type": "integer" }] }, + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, "type": "array", "title": "Location" }, - "msg": { "type": "string", "title": "Message" }, - "type": { "type": "string", "title": "Error Type" } + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } }, "type": "object", - "required": ["loc", "msg", "type"], + "required": [ + "loc", + "msg", + "type" + ], "title": "ValidationError" }, "WebhookEndpoint": { "properties": { - "id": { "type": "string", "title": "Id" }, + "id": { + "type": "string", + "title": "Id" + }, "workspace_id": { - "anyOf": [{ "type": "string" }, { "type": "null" }], + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Workspace Id" }, - "url": { "type": "string", "title": "Url" }, + "url": { + "type": "string", + "title": "Url" + }, "created_at": { "type": "string", "format": "date-time", @@ -4312,18 +5301,33 @@ } }, "type": "object", - "required": ["id", "workspace_id", "url", "created_at"], + "required": [ + "id", + "workspace_id", + "url", + "created_at" + ], "title": "WebhookEndpoint" }, "WebhookEndpointCreate": { - "properties": { "url": { "type": "string", "title": "Url" } }, + "properties": { + "url": { + "type": "string", + "title": "Url" + } + }, "type": "object", - "required": ["url"], + "required": [ + "url" + ], "title": "WebhookEndpointCreate" }, "Workspace": { "properties": { - "id": { "type": "string", "title": "Id" }, + "id": { + "type": "string", + "title": "Id" + }, "metadata": { "additionalProperties": true, "type": "object", @@ -4341,38 +5345,57 @@ } }, "type": "object", - "required": ["id", "created_at"], + "required": [ + "id", + "created_at" + ], "title": "Workspace" }, "WorkspaceConfiguration": { "properties": { - "deriver": { + "reasoning": { "anyOf": [ - { "$ref": "#/components/schemas/DeriverConfiguration" }, - { "type": "null" } + { + "$ref": "#/components/schemas/ReasoningConfiguration" + }, + { + "type": "null" + } ], - "description": "Configuration for deriver functionality." + "description": "Configuration for reasoning functionality." }, "peer_card": { "anyOf": [ - { "$ref": "#/components/schemas/PeerCardConfiguration" }, - { "type": "null" } + { + "$ref": "#/components/schemas/PeerCardConfiguration" + }, + { + "type": "null" + } ], - "description": "Configuration for peer card functionality. If deriver is disabled, peer cards will also be disabled and these settings will be ignored." + "description": "Configuration for peer card functionality. If reasoning is disabled, peer cards will also be disabled and these settings will be ignored." }, "summary": { "anyOf": [ - { "$ref": "#/components/schemas/SummaryConfiguration" }, - { "type": "null" } + { + "$ref": "#/components/schemas/SummaryConfiguration" + }, + { + "type": "null" + } ], "description": "Configuration for summary functionality." }, "dream": { "anyOf": [ - { "$ref": "#/components/schemas/DreamConfiguration" }, - { "type": "null" } + { + "$ref": "#/components/schemas/DreamConfiguration" + }, + { + "type": "null" + } ], - "description": "Configuration for dream functionality. If deriver is disabled, dreams will also be disabled and these settings will be ignored." + "description": "Configuration for dream functionality. If reasoning is disabled, dreams will also be disabled and these settings will be ignored." } }, "additionalProperties": true, @@ -4400,15 +5423,22 @@ } }, "type": "object", - "required": ["id"], + "required": [ + "id" + ], "title": "WorkspaceCreate" }, "WorkspaceGet": { "properties": { "filters": { "anyOf": [ - { "additionalProperties": true, "type": "object" }, - { "type": "null" } + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } ], "title": "Filters" } @@ -4420,15 +5450,24 @@ "properties": { "metadata": { "anyOf": [ - { "additionalProperties": true, "type": "object" }, - { "type": "null" } + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } ], "title": "Metadata" }, "configuration": { "anyOf": [ - { "$ref": "#/components/schemas/WorkspaceConfiguration" }, - { "type": "null" } + { + "$ref": "#/components/schemas/WorkspaceConfiguration" + }, + { + "type": "null" + } ] } }, @@ -4436,6 +5475,11 @@ "title": "WorkspaceUpdate" } }, - "securitySchemes": { "HTTPBearer": { "type": "http", "scheme": "bearer" } } + "securitySchemes": { + "HTTPBearer": { + "type": "http", + "scheme": "bearer" + } + } } } diff --git a/examples/crewai/python/README.md b/examples/crewai/python/README.md index 3ba19d26..c9f77cee 100644 --- a/examples/crewai/python/README.md +++ b/examples/crewai/python/README.md @@ -45,7 +45,7 @@ crew = Crew( ## Documentation For comprehensive guides, examples, and API reference, visit: -**[https://docs.honcho.dev/v2/integrations/crewai](https://docs.honcho.dev/v2/integrations/crewai)** +**[https://docs.honcho.dev/v3/integrations/crewai](https://docs.honcho.dev/v3/integrations/crewai)** ## Examples diff --git a/examples/crewai/python/pyproject.toml b/examples/crewai/python/pyproject.toml index 12c9ff6b..06e4c6b3 100644 --- a/examples/crewai/python/pyproject.toml +++ b/examples/crewai/python/pyproject.toml @@ -41,7 +41,7 @@ dependencies = [ [project.urls] Homepage = "https://honcho.dev" -Documentation = "https://docs.honcho.dev/v2/integrations/crewai" +Documentation = "https://docs.honcho.dev/v3/integrations/crewai" Repository = "https://github.com/plastic-labs/honcho" "Bug Tracker" = "https://github.com/plastic-labs/honcho/issues" Changelog = "https://github.com/plastic-labs/honcho/blob/main/CHANGELOG.md" diff --git a/examples/crewai/python/src/honcho_crewai/storage.py b/examples/crewai/python/src/honcho_crewai/storage.py index 1cd7955f..9201e8d6 100644 --- a/examples/crewai/python/src/honcho_crewai/storage.py +++ b/examples/crewai/python/src/honcho_crewai/storage.py @@ -125,7 +125,7 @@ class HonchoStorage(Storage): including logical operators (AND, OR, NOT), comparison operators (gt, gte, lt, lte, eq, ne), and metadata filtering. Example: {"peer_id": "user123"} or {"metadata": {"type": "important"}} - See: https://docs.honcho.dev/v2/documentation/core-concepts/features/using-filters + See: https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters Returns: List of message dictionaries in CrewAI expected format. @@ -146,7 +146,9 @@ class HonchoStorage(Storage): # Build base metadata with peer_id and created_at metadata = { "peer_id": msg.peer_id, - "created_at": str(msg.created_at) if hasattr(msg, "created_at") else None, + "created_at": str(msg.created_at) + if hasattr(msg, "created_at") + else None, } # Merge custom metadata if present diff --git a/examples/n8n/n8n.json b/examples/n8n/n8n.json index 0d600154..e73c01ef 100644 --- a/examples/n8n/n8n.json +++ b/examples/n8n/n8n.json @@ -83,7 +83,7 @@ { "parameters": { "method": "POST", - "url": "=https://api.honcho.dev/v2/workspaces/{{ $('Get or Create Workspace').item.json.id }}/peers", + "url": "=https://api.honcho.dev/v3/workspaces/{{ $('Get or Create Workspace').item.json.id }}/peers", "authentication": "predefinedCredentialType", "nodeCredentialType": "httpBearerAuth", "sendBody": true, @@ -136,7 +136,7 @@ { "parameters": { "method": "POST", - "url": "=https://api.honcho.dev/v2/workspaces/{{ $('Get or Create Workspace').item.json.id }}/sessions", + "url": "=https://api.honcho.dev/v3/workspaces/{{ $('Get or Create Workspace').item.json.id }}/sessions", "authentication": "predefinedCredentialType", "nodeCredentialType": "httpBearerAuth", "sendBody": true, @@ -179,7 +179,7 @@ { "parameters": { "method": "POST", - "url": "https://api.honcho.dev/v2/workspaces", + "url": "https://api.honcho.dev/v3/workspaces", "authentication": "predefinedCredentialType", "nodeCredentialType": "httpBearerAuth", "sendBody": true, @@ -265,7 +265,7 @@ { "parameters": { "method": "POST", - "url": "=https://api.honcho.dev/v2/workspaces/{{ $('Get or Create Workspace').item.json.id }}/sessions/{{ $('Get or Create Session').item.json.id }}/messages/", + "url": "=https://api.honcho.dev/v3/workspaces/{{ $('Get or Create Workspace').item.json.id }}/sessions/{{ $('Get or Create Session').item.json.id }}/messages/", "authentication": "predefinedCredentialType", "nodeCredentialType": "httpBearerAuth", "sendBody": true, @@ -291,7 +291,7 @@ { "parameters": { "method": "POST", - "url": "=https://api.honcho.dev/v2/workspaces/{{ $('Get or Create Workspace').item.json.id }}/sessions/{{ $('Get or Create Session').item.json.id }}/peers", + "url": "=https://api.honcho.dev/v3/workspaces/{{ $('Get or Create Workspace').item.json.id }}/sessions/{{ $('Get or Create Session').item.json.id }}/peers", "authentication": "predefinedCredentialType", "nodeCredentialType": "httpBearerAuth", "sendBody": true, @@ -327,7 +327,7 @@ }, { "parameters": { - "url": "https://api.honcho.dev/v2/workspaces/email-test/sessions/new_session/context", + "url": "https://api.honcho.dev/v3/workspaces/email-test/sessions/new_session/context", "authentication": "predefinedCredentialType", "nodeCredentialType": "httpBearerAuth", "options": {} diff --git a/pyproject.toml b/pyproject.toml index 440958f7..6d7878a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho" -version = "2.5.1" +version = "3.0.0" description = "Honcho Server" authors = [ {name = "Plastic Labs", email = "hello@plasticlabs.ai"}, diff --git a/scripts/dialectic_cost_calculator.py b/scripts/dialectic_cost_calculator.py new file mode 100644 index 00000000..0c014628 --- /dev/null +++ b/scripts/dialectic_cost_calculator.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +""" +Dialectic Cost Calculator + +Calculates the maximum potential cost for each dialectic reasoning level based on +configured settings and model pricing. + +Usage: + uv run python scripts/dialectic_cost_calculator.py +""" + +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +# Add project root to path for imports +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from rich.console import Console # noqa: E402 +from rich.table import Table # noqa: E402 + +from src.config import REASONING_LEVELS, ReasoningLevel, settings # noqa: E402 + +# Number of dialectic tools (from src/utils/agent_tools.py) +# Hardcoded to avoid circular import issues when importing from agent_tools +NUM_DIALECTIC_TOOLS = 7 # Full tool set for low/medium/high/max +NUM_DIALECTIC_TOOLS_MINIMAL = 2 # Minimal: only search_memory, search_messages +TOKENS_PER_TOOL = 350 # Approximate tokens per tool definition + +# Prefetched observations: 25 explicit + 25 derived = ~2000 tokens (full) +# Minimal uses 10 + 10 = ~800 tokens +PREFETCH_OBSERVATIONS_FULL = 2_000 +PREFETCH_OBSERVATIONS_MINIMAL = 800 + +# Target costs per reasoning level +TARGET_COSTS: dict[str, float] = { + "minimal": 0.001, + "low": 0.01, + "medium": 0.05, + "high": 0.10, + "max": 0.50, +} + +# Pricing per 1M tokens (as of January 2025) +MODEL_PRICING: dict[str, dict[str, float]] = { + "gemini-2.5-flash-lite": { + "input": 0.10, + "output": 0.40, + "cached": 0.01, + }, + "gemini-3-flash-preview": { + "input": 0.50, + "output": 3.00, + "cached": 0.05, + }, + "claude-haiku-4-5": { + "input": 1.00, + "output": 5.00, + "cached": 0.10, + }, + "claude-opus-4-5": { + "input": 5.00, + "output": 25.00, + "cached": 0.50, + }, +} + + +@dataclass +class TokenEstimates: + """Token estimates for different components. + + Default values are fallbacks; main() overrides most with actual config values. + """ + + # Fixed components (per request) - estimates, not from config + system_prompt: int = 2_000 # ~2,000 tokens for agent system prompt + num_tools: int = NUM_DIALECTIC_TOOLS # Can be overridden for minimal + peer_cards: int = 500 # Optional, enabled by default + prefetched_observations: int = PREFETCH_OBSERVATIONS_FULL # Can be overridden + user_query: int = 200 # Assumption for typical query + + # Variable components - defaults from config + session_history_max: int = settings.DIALECTIC.SESSION_HISTORY_MAX_TOKENS + tool_result_per_iter: int = ( + settings.LLM.MAX_TOOL_OUTPUT_CHARS // 4 + ) # chars to tokens + assistant_message_per_iter: int = 200 # Tool calls + reasoning + + # Output - from config + max_output_tokens: int = settings.DIALECTIC.MAX_OUTPUT_TOKENS + + # Cap - from config + max_input_tokens: int = settings.DIALECTIC.MAX_INPUT_TOKENS + + # Realistic output estimates (tool calls are small, only final answer is large) + realistic_tool_call_output: int = 150 # JSON for tool_use block + realistic_thinking_per_tool: int = ( + 400 # Models don't use full budget for tool decisions + ) + realistic_final_answer: int = 1_500 # Final response to user + + @property + def tool_definitions(self) -> int: + """Tokens for tool definitions based on num_tools.""" + return self.num_tools * TOKENS_PER_TOOL + + @property + def first_iteration_input(self) -> int: + """Total input tokens for first iteration (all fresh).""" + return ( + self.system_prompt + + self.tool_definitions + + self.peer_cards + + self.session_history_max + + self.prefetched_observations + + self.user_query + ) + + @property + def cacheable_tokens(self) -> int: + """Tokens that can be cached across iterations (system + tools).""" + return self.system_prompt + self.tool_definitions + + def subsequent_iteration_growth(self) -> int: + """Additional tokens per subsequent iteration.""" + return self.tool_result_per_iter + self.assistant_message_per_iter + + +def calculate_level_cost( + level_name: ReasoningLevel, + base_estimates: TokenEstimates, +) -> dict[str, Any]: + """ + Calculate the maximum potential cost for a reasoning level. + + Returns dict with all cost components, including both worst-case and realistic estimates. + """ + level_config = settings.DIALECTIC.LEVELS[level_name] + + # Use minimal tools, reduced prefetch, and reduced output for minimal reasoning + is_minimal = level_name == "minimal" + num_tools = NUM_DIALECTIC_TOOLS_MINIMAL if is_minimal else NUM_DIALECTIC_TOOLS + prefetch = ( + PREFETCH_OBSERVATIONS_MINIMAL if is_minimal else PREFETCH_OBSERVATIONS_FULL + ) + # Get max_output_tokens from level config, fall back to global default + max_output = ( + level_config.MAX_OUTPUT_TOKENS + if level_config.MAX_OUTPUT_TOKENS is not None + else base_estimates.max_output_tokens + ) + # Realistic final answer is capped at max output + realistic_final = min(max_output, base_estimates.realistic_final_answer) + estimates = TokenEstimates( + system_prompt=base_estimates.system_prompt, + num_tools=num_tools, + peer_cards=base_estimates.peer_cards, + prefetched_observations=prefetch, + user_query=base_estimates.user_query, + session_history_max=base_estimates.session_history_max, + tool_result_per_iter=base_estimates.tool_result_per_iter, + assistant_message_per_iter=base_estimates.assistant_message_per_iter, + max_output_tokens=max_output, + max_input_tokens=base_estimates.max_input_tokens, + realistic_tool_call_output=base_estimates.realistic_tool_call_output, + realistic_thinking_per_tool=base_estimates.realistic_thinking_per_tool, + realistic_final_answer=realistic_final, + ) + + model = level_config.MODEL + max_iterations = level_config.MAX_TOOL_ITERATIONS + thinking_budget = level_config.THINKING_BUDGET_TOKENS + provider = level_config.PROVIDER + + # Get pricing for this model + pricing = MODEL_PRICING.get(model, {"input": 0, "output": 0, "cached": 0}) + + # Calculate input tokens per iteration + first_iter_input = min(estimates.first_iteration_input, estimates.max_input_tokens) + cacheable = estimates.cacheable_tokens + growth_per_iter = estimates.subsequent_iteration_growth() + + # === WORST-CASE OUTPUT CALCULATION === + # Assumes max output on every iteration (very conservative) + output_per_iter_worst = thinking_budget + estimates.max_output_tokens + + # === REALISTIC OUTPUT CALCULATION === + # Tool-calling iterations: small JSON output + partial thinking usage + # Final iteration: full thinking budget + actual response + realistic_thinking_per_tool = min( + estimates.realistic_thinking_per_tool, thinking_budget + ) + tool_iter_output = ( + realistic_thinking_per_tool + estimates.realistic_tool_call_output + ) + final_iter_output = thinking_budget + estimates.realistic_final_answer + + # Calculate costs across all iterations + # First iteration: 100% uncached + # Subsequent iterations: ~90% cache hit on system+tools + cache_hit_rate = 0.90 + + total_input_tokens = 0 + total_cached_tokens = 0 + total_uncached_tokens = 0 + total_output_tokens_worst = 0 + total_output_tokens_realistic = 0 + + for i in range(max_iterations): + if i == 0: + # First iteration: all fresh + iter_input = first_iter_input + cached = 0 + uncached = iter_input + else: + # Subsequent iterations: accumulated context + growth + iter_input = min( + first_iter_input + (i * growth_per_iter), estimates.max_input_tokens + ) + cached = int(cacheable * cache_hit_rate) + uncached = iter_input - cached + + total_input_tokens += iter_input + total_cached_tokens += cached + total_uncached_tokens += uncached + + # Worst-case: max output every iteration + total_output_tokens_worst += output_per_iter_worst + + # Realistic: tool calls are small, only final iteration has full response + is_final = i == max_iterations - 1 + total_output_tokens_realistic += ( + final_iter_output if is_final else tool_iter_output + ) + + # Calculate worst-case costs (per 1M tokens) + input_cost = (total_uncached_tokens / 1_000_000) * pricing["input"] + cached_cost = (total_cached_tokens / 1_000_000) * pricing["cached"] + output_cost_worst = (total_output_tokens_worst / 1_000_000) * pricing["output"] + total_cost_worst = input_cost + cached_cost + output_cost_worst + + # Calculate realistic costs + output_cost_realistic = (total_output_tokens_realistic / 1_000_000) * pricing[ + "output" + ] + total_cost_realistic = input_cost + cached_cost + output_cost_realistic + + return { + "level": level_name, + "provider": provider, + "model": model, + "max_iterations": max_iterations, + "thinking_tokens": thinking_budget, + "first_iter_input": first_iter_input, + "total_input_tokens": total_input_tokens, + "total_cached_tokens": total_cached_tokens, + "total_uncached_tokens": total_uncached_tokens, + # Worst-case output + "total_output_tokens": total_output_tokens_worst, + "output_cost": output_cost_worst, + "total_cost": total_cost_worst, + # Realistic output + "total_output_tokens_realistic": total_output_tokens_realistic, + "output_cost_realistic": output_cost_realistic, + "total_cost_realistic": total_cost_realistic, + # Shared input costs + "input_cost": input_cost, + "cached_cost": cached_cost, + } + + +def main(): + console = Console() + + # TokenEstimates defaults are already sourced from config + estimates = TokenEstimates() + + console.print("\n[bold]Dialectic Cost Calculator[/bold]\n") + + # Print assumptions + console.print("[dim]Token Estimates:[/dim]") + console.print(f" System prompt: {estimates.system_prompt:,} tokens") + console.print( + f" Tool definitions (full: {NUM_DIALECTIC_TOOLS} tools): {estimates.tool_definitions:,} tokens" + ) + console.print( + f" Tool definitions (minimal: {NUM_DIALECTIC_TOOLS_MINIMAL} tools): {NUM_DIALECTIC_TOOLS_MINIMAL * TOKENS_PER_TOOL:,} tokens" + ) + console.print(f" Peer cards: {estimates.peer_cards:,} tokens") + console.print(f" Session history (max): {estimates.session_history_max:,} tokens") + console.print( + f" Prefetched observations (full: 25+25): {PREFETCH_OBSERVATIONS_FULL:,} tokens" + ) + console.print( + f" Prefetched observations (minimal: 10+10): {PREFETCH_OBSERVATIONS_MINIMAL:,} tokens" + ) + console.print(f" User query: {estimates.user_query:,} tokens") + console.print( + f" Tool result per iteration: {estimates.tool_result_per_iter:,} tokens" + ) + console.print( + f" Max output tokens (default): {estimates.max_output_tokens:,} tokens" + ) + minimal_max_output = settings.DIALECTIC.LEVELS["minimal"].MAX_OUTPUT_TOKENS + if minimal_max_output is not None: + console.print( + f" Max output tokens (minimal override): {minimal_max_output:,} tokens" + ) + console.print(f" Max input tokens (cap): {estimates.max_input_tokens:,} tokens") + console.print( + f" First iteration input: {estimates.first_iteration_input:,} tokens" + ) + console.print() + console.print("[dim]Realistic Output Estimates:[/dim]") + console.print( + f" Tool call output: {estimates.realistic_tool_call_output:,} tokens (JSON for tool_use)" + ) + console.print( + f" Thinking per tool call: {estimates.realistic_thinking_per_tool:,} tokens (partial budget use)" + ) + console.print( + f" Final answer: {estimates.realistic_final_answer:,} tokens (actual response)" + ) + console.print() + + # Calculate costs for each level (from config.REASONING_LEVELS) + results = [calculate_level_cost(level, estimates) for level in REASONING_LEVELS] + + # Create summary table + table = Table(title="Cost by Reasoning Level", show_lines=True) + table.add_column("Level", style="cyan", no_wrap=True) + table.add_column("Model", style="dim", no_wrap=True) + table.add_column("Iters", justify="right") + table.add_column("Think", justify="right") + table.add_column("Target", justify="right", style="dim") + table.add_column("Realistic", justify="right", style="bold green") + table.add_column("Worst Case", justify="right", style="yellow") + + for r in results: + table.add_row( + r["level"], + r["model"], + str(r["max_iterations"]), + f"{r['thinking_tokens']:,}", + f"${TARGET_COSTS.get(r['level'], 0):.3f}", + f"${r['total_cost_realistic']:.4f}", + f"${r['total_cost']:.4f}", + ) + + console.print(table) + + # Detailed cost breakdown table + console.print() + detail_table = Table( + title="Cost Breakdown by Component (Realistic)", show_lines=True + ) + detail_table.add_column("Level", style="cyan", no_wrap=True) + detail_table.add_column("Input $", justify="right") + detail_table.add_column("Cached $", justify="right", style="dim") + detail_table.add_column("Output $", justify="right") + detail_table.add_column("Total $", justify="right", style="bold green") + + for r in results: + detail_table.add_row( + r["level"], + f"${r['input_cost']:.4f}", + f"${r['cached_cost']:.4f}", + f"${r['output_cost_realistic']:.4f}", + f"${r['total_cost_realistic']:.4f}", + ) + + console.print(detail_table) + + # Print detailed breakdown for max level + console.print("\n[bold]Detailed Breakdown for 'max' Level:[/bold]") + max_result = results[-1] + console.print(f" Model: {max_result['model']} ({max_result['provider']})") + console.print(f" Max iterations: {max_result['max_iterations']}") + console.print(f" Thinking budget per iteration: {max_result['thinking_tokens']:,}") + console.print(f" First iteration input: {max_result['first_iter_input']:,} tokens") + console.print( + f" Total input tokens (all iterations): {max_result['total_input_tokens']:,}" + ) + console.print( + f" - Uncached: {max_result['total_uncached_tokens']:,} @ ${MODEL_PRICING[max_result['model']]['input']}/1M" + ) + console.print( + f" - Cached: {max_result['total_cached_tokens']:,} @ ${MODEL_PRICING[max_result['model']]['cached']}/1M" + ) + console.print(" Output tokens:") + console.print( + f" - Realistic: {max_result['total_output_tokens_realistic']:,} " + + f"(9 tool calls × {estimates.realistic_thinking_per_tool + estimates.realistic_tool_call_output} + final {max_result['thinking_tokens'] + estimates.realistic_final_answer})" + ) + console.print( + f" - Worst case: {max_result['total_output_tokens']:,} " + + f"(10 × {max_result['thinking_tokens'] + estimates.max_output_tokens})" + ) + console.print( + f" - Output rate: ${MODEL_PRICING[max_result['model']]['output']}/1M" + ) + console.print( + f"\n [bold green]Realistic cost: ${max_result['total_cost_realistic']:.4f}[/bold green]" + ) + console.print( + f" [yellow]Worst case cost: ${max_result['total_cost']:.4f}[/yellow]" + ) + + # Print pricing table + console.print("\n[dim]Model Pricing ($/1M tokens):[/dim]") + pricing_table = Table(show_header=True, header_style="dim") + pricing_table.add_column("Model") + pricing_table.add_column("Input", justify="right") + pricing_table.add_column("Output", justify="right") + pricing_table.add_column("Cached", justify="right") + + for model, prices in MODEL_PRICING.items(): + pricing_table.add_row( + model, + f"${prices['input']:.2f}", + f"${prices['output']:.2f}", + f"${prices['cached']:.2f}", + ) + + console.print(pricing_table) + + console.print( + "\n[dim]Note: 'Realistic' assumes tool calls use ~550 output tokens each " + + "(400 thinking + 150 JSON), with full budget only on final answer.\n" + + "'Worst case' assumes max output tokens on every iteration. " + + "Actual costs may be even lower due to early termination.[/dim]\n" + ) + + +if __name__ == "__main__": + main() diff --git a/sdks/python/CHANGELOG.md b/sdks/python/CHANGELOG.md index 7f762188..6efde14f 100644 --- a/sdks/python/CHANGELOG.md +++ b/sdks/python/CHANGELOG.md @@ -5,6 +5,25 @@ 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.0.0] - 2026-01-13 + +### Added + +- `ConclusionScope` object for CRUD operations on conclusions (renamed from observations) +- Representation configuration support + +### Changed + +- Observations renamed to Conclusions across the SDK +- Major SDK refactoring and cleanup +- Simplified method signatures throughout +- Representation endpoints now return `string` instead of old Representation object + +### Removed + +- Standalone types module (now uses honcho-core types) +- Representation object + ## [1.6.0] - 2025-12-03 ### Added diff --git a/sdks/python/examples/chat.py b/sdks/python/examples/chat.py index 0917de6c..ecdeab98 100644 --- a/sdks/python/examples/chat.py +++ b/sdks/python/examples/chat.py @@ -1,7 +1,7 @@ import random import uuid -from honcho import Honcho +from honcho import Honcho, MessageCreateParams # Create a Honcho client with the default workspace honcho = Honcho(environment="local") @@ -16,7 +16,7 @@ peers = [ session = honcho.session("chat_test_" + str(uuid.uuid4())) # Generate some random messages from alice, bob, and charlie and add them to the session -messages = [] +messages: list[MessageCreateParams] = [] for i in range(10): random_peer = random.choice(peers) messages.append( @@ -25,15 +25,13 @@ for i in range(10): session.add_messages(messages) -honcho.poll_deriver_status() - # Chat with alice alice = peers[0] response = alice.chat("what did alice have for breakfast today?") print("response returned:", response) # Chat with alice in the session -response = alice.chat("what did alice have for breakfast today?", session=session.id) +response = alice.chat("what did alice have for breakfast today?", session=session) print("response returned:", response) # Chat with alice in the session with a target diff --git a/sdks/python/examples/file_upload.py b/sdks/python/examples/file_upload.py index a7d00db2..1dc3b3f9 100644 --- a/sdks/python/examples/file_upload.py +++ b/sdks/python/examples/file_upload.py @@ -14,6 +14,6 @@ with open(__file__, "rb") as file: # get the messages from the session # should contain the contents of this file! -messages = session.get_messages() +messages = session.messages() for message in messages: print(str(message)) diff --git a/sdks/python/examples/get_context.py b/sdks/python/examples/get_context.py index 300fb3b9..af183fb4 100644 --- a/sdks/python/examples/get_context.py +++ b/sdks/python/examples/get_context.py @@ -1,7 +1,7 @@ import random import uuid -from honcho import Honcho +from honcho import Honcho, MessageCreateParams # Create a Honcho client with the default workspace honcho = Honcho(environment="local") @@ -16,7 +16,7 @@ peers = [ session = honcho.session("context_test_" + str(uuid.uuid4())) # Generate some random messages from alice, bob, and charlie and add them to the session -messages = [] +messages: list[MessageCreateParams] = [] for i in range(10): random_peer = random.choice(peers) messages.append( @@ -27,5 +27,5 @@ session.add_messages(messages) # Get some context of the session # Set the token limit super low so we only get a few of the tiny messages created -context = session.get_context(summary=True, tokens=50) +context = session.context(summary=True, tokens=50) print("context returned:", context) diff --git a/sdks/python/examples/get_summaries.py b/sdks/python/examples/get_summaries.py index f70ee10e..8863a65c 100644 --- a/sdks/python/examples/get_summaries.py +++ b/sdks/python/examples/get_summaries.py @@ -6,10 +6,11 @@ for a session, including their metadata like message ID, creation timestamp, and token count. """ -from honcho import AsyncHoncho, Honcho, SessionSummaries import asyncio import os +from honcho import Honcho, SessionSummaries + # Initialize the Honcho client api_key = os.getenv("HONCHO_API_KEY") if not api_key: @@ -21,7 +22,7 @@ client = Honcho(api_key=api_key) session = client.session("my-conversation-session") # Get summaries for the session -summaries: SessionSummaries = session.get_summaries() +summaries: SessionSummaries = session.summaries() print(f"Session ID: {summaries.id}") print("-" * 50) @@ -50,20 +51,21 @@ if summaries.long_summary: else: print("No long summary available yet") -# Example with async client +# Example with async client using .aio accessor print("\n" + "=" * 50) print("ASYNC EXAMPLE:") print("=" * 50) -async def get_summaries_async(): - async_client = AsyncHoncho(api_key=api_key) +async def summaries_async(): + # Use the same Honcho client with .aio accessor for async operations + async_client = Honcho(api_key=api_key) - # Get a session - async_session = await async_client.session("my-conversation-session") + # Get a session using .aio accessor + async_session = await async_client.aio.session("my-conversation-session") - # Get summaries asynchronously - summaries = await async_session.get_summaries() + # Get summaries asynchronously using .aio accessor + summaries = await async_session.aio.summaries() print(f"Session ID (async): {summaries.id}") @@ -79,4 +81,4 @@ async def get_summaries_async(): # Run the async example -asyncio.run(get_summaries_async()) +asyncio.run(summaries_async()) diff --git a/sdks/python/examples/multi_user_representations.py b/sdks/python/examples/multi_user_representations.py index ca44007b..1d5d2097 100644 --- a/sdks/python/examples/multi_user_representations.py +++ b/sdks/python/examples/multi_user_representations.py @@ -1,7 +1,7 @@ import time import uuid -from honcho import Honcho +from honcho import Honcho, MessageCreateParams from honcho.session import SessionPeerConfig # Create a Honcho client with the default workspace @@ -21,7 +21,7 @@ session.add_peers( ) # Generate messages with personal information -messages = [] +messages: list[MessageCreateParams] = [] messages.append(alice.message("I had a great breakfast today!")) messages.append(bob.message("What did you eat?")) messages.append(alice.message("I had pancakes and eggs and bacon.")) @@ -48,25 +48,19 @@ session2.add_messages( ] ) -# wait for the deriver to process the messages -print("waiting for the deriver to process all the messages") -deriver_status = honcho.poll_deriver_status() -print("deriver status:", deriver_status) - - # # Chat with alice's honcho-level representation -# print( -# "\n\n\033[1m asking alice's honcho-level representation what she had for breakfast \033[0m" -# ) -# response = alice.chat("what did alice have for breakfast today?", session_id=session.id) -# print("response:", response) +print( + "\n\n\033[1m asking alice's honcho-level representation what she had for breakfast \033[0m" +) +response = alice.chat("what did alice have for breakfast today?", session=session) +print("response:", response) # Chat with bob's internal representation of alice print( "\n\n\033[1m asking bob what alice had for breakfast -- scoped to session 1 \033[0m" ) response = bob.chat( - "what did alice have for breakfast today?", target=alice, session=session.id + "what did alice have for breakfast today?", target=alice, session=session ) print("response:", response) @@ -74,7 +68,7 @@ print( "\n\n\033[1m asking bob what alice had for breakfast -- scoped to session 2 \033[0m" ) response = bob.chat( - "what did alice have for breakfast today?", target=alice, session=session2.id + "what did alice have for breakfast today?", target=alice, session=session2 ) print("response:", response) diff --git a/sdks/python/examples/pydantic_validation_example.py b/sdks/python/examples/pydantic_validation_example.py index b44fe8d1..1877bfc6 100644 --- a/sdks/python/examples/pydantic_validation_example.py +++ b/sdks/python/examples/pydantic_validation_example.py @@ -112,7 +112,7 @@ def demonstrate_validation(): # Create a valid message message = peer.message("Hello, world!", metadata={"type": "greeting"}) print( - f"✅ Created message: peer_id={message['peer_id']}, content='{message['content']}'" + f"✅ Created message: peer_id={message.peer_id}, content='{message.content}'" ) # Valid peer operations (validation passes, but no API calls made) diff --git a/sdks/python/examples/search.py b/sdks/python/examples/search.py index 76b5b915..6ec84cc5 100644 --- a/sdks/python/examples/search.py +++ b/sdks/python/examples/search.py @@ -1,7 +1,7 @@ import random import uuid -from honcho import Honcho +from honcho import Honcho, MessageCreateParams # Create a Honcho client with the default workspace honcho = Honcho(environment="local") @@ -22,7 +22,7 @@ keyword = f"~special-{str(uuid.uuid4())}~" session.add_messages(alice.message(f"I am a {keyword} message")) # Generate some random messages from alice, bob, and charlie and add them to the session -messages = [] +messages: list[MessageCreateParams] = [] for i in range(10): random_peer = random.choice(peers) messages.append( diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 5c6094bc..20204d04 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho-ai" -version = "1.6.0" +version = "2.0.0" description = "Official DX Optimized Python SDK for Honcho" dynamic = ["readme"] license = "Apache-2.0" @@ -8,7 +8,6 @@ authors = [ { name = "Plastic Labs", email = "hello@plasticlabs.ai" }, ] dependencies = [ - "honcho-core==1.11.0", "httpx>=0.28.0, <1", "pydantic>=2.0.0, <3", "typing-extensions>=4.12.0; python_version < \"3.12\"", @@ -67,4 +66,4 @@ explicit = true [tool.setuptools.packages.find] where = ["src"] -include = ["honcho", "honcho.async_client", "honcho.utils"] +include = ["honcho", "honcho.async_client", "honcho.http", "honcho.utils"] diff --git a/sdks/python/src/honcho/__init__.py b/sdks/python/src/honcho/__init__.py index 02f15f8a..cf8d8bb5 100644 --- a/sdks/python/src/honcho/__init__.py +++ b/sdks/python/src/honcho/__init__.py @@ -27,49 +27,88 @@ Usage: 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 me?") + response = alice.chat("What did Bob say to me?", session=session) + + # Async operations via .aio accessor + peer = await client.aio.peer("user-123") + await peer.aio.chat("query", session=session) + async for p in client.aio.peers(): + print(p.id) """ -from .async_client import ( - AsyncHoncho, - AsyncPage, - AsyncPeer, - AsyncSession, -) +from .aio import ConclusionScopeAio, HonchoAio, PeerAio, SessionAio +from .api_types import MessageCreateParams from .base import PeerBase, SessionBase from .client import Honcho -from .conclusions import AsyncConclusionScope, ConclusionScope -from .pagination import SyncPage +from .conclusions import Conclusion, ConclusionScope +from .http.exceptions import ( + APIError, + AuthenticationError, + BadRequestError, + ConflictError, + ConnectionError, + HonchoError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + ServerError, + TimeoutError, + UnprocessableEntityError, +) +from .message import Message +from .pagination import AsyncPage, SyncPage from .peer import Peer from .session import Session from .session_context import SessionContext, SessionSummaries, Summary from .types import ( + AsyncDialecticStreamResponse, DialecticStreamResponse, ) -__version__ = "1.6.0" +__version__ = "2.0.0" __author__ = "Plastic Labs" __email__ = "hello@plasticlabs.ai" __all__ = [ - "AsyncHoncho", - "AsyncConclusionScope", - "AsyncPeer", - "AsyncSession", - "AsyncPage", + # Client "Honcho", + # Domain classes + "Conclusion", "ConclusionScope", + "Message", + "MessageCreateParams", "Peer", - "PeerBase", "Session", + # Aio views (for type hints) + "ConclusionScopeAio", + "HonchoAio", + "PeerAio", + "SessionAio", + # Base classes + "PeerBase", "SessionBase", + # Response types "SessionContext", "SessionSummaries", "Summary", + # Pagination + "AsyncPage", "SyncPage", + # Streaming + "AsyncDialecticStreamResponse", "DialecticStreamResponse", + # Exceptions + "APIError", + "AuthenticationError", + "BadRequestError", + "ConflictError", + "ConnectionError", + "HonchoError", + "NotFoundError", + "PermissionDeniedError", + "RateLimitError", + "ServerError", + "TimeoutError", + "UnprocessableEntityError", ] diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py new file mode 100644 index 00000000..20cb488b --- /dev/null +++ b/sdks/python/src/honcho/aio.py @@ -0,0 +1,1372 @@ +# pyright: reportPrivateUsage=false +"""Async view classes for Honcho SDK. + +This module provides async accessor classes that wrap the main SDK classes +and provide async versions of all operations. Access via the `.aio` property +on Honcho, Peer, Session, and ConclusionScope instances. + +Example: + ```python + from honcho import Honcho + + honcho = Honcho(workspace_id="my-workspace") + + # Async operations + peer = await honcho.aio.peer("user-123") + await peer.aio.chat("query") + async for p in honcho.aio.peers(): + print(p.id) + ``` +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import AsyncGenerator +from datetime import datetime +from typing import TYPE_CHECKING, Any, ClassVar, Literal + +from pydantic import ConfigDict, Field, validate_call + +from .api_types import ( + ConclusionResponse, + MessageCreateParams, + MessageResponse, + PeerCardResponse, + PeerConfig, + PeerContextResponse, + PeerResponse, + QueueStatusResponse, + RepresentationResponse, + SessionConfiguration, + SessionPeerConfig, + SessionResponse, + WorkspaceConfiguration, + WorkspaceResponse, +) +from .base import PeerBase, SessionBase +from .conclusions import Conclusion +from .http import routes +from .message import Message +from .mixins import AsyncMetadataConfigMixin +from .pagination import AsyncPage +from .session_context import SessionContext, SessionSummaries, Summary +from .types import AsyncDialecticStreamResponse +from .utils import ( + datetime_to_iso, + normalize_peers_to_dict, + parse_sse_astream, + prepare_file_for_upload, + resolve_id, +) + +if TYPE_CHECKING: + from .client import Honcho + from .conclusions import ConclusionScope + +from .conclusions import ConclusionCreateParams +from .peer import Peer +from .session import Session + +logger = logging.getLogger(__name__) + +__all__ = [ + "HonchoAio", + "PeerAio", + "SessionAio", + "ConclusionScopeAio", +] + + +class HonchoAio(AsyncMetadataConfigMixin): + """ + Async view of the Honcho client. + + Access via `honcho.aio`. Provides async versions of all Honcho methods. + Shares state with the parent Honcho instance. + """ + + __slots__: ClassVar[tuple[str, ...]] = ("_honcho",) + _honcho: "Honcho" + + def __init__(self, honcho: "Honcho") -> None: + self._honcho = honcho + + # AsyncMetadataConfigMixin implementation + def _get_async_http_client(self): + return self._honcho._async_http_client + + def _get_fetch_route(self) -> str: + return routes.workspaces() + + def _get_update_route(self) -> str: + return routes.workspace(self._honcho.workspace_id) + + def _get_fetch_body(self) -> dict[str, Any]: + return {"id": self._honcho.workspace_id} + + def _parse_response( + self, data: dict[str, Any] + ) -> tuple[dict[str, object], dict[str, object]]: + workspace = WorkspaceResponse.model_validate(data) + # Return configuration as dict for mixin compatibility + return workspace.metadata or {}, workspace.configuration.model_dump( + exclude_none=True + ) + + def _set_metadata(self, metadata: dict[str, object]) -> None: + self._honcho._metadata = metadata + + def _set_configuration(self, configuration: dict[str, object]) -> None: + # Convert dict to typed configuration + self._honcho._configuration = WorkspaceConfiguration.model_validate( + configuration + ) + + def _get_metadata(self) -> dict[str, object]: + return self._honcho._metadata or {} + + def _get_configuration(self) -> dict[str, object]: + if self._honcho._configuration is None: + return {} + return self._honcho._configuration.model_dump(exclude_none=True) + + async def get_configuration(self) -> WorkspaceConfiguration: # pyright: ignore[reportIncompatibleMethodOverride] + """Get configuration from the server asynchronously.""" + data = await self._get_async_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + workspace = WorkspaceResponse.model_validate(data) + self._honcho._metadata = workspace.metadata or {} + self._honcho._configuration = workspace.configuration + return self._honcho._configuration + + async def set_configuration(self, configuration: WorkspaceConfiguration) -> None: # pyright: ignore[reportIncompatibleMethodOverride] + """Set configuration on the server asynchronously.""" + await self._get_async_http_client().put( + self._get_update_route(), + body={"configuration": configuration.model_dump(exclude_none=True)}, + ) + self._honcho._configuration = configuration + + async def peer( + self, + id: str, + *, + metadata: dict[str, object] | None = None, + configuration: PeerConfig | None = None, + ) -> Peer: + """ + Get or create a peer with the given ID asynchronously. + + Args: + id: Unique identifier for the peer within the workspace. + metadata: Optional metadata dictionary to associate with this peer. + configuration: Optional configuration to set for this peer. + + Returns: + A Peer object + """ + 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) + + 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) + + async def peers( + self, filters: dict[str, object] | None = None + ) -> AsyncPage[PeerResponse, Peer]: + """ + Get all peers in the current workspace asynchronously. + + Returns: + An AsyncPage of Peer objects + """ + await self._honcho._ensure_workspace_async() + data = await self._honcho._async_http_client.post( + routes.peers_list(self._honcho.workspace_id), + body={"filters": filters} if filters else None, + ) + + def transform(peer: PeerResponse) -> Peer: + return Peer( + peer.id, + self._honcho, + metadata=peer.metadata, + configuration=peer.configuration, + ) + + async def fetch_next(page: int) -> AsyncPage[PeerResponse, Peer]: + 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}, + ) + return AsyncPage(next_data, PeerResponse, transform, fetch_next) + + return AsyncPage(data, PeerResponse, transform, fetch_next) + + async def session( + self, + id: str, + *, + metadata: dict[str, object] | None = None, + configuration: SessionConfiguration | None = None, + ) -> Session: + """ + Get or create a session with the given ID asynchronously. + + Args: + id: Unique identifier for the session within the workspace. + metadata: Optional metadata dictionary to associate with this session. + configuration: Optional configuration to set for this session. + + Returns: + A Session object + """ + 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) + + 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) + + async def sessions( + self, filters: dict[str, object] | None = None + ) -> AsyncPage[SessionResponse, Session]: + """ + Get all sessions in the current workspace asynchronously. + + Returns: + An AsyncPage of Session objects + """ + await self._honcho._ensure_workspace_async() + data = await self._honcho._async_http_client.post( + routes.sessions_list(self._honcho.workspace_id), + body={"filters": filters} if filters else None, + ) + + def transform(session: SessionResponse) -> Session: + return Session( + session.id, + self._honcho, + metadata=session.metadata, + configuration=session.configuration, + ) + + async def fetch_next(page: int) -> AsyncPage[SessionResponse, Session]: + 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}, + ) + return AsyncPage(next_data, SessionResponse, transform, fetch_next) + + return AsyncPage(data, SessionResponse, transform, fetch_next) + + async def workspaces( + self, filters: dict[str, object] | None = None + ) -> AsyncPage[WorkspaceResponse, str]: + """Get all workspace IDs asynchronously.""" + data = await self._honcho._async_http_client.post( + routes.workspaces_list(), + body={"filters": filters} if filters else None, + ) + + def transform(workspace: WorkspaceResponse) -> str: + return workspace.id + + async def fetch_next(page: int) -> AsyncPage[WorkspaceResponse, str]: + next_data = await self._honcho._async_http_client.post( + routes.workspaces_list(), + body={"filters": filters} if filters else None, + query={"page": page}, + ) + return AsyncPage(next_data, WorkspaceResponse, transform, fetch_next) + + return AsyncPage(data, WorkspaceResponse, transform, fetch_next) + + async def delete_workspace(self, workspace_id: str) -> None: + """Delete a workspace asynchronously.""" + await self._honcho._async_http_client.delete(routes.workspace(workspace_id)) + + @validate_call + async def search( + self, + query: str = Field(..., min_length=1, description="The search query to use"), + filters: dict[str, object] | None = Field( + None, description="Filters to scope the search" + ), + limit: int = Field( + default=10, ge=1, le=100, description="Number of results to return" + ), + ) -> list[Message]: + """Search for messages in the current workspace asynchronously.""" + await self._honcho._ensure_workspace_async() + data = await self._honcho._async_http_client.post( + routes.workspace_search(self._honcho.workspace_id), + body={"query": query, "filters": filters, "limit": limit}, + ) + return [ + Message.from_api_response(MessageResponse.model_validate(item)) + for item in data + ] + + async def queue_status( + self, + observer: str | PeerBase | None = None, + sender: str | PeerBase | None = None, + session: str | SessionBase | None = None, + ) -> QueueStatusResponse: + """Get queue processing status asynchronously.""" + await self._honcho._ensure_workspace_async() + resolved_observer_id = resolve_id(observer) + resolved_sender_id = resolve_id(sender) + resolved_session_id = resolve_id(session) + + query: dict[str, Any] = {} + if resolved_observer_id: + query["observer_id"] = resolved_observer_id + if resolved_sender_id: + query["sender_id"] = resolved_sender_id + if resolved_session_id: + query["session_id"] = resolved_session_id + + data = await self._honcho._async_http_client.get( + routes.workspace_queue_status(self._honcho.workspace_id), + query=query if query else None, + ) + return QueueStatusResponse.model_validate(data) + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def schedule_dream( + self, + observer: str | PeerBase, + session: str | SessionBase, + observed: str | PeerBase | None = None, + ) -> None: + """ + Schedule a dream task for memory consolidation asynchronously. + + Dreams are background processes that consolidate observations into higher-level + insights and update peer cards. This method schedules a dream task for immediate + processing. + + Args: + observer: The observer peer (ID string or Peer object) whose perspective + to use for the dream. + session: The session (ID string or Session object) to scope the dream to. + observed: Optional observed peer (ID string or Peer object). If not provided, + defaults to the observer (self-reflection). + """ + await self._honcho._ensure_workspace_async() + resolved_observer_id = resolve_id(observer) + resolved_session_id = resolve_id(session) + resolved_observed_id = ( + resolve_id(observed) if observed else resolved_observer_id + ) + + await self._honcho._async_http_client.post( + routes.workspace_schedule_dream(self._honcho.workspace_id), + body={ + "observer": resolved_observer_id, + "observed": resolved_observed_id, + "session_id": resolved_session_id, + "dream_type": "omni", + }, + ) + + +class PeerAio(AsyncMetadataConfigMixin): + """ + Async view of a Peer. + + Access via `peer.aio`. Provides async versions of all Peer methods. + Shares state with the parent Peer instance. + """ + + __slots__: ClassVar[tuple[str, ...]] = ("_peer",) + _peer: "Peer" + + def __init__(self, peer: "Peer") -> None: + self._peer = peer + + # AsyncMetadataConfigMixin implementation + def _get_async_http_client(self): + return self._peer._honcho._async_http_client + + def _get_fetch_route(self) -> str: + return routes.peers(self._peer.workspace_id) + + def _get_update_route(self) -> str: + return routes.peer(self._peer.workspace_id, self._peer.id) + + def _get_fetch_body(self) -> dict[str, Any]: + return {"id": self._peer.id} + + def _parse_response( + self, data: dict[str, Any] + ) -> tuple[dict[str, object], dict[str, object]]: + peer = PeerResponse.model_validate(data) + # Return configuration as dict for mixin compatibility + return peer.metadata or {}, peer.configuration.model_dump(exclude_none=True) + + def _set_metadata(self, metadata: dict[str, object]) -> None: + self._peer._metadata = metadata + + def _set_configuration(self, configuration: dict[str, object]) -> None: + # Convert dict to typed configuration + self._peer._configuration = PeerConfig.model_validate(configuration) + + def _get_metadata(self) -> dict[str, object]: + return self._peer._metadata or {} + + def _get_configuration(self) -> dict[str, object]: + if self._peer._configuration is None: + return {} + return self._peer._configuration.model_dump(exclude_none=True) + + async def get_configuration(self) -> PeerConfig: # pyright: ignore[reportIncompatibleMethodOverride] + """Get configuration 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._peer._metadata = peer.metadata or {} + self._peer._configuration = peer.configuration + return self._peer._configuration + + async def set_configuration(self, configuration: PeerConfig) -> None: # pyright: ignore[reportIncompatibleMethodOverride] + """Set configuration on the server asynchronously.""" + await self._get_async_http_client().put( + self._get_update_route(), + body={"configuration": configuration.model_dump(exclude_none=True)}, + ) + self._peer._configuration = configuration + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def chat( + self, + query: str = Field(..., min_length=1, description="The natural language query"), + *, + target: str | PeerBase | None = None, + session: str | SessionBase | None = None, + reasoning_level: Literal["minimal", "low", "medium", "high", "max"] + | None = None, + ) -> str | None: + """Query the peer's representation asynchronously.""" + await self._peer._honcho._ensure_workspace_async() + target_id = resolve_id(target) + resolved_session_id = resolve_id(session) + + body: dict[str, Any] = {"query": query, "stream": False} + if target_id: + body["target"] = target_id + if resolved_session_id: + body["session_id"] = resolved_session_id + if reasoning_level: + body["reasoning_level"] = reasoning_level + + data = await self._peer._honcho._async_http_client.post( + routes.peer_chat(self._peer.workspace_id, self._peer.id), + body=body, + ) + content = data.get("content") + if not content: + return None + return content + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def chat_stream( + self, + query: str = Field(..., min_length=1, description="The natural language query"), + *, + target: str | PeerBase | None = None, + session: str | SessionBase | None = None, + reasoning_level: Literal["minimal", "low", "medium", "high", "max"] + | None = None, + ) -> AsyncDialecticStreamResponse: + """Query the peer's representation with streaming asynchronously.""" + await self._peer._honcho._ensure_workspace_async() + target_id = resolve_id(target) + resolved_session_id = resolve_id(session) + + body: dict[str, Any] = {"query": query, "stream": True} + if target_id: + body["target"] = target_id + if resolved_session_id: + body["session_id"] = resolved_session_id + if reasoning_level: + body["reasoning_level"] = reasoning_level + + async def stream_response() -> AsyncGenerator[str, None]: + async for content in parse_sse_astream( + self._peer._honcho._async_http_client.stream( + "POST", + routes.peer_chat(self._peer.workspace_id, self._peer.id), + body=body, + ) + ): + yield content + + return AsyncDialecticStreamResponse(stream_response()) + + async def sessions( + self, filters: dict[str, object] | None = None + ) -> AsyncPage[SessionResponse, Session]: + """Get all sessions this peer is a member of asynchronously.""" + await self._peer._honcho._ensure_workspace_async() + 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, + ) + + def transform(session: SessionResponse) -> Session: + return Session(session.id, self._peer._honcho) + + async def fetch_next(page: int) -> AsyncPage[SessionResponse, Session]: + 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}, + ) + return AsyncPage(next_data, SessionResponse, transform, fetch_next) + + return AsyncPage(data, SessionResponse, transform, fetch_next) + + @validate_call + async def search( + self, + query: str = Field(..., min_length=1, description="The search query to use"), + filters: dict[str, object] | None = Field( + None, description="Filters to scope the search" + ), + limit: int = Field( + default=10, ge=1, le=100, description="Number of results to return" + ), + ) -> list[Message]: + """Search across all messages with this peer as author asynchronously.""" + await self._peer._honcho._ensure_workspace_async() + data = await self._peer._honcho._async_http_client.post( + routes.peer_search(self._peer.workspace_id, self._peer.id), + body={"query": query, "filters": filters, "limit": limit}, + ) + return [ + Message.from_api_response(MessageResponse.model_validate(item)) + for item in data + ] + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def card( + self, + target: str | PeerBase | None = None, + ) -> list[str] | None: + """Get the peer card asynchronously.""" + await self._peer._honcho._ensure_workspace_async() + target_id = resolve_id(target) + + query = {"target": target_id} if target_id else None + data = await self._peer._honcho._async_http_client.get( + routes.peer_card(self._peer.workspace_id, self._peer.id), + query=query, + ) + response = PeerCardResponse.model_validate(data) + return response.peer_card + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def representation( + self, + session: str | SessionBase | None = None, + target: str | PeerBase | None = None, + search_query: str | None = None, + search_top_k: int | None = Field(None, ge=1, le=100), + search_max_distance: float | None = Field(None, ge=0.0, le=1.0), + include_most_frequent: bool | None = None, + max_conclusions: int | None = Field(None, ge=1, le=100), + ) -> str: + """Get a subset of the representation of the peer asynchronously.""" + await self._peer._honcho._ensure_workspace_async() + session_id = resolve_id(session) + target_id = resolve_id(target) + + body: dict[str, Any] = {} + if session_id: + body["session_id"] = session_id + if target_id: + body["target"] = target_id + if search_query is not None: + body["search_query"] = search_query + if search_top_k is not None: + body["search_top_k"] = search_top_k + if search_max_distance is not None: + body["search_max_distance"] = search_max_distance + if include_most_frequent is not None: + body["include_most_frequent"] = include_most_frequent + if max_conclusions is not None: + body["max_conclusions"] = max_conclusions + + data = await self._peer._honcho._async_http_client.post( + routes.peer_representation(self._peer.workspace_id, self._peer.id), + body=body, + ) + response = RepresentationResponse.model_validate(data) + return response.representation + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def context( + self, + target: str | PeerBase | None = None, + search_query: str | None = None, + search_top_k: int | None = Field(None, ge=1, le=100), + search_max_distance: float | None = Field(None, ge=0.0, le=1.0), + include_most_frequent: bool | None = None, + max_conclusions: int | None = Field(None, ge=1, le=100), + ) -> PeerContextResponse: + """Get context for this peer asynchronously.""" + await self._peer._honcho._ensure_workspace_async() + target_id = resolve_id(target) + + query: dict[str, Any] = {} + if target_id: + query["target"] = target_id + if search_query is not None: + query["search_query"] = search_query + if search_top_k is not None: + query["search_top_k"] = search_top_k + if search_max_distance is not None: + query["search_max_distance"] = search_max_distance + if include_most_frequent is not None: + query["include_most_frequent"] = include_most_frequent + if max_conclusions is not None: + query["max_conclusions"] = max_conclusions + + data = await self._peer._honcho._async_http_client.get( + routes.peer_context(self._peer.workspace_id, self._peer.id), + query=query if query else None, + ) + return PeerContextResponse.model_validate(data) + + +class SessionAio(AsyncMetadataConfigMixin): + """ + Async view of a Session. + + Access via `session.aio`. Provides async versions of all Session methods. + Shares state with the parent Session instance. + """ + + __slots__: ClassVar[tuple[str, ...]] = ("_session",) + _session: "Session" + + def __init__(self, session: "Session") -> None: + self._session = session + + # AsyncMetadataConfigMixin implementation + def _get_async_http_client(self): + return self._session._honcho._async_http_client + + def _get_fetch_route(self) -> str: + return routes.sessions(self._session.workspace_id) + + def _get_update_route(self) -> str: + return routes.session(self._session.workspace_id, self._session.id) + + def _get_fetch_body(self) -> dict[str, Any]: + return {"id": self._session.id} + + def _parse_response( + self, data: dict[str, Any] + ) -> tuple[dict[str, object], dict[str, object]]: + session = SessionResponse.model_validate(data) + # Return configuration as dict for mixin compatibility + return session.metadata or {}, session.configuration.model_dump( + exclude_none=True + ) + + def _set_metadata(self, metadata: dict[str, object]) -> None: + self._session._metadata = metadata + + def _set_configuration(self, configuration: dict[str, object]) -> None: + # Convert dict to typed configuration + self._session._configuration = SessionConfiguration.model_validate( + configuration + ) + + def _get_metadata(self) -> dict[str, object]: + return self._session._metadata or {} + + def _get_configuration(self) -> dict[str, object]: + if self._session._configuration is None: + return {} + return self._session._configuration.model_dump(exclude_none=True) + + async def get_configuration(self) -> SessionConfiguration: # pyright: ignore[reportIncompatibleMethodOverride] + """Get configuration 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._session._metadata = session.metadata or {} + self._session._configuration = session.configuration + return self._session._configuration + + async def set_configuration(self, configuration: SessionConfiguration) -> None: # pyright: ignore[reportIncompatibleMethodOverride] + """Set configuration on the server asynchronously.""" + await self._get_async_http_client().put( + self._get_update_route(), + body={"configuration": configuration.model_dump(exclude_none=True)}, + ) + self._session._configuration = configuration + + async def add_peers( + self, + peers: str + | PeerBase + | tuple[str, SessionPeerConfig] + | tuple[PeerBase, SessionPeerConfig] + | list[PeerBase | str] + | list[tuple[PeerBase | str, SessionPeerConfig]] + | list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]], + ) -> None: + """Add peers to this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + await self._session._honcho._async_http_client.post( + routes.session_peers(self._session.workspace_id, self._session.id), + body=normalize_peers_to_dict(peers), + ) + + async def set_peers( + self, + peers: str + | PeerBase + | tuple[str, SessionPeerConfig] + | tuple[PeerBase, SessionPeerConfig] + | list[PeerBase | str] + | list[tuple[PeerBase | str, SessionPeerConfig]] + | list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]], + ) -> None: + """Set the complete peer list for this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + await self._session._honcho._async_http_client.put( + routes.session_peers(self._session.workspace_id, self._session.id), + body=normalize_peers_to_dict(peers), + ) + + async def remove_peers( + self, + peers: str | PeerBase | list[PeerBase | str], + ) -> None: + """Remove peers from this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + if not isinstance(peers, list): + peers = [peers] + + peer_ids = [peer if isinstance(peer, str) else peer.id for peer in peers] + + await self._session._honcho._async_http_client.delete( + routes.session_peers(self._session.workspace_id, self._session.id), + body=peer_ids, + ) + + async def peers(self) -> list[Peer]: + """Get all peers in this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + data: dict[str, Any] = await self._session._honcho._async_http_client.get( + routes.session_peers(self._session.workspace_id, self._session.id) + ) + + peers_data: list[Any] = data.get("items", []) + return [ + Peer(PeerResponse.model_validate(peer).id, self._session._honcho) + for peer in peers_data + ] + + async def get_peer_configuration(self, peer: str | PeerBase) -> SessionPeerConfig: + """Get the configuration for a peer in this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + peer_id = peer if isinstance(peer, str) else peer.id + data = await self._session._honcho._async_http_client.get( + routes.session_peer_config( + self._session.workspace_id, self._session.id, peer_id + ) + ) + return SessionPeerConfig( + observe_others=data.get("observe_others"), + observe_me=data.get("observe_me"), + ) + + async def set_peer_configuration( + self, peer: str | PeerBase, configuration: SessionPeerConfig + ) -> None: + """Set the configuration for a peer in this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + peer_id = peer if isinstance(peer, str) else peer.id + body: dict[str, Any] = {} + if configuration.observe_others is not None: + body["observe_others"] = configuration.observe_others + if configuration.observe_me is not None: + body["observe_me"] = configuration.observe_me + + await self._session._honcho._async_http_client.put( + routes.session_peer_config( + self._session.workspace_id, self._session.id, peer_id + ), + body=body, + ) + + @validate_call + async def add_messages( + self, + messages: MessageCreateParams | list[MessageCreateParams] = Field( + ..., description="Messages to add to the session" + ), + ) -> list[Message]: + """Add one or more messages to this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + if not isinstance(messages, list): + messages = [messages] + + messages_data = [ + msg.model_dump(mode="json", exclude_none=True) for msg in messages + ] + + data = await self._session._honcho._async_http_client.post( + routes.messages(self._session.workspace_id, self._session.id), + body={"messages": messages_data}, + ) + return [ + Message.from_api_response(MessageResponse.model_validate(msg)) + for msg in data + ] + + async def messages( + self, + *, + filters: dict[str, object] | None = None, + ) -> AsyncPage[MessageResponse, Message]: + """Get messages from this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + 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, + ) + + def transform(response: MessageResponse) -> Message: + return Message.from_api_response(response) + + async def fetch_next(page: int) -> AsyncPage[MessageResponse, Message]: + 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}, + ) + return AsyncPage(next_data, MessageResponse, transform, fetch_next) + + return AsyncPage(data, MessageResponse, transform, fetch_next) + + async def delete(self) -> None: + """Delete this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + await self._session._honcho._async_http_client.delete( + routes.session(self._session.workspace_id, self._session.id) + ) + + async def clone(self, *, message_id: str | None = None) -> Session: + """Clone this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + query: dict[str, Any] = {} + if message_id is not None: + query["message_id"] = message_id + + data = await self._session._honcho._async_http_client.post( + routes.session_clone(self._session.workspace_id, self._session.id), + query=query if query else None, + ) + cloned = SessionResponse.model_validate(data) + return Session( + cloned.id, + self._session._honcho, + metadata=cloned.metadata, + configuration=cloned.configuration, + ) + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def context( + self, + *, + summary: bool = True, + tokens: int | None = Field( + None, gt=0, description="Maximum number of tokens to include in the context" + ), + peer_target: str | None = Field( + None, + description="A peer ID to get context for.", + ), + last_user_message: str | Message | None = Field( + None, + description="The most recent message text (string or Message object), used to fetch semantically relevant conclusions.", + ), + peer_perspective: str | None = Field( + None, + description="A peer ID to get context from the perspective of.", + ), + limit_to_session: bool = Field( + False, + description="Whether to limit the representation to this session only.", + ), + search_top_k: int | None = Field( + None, + ge=1, + le=100, + description="Number of semantically relevant facts to return.", + ), + search_max_distance: float | None = Field( + None, + ge=0.0, + le=1.0, + description="Maximum semantic distance for search results (0.0-1.0).", + ), + include_most_frequent: bool | None = Field( + None, + description="Whether to include the most frequent conclusions in the representation.", + ), + max_conclusions: int | None = Field( + None, + ge=1, + le=100, + description="Maximum number of conclusions to include in the representation.", + ), + ) -> SessionContext: + """Get optimized context for this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + if peer_target is None and peer_perspective is not None: + raise ValueError( + "You must provide a `peer_target` when `peer_perspective` is provided" + ) + + if peer_target is None and last_user_message is not None: + raise ValueError( + "You must provide a `peer_target` when `last_user_message` is provided" + ) + + last_user_message_text = ( + last_user_message.content + if isinstance(last_user_message, Message) + else last_user_message + ) + + query: dict[str, Any] = { + "summary": summary, + "limit_to_session": limit_to_session, + } + if tokens is not None: + query["tokens"] = tokens + if last_user_message_text is not None: + query["last_message"] = last_user_message_text + if peer_target is not None: + query["peer_target"] = peer_target + if peer_perspective is not None: + query["peer_perspective"] = peer_perspective + if search_top_k is not None: + query["search_top_k"] = search_top_k + if search_max_distance is not None: + query["search_max_distance"] = search_max_distance + if include_most_frequent is not None: + query["include_most_frequent"] = include_most_frequent + if max_conclusions is not None: + query["max_conclusions"] = max_conclusions + + data = await self._session._honcho._async_http_client.get( + routes.session_context(self._session.workspace_id, self._session.id), + query=query, + ) + + session_summary = None + if data.get("summary"): + s = data["summary"] + session_summary = Summary( + content=s["content"], + message_id=s["message_id"], + summary_type=s["summary_type"], + created_at=s["created_at"], + token_count=s["token_count"], + ) + + messages = [ + Message.from_api_response(MessageResponse.model_validate(msg)) + for msg in data.get("messages", []) + ] + + return SessionContext( + session_id=self._session.id, + messages=messages, + summary=session_summary, + peer_representation=str(data.get("peer_representation")) + if data.get("peer_representation") + else None, + peer_card=data.get("peer_card"), + ) + + async def summaries(self) -> SessionSummaries: + """Get available summaries for this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + data = await self._session._honcho._async_http_client.get( + routes.session_summaries(self._session.workspace_id, self._session.id) + ) + + short_summary = None + if data.get("short_summary"): + s = data["short_summary"] + short_summary = Summary( + content=s["content"], + message_id=s["message_id"], + summary_type=s["summary_type"], + created_at=s["created_at"], + token_count=s["token_count"], + ) + + long_summary = None + if data.get("long_summary"): + s = data["long_summary"] + long_summary = Summary( + content=s["content"], + message_id=s["message_id"], + summary_type=s["summary_type"], + created_at=s["created_at"], + token_count=s["token_count"], + ) + + return SessionSummaries( + id=data.get("id") or self._session.id, + short_summary=short_summary, + long_summary=long_summary, + ) + + @validate_call + async def search( + self, + query: str = Field(..., min_length=1, description="The search query to use"), + filters: dict[str, object] | None = Field( + None, description="Filters to scope the search" + ), + limit: int = Field( + default=10, ge=1, le=100, description="Number of results to return" + ), + ) -> list[Message]: + """Search for messages in this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + data = await self._session._honcho._async_http_client.post( + routes.session_search(self._session.workspace_id, self._session.id), + body={"query": query, "filters": filters, "limit": limit}, + ) + return [ + Message.from_api_response(MessageResponse.model_validate(msg)) + for msg in data + ] + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def upload_file( + self, + file: tuple[str, bytes, str] | tuple[str, Any, str] | Any = Field( + ..., + description="File to upload. Can be a file object, (filename, bytes, content_type) tuple, or (filename, fileobj, content_type) tuple.", + ), + peer: str | PeerBase = Field( + ..., description="The peer creating the messages (ID string or Peer object)" + ), + metadata: dict[str, object] | None = Field( + None, + description="Optional metadata dictionary to associate with the messages", + ), + configuration: dict[str, Any] | None = Field( + None, + description="Optional configuration dictionary to associate with the messages", + ), + created_at: str | datetime | None = Field( + None, + description="Optional created-at timestamp for the messages.", + ), + ) -> list[Message]: + """Upload file to create message(s) in this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + filename, content_bytes, content_type = prepare_file_for_upload(file) + resolved_peer_id = peer if isinstance(peer, str) else peer.id + + data_dict: dict[str, str] = {"peer_id": resolved_peer_id} + if metadata is not None: + data_dict["metadata"] = json.dumps(metadata) + if configuration is not None: + data_dict["configuration"] = json.dumps(configuration) + created_at_iso = datetime_to_iso(created_at) + if created_at_iso is not None: + data_dict["created_at"] = created_at_iso + + response = await self._session._honcho._async_http_client.upload( + routes.messages_upload(self._session.workspace_id, self._session.id), + files={"file": (filename, content_bytes, content_type)}, + data=data_dict, + ) + + return [ + Message.from_api_response(MessageResponse.model_validate(msg)) + for msg in response + ] + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def representation( + self, + peer: str | PeerBase, + *, + target: str | PeerBase | None = None, + search_query: str | None = None, + search_top_k: int | None = Field(None, ge=1, le=100), + search_max_distance: float | None = Field(None, ge=0.0, le=1.0), + include_most_frequent: bool | None = None, + max_conclusions: int | None = Field(None, ge=1, le=100), + ) -> str: + """Get a subset of the representation of the peer in this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + peer_id = resolve_id(peer) + target_id = resolve_id(target) + + query: dict[str, Any] = {"session_id": self._session.id} + if target_id: + query["target"] = target_id + if search_query is not None: + query["search_query"] = search_query + if search_top_k is not None: + query["search_top_k"] = search_top_k + if search_max_distance is not None: + query["search_max_distance"] = search_max_distance + if include_most_frequent is not None: + query["include_most_frequent"] = include_most_frequent + if max_conclusions is not None: + query["max_conclusions"] = max_conclusions + + data = await self._session._honcho._async_http_client.post( + routes.peer_representation(self._session.workspace_id, peer_id), + body=query, + ) + response = RepresentationResponse.model_validate(data) + return response.representation + + async def queue_status( + self, + observer: str | PeerBase | None = None, + sender: str | PeerBase | None = None, + ) -> QueueStatusResponse: + """Get the queue processing status for this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + resolved_observer_id = resolve_id(observer) + resolved_sender_id = resolve_id(sender) + + query: dict[str, Any] = {"session_id": self._session.id} + if resolved_observer_id: + query["observer_id"] = resolved_observer_id + if resolved_sender_id: + query["sender_id"] = resolved_sender_id + + data = await self._session._honcho._async_http_client.get( + routes.workspace_queue_status(self._session.workspace_id), + query=query, + ) + return QueueStatusResponse.model_validate(data) + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def update_message( + self, + message: Message | str = Field( + ..., description="The Message object or message ID to update" + ), + metadata: dict[str, object] = Field( + ..., description="The metadata to update for the message" + ), + ) -> Message: + """Update message metadata in this session asynchronously.""" + await self._session._honcho._ensure_workspace_async() + message_id = message.id if isinstance(message, Message) else message + + data = await self._session._honcho._async_http_client.put( + routes.message(self._session.workspace_id, self._session.id, message_id), + body={"metadata": metadata}, + ) + return Message.from_api_response(MessageResponse.model_validate(data)) + + +class ConclusionScopeAio: + """ + Async view of a ConclusionScope. + + Access via `scope.aio`. Provides async versions of all ConclusionScope methods. + Shares state with the parent ConclusionScope instance. + """ + + __slots__: ClassVar[tuple[str, ...]] = ("_scope",) + _scope: "ConclusionScope" + + def __init__(self, scope: "ConclusionScope") -> None: + self._scope = scope + + async def list( + self, + page: int = 1, + size: int = 50, + session: str | SessionBase | None = None, + ) -> AsyncPage[ConclusionResponse, Conclusion]: + """List conclusions in this scope asynchronously.""" + await self._scope._honcho._ensure_workspace_async() + resolved_session_id = resolve_id(session) + filters: dict[str, Any] = { + "observer_id": self._scope.observer, + "observed_id": self._scope.observed, + } + if resolved_session_id: + filters["session_id"] = resolved_session_id + + data = await self._scope._honcho._async_http_client.post( + routes.conclusions_list(self._scope.workspace_id), + body={"filters": filters}, + query={"page": page, "size": size}, + ) + + def transform(response: ConclusionResponse) -> Conclusion: + return Conclusion.from_api_response(response) + + async def fetch_next( + page: int, + ) -> AsyncPage[ConclusionResponse, Conclusion]: + 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}, + ) + return AsyncPage(next_data, ConclusionResponse, transform, fetch_next) + + return AsyncPage(data, ConclusionResponse, transform, fetch_next) + + async def query( + self, + query: str, + top_k: int = 10, + distance: float | None = None, + ) -> list[Conclusion]: + """Semantic search for conclusions asynchronously.""" + await self._scope._honcho._ensure_workspace_async() + filters: dict[str, Any] = { + "observer_id": self._scope.observer, + "observed_id": self._scope.observed, + } + + body: dict[str, Any] = { + "query": query, + "top_k": top_k, + "filters": filters, + } + if distance is not None: + body["distance"] = distance + + data = await self._scope._honcho._async_http_client.post( + routes.conclusions_query(self._scope.workspace_id), + body=body, + ) + return [ + Conclusion.from_api_response(ConclusionResponse.model_validate(item)) + for item in data + ] + + async def delete(self, conclusion_id: str) -> None: + """Delete a conclusion by ID asynchronously.""" + await self._scope._honcho._ensure_workspace_async() + await self._scope._honcho._async_http_client.delete( + routes.conclusion(self._scope.workspace_id, conclusion_id) + ) + + async def create( + self, + conclusions: list[ConclusionCreateParams | dict[str, Any]], + ) -> list[Conclusion]: + """Create conclusions in this scope asynchronously.""" + await self._scope._honcho._ensure_workspace_async() + conclusion_params = [ + { + "content": c.content + if isinstance(c, ConclusionCreateParams) + else c["content"], + "session_id": c.session_id + if isinstance(c, ConclusionCreateParams) + else c["session_id"], + "observer_id": self._scope.observer, + "observed_id": self._scope.observed, + } + for c in conclusions + ] + + data = await self._scope._honcho._async_http_client.post( + routes.conclusions(self._scope.workspace_id), + body={"conclusions": conclusion_params}, + ) + return [ + Conclusion.from_api_response(ConclusionResponse.model_validate(item)) + for item in data + ] + + async def representation( + self, + search_query: str | None = None, + search_top_k: int | None = None, + search_max_distance: float | None = None, + include_most_frequent: bool | None = None, + max_conclusions: int | None = None, + ) -> str: + """Get the computed representation for this scope asynchronously.""" + await self._scope._honcho._ensure_workspace_async() + body: dict[str, Any] = {"target": self._scope.observed} + if search_query is not None: + body["search_query"] = search_query + if search_top_k is not None: + body["search_top_k"] = search_top_k + if search_max_distance is not None: + body["search_max_distance"] = search_max_distance + if include_most_frequent is not None: + body["include_most_frequent"] = include_most_frequent + if max_conclusions is not None: + body["max_conclusions"] = max_conclusions + + data = await self._scope._honcho._async_http_client.post( + routes.peer_representation(self._scope.workspace_id, self._scope.observer), + body=body, + ) + response = RepresentationResponse.model_validate(data) + return response.representation diff --git a/sdks/python/src/honcho/api_types.py b/sdks/python/src/honcho/api_types.py new file mode 100644 index 00000000..c5858a40 --- /dev/null +++ b/sdks/python/src/honcho/api_types.py @@ -0,0 +1,484 @@ +"""API types for Honcho SDK. + +These types mirror the server's Pydantic schemas for API responses and requests. +""" + +from __future__ import annotations + +import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +# ============================================================================== +# Configuration Types +# ============================================================================== + + +class ReasoningConfiguration(BaseModel): + """Configuration for reasoning functionality.""" + + enabled: bool | None = None + custom_instructions: str | None = None + + +class PeerCardConfiguration(BaseModel): + """Configuration for peer card functionality.""" + + use: bool | None = None + create: bool | None = None + + +class SummaryConfiguration(BaseModel): + """Configuration for summary functionality.""" + + enabled: bool | None = None + messages_per_short_summary: int | None = None + messages_per_long_summary: int | None = None + + +class DreamConfiguration(BaseModel): + """Configuration for dream functionality.""" + + enabled: bool | None = None + + +class WorkspaceConfiguration(BaseModel): + """Workspace-level configuration options.""" + + model_config = ConfigDict(extra="allow") # pyright: ignore[reportUnannotatedClassAttribute] + + reasoning: ReasoningConfiguration | None = None + peer_card: PeerCardConfiguration | None = None + summary: SummaryConfiguration | None = None + dream: DreamConfiguration | None = None + + +class SessionConfiguration(WorkspaceConfiguration): + """Session-level configuration options.""" + + pass + + +class MessageConfiguration(BaseModel): + """Message-level configuration options.""" + + reasoning: ReasoningConfiguration | None = None + + +# ============================================================================== +# Peer Config Types +# ============================================================================== + + +class PeerConfig(BaseModel): + """Configuration for peer-level settings.""" + + observe_me: bool | None = None + """Whether Honcho will use reasoning to form a representation of this peer.""" + + +class SessionPeerConfig(BaseModel): + """Configuration for a peer within a session.""" + + 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", + ) + observe_me: bool | None = Field( + None, + description="Whether other peers in this session should try to form a session-level theory-of-mind representation of this peer", + ) + + +# ============================================================================== +# Workspace Types +# ============================================================================== + + +class WorkspaceResponse(BaseModel): + """Workspace API response.""" + + model_config = ConfigDict(populate_by_name=True) # pyright: ignore[reportUnannotatedClassAttribute] + + id: str + metadata: dict[str, Any] = Field(default_factory=dict) + configuration: WorkspaceConfiguration = Field( + default_factory=WorkspaceConfiguration + ) + created_at: datetime.datetime + + +class WorkspaceCreateParams(BaseModel): + """Parameters for creating a workspace.""" + + id: str = Field(min_length=1, max_length=100) + metadata: dict[str, Any] = Field(default_factory=dict) + configuration: WorkspaceConfiguration = Field( + default_factory=WorkspaceConfiguration + ) + + +class WorkspaceUpdateParams(BaseModel): + """Parameters for updating a workspace.""" + + metadata: dict[str, Any] | None = None + configuration: WorkspaceConfiguration | None = None + + +class WorkspaceListParams(BaseModel): + """Parameters for listing workspaces.""" + + filters: dict[str, Any] | None = None + + +# ============================================================================== +# Peer Types +# ============================================================================== + + +class PeerResponse(BaseModel): + """Peer API response.""" + + model_config = ConfigDict(populate_by_name=True) # pyright: ignore[reportUnannotatedClassAttribute] + + id: str + workspace_id: str + created_at: datetime.datetime + metadata: dict[str, Any] = Field(default_factory=dict) + configuration: PeerConfig = Field(default_factory=PeerConfig) + + +class PeerCreateParams(BaseModel): + """Parameters for creating a peer.""" + + id: str = Field(min_length=1, max_length=100) + metadata: dict[str, Any] | None = None + configuration: PeerConfig | None = None + + +class PeerUpdateParams(BaseModel): + """Parameters for updating a peer.""" + + metadata: dict[str, Any] | None = None + configuration: PeerConfig | None = None + + +class PeerListParams(BaseModel): + """Parameters for listing peers.""" + + filters: dict[str, Any] | None = None + + +class PeerRepresentationParams(BaseModel): + """Parameters for getting peer representation.""" + + session_id: str | None = None + target: str | None = None + search_query: str | None = None + search_top_k: int | None = Field(default=None, ge=1, le=100) + search_max_distance: float | None = Field(default=None, ge=0.0, le=1.0) + include_most_frequent: bool | None = None + max_conclusions: int | None = Field(default=25, ge=1, le=100) + + +class RepresentationResponse(BaseModel): + """Representation API response.""" + + representation: str + + +class PeerCardResponse(BaseModel): + """Peer card API response.""" + + peer_card: list[str] | None = None + + +class PeerContextResponse(BaseModel): + """Peer context API response.""" + + peer_id: str + target_id: str + representation: str | None = None + peer_card: list[str] | None = None + + +# ============================================================================== +# Session Types +# ============================================================================== + + +class SessionResponse(BaseModel): + """Session API response.""" + + model_config = ConfigDict(populate_by_name=True) # pyright: ignore[reportUnannotatedClassAttribute] + + id: str + is_active: bool + workspace_id: str + metadata: dict[str, Any] = Field(default_factory=dict) + configuration: SessionConfiguration = Field(default_factory=SessionConfiguration) + created_at: datetime.datetime + + +class SessionCreateParams(BaseModel): + """Parameters for creating a session.""" + + id: str = Field(min_length=1, max_length=100) + metadata: dict[str, Any] | None = None + peers: dict[str, SessionPeerConfig] | None = None + configuration: SessionConfiguration | None = None + + +class SessionUpdateParams(BaseModel): + """Parameters for updating a session.""" + + metadata: dict[str, Any] | None = None + configuration: SessionConfiguration | None = None + + +class SessionListParams(BaseModel): + """Parameters for listing sessions.""" + + filters: dict[str, Any] | None = None + + +# ============================================================================== +# Summary Types +# ============================================================================== + + +class Summary(BaseModel): + """Summary model.""" + + content: str + message_id: str + summary_type: str + created_at: str + token_count: int + + +class SessionSummariesResponse(BaseModel): + """Session summaries API response.""" + + model_config = ConfigDict(populate_by_name=True) # pyright: ignore[reportUnannotatedClassAttribute] + + id: str + short_summary: Summary | None = None + long_summary: Summary | None = None + + +# ============================================================================== +# Session Context Types +# ============================================================================== + + +class SessionContextResponse(BaseModel): + """Session context API response.""" + + model_config = ConfigDict(populate_by_name=True) # pyright: ignore[reportUnannotatedClassAttribute] + + id: str + messages: list["MessageResponse"] + summary: Summary | None = None + peer_representation: str | None = None + peer_card: list[str] | None = None + + +# ============================================================================== +# Message Types +# ============================================================================== + + +class MessageResponse(BaseModel): + """Message API response.""" + + model_config = ConfigDict(populate_by_name=True) # pyright: ignore[reportUnannotatedClassAttribute] + + id: str + content: str + peer_id: str + session_id: str + metadata: dict[str, Any] = Field(default_factory=dict) + created_at: datetime.datetime + workspace_id: str + token_count: int + + +class MessageCreateParams(BaseModel): + """Parameters for creating a message.""" + + content: str + peer_id: str + metadata: dict[str, Any] | None = None + configuration: MessageConfiguration | None = None + created_at: datetime.datetime | None = None + + +class MessageBatchCreateParams(BaseModel): + """Parameters for batch message creation.""" + + messages: list[MessageCreateParams] = Field(min_length=1, max_length=100) + + +class MessageUpdateParams(BaseModel): + """Parameters for updating a message.""" + + metadata: dict[str, Any] | None = None + + +class MessageListParams(BaseModel): + """Parameters for listing messages.""" + + filters: dict[str, Any] | None = None + + +class MessageSearchParams(BaseModel): + """Parameters for searching messages.""" + + query: str + filters: dict[str, Any] | None = None + limit: int = Field(default=10, ge=1, le=100) + + +# ============================================================================== +# Conclusion Types +# ============================================================================== + + +class ConclusionResponse(BaseModel): + """Conclusion API response.""" + + model_config = ConfigDict(populate_by_name=True) # pyright: ignore[reportUnannotatedClassAttribute] + + id: str + content: str + observer_id: str + observed_id: str + session_id: str + created_at: datetime.datetime + + +class ConclusionCreateParams(BaseModel): + """Parameters for creating a conclusion.""" + + content: str = Field(min_length=1, max_length=65535) + observer_id: str + observed_id: str + session_id: str + + +class ConclusionBatchCreateParams(BaseModel): + """Parameters for batch conclusion creation.""" + + conclusions: list[ConclusionCreateParams] = Field(min_length=1, max_length=100) + + +class ConclusionListParams(BaseModel): + """Parameters for listing conclusions.""" + + filters: dict[str, Any] | None = None + + +class ConclusionQueryParams(BaseModel): + """Parameters for querying conclusions.""" + + query: str + top_k: int = Field(default=10, ge=1, le=100) + distance: float | None = Field(default=None, ge=0.0, le=1.0) + filters: dict[str, Any] | None = None + + +# ============================================================================== +# Queue Status Types +# ============================================================================== + + +class SessionQueueStatus(BaseModel): + """Status for a specific session in the queue.""" + + session_id: str | None = None + total_work_units: int + completed_work_units: int + in_progress_work_units: int + pending_work_units: int + + +class QueueStatusResponse(BaseModel): + """Queue status API response.""" + + total_work_units: int + completed_work_units: int + in_progress_work_units: int + pending_work_units: int + sessions: dict[str, SessionQueueStatus] | None = None + + +# ============================================================================== +# Dialectic (Chat) Types +# ============================================================================== + + +ReasoningLevel = Literal["minimal", "low", "medium", "high", "max"] + + +class DialecticParams(BaseModel): + """Parameters for dialectic chat.""" + + session_id: str | None = None + target: str | None = None + query: str = Field(min_length=1, max_length=10000) + stream: bool = False + reasoning_level: ReasoningLevel = "low" + + +class DialecticResponse(BaseModel): + """Dialectic chat API response.""" + + content: str | None + + +class DialecticStreamDelta(BaseModel): + """Delta for streaming dialectic responses.""" + + content: str | None = None + + +class DialecticStreamChunk(BaseModel): + """Chunk in a streaming dialectic response.""" + + delta: DialecticStreamDelta + done: bool = False + + +# ============================================================================== +# Pagination Types +# ============================================================================== + + +class PageResponse(BaseModel): + """Generic paginated response.""" + + items: list[Any] + page: int + size: int + total: int + pages: int + + +# ============================================================================== +# File Upload Types +# ============================================================================== + + +class MessageUploadParams(BaseModel): + """Parameters for file upload message creation.""" + + peer_id: str + metadata: dict[str, Any] | None = None + configuration: MessageConfiguration | None = None + created_at: datetime.datetime | None = None + + +# Update forward reference +SessionContextResponse.model_rebuild() diff --git a/sdks/python/src/honcho/async_client/__init__.py b/sdks/python/src/honcho/async_client/__init__.py deleted file mode 100644 index c02a9b72..00000000 --- a/sdks/python/src/honcho/async_client/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -Async client module for the Honcho Python SDK. - -Provides async versions of all client classes for asynchronous operations -with the Honcho conversational memory platform. -""" - -from .client import AsyncHoncho -from .pagination import AsyncPage -from .peer import AsyncPeer -from .session import AsyncSession - -__all__ = [ - "AsyncHoncho", - "AsyncPeer", - "AsyncSession", - "AsyncPage", -] diff --git a/sdks/python/src/honcho/async_client/client.py b/sdks/python/src/honcho/async_client/client.py deleted file mode 100644 index 814d89db..00000000 --- a/sdks/python/src/honcho/async_client/client.py +++ /dev/null @@ -1,776 +0,0 @@ -import asyncio -import logging -import os -import time -from collections.abc import Mapping -from typing import Any, Literal - -import httpx -from honcho_core import AsyncHoncho as AsyncHonchoCore -from honcho_core import Honcho as HonchoCore -from honcho_core.types.workspaces import QueueStatusResponse -from honcho_core.types.workspaces.peer import Peer as PeerCore -from honcho_core.types.workspaces.session import Session as SessionCore -from honcho_core.types.workspaces.sessions.message import Message -from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call - -from ..base import PeerBase, SessionBase -from .pagination import AsyncPage -from .peer import AsyncPeer -from .session import AsyncSession - -logger = logging.getLogger(__name__) - - -class AsyncHoncho(BaseModel): - """ - Main async client for the Honcho SDK. - - Provides async access to peers, sessions, and workspace operations with configuration - from environment variables or explicit parameters. This is the primary entry - point for interacting with the Honcho conversational memory platform asynchronously. - - For advanced usage, the underlying honcho_core client can be accessed via the - `core` property to use functionality not exposed through this SDK. - - Attributes: - workspace_id: Workspace ID for scoping operations - metadata: Cached metadata for this workspace. May be stale if not recently - fetched. Call get_metadata() for fresh data. - configuration: Cached configuration for this workspace. May be stale if not - recently fetched. Call get_config() for fresh data. - core: Access to the underlying honcho_core client for advanced usage - """ - - model_config = ConfigDict(extra="allow") # pyright: ignore - - workspace_id: str = Field( - ..., - min_length=1, - description="Workspace ID for scoping operations", - ) - _metadata: dict[str, object] | None = PrivateAttr(default=None) - _configuration: dict[str, object] | None = PrivateAttr(default=None) - _client: AsyncHonchoCore = PrivateAttr() - - @property - def metadata(self) -> dict[str, object] | None: - """Cached metadata for this workspace. May be stale. Use get_metadata() for fresh data.""" - return self._metadata - - @property - def configuration(self) -> dict[str, object] | None: - """Cached configuration for this workspace. May be stale. Use get_config() for fresh data.""" - return self._configuration - - @property - def core(self) -> AsyncHonchoCore: - """ - Access the underlying honcho_core client. The honcho_core client is the raw Stainless-generated client, - allowing users to access functionality that is not exposed through this SDK. - - Returns: - The underlying AsyncHonchoCore client instance - - Example: - ```python - from honcho import AsyncHoncho - - client = AsyncHoncho() - - workspace = await client.core.workspaces.get_or_create(id="custom-workspace-id") - ``` - """ - return self._client - - @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) - def __init__( - self, - api_key: str | None = None, - environment: Literal["local", "production", "demo"] | None = None, - base_url: str | None = Field(None, description="Base URL for the Honcho API"), - workspace_id: str | None = Field( - None, min_length=1, description="Workspace ID for scoping operations" - ), - timeout: float | None = Field(None, gt=0, description="Timeout in seconds"), - max_retries: int | None = Field( - None, ge=0, description="Maximum number of retries" - ), - default_headers: Mapping[str, str] | None = None, - default_query: Mapping[str, object] | None = None, - async_http_client: httpx.AsyncClient | None = Field( - None, description="Custom HTTP client" - ), - http_client: httpx.Client | None = Field( - None, description="Custom HTTP client" - ), - ) -> None: - """ - Initialize the AsyncHoncho client. - - Args: - api_key: - API key for authentication. If not provided, will attempt to - read from HONCHO_API_KEY environment variable - environment: - Environment to use (local or production) - base_url: - Base URL for the Honcho API. If not provided, will attempt to - read from HONCHO_URL environment variable or default to the - production API URL - workspace_id: - Workspace ID to use for operations. If not provided, will - attempt to read from HONCHO_WORKSPACE_ID environment variable - or default to "default" - timeout: - Optional custom timeout for the HTTP client. - max_retries: - Optional custom maximum number of retries for the HTTP client. - default_headers: - Optional custom default headers for the HTTP client. - default_query: - Optional custom default query parameters for the HTTP client. - http_client: - Optional custom httpx client. - """ - # Resolve workspace_id before calling super().__init__ - resolved_workspace_id = workspace_id or os.getenv( - "HONCHO_WORKSPACE_ID", "default" - ) - - super().__init__(workspace_id=resolved_workspace_id) - - # Build client kwargs, excluding None values that AsyncHonchoCore doesn't handle well - client_kwargs: dict[str, Any] = {} - - if api_key is not None: - client_kwargs["api_key"] = api_key - if environment is not None: - client_kwargs["environment"] = environment - if base_url is not None: - client_kwargs["base_url"] = base_url - if timeout is not None: - client_kwargs["timeout"] = timeout - if max_retries is not None: - client_kwargs["max_retries"] = max_retries - if default_headers is not None: - client_kwargs["default_headers"] = default_headers - if default_query is not None: - client_kwargs["default_query"] = default_query - - sync_client_kwargs = client_kwargs.copy() - async_client_kwargs = client_kwargs.copy() - - if http_client is not None: - sync_client_kwargs["http_client"] = http_client - if async_http_client is not None: - async_client_kwargs["http_client"] = async_http_client - - self._client = AsyncHonchoCore(**async_client_kwargs) - - # Get or create the workspace using synchronous client - sync_client = HonchoCore(**sync_client_kwargs) - sync_client.workspaces.get_or_create(id=self.workspace_id) - - @validate_call - async def peer( - self, - id: str = Field( - ..., min_length=1, description="Unique identifier for the peer" - ), - *, - metadata: dict[str, object] | None = Field( - None, - description="Optional metadata dictionary to associate with this peer. If set, will get/create peer immediately with metadata.", - ), - config: dict[str, object] | None = Field( - None, - description="Optional configuration to set for this peer. If set, will get/create peer immediately with flags.", - ), - ) -> AsyncPeer: - """ - Get or create a peer with the given ID. - - Creates an AsyncPeer object that can be used to interact with the specified peer. - This method does not make an API call unless `config` or `metadata` is - provided. - - Args: - id: Unique identifier for the peer within the workspace. Should be a - stable identifier that can be used consistently across sessions - metadata: Optional metadata dictionary to associate with this peer. - If set, will get/create peer immediately with metadata. - config: Optional configuration to set for this peer. - If set, will get/create peer immediately with flags. - - Returns: - An AsyncPeer 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 - """ - if config or metadata: - return await AsyncPeer.create( - id, self.workspace_id, self._client, config=config, metadata=metadata - ) - return AsyncPeer(id, self.workspace_id, self._client) - - async def get_peers( - self, filters: dict[str, object] | None = None - ) -> AsyncPage[PeerCore, AsyncPeer]: - """ - Get all peers in the current workspace. - - Makes an async 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 AsyncPeer objects as they are consumed. - - Returns: - An AsyncPage of AsyncPeer objects representing all peers in the workspace - """ - peers_page = await self._client.workspaces.peers.list( - workspace_id=self.workspace_id, filters=filters - ) - return AsyncPage( - peers_page, - lambda peer: AsyncPeer( - peer.id, - self.workspace_id, - self._client, - metadata=peer.metadata, - config=peer.configuration, - ), - ) - - @validate_call - async def session( - self, - id: str = Field( - ..., min_length=1, description="Unique identifier for the session" - ), - *, - metadata: dict[str, object] | None = Field( - None, - description="Optional metadata dictionary to associate with this session. If set, will get/create session immediately with metadata.", - ), - config: dict[str, object] | None = Field( - None, - description="Optional configuration to set for this session. If set, will get/create session immediately with flags.", - ), - ) -> AsyncSession: - """ - Get or create a session with the given ID. - - Creates an AsyncSession object that can be used to manage conversations between - multiple peers. This method does not make an API call unless `config` or - `metadata` is provided. - - 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 - metadata: Optional metadata dictionary to associate with this session. - If set, will get/create session immediately with metadata. - config: Optional configuration to set for this session. - If set, will get/create session immediately with flags. - Returns: - An AsyncSession object that can be used to add peers, send messages, and - manage conversation context - - Raises: - ValidationError: If the session ID is empty or invalid - """ - if config or metadata: - return await AsyncSession.create( - id, self.workspace_id, self._client, config=config, metadata=metadata - ) - return AsyncSession(id, self.workspace_id, self._client) - - async def get_sessions( - self, filters: dict[str, object] | None = None - ) -> AsyncPage[SessionCore, AsyncSession]: - """ - Get all sessions in the current workspace. - - Makes an async API call to retrieve all sessions that have been created within - the current workspace. - - Returns: - An AsyncPage of AsyncSession objects representing all sessions in the workspace. - Returns an empty page if no sessions exist - """ - sessions_page = await self._client.workspaces.sessions.list( - workspace_id=self.workspace_id, filters=filters - ) - return AsyncPage( - sessions_page, - lambda session: AsyncSession( - session.id, - self.workspace_id, - self._client, - metadata=session.metadata, - config=session.configuration, - ), - ) - - async def get_metadata(self) -> dict[str, object]: - """ - Get metadata for the current workspace. - - Makes an async API call to retrieve metadata associated with the current workspace. - Workspace metadata can include settings, configuration, or any other - key-value data associated with the workspace. This method also updates the - cached metadata attribute. - - Returns: - A dictionary containing the workspace's metadata. Returns an empty - dictionary if no metadata is set - """ - workspace = await self._client.workspaces.get_or_create(id=self.workspace_id) - self._metadata = workspace.metadata or {} - return self._metadata - - @validate_call - async def set_metadata( - self, - metadata: dict[str, object] = Field(..., description="Metadata dictionary"), - ) -> None: - """ - Set metadata for the current workspace. - - Makes an async API call to update the metadata associated with the current workspace. - This will overwrite any existing metadata with the provided values. - This method also updates the cached metadata attribute. - - Args: - metadata: A dictionary of metadata to associate with the workspace. - Keys must be strings, values can be any JSON-serializable type - """ - await self._client.workspaces.update(self.workspace_id, metadata=metadata) - self._metadata = metadata - - async def get_config(self) -> dict[str, object]: - """ - Get configuration for the current workspace. - - Makes an async API call to retrieve configuration associated with the current workspace. - Configuration includes settings that control workspace behavior. - This method also updates the cached configuration attribute. - - Returns: - A dictionary containing the workspace's configuration. Returns an empty - dictionary if no configuration is set - """ - workspace = await self._client.workspaces.get_or_create(id=self.workspace_id) - self._configuration = workspace.configuration or {} - return self._configuration - - @validate_call - async def set_config( - self, - configuration: dict[str, object] = Field( - ..., description="Configuration dictionary" - ), - ) -> None: - """ - Set configuration for the current workspace. - - Makes an async API call to update the configuration associated with the current workspace. - This will overwrite any existing configuration with the provided values. - This method also updates the cached configuration attribute. - - Args: - configuration: A dictionary of configuration to associate with the workspace. - Keys must be strings, values can be any JSON-serializable type - """ - await self._client.workspaces.update( - self.workspace_id, configuration=configuration - ) - self._configuration = configuration - - async def refresh(self) -> None: - """ - Refresh cached metadata and configuration for the current workspace. - - Makes a single async API call to retrieve the latest metadata and configuration - associated with the current workspace and updates the cached attributes. - """ - workspace = await self._client.workspaces.get_or_create(id=self.workspace_id) - self._metadata = workspace.metadata or {} - self._configuration = workspace.configuration or {} - - async def get_workspaces( - self, filters: dict[str, object] | None = None - ) -> list[str]: - """ - Get all workspace IDs from the Honcho instance. - - Makes an async API call to retrieve all workspace IDs that the authenticated - user has access to. - - Returns: - A list of workspace ID strings. Returns an empty list if no workspaces - are accessible or none exist - """ - workspaces_page = await self._client.workspaces.list(filters=filters) - workspace_ids: list[str] = [] - async for workspace in workspaces_page: - workspace_ids.append(workspace.id) - return workspace_ids - - @validate_call - async def delete_workspace( - self, - workspace_id: str = Field( - ..., min_length=1, description="ID of the workspace to delete" - ), - ) -> None: - """ - Delete a workspace. - - Makes an async API call to delete the specified workspace. This action cannot be undone. - - Args: - workspace_id: The ID of the workspace to delete - """ - await self._client.workspaces.delete(workspace_id) - - @validate_call - async def search( - self, - query: str = Field(..., min_length=1, description="The search query to use"), - filters: dict[str, object] | None = Field( - None, description="Filters to scope the search" - ), - limit: int = Field( - default=10, ge=1, le=100, description="Number of results to return" - ), - ) -> list[Message]: - """ - Search for messages in the current workspace. - - Makes an async API call to search for messages in the current workspace. - - Args: - query: The search query to use - filters: Filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v2/guides/using-filters). - limit: Number of results to return (1-100, default: 10) - - Returns: - A list of Message objects representing the search results. - Returns an empty list if no messages are found. - """ - return await self._client.workspaces.search( - self.workspace_id, - query=query, - filters=filters, - limit=limit, - ) - - @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) - async def get_queue_status( - self, - observer: str | PeerBase | None = None, - sender: str | PeerBase | None = None, - session: str | SessionBase | None = None, - ) -> QueueStatusResponse: - """ - Get the queue processing status, optionally scoped to an observer, sender, and/or session. - - Args: - observer: Optional observer (ID string or Peer object) to scope the status check - sender: Optional sender (ID string or Peer object) to scope the status check - session: Optional session (ID string or Session object) to scope the status check - """ - resolved_observer_id = ( - None - if observer is None - else (observer if isinstance(observer, str) else observer.id) - ) - resolved_sender_id = ( - None - if sender is None - else (sender if isinstance(sender, str) else sender.id) - ) - resolved_session_id = ( - None - if session is None - else (session if isinstance(session, str) else session.id) - ) - - return await self._client.workspaces.queue.status( - workspace_id=self.workspace_id, - observer_id=resolved_observer_id, - sender_id=resolved_sender_id, - session_id=resolved_session_id, - ) - - @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) - async def poll_queue_status( - self, - observer: str | PeerBase | None = None, - sender: str | PeerBase | None = None, - session: str | SessionBase | None = None, - timeout: float = Field( - 300.0, - gt=0, - description="Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).", - ), - ) -> QueueStatusResponse: - """ - Poll get_queue_status until pending_work_units and in_progress_work_units are both 0. - This allows you to guarantee that all messages have been processed by the queue for - use with the chat endpoint. - - The polling estimates sleep time by assuming each work unit takes 1 second. - - Args: - observer: Optional observer (ID string or AsyncPeer object) to scope the status check - sender: Optional sender (ID string or AsyncPeer object) to scope the status check - session: Optional session (ID string or AsyncSession object) to scope the status check - timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds). - - Returns: - QueueStatusResponse when all work units are complete - - Raises: - TimeoutError: If timeout is exceeded before work units complete - Exception: If get_queue_status fails repeatedly - """ - start_time = time.time() - - while True: - try: - status = await self.get_queue_status(observer, sender, session) - except Exception as e: - logger.warning(f"Failed to get queue status: {e}") - # Sleep briefly before retrying - await asyncio.sleep(1) - - # Check timeout after error - elapsed_time = time.time() - start_time - if elapsed_time >= timeout: - raise TimeoutError( - f"Polling timeout exceeded after {timeout}s. " - + f"Error during status check: {e}" - ) from e - continue - - if status.pending_work_units == 0 and status.in_progress_work_units == 0: - return status - - # Check timeout before sleeping - elapsed_time = time.time() - start_time - if elapsed_time >= timeout: - raise TimeoutError( - f"Polling timeout exceeded after {timeout}s. " - + f"Current status: {status.pending_work_units} pending, " - + f"{status.in_progress_work_units} in progress work units." - ) - - # Sleep for the expected time to complete all current work units - # Assuming each pending and in-progress work unit takes 1 second - total_work_units = status.pending_work_units + status.in_progress_work_units - sleep_time = max(1, total_work_units) - - # Don't sleep past the timeout - remaining_time = timeout - elapsed_time - sleep_time = min(sleep_time, remaining_time) - if sleep_time <= 0: - raise TimeoutError( - f"Polling timeout exceeded after {timeout}s. " - + f"Current status: {status.pending_work_units} pending, " - + f"{status.in_progress_work_units} in progress work units." - ) - - await asyncio.sleep(sleep_time) - - @validate_call - async def list_conclusions( - self, - filters: dict[str, object] | None = Field( - None, description="Filters to scope the conclusions" - ), - reverse: bool = Field( - False, description="Whether to reverse the order of results" - ), - ): - """ - List all conclusions in the current workspace with optional filtering. - - Makes an async API call to retrieve conclusions that match the specified filters. - Conclusions can be filtered by session_id, observer_id, and observed_id. - - Args: - filters: Optional filter criteria for conclusions. Supported filters include: - - session_id: Filter conclusions by session - - observer_id: Filter conclusions by observer peer - - observed_id: Filter conclusions by observed peer - reverse: Whether to reverse the order of results (default: False) - - Returns: - A paginated list of Conclusion objects matching the specified criteria - - Example: - >>> conclusions = await client.list_conclusions( - ... filters={"observer_id": "user123", "observed_id": "assistant"} - ... ) - """ - return await self._client.workspaces.conclusions.list( - workspace_id=self.workspace_id, - filters=filters, - reverse=reverse, - ) - - @validate_call - async def query_conclusions( - self, - query: str = Field(..., min_length=1, description="Semantic search query"), - observer: str = Field( - ..., min_length=1, description="Observer peer ID (required)" - ), - observed: str = Field( - ..., min_length=1, description="Observed peer ID (required)" - ), - top_k: int = Field( - default=10, ge=1, le=100, description="Number of results to return" - ), - distance: float | None = Field( - default=None, - ge=0.0, - le=1.0, - description="Maximum cosine distance threshold for results", - ), - filters: dict[str, object] | None = Field( - None, description="Additional filters to apply" - ), - ): - """ - Query conclusions using semantic search. - - Performs vector similarity search on conclusions to find semantically relevant results. - Observer and observed peer IDs are required for semantic search. - - Args: - query: The semantic search query - observer: The observer peer ID (required) - observed: The observed peer ID (required) - top_k: Number of results to return (1-100, default: 10) - distance: Maximum cosine distance threshold for results (0.0-1.0) - filters: Optional filters to scope the query - - Returns: - A list of Conclusion objects matching the query - - Example: - >>> conclusions = await client.query_conclusions( - ... query="user preferences about music", - ... observer="user123", - ... observed="assistant", - ... top_k=5, - ... distance=0.8 - ... ) - """ - # Merge observer/observed into filters without mutating the input - query_filters: dict[str, object | str] = { - **(filters or {}), - "observer": observer, - "observed": observed, - } - - return await self._client.workspaces.conclusions.query( - workspace_id=self.workspace_id, - query=query, - top_k=top_k, - distance=distance, - filters=query_filters, - ) - - @validate_call - async def delete_conclusion( - self, - conclusion_id: str = Field( - ..., min_length=1, description="ID of the conclusion to delete" - ), - ) -> None: - """ - Delete a specific conclusion by ID. - - This permanently deletes the conclusion (document) from the theory-of-mind system. - This action cannot be undone. - - Args: - conclusion_id: The ID of the conclusion to delete - - Example: - >>> await client.delete_conclusion('con_123abc') - """ - await self._client.workspaces.conclusions.delete( - workspace_id=self.workspace_id, - conclusion_id=conclusion_id, - ) - - @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) - async def update_message( - self, - message: Message | str = Field( - ..., description="The Message object or message ID to update" - ), - metadata: dict[str, object] = Field( - ..., description="The metadata to update for the message" - ), - session: str | SessionBase | None = Field( - None, - description="The session (ID string or Session object) - required if message is a string ID", - ), - ) -> Message: - """ - Update the metadata of a message. - - Makes an API call to update the metadata of a specific message within a session. - - Args: - message: Either a Message object or a message ID string - metadata: The metadata to update for the message - session: The session (ID string or Session object) - required if message is a string ID, ignored if message is a Message object - - Returns: - The updated Message object - - Raises: - ValidationError: If message is a string ID but session_id is not provided - """ - if isinstance(message, Message): - message_id = message.id - resolved_session_id = message.session_id - else: - message_id = message - if not session: - raise ValueError("session is required when message is a string ID") - resolved_session_id = session if isinstance(session, str) else session.id - - return await self._client.workspaces.sessions.messages.update( - message_id=message_id, - workspace_id=self.workspace_id, - session_id=resolved_session_id, - metadata=metadata, - ) - - def __repr__(self) -> str: - """ - Return a string representation of the AsyncHoncho client. - - Returns: - A string representation suitable for debugging - """ - return f"AsyncHoncho(workspace_id='{self.workspace_id}', base_url='{self._client.base_url}')" - - def __str__(self) -> str: - """ - Return a human-readable string representation of the AsyncHoncho client. - - Returns: - A string showing the workspace ID - """ - return f"AsyncHoncho Client (workspace: {self.workspace_id})" diff --git a/sdks/python/src/honcho/async_client/pagination.py b/sdks/python/src/honcho/async_client/pagination.py deleted file mode 100644 index 4fd3ae6e..00000000 --- a/sdks/python/src/honcho/async_client/pagination.py +++ /dev/null @@ -1,103 +0,0 @@ -from collections.abc import AsyncIterator, Callable - -from honcho_core.pagination import AsyncPage as AsyncPageCore -from typing_extensions import Generic, TypeVar - -T = TypeVar("T") -U = TypeVar("U", default=T) - - -class AsyncPage(Generic[T, U]): - """ - Async paginated result wrapper that transforms objects from type T to type U. - - Provides async iteration and transformation capabilities while preserving - pagination functionality from the underlying core AsyncPage. - """ - - _original_page: AsyncPageCore[T] - _transform_func: Callable[[T], U] | None - - def __init__( - self, - original_page: AsyncPageCore[T], - transform_func: Callable[[T], U] | None = None, - ) -> None: - """ - Initialize the transformed async page. - - Args: - original_page: The original AsyncPage to wrap - transform_func: Optional function to transform objects from type T to type U. - If None, objects are passed through unchanged. - """ - self._original_page = original_page - self._transform_func = transform_func - - async def __aiter__(self) -> AsyncIterator[U] | AsyncIterator[T]: - """Async iterate over all transformed items across all pages.""" - async for item in self._original_page: - if self._transform_func is not None: - yield self._transform_func(item) - else: - yield item - - def __getitem__(self, index: int) -> U | T: - """Get a transformed item by index on the current page.""" - items = self._original_page.items or [] - item = items[index] - if self._transform_func is not None: - return self._transform_func(item) - return item - - def __len__(self) -> int: - """Get the number of items on the current page.""" - items = self._original_page.items or [] - return len(items) - - @property - def items(self) -> list[U] | list[T]: - """Get all transformed items on the current page.""" - items = self._original_page.items or [] - if self._transform_func is not None: - return [self._transform_func(item) for item in items] - return items - - @property - def total(self) -> int | None: - """Get the total number of items across all pages.""" - return self._original_page.total - - @property - def page(self) -> int | None: - """Get the current page number.""" - return self._original_page.page - - @property - def size(self) -> int | None: - """Get the page size.""" - return self._original_page.size - - @property - def pages(self) -> int | None: - """Get the total number of pages.""" - return self._original_page.pages - - def has_next_page(self) -> bool: - """Check if there's a next page.""" - return self._original_page.has_next_page() - - async def get_next_page(self) -> "AsyncPage[T, U] | None": - """ - Fetch the next page of results. - - Returns None if there are no more pages. - """ - if not hasattr(self._original_page, "get_next_page"): - return None - - next_original_page = await self._original_page.get_next_page() - if not next_original_page: - return None - - return AsyncPage(next_original_page, self._transform_func) diff --git a/sdks/python/src/honcho/async_client/peer.py b/sdks/python/src/honcho/async_client/peer.py deleted file mode 100644 index 22c8256f..00000000 --- a/sdks/python/src/honcho/async_client/peer.py +++ /dev/null @@ -1,731 +0,0 @@ -from __future__ import annotations - -import datetime -from collections.abc import AsyncGenerator -from typing import Literal - -from honcho_core import AsyncHoncho as AsyncHonchoCore -from honcho_core._types import omit -from honcho_core.types.workspaces import PeerCardResponse -from honcho_core.types.workspaces.peer_context_response import ( - PeerContextResponse, -) -from honcho_core.types.workspaces.peer_representation_response import ( - PeerRepresentationResponse, -) -from honcho_core.types.workspaces.session import Session as SessionCore -from honcho_core.types.workspaces.sessions import MessageCreateParam -from honcho_core.types.workspaces.sessions.message import Message -from honcho_core.types.workspaces.sessions.message_create_param import Configuration -from pydantic import ConfigDict, Field, PrivateAttr, validate_call - -from ..base import PeerBase, SessionBase -from ..conclusions import AsyncConclusionScope -from ..types import DialecticStreamResponse -from .pagination import AsyncPage -from .session import AsyncSession - - -class AsyncPeer(PeerBase): - """ - Represents a peer in the Honcho system with async operations. - - Peers can send messages, participate in sessions, and maintain both global - and local representations for contextual interactions. A peer represents - an entity (user, assistant, etc.) that can communicate within the system. - - Attributes: - id: Unique identifier for this peer - workspace_id: Workspace ID for scoping operations - metadata: Cached metadata for this peer. May be stale if not recently - fetched. Call get_metadata() for fresh data. - configuration: Cached configuration for this peer. May be stale if not - recently fetched. Call get_config() for fresh data. - """ - - _metadata: dict[str, object] | None = PrivateAttr(default=None) - _configuration: dict[str, object] | None = PrivateAttr(default=None) - _client: AsyncHonchoCore = PrivateAttr() - - @property - def metadata(self) -> dict[str, object] | None: - """Cached metadata for this peer. May be stale. Use get_metadata() for fresh data.""" - return self._metadata - - @property - def configuration(self) -> dict[str, object] | None: - """Cached configuration for this peer. May be stale. Use get_config() for fresh data.""" - return self._configuration - - @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) - def __init__( - self, - peer_id: str = Field( - ..., - min_length=1, - description="Unique identifier for this peer within the workspace", - ), - workspace_id: str = Field( - ..., min_length=1, description="Workspace ID for scoping operations" - ), - client: AsyncHonchoCore = Field( - ..., description="Reference to the parent AsyncHoncho client instance" - ), - *, - metadata: dict[str, object] | None = None, - config: dict[str, object] | None = None, - ) -> None: - """ - Initialize a new AsyncPeer. - - Args: - peer_id: Unique identifier for this peer within the workspace - workspace_id: Workspace ID for scoping operations - client: Reference to the parent AsyncHoncho client instance - metadata: Optional metadata to initialize the cached value - config: Optional configuration to initialize the cached value - """ - super().__init__( - id=peer_id, - workspace_id=workspace_id, - ) - self._client = client - self._metadata = metadata - self._configuration = config - - @classmethod - async def create( - cls, - peer_id: str, - workspace_id: str, - client: AsyncHonchoCore, - *, - metadata: dict[str, object] | None = None, - config: dict[str, object] | None = None, - ) -> AsyncPeer: - """ - Create a new AsyncPeer with optional configuration. - - Provided metadata and configuration will overwrite any existing data in those - locations if given. - - Args: - peer_id: Unique identifier for this peer within the workspace - workspace_id: Workspace ID for scoping operations - client: Reference to the parent AsyncHoncho client instance - metadata: Optional metadata dictionary to associate with this peer. - If set, will get/create peer immediately with metadata. - config: Optional configuration to set for this peer. - If set, will get/create peer immediately with flags. - - Returns: - A new AsyncPeer instance - """ - if config is not None or metadata is not None: - peer_data = await client.workspaces.peers.get_or_create( - workspace_id=workspace_id, - id=peer_id, - configuration=config if config is not None else omit, - metadata=metadata if metadata is not None else omit, - ) - return cls( - peer_id, - workspace_id, - client, - metadata=peer_data.metadata, - config=peer_data.configuration, - ) - - return cls(peer_id, workspace_id, client) - - async def chat( - self, - query: str, - *, - stream: bool = False, - target: str | PeerBase | None = None, - session: str | SessionBase | None = None, - reasoning_level: Literal["minimal", "low", "medium", "high", "max"] - | None = None, - ) -> str | DialecticStreamResponse | None: - """ - Query the peer's representation with a natural language question. - - Makes an async API call to the Honcho dialectic endpoint to query either the peer's - global representation (all content associated with this peer) or their local - representation of another peer (what this peer knows about the target peer). - - Args: - query: The natural language question to ask. - stream: Whether to stream the response - target: Optional target peer for local representation query. If provided, - queries what this peer knows about the target peer rather than - querying the peer's global representation. Can be a peer ID string - or an AsyncPeer object. - session: Optional session to scope the query to. If provided, only - information from that session is considered. Can be a session - ID string or an AsyncSession object. - reasoning_level: Optional reasoning level for the query: "minimal", "low", "medium", - "high", or "max". Defaults to "low" if not provided. - - Returns: - For non-streaming: Response string containing the answer, or None if no relevant information - For streaming: DialecticStreamResponse object that can be iterated over and provides final response - """ - # Extract IDs from objects if needed - target_id = ( - None - if target is None - else (target if isinstance(target, str) else target.id) - ) - resolved_session_id = ( - None - if session is None - else (session if isinstance(session, str) else session.id) - ) - - if stream: - - async def stream_response() -> AsyncGenerator[str]: - import json - - # Use core SDK with_streaming_response - async with self._client.workspaces.peers.with_streaming_response.chat( - peer_id=self.id, - workspace_id=self.workspace_id, - query=query, - stream=True, - target=target_id, - session_id=resolved_session_id, - reasoning_level=reasoning_level - if reasoning_level is not None - else omit, - ) as response: - response.http_response.raise_for_status() - async for line in response.iter_lines(): - if line.startswith("data: "): - json_str = line[6:] # Remove "data: " prefix - try: - chunk_data = json.loads(json_str) - if chunk_data.get("done"): - break - delta_obj = chunk_data.get("delta", {}) - content = delta_obj.get("content") - if content: - yield content - except json.JSONDecodeError: - continue - - return DialecticStreamResponse(stream_response()) - - response = await self._client.workspaces.peers.chat( - peer_id=self.id, - workspace_id=self.workspace_id, - query=query, - stream=stream, - target=target_id, - session_id=resolved_session_id, - reasoning_level=reasoning_level if reasoning_level is not None else omit, - ) - # "If the context provided doesn't help address the query, write absolutely NOTHING but "None"" - if response.content in ("", None, "None"): - return None - return response.content - - async def get_sessions( - self, filters: dict[str, object] | None = None - ) -> AsyncPage[SessionCore, AsyncSession]: - """ - Get all sessions this peer is a member of. - - Makes an async 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. - - Returns: - An async paginated list of AsyncSession objects this peer belongs to. Returns an empty - list if the peer is not a member of any sessions - """ - from .session import AsyncSession - - sessions_page = await self._client.workspaces.peers.sessions.list( - peer_id=self.id, - workspace_id=self.workspace_id, - filters=filters, - ) - return AsyncPage( - sessions_page, - lambda session: AsyncSession(session.id, self.workspace_id, self._client), - ) - - @validate_call - def message( - self, - content: str = Field( - ..., min_length=1, description="The text content for the message" - ), - *, - config: Configuration | None = Field( - None, - description="Optional configuration dictionary to associate with the message", - ), - metadata: dict[str, object] | None = Field( - None, description="Optional metadata dictionary" - ), - created_at: datetime.datetime | str | None = Field( - None, - description="Optional created-at timestamp for the message. Accepts a datetime which will be converted to an ISO 8601 string, or a preformatted string.", - ), - ) -> MessageCreateParam: - """ - Create a MessageCreateParam object attributed to this peer. - - This is a convenience method for creating MessageCreateParam objects with this peer's ID. - The created MessageCreateParam can then be added to sessions or used in other operations. - - Args: - content: The text content for the message - metadata: Optional metadata dictionary to associate with the message - - Returns: - A new MessageCreateParam object with this peer's ID and the provided content - """ - created_at_str: str | None - if isinstance(created_at, datetime.datetime): - created_at_str = created_at.isoformat() - else: - created_at_str = created_at - - return MessageCreateParam( - peer_id=self.id, - content=content, - configuration=config, - metadata=metadata, - created_at=created_at_str, - ) - - async def get_metadata(self) -> dict[str, object]: - """ - Get the current metadata for this peer. - - Makes an async API call to retrieve metadata associated with this peer. Metadata - can include custom attributes, settings, or any other key-value data - associated with the peer. This method also updates the cached metadata attribute. - - Returns: - A dictionary containing the peer's metadata. Returns an empty dictionary - if no metadata is set - """ - peer = await self._client.workspaces.peers.get_or_create( - workspace_id=self.workspace_id, - id=self.id, - ) - self._metadata = peer.metadata or {} - return self._metadata - - @validate_call - async def set_metadata( - self, - metadata: dict[str, object] = Field( - ..., description="Metadata dictionary to associate with this peer" - ), - ) -> None: - """ - Set the metadata for this peer. - - Makes an async API call to update the metadata associated with this peer. - This will overwrite any existing metadata with the provided values. - This method also updates the cached metadata attribute. - - Args: - metadata: A dictionary of metadata to associate with this peer. - Keys must be strings, values can be any JSON-serializable type - """ - await self._client.workspaces.peers.update( - peer_id=self.id, - workspace_id=self.workspace_id, - metadata=metadata, - ) - self._metadata = metadata - - async def get_config(self) -> dict[str, object]: - """ - Get the current workspace-level configuration for this peer. - - Makes an API call to retrieve configuration associated with this peer. - Configuration currently includes one optional flag, `observe_me`. - This method also updates the cached configuration attribute. - - Returns: - A dictionary containing the peer's configuration - """ - peer = await self._client.workspaces.peers.get_or_create( - workspace_id=self.workspace_id, - id=self.id, - ) - self._configuration = peer.configuration or {} - return self._configuration - - @validate_call - async def set_config( - self, - config: dict[str, object] = Field( - ..., description="Configuration dictionary to associate with this peer" - ), - ) -> None: - """ - Set the configuration for this peer. Currently the only supported config - value is the `observe_me` flag, which controls whether derivation tasks - should be created for this peer's global representation. Default is True. - - Makes an API call to update the configuration associated with this peer. - This will overwrite any existing configuration with the provided values. - This method also updates the cached configuration attribute. - - Args: - config: A dictionary of configuration to associate with this peer. - Keys must be strings, values can be any JSON-serializable type - """ - await self._client.workspaces.peers.update( - peer_id=self.id, - workspace_id=self.workspace_id, - configuration=config, - ) - self._configuration = config - - async def get_peer_config(self) -> dict[str, object]: - """ - Get the current workspace-level configuration for this peer. - - .. deprecated:: - Use :meth:`get_config` instead. - - Returns: - A dictionary containing the peer's configuration - """ - return await self.get_config() - - @validate_call - async def set_peer_config( - self, - config: dict[str, object] = Field( - ..., description="Configuration dictionary to associate with this peer" - ), - ) -> None: - """ - Set the configuration for this peer. - - .. deprecated:: - Use :meth:`set_config` instead. - - Args: - config: A dictionary of configuration to associate with this peer - """ - return await self.set_config(config) - - async def refresh(self) -> None: - """ - Refresh cached metadata and configuration for this peer. - - Makes a single async API call to retrieve the latest metadata and configuration - associated with this peer and updates the cached attributes. - """ - peer = await self._client.workspaces.peers.get_or_create( - workspace_id=self.workspace_id, - id=self.id, - ) - self._metadata = peer.metadata or {} - self._configuration = peer.configuration or {} - - @validate_call - async def search( - self, - query: str = Field(..., min_length=1, description="The search query to use"), - filters: dict[str, object] | None = Field( - None, description="Filters to scope the search" - ), - limit: int = Field( - default=10, ge=1, le=100, description="Number of results to return" - ), - ) -> list[Message]: - """ - Search across all messages in the workspace with this peer as author. - - Makes an API call to search endpoint. - - Args: - query: The search query to use - filters: Filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v2/guides/using-filters). - limit: Number of results to return (1-100, default: 10) - - Returns: - A list of Message objects representing the search results. - Returns an empty list if no messages are found. - """ - return await self._client.workspaces.peers.search( - self.id, - workspace_id=self.workspace_id, - query=query, - filters=filters, - limit=limit, - ) - - async def card( - self, - target: str | PeerBase | None = None, - ) -> str: - """ - Get the peer card for this peer. - - Makes an API call to retrieve the peer card, which contains a representation - of what this peer knows. If a target is provided, returns this peer's local - representation of the target peer. - - Args: - target: Optional target peer for local card. If provided, returns this - peer's card of the target peer. Can be an AsyncPeer object or peer ID string. - - Returns: - A string containing the peer card joined with newlines, or an empty string if none is available - """ - # Validate target parameter - if isinstance(target, str) and len(target.strip()) == 0: - raise ValueError("target string cannot be empty") - - target_id = ( - None - if target is None - else (target if isinstance(target, str) else target.id) - ) - response: PeerCardResponse = await self._client.workspaces.peers.card( - peer_id=self.id, - workspace_id=self.workspace_id, - target=target_id, - ) - - if response.peer_card is None: - return "" - - items: list[str] = response.peer_card - return "\n".join(items) - - async def get_representation( - self, - session: str | SessionBase | None = None, - target: str | PeerBase | None = None, - search_query: str | None = None, - search_top_k: int | None = None, - search_max_distance: float | None = None, - include_most_frequent: bool | None = None, - max_conclusions: int | None = None, - ) -> str: - """ - Get a subset of the representation of the peer. - - Args: - session: Optional session to scope the representation to. - target: Optional target peer to get the representation of. If provided, - returns the representation of the target from the perspective of this peer. - search_query: Semantic search query to filter relevant conclusions - search_top_k: Number of semantically relevant facts to return - search_max_distance: Maximum semantic distance for search results (0.0-1.0) - include_most_frequent: Whether to include the most frequent conclusions - max_conclusions: Maximum number of conclusions to include - - Returns: - A Representation string - - Example: - ```python - # Get global representation - rep = await peer.get_representation() - print(rep) - - # Get representation scoped to a session - session_rep = await peer.get_representation(session='session-123') - - # Get representation with semantic search - searched_rep = await peer.get_representation( - search_query='preferences', - search_top_k=10, - max_conclusions=50 - ) - ``` - """ - - session_id = ( - None - if session is None - else session - if isinstance(session, str) - else session.id - ) - - target_id = ( - None - if target is None - else (target if isinstance(target, str) else target.id) - ) - data: PeerRepresentationResponse = ( - await self._client.workspaces.peers.representation( - peer_id=self.id, - workspace_id=self.workspace_id, - session_id=session_id, - target=target_id, - search_query=search_query if search_query is not None else omit, - search_top_k=search_top_k if search_top_k is not None else omit, - search_max_distance=search_max_distance - if search_max_distance is not None - else omit, - include_most_frequent=include_most_frequent - if include_most_frequent is not None - else omit, - max_conclusions=max_conclusions - if max_conclusions is not None - else omit, - ) - ) - return data.representation - - async def get_context( - self, - target: str | PeerBase | None = None, - search_query: str | None = None, - search_top_k: int | None = None, - search_max_distance: float | None = None, - include_most_frequent: bool | None = None, - max_conclusions: int | None = None, - ) -> PeerContextResponse: - """ - Get context for this peer, including representation and peer card. - - This is a convenience method that retrieves both the working representation - and peer card in a single API call. - - Args: - target: Optional target peer to get context for. If provided, returns - the context for the target from this peer's perspective. - Can be an AsyncPeer object or peer ID string. - search_query: Semantic search query to filter relevant conclusions - search_top_k: Number of semantically relevant facts to return - search_max_distance: Maximum semantic distance for search results (0.0-1.0) - include_most_frequent: Whether to include the most frequent conclusions - max_conclusions: Maximum number of conclusions to include - - Returns: - A PeerContext object containing the representation and peer card - - Example: - ```python - # Get own context - context = await peer.get_context() - print(context.representation) - print(context.peer_card) - - # Get context for another peer - context = await peer.get_context(target='other-peer-id') - - # Get context with semantic search - context = await peer.get_context( - search_query='preferences', - search_top_k=10 - ) - ``` - """ - target_id = ( - None - if target is None - else (target if isinstance(target, str) else target.id) - ) - - return await self._client.workspaces.peers.context( - peer_id=self.id, - workspace_id=self.workspace_id, - target=target_id, - search_query=search_query if search_query is not None else omit, - search_top_k=search_top_k if search_top_k is not None else omit, - search_max_distance=search_max_distance - if search_max_distance is not None - else omit, - include_most_frequent=include_most_frequent - if include_most_frequent is not None - else omit, - max_conclusions=max_conclusions if max_conclusions is not None else omit, - ) - - @property - def conclusions(self) -> "AsyncConclusionScope": - """ - Access this peer's self-conclusions (where observer == observed == self). - - This property provides a convenient way to access conclusions that this peer - has made about themselves. Use this for self-conclusion scenarios. - - Returns: - An AsyncConclusionScope scoped to this peer's self-conclusions - - Example: - ```python - # List self-conclusions - obs_list = await peer.conclusions.list() - - # Search self-conclusions - results = await peer.conclusions.query("preferences") - - # Delete a self-conclusion - await peer.conclusions.delete("obs-123") - ``` - """ - return AsyncConclusionScope(self._client, self.workspace_id, self.id, self.id) - - def conclusions_of(self, target: str | PeerBase) -> "AsyncConclusionScope": - """ - Access conclusions this peer has made about another peer. - - This method provides scoped access to conclusions where this peer is the - observer and the target is the observed peer. - - Args: - target: The target peer (either an AsyncPeer object or peer ID string) - - Returns: - An AsyncConclusionScope scoped to this peer's conclusions of the target - - Example: - ```python - # Get conclusions about another peer - bob_conclusions = peer.conclusions_of("bob") - - # List conclusions - obs_list = await bob_conclusions.list() - - # Search conclusions - results = await bob_conclusions.query("work history") - - # Get the representation from these conclusions - rep = await bob_conclusions.get_representation() - ``` - """ - from ..conclusions import AsyncConclusionScope as _AsyncConclusionScope - - target_id = target.id if isinstance(target, PeerBase) else target - return _AsyncConclusionScope( - self._client, self.workspace_id, self.id, target_id - ) - - def __repr__(self) -> str: - """ - Return a string representation of the AsyncPeer. - - Returns: - A string representation suitable for debugging - """ - return f"AsyncPeer(id='{self.id}')" - - def __str__(self) -> str: - """ - Return a human-readable string representation of the AsyncPeer. - - Returns: - The peer's ID - """ - return self.id diff --git a/sdks/python/src/honcho/async_client/session.py b/sdks/python/src/honcho/async_client/session.py deleted file mode 100644 index f68c38c0..00000000 --- a/sdks/python/src/honcho/async_client/session.py +++ /dev/null @@ -1,1063 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -import time -from datetime import datetime -from typing import TYPE_CHECKING, Any - -from honcho_core import AsyncHoncho as AsyncHonchoCore -from honcho_core._types import omit -from honcho_core.types.workspaces import QueueStatusResponse -from honcho_core.types.workspaces.peer_representation_response import ( - PeerRepresentationResponse, -) -from honcho_core.types.workspaces.sessions import MessageCreateParam -from honcho_core.types.workspaces.sessions.message import Message -from honcho_core.types.workspaces.sessions.message_create_param import Configuration -from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call - -from ..base import PeerBase, SessionBase -from ..session_context import SessionContext, SessionSummaries, Summary -from ..utils import prepare_file_for_upload -from .pagination import AsyncPage - -if TYPE_CHECKING: - from .peer import AsyncPeer - -logger = logging.getLogger(__name__) - - -class SessionPeerConfig(BaseModel): - 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", - ) - observe_me: bool | None = Field( - None, - description="Whether other peers in this session should try to form a session-level theory-of-mind representation of this peer", - ) - - -class AsyncSession(SessionBase): - """ - Represents a session in Honcho with async operations. - - Sessions are scoped to a set of peers and contain messages/content. - They create bidirectional relationships between peers and provide - a context for multi-party conversations and interactions. - - Attributes: - id: Unique identifier for this session - workspace_id: Workspace ID for scoping operations - metadata: Cached metadata for this session. May be stale if not recently - fetched. Call get_metadata() for fresh data. - configuration: Cached configuration for this session. May be stale if not - recently fetched. Call get_config() for fresh data. - """ - - _metadata: dict[str, object] | None = PrivateAttr(default=None) - _configuration: dict[str, object] | None = PrivateAttr(default=None) - _client: AsyncHonchoCore = PrivateAttr() - - @property - def metadata(self) -> dict[str, object] | None: - """Cached metadata for this session. May be stale. Use get_metadata() for fresh data.""" - return self._metadata - - @property - def configuration(self) -> dict[str, object] | None: - """Cached configuration for this session. May be stale. Use get_config() for fresh data.""" - return self._configuration - - @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) - def __init__( - self, - session_id: str = Field( - ..., min_length=1, description="Unique identifier for this session" - ), - workspace_id: str = Field( - ..., min_length=1, description="Workspace ID for scoping operations" - ), - client: AsyncHonchoCore = Field( - ..., description="Reference to the parent AsyncHoncho client instance" - ), - *, - metadata: dict[str, object] | None = None, - config: dict[str, object] | None = None, - ) -> None: - """ - Initialize a new AsyncSession. - - Args: - session_id: Unique identifier for this session within the workspace - workspace_id: Workspace ID for scoping operations - client: Reference to the parent AsyncHoncho client instance - metadata: Optional metadata to initialize the cached value - config: Optional configuration to initialize the cached value - """ - super().__init__( - id=session_id, - workspace_id=workspace_id, - ) - self._client = client - self._metadata = metadata - self._configuration = config - - @classmethod - async def create( - cls, - session_id: str, - workspace_id: str, - client: AsyncHonchoCore, - *, - metadata: dict[str, object] | None = None, - config: dict[str, object] | None = None, - ) -> AsyncSession: - """ - Create a new AsyncSession with optional configuration. - - Provided metadata and configuration will overwrite any existing data in those - locations if given. - - Args: - session_id: Unique identifier for this session within the workspace - workspace_id: Workspace ID for scoping operations - client: Reference to the parent AsyncHoncho client instance - metadata: Optional metadata dictionary to associate with this session. - If set, will get/create session immediately with metadata. - config: Optional configuration to set for this session. - If set, will get/create session immediately with flags. - - Returns: - A new AsyncSession instance - """ - if config is not None or metadata is not None: - session_data = await client.workspaces.sessions.get_or_create( - workspace_id=workspace_id, - id=session_id, - configuration=config if config is not None else omit, - metadata=metadata if metadata is not None else omit, - ) - return cls( - session_id, - workspace_id, - client, - metadata=session_data.metadata, - config=session_data.configuration, - ) - - return cls(session_id, workspace_id, client) - - async def add_peers( - self, - peers: str - | PeerBase - | tuple[str, SessionPeerConfig] - | tuple[PeerBase, SessionPeerConfig] - | list[PeerBase | str] - | list[tuple[PeerBase | str, SessionPeerConfig]] - | list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]] = Field( - ..., description="Peers to add to the session" - ), - ) -> None: - """ - Add peers to this session. - - Makes an async API call to add one or more peers to this session. Adding peers - creates bidirectional relationships and allows them to participate in - the session's conversations. - - Args: - peers: Peers to add to the session. Can be: - - str: Single peer ID - - AsyncPeer: Single AsyncPeer object - - List[Union[AsyncPeer, str]]: List of AsyncPeer objects and/or peer IDs - - tuple[str, SessionPeerConfig]: Single peer ID and SessionPeerConfig - - tuple[AsyncPeer, SessionPeerConfig]: Single AsyncPeer object and SessionPeerConfig - - List[tuple[Union[AsyncPeer, str], SessionPeerConfig]]: List of AsyncPeer objects and/or peer IDs and SessionPeerConfig - - Mixed lists with peers and tuples/lists containing peer+config combinations - """ - if not isinstance(peers, list): - peers = [peers] - - peer_dict: dict[str, Any] = {} - for peer in peers: - if isinstance(peer, tuple): - # Handle tuple[str/AsyncPeer, SessionPeerConfig] - peer_id = peer[0] if isinstance(peer[0], str) else peer[0].id - peer_config = peer[1] - peer_dict[peer_id] = peer_config.model_dump(exclude_none=True) - else: - # Handle direct str or AsyncPeer - peer_id = peer if isinstance(peer, str) else peer.id - peer_dict[peer_id] = {} - - await self._client.workspaces.sessions.peers.add( - session_id=self.id, - workspace_id=self.workspace_id, - body=peer_dict, - ) - - async def set_peers( - self, - peers: str - | PeerBase - | tuple[str, SessionPeerConfig] - | tuple[PeerBase, SessionPeerConfig] - | list[PeerBase | str] - | list[tuple[PeerBase | str, SessionPeerConfig]] - | list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]] = Field( - ..., description="Peers to set for the session" - ), - ) -> None: - """ - Set the complete peer list for this session. - - Makes an API call to replace the current peer list with the provided peers. - This will remove any peers not in the new list and add any that are missing. - - Args: - peers: Peers to set for the session. Can be: - - str: Single peer ID - - AsyncPeer: Single AsyncPeer object - - List[Union[AsyncPeer, str]]: List of AsyncPeer objects and/or peer IDs - - tuple[str, SessionPeerConfig]: Single peer ID and SessionPeerConfig - - tuple[AsyncPeer, SessionPeerConfig]: Single AsyncPeer object and SessionPeerConfig - - List[tuple[Union[AsyncPeer, str], SessionPeerConfig]]: List of AsyncPeer objects and/or peer IDs and SessionPeerConfig - - Mixed lists with peers and tuples/lists containing peer+config combinations - """ - if not isinstance(peers, list): - peers = [peers] - - peer_dict: dict[str, Any] = {} - for peer in peers: - if isinstance(peer, tuple): - # Handle tuple[str/AsyncPeer, SessionPeerConfig] - peer_id = peer[0] if isinstance(peer[0], str) else peer[0].id - peer_config = peer[1] - peer_dict[peer_id] = peer_config.model_dump(exclude_none=True) - else: - # Handle direct str or AsyncPeer - peer_id = peer if isinstance(peer, str) else peer.id - peer_dict[peer_id] = {} - - await self._client.workspaces.sessions.peers.set( - session_id=self.id, - workspace_id=self.workspace_id, - body=peer_dict, - ) - - async def remove_peers( - self, - peers: str | PeerBase | list[PeerBase | str] = Field( - ..., description="Peers to remove from the session" - ), - ) -> None: - """ - Remove peers from this session. - - Makes an async API call to remove one or more peers from this session. - Removed peers will no longer be able to participate in the session - unless added back. - - Args: - peers: Peers to remove from the session. Can be: - - str: Single peer ID - - AsyncPeer: Single AsyncPeer object - - List[Union[AsyncPeer, str]]: List of AsyncPeer objects and/or peer IDs - """ - if not isinstance(peers, list): - peers = [peers] - - peer_ids = [peer if isinstance(peer, str) else peer.id for peer in peers] - - await self._client.workspaces.sessions.peers.remove( - session_id=self.id, - workspace_id=self.workspace_id, - body=peer_ids, - ) - - async def get_peers(self) -> list[AsyncPeer]: - """ - Get all peers in this session. - - Makes an async API call to retrieve the list of peer IDs that are currently - members of this session. Automatically converts the paginated response - into a list for us -- the max number of peers in a session is usually 10. - - Returns: - A list of AsyncPeer objects that are members of this session - """ - from .peer import AsyncPeer - - peers_page = await self._client.workspaces.sessions.peers.list( - session_id=self.id, - workspace_id=self.workspace_id, - ) - return [ - AsyncPeer(peer.id, self.workspace_id, self._client) - for peer in peers_page.items - ] - - async def get_peer_config(self, peer: str | PeerBase) -> SessionPeerConfig: - """ - Get the configuration for a peer in this session. - """ - peer_id = peer if isinstance(peer, str) else peer.id - peer_config_response = await self._client.workspaces.sessions.peers.config( - peer_id=peer_id, - workspace_id=self.workspace_id, - session_id=self.id, - ) - return SessionPeerConfig( - observe_others=peer_config_response.observe_others, - observe_me=peer_config_response.observe_me, - ) - - async def set_peer_config( - self, peer: str | PeerBase, config: SessionPeerConfig - ) -> None: - """ - Set the configuration for a peer in this session. - """ - peer_id = peer if isinstance(peer, str) else peer.id - await self._client.workspaces.sessions.peers.set_config( - peer_id=peer_id, - workspace_id=self.workspace_id, - session_id=self.id, - observe_others=omit - if config.observe_others is None - else config.observe_others, - observe_me=omit if config.observe_me is None else config.observe_me, - ) - - @validate_call - async def add_messages( - self, - messages: MessageCreateParam | list[MessageCreateParam] = Field( - ..., description="Messages to add to the session" - ), - ) -> list[Message]: - """ - Add one or more messages to this session. - - Makes an API call to store messages in this session. Any message added - to a session will automatically add the creating peer to the session - if they are not already a member. - - Args: - messages: Messages to add to the session. Can be: - - MessageCreateParam: Single MessageCreateParam object - - List[MessageCreateParam]: List of MessageCreateParam objects - """ - if not isinstance(messages, list): - messages = [messages] - - return await self._client.workspaces.sessions.messages.create( - session_id=self.id, - workspace_id=self.workspace_id, - messages=[MessageCreateParam(**message) for message in messages], - ) - - @validate_call - async def get_messages( - self, - *, - filters: dict[str, object] | None = Field( - None, description="Dictionary of filter criteria" - ), - ) -> AsyncPage[Message]: - """ - Get messages from this session with optional filtering. - - Makes an async 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 - - Returns: - An async paginated list of Message objects matching the specified criteria, ordered by - creation time (most recent first) - """ - messages_page = await self._client.workspaces.sessions.messages.list( - session_id=self.id, - workspace_id=self.workspace_id, - filters=filters, - ) - return AsyncPage(messages_page) - - async def delete(self) -> None: - """ - Delete this session and all associated data. - - Makes an async API call to permanently delete this session and all related data including: - - Messages - - Message embeddings - - Conclusions - - Session-Peer associations - - Background processing queue items - - This action cannot be undone. - """ - await self._client.workspaces.sessions.delete( - session_id=self.id, - workspace_id=self.workspace_id, - ) - - async def clone( - self, - *, - message_id: str | None = None, - ) -> "AsyncSession": - """ - Clone this session, optionally up to a specific message. - - Makes an async API call to create a copy of this session with a new ID. - All messages and peers from the original session are copied to the new session. - If a message_id is provided, only messages up to and including that message - are copied. - - Args: - message_id: Optional message ID to cut off the clone at. If provided, - the cloned session will only contain messages up to and - including this message. - - Returns: - A new AsyncSession object representing the cloned session - - Example: - ```python - # Clone entire session - cloned = await session.clone() - - # Clone session up to a specific message - cloned = await session.clone(message_id="msg_abc123") - ``` - """ - # Make the API call using the core SDK's clone method - cloned_session_data = await self._client.workspaces.sessions.clone( - session_id=self.id, - workspace_id=self.workspace_id, - message_id=message_id if message_id is not None else omit, - ) - - # Return a new AsyncSession object with the cloned session's data - return AsyncSession( - cloned_session_data.id, - self.workspace_id, - self._client, - metadata=cloned_session_data.metadata, - config=cloned_session_data.configuration, - ) - - async def get_metadata(self) -> dict[str, object]: - """ - Get metadata for this session. - - Makes an async API call to retrieve the current metadata associated with this session. - Metadata can include custom attributes, settings, or any other key-value data. - This method also updates the cached metadata attribute. - - Returns: - A dictionary containing the session's metadata. Returns an empty dictionary - if no metadata is set - """ - session = await self._client.workspaces.sessions.get_or_create( - workspace_id=self.workspace_id, - id=self.id, - ) - self._metadata = session.metadata or {} - return self._metadata - - @validate_call - async def set_metadata( - self, - metadata: dict[str, object] = Field( - ..., description="Metadata dictionary to associate with this session" - ), - ) -> None: - """ - Set metadata for this session. - - Makes an async API call to update the metadata associated with this session. - This will overwrite any existing metadata with the provided values. - This method also updates the cached metadata attribute. - - Args: - metadata: A dictionary of metadata to associate with this session. - Keys must be strings, values can be any JSON-serializable type - """ - await self._client.workspaces.sessions.update( - session_id=self.id, - workspace_id=self.workspace_id, - metadata=metadata, - ) - self._metadata = metadata - - async def get_config(self) -> dict[str, object]: - """ - Get configuration for this session. - - Makes an async API call to retrieve the current configuration associated with this session. - Configuration includes settings that control session behavior. - This method also updates the cached configuration attribute. - - Returns: - A dictionary containing the session's configuration. Returns an empty dictionary - if no configuration is set - """ - session = await self._client.workspaces.sessions.get_or_create( - workspace_id=self.workspace_id, - id=self.id, - ) - self._configuration = session.configuration or {} - return self._configuration - - @validate_call - async def set_config( - self, - configuration: dict[str, object] = Field( - ..., description="Configuration dictionary to associate with this session" - ), - ) -> None: - """ - Set configuration for this session. - - Makes an async API call to update the configuration associated with this session. - This will overwrite any existing configuration with the provided values. - This method also updates the cached configuration attribute. - - Args: - configuration: A dictionary of configuration to associate with this session. - Keys must be strings, values can be any JSON-serializable type - """ - await self._client.workspaces.sessions.update( - session_id=self.id, - workspace_id=self.workspace_id, - configuration=configuration, - ) - self._configuration = configuration - - async def refresh(self) -> None: - """ - Refresh cached metadata and configuration for this session. - - Makes a single async API call to retrieve the latest metadata and configuration - associated with this session and updates the cached attributes. - """ - session = await self._client.workspaces.sessions.get_or_create( - workspace_id=self.workspace_id, - id=self.id, - ) - self._metadata = session.metadata or {} - self._configuration = session.configuration or {} - - @validate_call - async def get_context( - self, - *, - summary: bool = True, - tokens: int | None = Field( - None, gt=0, description="Maximum number of tokens to include in the context" - ), - peer_target: str | None = Field( - None, - description="A peer ID to get context for. If given *without* `peer_perspective`, a representation and peer card will be included from the omniscient Honcho-level view of `peer_target`. If given *with* `peer_perspective`, will get the representation and card for `peer_target` *from the perspective of `peer_perspective`*.", - ), - last_user_message: str | Message | None = Field( - None, - description="The most recent message (string or Message object), used to fetch semantically relevant conclusions and returned as part of the context object. Use this alongside `peer_target` to get a more focused context -- does nothing if `peer_target` is not provided.", - ), - peer_perspective: str | None = Field( - None, - description="A peer ID to get context *from the perspective of*. If given, response will attempt to include representation and card from the perspective of `peer_perspective`. Must be provided with `peer_target`.", - ), - limit_to_session: bool = Field( - False, - description="Whether to limit the representation to this session only. If True, only conclusions from this session will be included.", - ), - search_top_k: int | None = Field( - None, - ge=1, - le=100, - description="Number of semantically relevant facts to return when searching with `last_user_message`.", - ), - search_max_distance: float | None = Field( - None, - ge=0.0, - le=1.0, - description="Maximum semantic distance for search results (0.0-1.0) when searching with `last_user_message`.", - ), - include_most_frequent: bool | None = Field( - None, - description="Whether to include the most frequent conclusions in the representation.", - ), - max_conclusions: int | None = Field( - None, - ge=1, - le=100, - description="Maximum number of conclusions to include in the representation.", - ), - ) -> SessionContext: - """ - Get optimized context for this session within a token limit. - - Makes an API call to retrieve a curated list of messages that provides - optimal context for the conversation while staying within the specified - token limit. Uses tiktoken for token counting, so results should be - compatible with OpenAI models. - - Args: - summary: Whether to include summary information - tokens: Maximum number of tokens to include in the context. Will default - to Honcho server configuration if not provided. - peer_target: A peer ID to get context for. If given *without* `peer_perspective`, a representation and peer card will be included from the omniscient Honcho-level view of `peer_target`. If given *with* `peer_perspective`, will get the representation and card for `peer_target` *from the perspective of `peer_perspective`*. - last_user_message: The most recent message (string or Message object), used to fetch semantically relevant conclusions and returned as part of the context object. Use this alongside `peer_target` to get a more focused context -- does nothing if `peer_target` is not provided. - peer_perspective: A peer ID to get context *from the perspective of*. If given, response will attempt to include representation and card from the perspective of `peer_perspective`. Must be provided with `peer_target`. - limit_to_session: Whether to limit the representation to this session only. If True, only conclusions from this session will be included. - search_top_k: Number of semantically relevant facts to return when searching with `last_user_message`. - search_max_distance: Maximum semantic distance for search results (0.0-1.0) when searching with `last_user_message`. - include_most_frequent: Whether to include the most frequent conclusions in the representation. - max_conclusions: Maximum number of conclusions to include in the representation. - - Returns: - A SessionContext object containing the optimized message history and - summary, if available, that maximizes conversational context while - respecting the token limit - - Note: - Token counting is performed using tiktoken. For models using different - tokenizers, you may need to adjust the token limit accordingly. - """ - - if peer_target is None and peer_perspective is not None: - raise ValueError( - "You must provide a `peer_target` when `peer_perspective` is provided" - ) - - if peer_target is None and last_user_message is not None: - raise ValueError( - "You must provide a `peer_target` when `last_user_message` is provided" - ) - - last_user_message_id = ( - last_user_message.id - if isinstance(last_user_message, Message) - else last_user_message - ) - context = await self._client.workspaces.sessions.context( - session_id=self.id, - workspace_id=self.workspace_id, - tokens=tokens if tokens is not None else omit, - summary=summary, - last_message=last_user_message_id - if last_user_message_id is not None - else omit, - peer_target=peer_target if peer_target is not None else omit, - peer_perspective=peer_perspective if peer_perspective is not None else omit, - limit_to_session=limit_to_session, - search_top_k=search_top_k if search_top_k is not None else omit, - search_max_distance=search_max_distance - if search_max_distance is not None - else omit, - include_most_frequent=include_most_frequent - if include_most_frequent is not None - else omit, - max_conclusions=max_conclusions if max_conclusions is not None else omit, - ) - - # Convert the honcho_core summary to our Summary if it exists - session_summary = None - if context.summary: - session_summary = Summary( - content=context.summary.content, - message_id=context.summary.message_id, - summary_type=context.summary.summary_type, - created_at=context.summary.created_at, - token_count=context.summary.token_count, - ) - - return SessionContext( - session_id=self.id, - messages=context.messages, - summary=session_summary, - peer_representation=str(context.peer_representation) - if context.peer_representation - else None, - peer_card=context.peer_card, - ) - - async def get_summaries(self) -> SessionSummaries: - """ - Get available summaries for this session. - - Makes an async API call to retrieve both short and long summaries for this session, - if they are available. Summaries are created asynchronously by the backend - as messages are added to the session. - - Returns: - A SessionSummaries object containing: - - id: The session ID - - short_summary: The short summary if available, including metadata - - long_summary: The long summary if available, including metadata - - Note: - Summaries may be None if: - - Not enough messages have been added to trigger summary generation - - The summary generation is still in progress - - Summary generation is disabled for this session - """ - # Use the honcho_core client to get summaries - response = await self._client.workspaces.sessions.summaries( - session_id=self.id, - workspace_id=self.workspace_id, - ) - - # Create Summary objects from the response data - short_summary = None - if response.short_summary: - short_summary = Summary( - content=response.short_summary.content, - message_id=response.short_summary.message_id, - summary_type=response.short_summary.summary_type, - created_at=response.short_summary.created_at, - token_count=response.short_summary.token_count, - ) - - long_summary = None - if response.long_summary: - long_summary = Summary( - content=response.long_summary.content, - message_id=response.long_summary.message_id, - summary_type=response.long_summary.summary_type, - created_at=response.long_summary.created_at, - token_count=response.long_summary.token_count, - ) - - return SessionSummaries( - id=response.id or self.id, - short_summary=short_summary, - long_summary=long_summary, - ) - - @validate_call - async def search( - self, - query: str = Field(..., min_length=1, description="The search query to use"), - filters: dict[str, object] | None = Field( - None, description="Filters to scope the search" - ), - limit: int = Field( - default=10, ge=1, le=100, description="Number of results to return" - ), - ) -> list[Message]: - """ - Search for messages in this session. - - Makes an async API call to search for messages in this session. - - Args: - query: The search query to use - filters: Filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v2/guides/using-filters). - limit: Number of results to return (1-100, default: 10) - - Returns: - A list of Message objects representing the search results. - Returns an empty list if no messages are found. - """ - return await self._client.workspaces.sessions.search( - self.id, - workspace_id=self.workspace_id, - query=query, - filters=filters, - limit=limit, - ) - - @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) - async def upload_file( - self, - file: tuple[str, bytes, str] | tuple[str, Any, str] | Any = Field( - ..., - description="File to upload. Can be a file object, (filename, bytes, content_type) tuple, or (filename, fileobj, content_type) tuple.", - ), - peer: str | PeerBase = Field( - ..., description="The peer creating the messages (ID string or Peer object)" - ), - metadata: dict[str, object] | None = Field( - None, - description="Optional metadata dictionary to associate with the messages", - ), - configuration: Configuration | None = Field( - None, - description="Optional configuration dictionary to associate with the messages", - ), - created_at: str | datetime | None = Field( - None, - description="Optional created-at timestamp for the messages. Should be an ISO 8601 formatted string.", - ), - ) -> list[Message]: - """ - Upload file to create message(s) in this session. - - Accepts a flexible payload: - - File objects (opened in binary mode) - - (filename, bytes, content_type) tuples - - (filename, fileobj, content_type) tuples - - Files are normalized to (filename, fileobj, content_type) tuples for the Stainless client. - - Args: - file: File to upload. Can be: - - a file object (must have .name and .read()) - - a tuple (filename, bytes, content_type) - - a tuple (filename, fileobj, content_type) - peer: The peer who will be attributed as the creator of the messages. - Can be a peer ID string or an AsyncPeer object. - metadata: Optional metadata dictionary to associate with the messages - configuration: Optional configuration dictionary to associate with the messages - created_at: Optional created-at timestamp for the messages. Should be an ISO 8601 formatted string. - - Returns: - A list of Message objects representing the created messages - - Note: - Supported file types include PDFs, text files, and JSON documents. - Large files will be automatically split into multiple messages to fit - within message size limits. - """ - - # Prepare file for upload using shared utility - filename, content_bytes, content_type = prepare_file_for_upload(file) - - # Extract peer ID from AsyncPeer object if needed - resolved_peer_id = peer if isinstance(peer, str) else peer.id - - # Build extra_body dict with optional fields as JSON strings (backend expects Form fields) - extra_body_data: dict[str, str] = {} - if metadata is not None: - extra_body_data["metadata"] = json.dumps(metadata) - if configuration is not None: - extra_body_data["configuration"] = json.dumps(configuration) - if created_at is not None: - # Ensure created_at is a string (ISO format) - if isinstance(created_at, datetime): - extra_body_data["created_at"] = created_at.isoformat() - else: - extra_body_data["created_at"] = created_at - - # Call the upload endpoint with extra_body for the additional form fields - response = await self._client.workspaces.sessions.messages.upload( - session_id=self.id, - workspace_id=self.workspace_id, - file=(filename, content_bytes, content_type), - peer_id=resolved_peer_id, - extra_body=extra_body_data if extra_body_data else None, - ) - - return [Message.model_validate(msg) for msg in response] - - async def get_representation( - self, - peer: str | PeerBase, - *, - target: str | PeerBase | None = None, - search_query: str | None = None, - search_top_k: int | None = None, - search_max_distance: float | None = None, - include_most_frequent: bool | None = None, - max_conclusions: int | None = None, - ) -> str: - """ - Get a subset of the representation of the peer in this session. - - Args: - peer: Peer to get the representation of. - target: Optional target peer to get the representation of. If provided, - queries what `peer` knows about the `target`. - search_query: Semantic search query to filter relevant conclusions - search_top_k: Number of semantically relevant facts to return - search_max_distance: Maximum semantic distance for search results (0.0-1.0) - include_most_frequent: Whether to include the most frequent conclusions - max_conclusions: Maximum number of conclusions to include - - Returns: - A Representation string - - Example: - ```python - # Get peer's representation in this session - rep = await session.get_representation('user123') - print(rep) - - # Get what user123 knows about assistant in this session - local_rep = await session.get_representation('user123', target='assistant') - - # Get representation with semantic search - searched_rep = await session.get_representation( - 'user123', - search_query='preferences', - search_top_k=10 - ) - ``` - """ - - peer_id = peer if isinstance(peer, str) else peer.id - target_id = ( - None - if target is None - else (target if isinstance(target, str) else target.id) - ) - data: PeerRepresentationResponse = ( - await self._client.workspaces.peers.representation( - peer_id, - workspace_id=self.workspace_id, - session_id=self.id, - target=target_id, - search_query=search_query if search_query is not None else omit, - search_top_k=search_top_k if search_top_k is not None else omit, - search_max_distance=search_max_distance - if search_max_distance is not None - else omit, - include_most_frequent=include_most_frequent - if include_most_frequent is not None - else omit, - max_conclusions=max_conclusions - if max_conclusions is not None - else omit, - ) - ) - return data.representation - - @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) - async def get_queue_status( - self, - observer: str | PeerBase | None = None, - sender: str | PeerBase | None = None, - ) -> QueueStatusResponse: - """ - Get the queue processing status, optionally scoped to an observer, sender, and/or session. - - Args: - observer: Optional observer (ID string or AsyncPeer object) to scope the status check - sender: Optional sender (ID string or AsyncPeer object) to scope the status check - """ - resolved_observer_id = ( - None - if observer is None - else (observer if isinstance(observer, str) else observer.id) - ) - resolved_sender_id = ( - None - if sender is None - else (sender if isinstance(sender, str) else sender.id) - ) - - return await self._client.workspaces.queue.status( - workspace_id=self.workspace_id, - observer_id=resolved_observer_id, - sender_id=resolved_sender_id, - session_id=self.id, - ) - - @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) - async def poll_queue_status( - self, - observer: str | PeerBase | None = None, - sender: str | PeerBase | None = None, - timeout: float = Field( - 300.0, - gt=0, - description="Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).", - ), - ) -> QueueStatusResponse: - """ - Poll get_queue_status until pending_work_units and in_progress_work_units are both 0. - This allows you to guarantee that all messages have been processed by the queue for - use with the dialectic endpoint. - - The polling estimates sleep time by assuming each work unit takes 1 second. - - Args: - observer: Optional observer (ID string or AsyncPeer object) to scope the status check - sender: Optional sender (ID string or AsyncPeer object) to scope the status check - timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds). - - Returns: - QueueStatusResponse when all work units are complete - - Raises: - TimeoutError: If timeout is exceeded before work units complete - Exception: If get_queue_status fails repeatedly - """ - start_time = time.time() - - while True: - try: - status = await self.get_queue_status(observer, sender) - except Exception as e: - logger.warning(f"Failed to get queue status: {e}") - # Sleep briefly before retrying - await asyncio.sleep(1) - - # Check timeout after error - elapsed_time = time.time() - start_time - if elapsed_time >= timeout: - raise TimeoutError( - f"Polling timeout exceeded after {timeout}s. " - + f"Error during status check: {e}" - ) from e - continue - - if status.pending_work_units == 0 and status.in_progress_work_units == 0: - return status - - # Check timeout before sleeping - elapsed_time = time.time() - start_time - if elapsed_time >= timeout: - raise TimeoutError( - f"Polling timeout exceeded after {timeout}s. " - + f"Current status: {status.pending_work_units} pending, " - + f"{status.in_progress_work_units} in progress work units." - ) - - # Sleep for the expected time to complete all current work units - # Assuming each pending and in-progress work unit takes 1 second - total_work_units = status.pending_work_units + status.in_progress_work_units - sleep_time = max(1, total_work_units) - - # Don't sleep past the timeout - remaining_time = timeout - elapsed_time - sleep_time = min(sleep_time, remaining_time) - if sleep_time <= 0: - raise TimeoutError( - f"Polling timeout exceeded after {timeout}s. " - + f"Current status: {status.pending_work_units} pending, " - + f"{status.in_progress_work_units} in progress work units." - ) - - await asyncio.sleep(sleep_time) - - def __repr__(self) -> str: - """ - Return a string representation of the AsyncSession. - - Returns: - A string representation suitable for debugging - """ - return f"AsyncSession(id='{self.id}')" - - def __str__(self) -> str: - """ - Return a human-readable string representation of the AsyncSession. - - Returns: - The session's ID - """ - return self.id diff --git a/sdks/python/src/honcho/client.py b/sdks/python/src/honcho/client.py index 75711906..35efc2c5 100644 --- a/sdks/python/src/honcho/client.py +++ b/sdks/python/src/honcho/client.py @@ -1,26 +1,45 @@ +"""Sync Honcho client.""" + +from __future__ import annotations + import logging import os -import time from collections.abc import Mapping from typing import Any, Literal import httpx -from honcho_core import Honcho as HonchoCore -from honcho_core.types.workspaces import QueueStatusResponse -from honcho_core.types.workspaces.peer import Peer as PeerCore -from honcho_core.types.workspaces.session import Session as SessionCore -from honcho_core.types.workspaces.sessions.message import Message from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call +from .aio import HonchoAio +from .api_types import ( + MessageResponse, + PeerConfig, + PeerResponse, + QueueStatusResponse, + SessionConfiguration, + SessionResponse, + WorkspaceConfiguration, + WorkspaceResponse, +) from .base import PeerBase, SessionBase +from .http import AsyncHonchoHTTPClient, HonchoHTTPClient, routes +from .message import Message +from .mixins import MetadataConfigMixin from .pagination import SyncPage from .peer import Peer from .session import Session +from .utils import resolve_id logger = logging.getLogger(__name__) +# Environment configuration +ENVIRONMENTS = { + "local": "http://localhost:8000", + "production": "https://api.honcho.dev", +} -class Honcho(BaseModel): + +class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMultipleInheritance] """ Main client for the Honcho SDK. @@ -28,16 +47,12 @@ class Honcho(BaseModel): from environment variables or explicit parameters. This is the primary entry point for interacting with the Honcho conversational memory platform. - For advanced usage, the underlying honcho_core client can be accessed via the - `core` property to use functionality not exposed through this SDK. - Attributes: workspace_id: Workspace ID for scoping operations metadata: Cached metadata for this workspace. May be stale if not recently fetched. Call get_metadata() for fresh data. configuration: Cached configuration for this workspace. May be stale if not - recently fetched. Call get_config() for fresh data. - core: Access to the underlying honcho_core client for advanced usage + recently fetched. Call get_configuration() for fresh data. """ model_config = ConfigDict(extra="allow") # pyright: ignore @@ -48,8 +63,12 @@ class Honcho(BaseModel): description="Workspace ID for scoping operations", ) _metadata: dict[str, object] | None = PrivateAttr(default=None) - _configuration: dict[str, object] | None = PrivateAttr(default=None) - _client: HonchoCore = PrivateAttr() + _configuration: WorkspaceConfiguration | None = PrivateAttr(default=None) + _http: HonchoHTTPClient = PrivateAttr() + _async_http: AsyncHonchoHTTPClient | None = PrivateAttr(default=None) + _http_config: dict[str, Any] = PrivateAttr() + _base_url: str = PrivateAttr() + _workspace_ensured: bool = PrivateAttr(default=False) @property def metadata(self) -> dict[str, object] | None: @@ -57,35 +76,103 @@ class Honcho(BaseModel): return self._metadata @property - def configuration(self) -> dict[str, object] | None: - """Cached configuration for this workspace. May be stale. Use get_config() for fresh data.""" + def configuration(self) -> WorkspaceConfiguration | None: + """Cached configuration for this workspace. May be stale. Use get_configuration() for fresh data.""" return self._configuration - @property - def core(self) -> HonchoCore: + # MetadataConfigMixin implementation + def _get_http_client(self): + return self._http + + def _get_fetch_route(self) -> str: + return routes.workspaces() + + def _get_update_route(self) -> str: + return routes.workspace(self.workspace_id) + + def _get_fetch_body(self) -> dict[str, Any]: + return {"id": self.workspace_id} + + def _parse_response( + self, data: dict[str, Any] + ) -> tuple[dict[str, object], dict[str, object]]: + workspace = WorkspaceResponse.model_validate(data) + # Return configuration as dict for mixin compatibility + return workspace.metadata or {}, workspace.configuration.model_dump( + exclude_none=True + ) + + def get_configuration(self) -> WorkspaceConfiguration: # pyright: ignore[reportIncompatibleMethodOverride] """ - Access the underlying honcho_core client. The honcho_core client is the raw Stainless-generated client, - allowing users to access functionality that is not exposed through this SDK. + Get configuration from the server and update the cache. Returns: - The underlying HonchoCore client instance + A WorkspaceConfiguration object containing the configuration settings. + """ + data = self._get_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + workspace = WorkspaceResponse.model_validate(data) + self._metadata = workspace.metadata or {} + self._configuration = workspace.configuration + return self._configuration + + @validate_call + def set_configuration( # pyright: ignore[reportIncompatibleMethodOverride] + self, + configuration: WorkspaceConfiguration = Field( + ..., description="Configuration to set" + ), + ) -> None: + """ + Set configuration on the server and update the cache. + + Args: + configuration: A WorkspaceConfiguration object with configuration settings. + """ + self._get_http_client().put( + self._get_update_route(), + body={"configuration": configuration.model_dump(exclude_none=True)}, + ) + self._configuration = configuration # pyright: ignore[reportIncompatibleVariableOverride] + + @property + def base_url(self) -> str: + """The base URL of the Honcho API.""" + return self._base_url + + @property + def _async_http_client(self) -> AsyncHonchoHTTPClient: + """Lazily create and return the async HTTP client.""" + if self._async_http is None: + self._async_http = AsyncHonchoHTTPClient(**self._http_config) + return self._async_http + + @property + def aio(self) -> HonchoAio: + """ + Access async versions of all Honcho methods. + + Returns an HonchoAio view that provides async versions of all methods + while sharing state with this Honcho instance. Example: ```python - from honcho import Honcho + honcho = Honcho(workspace_id="my-workspace") - client = Honcho() - - workspace = client.core.workspaces.get_or_create(id="custom-workspace-id") + # Async operations + peer = await honcho.aio.peer("user-123") + async for p in honcho.aio.peers(): + print(p.id) ``` """ - return self._client + return HonchoAio(self) @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def __init__( self, api_key: str | None = None, - environment: Literal["local", "production", "demo"] | None = None, + environment: Literal["local", "production"] | None = None, base_url: str | None = Field(None, description="Base URL for the Honcho API"), workspace_id: str | None = Field( None, min_length=1, description="Workspace ID for scoping operations" @@ -135,30 +222,69 @@ class Honcho(BaseModel): super().__init__(workspace_id=resolved_workspace_id) - # Build client kwargs, excluding None values that HonchoCore doesn't handle well - client_kwargs: dict[str, Any] = {} + # Resolve API key + resolved_api_key = api_key or os.getenv("HONCHO_API_KEY") + + # Resolve base URL + if base_url: + resolved_base_url = base_url + elif environment: + resolved_base_url = ENVIRONMENTS[environment] + else: + resolved_base_url = os.getenv("HONCHO_URL", ENVIRONMENTS["production"]) + + self._base_url = resolved_base_url + + # Build HTTP client kwargs + http_kwargs: dict[str, Any] = { + "base_url": resolved_base_url, + "api_key": resolved_api_key, + } - if api_key is not None: - client_kwargs["api_key"] = api_key - if environment is not None: - client_kwargs["environment"] = environment - if base_url is not None: - client_kwargs["base_url"] = base_url if timeout is not None: - client_kwargs["timeout"] = timeout + http_kwargs["timeout"] = timeout if max_retries is not None: - client_kwargs["max_retries"] = max_retries + http_kwargs["max_retries"] = max_retries if default_headers is not None: - client_kwargs["default_headers"] = default_headers + http_kwargs["default_headers"] = dict(default_headers) if default_query is not None: - client_kwargs["default_query"] = default_query + http_kwargs["default_query"] = dict(default_query) + + # Store config for lazy async client creation (without custom http_client) + self._http_config = dict(http_kwargs) + if http_client is not None: - client_kwargs["http_client"] = http_client + http_kwargs["http_client"] = http_client - self._client = HonchoCore(**client_kwargs) + self._http = HonchoHTTPClient(**http_kwargs) - # Get or create the workspace - self._client.workspaces.get_or_create(id=self.workspace_id) + def _ensure_workspace(self) -> None: + """ + Ensure the workspace exists on the server. + + The Honcho API uses get-or-create semantics for workspaces via + `POST /v3/workspaces`. This SDK uses that endpoint once per client + instance to guarantee that subsequent workspace-scoped calls (peers, + sessions, queue status, etc.) operate on an existing workspace. + """ + if self._workspace_ensured: + return + self._http.post(routes.workspaces(), body={"id": self.workspace_id}) + self._workspace_ensured = True + + async def _ensure_workspace_async(self) -> None: + """ + Async version of `_ensure_workspace`. + + This performs the same get-or-create call, but via the async HTTP client + used by `honcho.aio`. + """ + if self._workspace_ensured: + return + await self._async_http_client.post( + routes.workspaces(), body={"id": self.workspace_id} + ) + self._workspace_ensured = True @validate_call def peer( @@ -171,7 +297,7 @@ class Honcho(BaseModel): None, description="Optional metadata dictionary to associate with this peer. If set, will get/create peer immediately with metadata.", ), - config: dict[str, object] | None = Field( + configuration: PeerConfig | None = Field( None, description="Optional configuration to set for this peer. If set, will get/create peer immediately with flags.", ), @@ -180,16 +306,16 @@ class Honcho(BaseModel): 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 `config` or `metadata` is + This method does not make an API call unless `configuration` or `metadata` is provided. Args: id: Unique identifier for the peer within the workspace. Should be a - stable identifier that can be used consistently across sessions. + stable identifier that can be used consistently across sessions. metadata: Optional metadata dictionary to associate with this peer. - If set, will get/create peer immediately with metadata. - config: Optional configuration to set for this peer. - If set, will get/create peer immediately with flags. + 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 @@ -198,14 +324,11 @@ class Honcho(BaseModel): Raises: ValidationError: If the peer ID is empty or invalid """ - # Peer constructor handles API call and caching when metadata/config provided - return Peer( - id, self.workspace_id, self._client, config=config, metadata=metadata - ) + return Peer(id, self, configuration=configuration, metadata=metadata) - def get_peers( + def peers( self, filters: dict[str, object] | None = None - ) -> SyncPage[PeerCore, Peer]: + ) -> SyncPage[PeerResponse, Peer]: """ Get all peers in the current workspace. @@ -216,19 +339,29 @@ class Honcho(BaseModel): Returns: A SyncPage of Peer objects representing all peers in the workspace """ - peers_page = self._client.workspaces.peers.list( - workspace_id=self.workspace_id, filters=filters + self._ensure_workspace() + data = self._http.post( + routes.peers_list(self.workspace_id), + body={"filters": filters} if filters else None, ) - return SyncPage( - peers_page, - lambda peer: Peer( + + def transform(peer: PeerResponse) -> Peer: + return Peer( peer.id, - self.workspace_id, - self._client, + self, metadata=peer.metadata, - config=peer.configuration, - ), - ) + configuration=peer.configuration, + ) + + def fetch_next(page: int) -> SyncPage[PeerResponse, Peer]: + next_data = self._http.post( + routes.peers_list(self.workspace_id), + body={"filters": filters} if filters else None, + query={"page": page}, + ) + return SyncPage(next_data, PeerResponse, transform, fetch_next) + + return SyncPage(data, PeerResponse, transform, fetch_next) @validate_call def session( @@ -241,7 +374,7 @@ class Honcho(BaseModel): None, description="Optional metadata dictionary to associate with this session. If set, will get/create session immediately with metadata.", ), - config: dict[str, object] | None = Field( + configuration: SessionConfiguration | None = Field( None, description="Optional configuration to set for this session. If set, will get/create session immediately with flags.", ), @@ -250,17 +383,18 @@ class Honcho(BaseModel): 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 `config` or + multiple peers. This method does not make an API call unless `configuration` or `metadata` is provided. 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 + stable identifier that can be used consistently to reference the + same conversation metadata: Optional metadata dictionary to associate with this session. - If set, will get/create session immediately with metadata. - config: Optional configuration to set for this session. - If set, will get/create session immediately with flags. + 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 @@ -268,13 +402,11 @@ class Honcho(BaseModel): Raises: ValidationError: If the session ID is empty or invalid """ - return Session( - id, self.workspace_id, self._client, config=config, metadata=metadata - ) + return Session(id, self, configuration=configuration, metadata=metadata) - def get_sessions( + def sessions( self, filters: dict[str, object] | None = None - ) -> SyncPage[SessionCore, Session]: + ) -> SyncPage[SessionResponse, Session]: """ Get all sessions in the current workspace. @@ -285,105 +417,33 @@ class Honcho(BaseModel): A SyncPage of Session objects representing all sessions in the workspace. Returns an empty page if no sessions exist """ - sessions_page = self._client.workspaces.sessions.list( - workspace_id=self.workspace_id, filters=filters + self._ensure_workspace() + data = self._http.post( + routes.sessions_list(self.workspace_id), + body={"filters": filters} if filters else None, ) - return SyncPage( - sessions_page, - lambda session: Session( + + def transform(session: SessionResponse) -> Session: + return Session( session.id, - self.workspace_id, - self._client, + self, metadata=session.metadata, - config=session.configuration, - ), - ) + configuration=session.configuration, + ) - def get_metadata(self) -> dict[str, object]: - """ - Get metadata for the current workspace. + def fetch_next(page: int) -> SyncPage[SessionResponse, Session]: + next_data = self._http.post( + routes.sessions_list(self.workspace_id), + body={"filters": filters} if filters else None, + query={"page": page}, + ) + return SyncPage(next_data, SessionResponse, transform, fetch_next) - Makes an API call to retrieve metadata associated with the current workspace. - Workspace metadata can include settings, configuration, or any other - key-value data associated with the workspace. This method also updates the - cached metadata attribute. + return SyncPage(data, SessionResponse, transform, fetch_next) - Returns: - A dictionary containing the workspace's metadata. Returns an empty - dictionary if no metadata is set - """ - workspace = self._client.workspaces.get_or_create(id=self.workspace_id) - self._metadata = workspace.metadata or {} - return self._metadata - - @validate_call - def set_metadata( - self, - metadata: dict[str, object] = Field(..., description="Metadata dictionary"), - ) -> None: - """ - Set metadata for the current workspace. - - Makes an API call to update the metadata associated with the current workspace. - This will overwrite any existing metadata with the provided values. - This method also updates the cached metadata attribute. - - Args: - metadata: A dictionary of metadata to associate with the workspace. - Keys must be strings, values can be any JSON-serializable type - """ - self._client.workspaces.update(self.workspace_id, metadata=metadata) - self._metadata = metadata - - def get_config(self) -> dict[str, object]: - """ - Get configuration for the current workspace. - - Makes an API call to retrieve configuration associated with the current workspace. - Configuration includes settings that control workspace behavior. - This method also updates the cached configuration attribute. - - Returns: - A dictionary containing the workspace's configuration. Returns an empty - dictionary if no configuration is set - """ - workspace = self._client.workspaces.get_or_create(id=self.workspace_id) - self._configuration = workspace.configuration or {} - return self._configuration - - @validate_call - def set_config( - self, - configuration: dict[str, object] = Field( - ..., description="Configuration dictionary" - ), - ) -> None: - """ - Set configuration for the current workspace. - - Makes an API call to update the configuration associated with the current workspace. - This will overwrite any existing configuration with the provided values. - This method also updates the cached configuration attribute. - - Args: - configuration: A dictionary of configuration to associate with the workspace. - Keys must be strings, values can be any JSON-serializable type - """ - self._client.workspaces.update(self.workspace_id, configuration=configuration) - self._configuration = configuration - - def refresh(self) -> None: - """ - Refresh cached metadata and configuration for the current workspace. - - Makes a single API call to retrieve the latest metadata and configuration - associated with the current workspace and updates the cached attributes. - """ - workspace = self._client.workspaces.get_or_create(id=self.workspace_id) - self._metadata = workspace.metadata or {} - self._configuration = workspace.configuration or {} - - def get_workspaces(self, filters: dict[str, object] | None = None) -> list[str]: + def workspaces( + self, filters: dict[str, object] | None = None + ) -> SyncPage[WorkspaceResponse, str]: """ Get all workspace IDs from the Honcho instance. @@ -391,11 +451,25 @@ class Honcho(BaseModel): user has access to. Returns: - A list of workspace ID strings. Returns an empty list if no workspaces - are accessible or none exist + A paginated SyncPage of workspace ID strings """ - workspaces = self._client.workspaces.list(filters=filters) - return [workspace.id for workspace in workspaces] + data = self._http.post( + routes.workspaces_list(), + body={"filters": filters} if filters else None, + ) + + def transform(workspace: WorkspaceResponse) -> str: + return workspace.id + + def fetch_next(page: int) -> SyncPage[WorkspaceResponse, str]: + next_data = self._http.post( + routes.workspaces_list(), + body={"filters": filters} if filters else None, + query={"page": page}, + ) + return SyncPage(next_data, WorkspaceResponse, transform, fetch_next) + + return SyncPage(data, WorkspaceResponse, transform, fetch_next) @validate_call def delete_workspace( @@ -412,7 +486,7 @@ class Honcho(BaseModel): Args: workspace_id: The ID of the workspace to delete """ - self._client.workspaces.delete(workspace_id) + self._http.delete(routes.workspace(workspace_id)) @validate_call def search( @@ -432,19 +506,25 @@ class Honcho(BaseModel): Args: query: The search query to use - filters: Filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v2/guides/using-filters). + filters: Filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). limit: Number of results to return (1-100, default: 10) Returns: A list of Message objects representing the search results. Returns an empty list if no messages are found. """ - return self._client.workspaces.search( - self.workspace_id, query=query, filters=filters, limit=limit + self._ensure_workspace() + data = self._http.post( + routes.workspace_search(self.workspace_id), + body={"query": query, "filters": filters, "limit": limit}, ) + return [ + Message.from_api_response(MessageResponse.model_validate(item)) + for item in data + ] @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) - def get_queue_status( + def queue_status( self, observer: str | PeerBase | None = None, sender: str | PeerBase | None = None, @@ -458,278 +538,61 @@ class Honcho(BaseModel): sender: Optional sender (ID string or Peer object) to scope the status check session: Optional session (ID string or Session object) to scope the status check """ - resolved_observer_id = ( - None - if observer is None - else (observer if isinstance(observer, str) else observer.id) - ) - resolved_sender_id = ( - None - if sender is None - else (sender if isinstance(sender, str) else sender.id) - ) - resolved_session_id = ( - None - if session is None - else (session if isinstance(session, str) else session.id) - ) + self._ensure_workspace() + resolved_observer_id = resolve_id(observer) + resolved_sender_id = resolve_id(sender) + resolved_session_id = resolve_id(session) - return self._client.workspaces.queue.status( - workspace_id=self.workspace_id, - observer_id=resolved_observer_id, - sender_id=resolved_sender_id, - session_id=resolved_session_id, + query: dict[str, Any] = {} + if resolved_observer_id: + query["observer_id"] = resolved_observer_id + if resolved_sender_id: + query["sender_id"] = resolved_sender_id + if resolved_session_id: + query["session_id"] = resolved_session_id + + data = self._http.get( + routes.workspace_queue_status(self.workspace_id), + query=query if query else None, ) + return QueueStatusResponse.model_validate(data) @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) - def poll_queue_status( + def schedule_dream( self, - observer: str | PeerBase | None = None, - sender: str | PeerBase | None = None, - session: str | SessionBase | None = None, - timeout: float = Field( - 300.0, - gt=0, - description="Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).", - ), - ) -> QueueStatusResponse: - """ - Poll get_queue_status until pending_work_units and in_progress_work_units are both 0. - This allows you to guarantee that all messages have been processed by the queue for - use with the dialectic endpoint. - - The polling estimates sleep time by assuming each work unit takes 1 second. - - Args: - observer: Optional observer (ID string or Peer object) to scope the status check - sender: Optional sender (ID string or Peer object) to scope the status check - session: Optional session (ID string or Session object) to scope the status check - timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds). - - Returns: - QueueStatusResponse when all work units are complete - - Raises: - TimeoutError: If timeout is exceeded before work units complete - Exception: If get_queue_status fails repeatedly - """ - start_time = time.time() - - while True: - try: - status = self.get_queue_status(observer, sender, session) - except Exception as e: - logger.warning(f"Failed to get queue status: {e}") - # Sleep briefly before retrying - time.sleep(1) - - # Check timeout after error - elapsed_time = time.time() - start_time - if elapsed_time >= timeout: - raise TimeoutError( - f"Polling timeout exceeded after {timeout}s. " - + f"Error during status check: {e}" - ) from e - continue - - if status.pending_work_units == 0 and status.in_progress_work_units == 0: - return status - - # Check timeout before sleeping - elapsed_time = time.time() - start_time - if elapsed_time >= timeout: - raise TimeoutError( - f"Polling timeout exceeded after {timeout}s. " - + f"Current status: {status.pending_work_units} pending, " - + f"{status.in_progress_work_units} in progress work units." - ) - - # Sleep for the expected time to complete all current work units - # Assuming each pending and in-progress work unit takes 1 second - total_work_units = status.pending_work_units + status.in_progress_work_units - sleep_time = max(1, total_work_units) - - # Don't sleep past the timeout - remaining_time = timeout - elapsed_time - sleep_time = min(sleep_time, remaining_time) - if sleep_time <= 0: - raise TimeoutError( - f"Polling timeout exceeded after {timeout}s. " - + f"Current status: {status.pending_work_units} pending, " - + f"{status.in_progress_work_units} in progress work units." - ) - - time.sleep(sleep_time) - - @validate_call - def list_conclusions( - self, - filters: dict[str, object] | None = Field( - None, description="Filters to scope the conclusions" - ), - reverse: bool = Field( - False, description="Whether to reverse the order of results" - ), - ): - """ - List all conclusions in the current workspace with optional filtering. - - Makes an API call to retrieve conclusions that match the specified filters. - conclusions can be filtered by session_id, observer_id, and observed_id. - - Args: - filters: Optional filter criteria for conclusions. Supported filters include: - - session_id: Filter conclusions by session - - observer_id: Filter conclusions by observer peer - - observed_id: Filter conclusions by observed peer - reverse: Whether to reverse the order of results (default: False) - - Returns: - A paginated list of conclusion objects matching the specified criteria - - Example: - >>> conclusions = client.list_conclusions( - ... filters={"observer_id": "user123", "observed_id": "assistant"} - ... ) - """ - return self._client.workspaces.conclusions.list( - workspace_id=self.workspace_id, - filters=filters, - reverse=reverse, - ) - - @validate_call - def query_conclusions( - self, - query: str = Field(..., min_length=1, description="Semantic search query"), - observer: str = Field( - ..., min_length=1, description="Observer peer ID (required)" - ), - observed: str = Field( - ..., min_length=1, description="Observed peer ID (required)" - ), - top_k: int = Field( - default=10, ge=1, le=100, description="Number of results to return" - ), - distance: float | None = Field( - default=None, - ge=0.0, - le=1.0, - description="Maximum cosine distance threshold for results", - ), - filters: dict[str, object] | None = Field( - None, description="Additional filters to apply" - ), - ): - """ - Query conclusions using semantic search. - - Performs vector similarity search on conclusions to find semantically relevant results. - Observer and observed peer IDs are required for semantic search. - - Args: - query: The semantic search query - observer: The observer peer ID (required) - observed: The observed peer ID (required) - top_k: Number of results to return (1-100, default: 10) - distance: Maximum cosine distance threshold for results (0.0-1.0) - filters: Optional filters to scope the query - - Returns: - A list of conclusion objects matching the query - - Example: - >>> conclusions = client.query_conclusions( - ... query="user preferences about music", - ... observer="user123", - ... observed="assistant", - ... top_k=5, - ... distance=0.8 - ... ) - """ - # Merge observer/observed into filters without mutating the input - query_filters: dict[str, object | str] = { - **(filters or {}), - "observer": observer, - "observed": observed, - } - - return self._client.workspaces.conclusions.query( - workspace_id=self.workspace_id, - query=query, - top_k=top_k, - distance=distance, - filters=query_filters, - ) - - @validate_call - def delete_conclusion( - self, - conclusion_id: str = Field( - ..., min_length=1, description="ID of the conclusion to delete" - ), + observer: str | PeerBase, + session: str | SessionBase, + observed: str | PeerBase | None = None, ) -> None: """ - Delete a specific conclusion by ID. + Schedule a dream task for memory consolidation. - This permanently deletes the conclusion (document) from the theory-of-mind system. - This action cannot be undone. + Dreams are background processes that consolidate observations into higher-level + insights and update peer cards. This method schedules a dream task for immediate + processing. Args: - conclusion_id: The ID of the conclusion to delete - - Example: - >>> client.delete_conclusion('obs_123abc') + observer: The observer peer (ID string or Peer object) whose perspective + to use for the dream. + session: The session (ID string or Session object) to scope the dream to. + observed: Optional observed peer (ID string or Peer object). If not provided, + defaults to the observer (self-reflection). """ - self._client.workspaces.conclusions.delete( - workspace_id=self.workspace_id, - conclusion_id=conclusion_id, + self._ensure_workspace() + resolved_observer_id = resolve_id(observer) + resolved_session_id = resolve_id(session) + resolved_observed_id = ( + resolve_id(observed) if observed else resolved_observer_id ) - @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) - def update_message( - self, - message: Message | str = Field( - ..., description="The Message object or message ID to update" - ), - metadata: dict[str, object] = Field( - ..., description="The metadata to update for the message" - ), - session: str | SessionBase | None = Field( - None, - description="The session (ID string or Session object) - required if message is a string ID", - ), - ) -> Message: - """ - Update the metadata of a message. - - Makes an API call to update the metadata of a specific message within a session. - - Args: - message: Either a Message object or a message ID string - metadata: The metadata to update for the message - session: The session (ID string or Session object) - required if message is a string ID, ignored if message is a Message object - - Returns: - The updated Message object - - Raises: - ValidationError: If message is a string ID but session_id is not provided - """ - if isinstance(message, Message): - message_id = message.id - resolved_session_id = message.session_id - else: - message_id = message - if not session: - raise ValueError("session is required when message is a string ID") - resolved_session_id = session if isinstance(session, str) else session.id - - return self._client.workspaces.sessions.messages.update( - message_id=message_id, - workspace_id=self.workspace_id, - session_id=resolved_session_id, - metadata=metadata, + self._http.post( + routes.workspace_schedule_dream(self.workspace_id), + body={ + "observer": resolved_observer_id, + "observed": resolved_observed_id, + "session_id": resolved_session_id, + "dream_type": "omni", + }, ) def __repr__(self) -> str: @@ -739,7 +602,9 @@ class Honcho(BaseModel): Returns: A string representation suitable for debugging """ - return f"Honcho(workspace_id='{self.workspace_id}', base_url='{self._client.base_url}')" + return ( + f"Honcho(workspace_id='{self.workspace_id}', base_url='{self._base_url}')" + ) def __str__(self) -> str: """ diff --git a/sdks/python/src/honcho/conclusions.py b/sdks/python/src/honcho/conclusions.py index 97aaf7c4..abba9ba8 100644 --- a/sdks/python/src/honcho/conclusions.py +++ b/sdks/python/src/honcho/conclusions.py @@ -1,35 +1,96 @@ +# pyright: reportPrivateUsage=false """Conclusion types and scoped access for the Honcho SDK.""" from __future__ import annotations -from typing import Any +import datetime +from typing import TYPE_CHECKING, Any -from honcho_core import AsyncHoncho as AsyncHonchoCore -from honcho_core import Honcho as HonchoCore -from honcho_core.pagination import AsyncPage, SyncPage -from honcho_core.types.workspaces import conclusion_create_params -from honcho_core.types.workspaces.conclusion import Conclusion -from pydantic import BaseModel, PrivateAttr -from typing_extensions import TypeAlias +from pydantic import BaseModel +from .api_types import ConclusionResponse, RepresentationResponse from .base import SessionBase +from .http import routes +from .pagination import SyncPage +from .utils import resolve_id + +if TYPE_CHECKING: + from .aio import ConclusionScopeAio + from .client import Honcho __all__ = [ "Conclusion", - "ConclusionCreateResponse", "ConclusionScope", "ConclusionCreateParams", - "AsyncConclusionScope", ] -ConclusionCreateResponse: TypeAlias = list[Conclusion] - class ConclusionCreateParams(BaseModel): content: str session_id: str +class Conclusion: + """ + A conclusion from Honcho's reasoning system. + + Conclusions are facts derived from messages that help build a representation + of a peer. + + Attributes: + id: Unique identifier for this conclusion + content: The conclusion content/text + observer_id: The peer ID who made this conclusion + observed_id: The peer ID this conclusion is about + session_id: The session this conclusion relates to + created_at: Timestamp for when the conclusion was created + """ + + id: str + content: str + observer_id: str + observed_id: str + session_id: str + created_at: datetime.datetime + + def __init__( + self, + id: str, + content: str, + observer_id: str, + observed_id: str, + session_id: str, + created_at: datetime.datetime, + ) -> None: + self.id = id + self.content = content + self.observer_id = observer_id + self.observed_id = observed_id + self.session_id = session_id + self.created_at = created_at + + @classmethod + def from_api_response(cls, data: ConclusionResponse) -> "Conclusion": + """Create a Conclusion from an API response.""" + return cls( + id=data.id, + content=data.content, + observer_id=data.observer_id, + observed_id=data.observed_id, + session_id=data.session_id, + created_at=data.created_at, + ) + + def __repr__(self) -> str: + truncated = ( + f"{self.content[:50]}..." if len(self.content) > 50 else self.content + ) + return f"Conclusion(id='{self.id}', content='{truncated}')" + + def __str__(self) -> str: + return self.content + + class ConclusionScope: """ Scoped access to conclusions for a specific observer/observed relationship. @@ -50,17 +111,20 @@ class ConclusionScope: # Get conclusions about another peer bob_conclusions = peer.conclusions_of("bob") bob_list = bob_conclusions.list() + + # Async operations via .aio accessor + obs_list = await peer.conclusions.aio.list() ``` """ - _client: HonchoCore = PrivateAttr() + _honcho: "Honcho" workspace_id: str observer: str observed: str def __init__( self, - client: HonchoCore, + honcho: "Honcho", workspace_id: str, observer: str, observed: str, @@ -69,22 +133,42 @@ class ConclusionScope: Initialize a ConclusionScope. Args: - client: The Honcho client instance + honcho: The Honcho client instance workspace_id: The workspace ID observer: The observer peer ID observed: The observed peer ID """ - self._client = client + self._honcho = honcho self.workspace_id = workspace_id self.observer = observer self.observed = observed + @property + def aio(self) -> "ConclusionScopeAio": + """ + Access async versions of all ConclusionScope methods. + + Returns a ConclusionScopeAio view that provides async versions of all methods + while sharing state with this ConclusionScope instance. + + Example: + ```python + # Async operations + obs_list = await scope.aio.list() + results = await scope.aio.query("preferences") + ``` + """ + # Import here to avoid circular import (aio.py imports from this module) + from .aio import ConclusionScopeAio + + return ConclusionScopeAio(self) + def list( self, page: int = 1, size: int = 50, session: str | SessionBase | None = None, - ) -> SyncPage[Conclusion]: + ) -> SyncPage[ConclusionResponse, Conclusion]: """ List conclusions in this scope. @@ -96,25 +180,36 @@ class ConclusionScope: Returns: Paginated response containing Conclusion objects """ - resolved_session_id = ( - None - if session is None - else (session if isinstance(session, str) else session.id) - ) + self._honcho._ensure_workspace() + resolved_session_id = resolve_id(session) filters: dict[str, Any] = { - "observer": self.observer, - "observed": self.observed, + "observer_id": self.observer, + "observed_id": self.observed, } if resolved_session_id: filters["session_id"] = resolved_session_id - return self._client.workspaces.conclusions.list( - workspace_id=self.workspace_id, - filters=filters, - page=page, - size=size, + data = self._honcho._http.post( + routes.conclusions_list(self.workspace_id), + body={"filters": filters}, + query={"page": page, "size": size}, ) + def transform(response: ConclusionResponse) -> Conclusion: + return Conclusion.from_api_response(response) + + def fetch_next( + page: int, + ) -> SyncPage[ConclusionResponse, Conclusion]: + next_data = self._honcho._http.post( + routes.conclusions_list(self.workspace_id), + body={"filters": filters}, + query={"page": page, "size": size}, + ) + return SyncPage(next_data, ConclusionResponse, transform, fetch_next) + + return SyncPage(data, ConclusionResponse, transform, fetch_next) + def query( self, query: str, @@ -132,18 +227,28 @@ class ConclusionScope: Returns: List of matching Conclusion objects """ + self._honcho._ensure_workspace() filters: dict[str, Any] = { - "observer": self.observer, - "observed": self.observed, + "observer_id": self.observer, + "observed_id": self.observed, } - return self._client.workspaces.conclusions.query( - workspace_id=self.workspace_id, - query=query, - top_k=top_k, - distance=distance, - filters=filters, + body: dict[str, Any] = { + "query": query, + "top_k": top_k, + "filters": filters, + } + if distance is not None: + body["distance"] = distance + + data = self._honcho._http.post( + routes.conclusions_query(self.workspace_id), + body=body, ) + return [ + Conclusion.from_api_response(ConclusionResponse.model_validate(item)) + for item in data + ] def delete(self, conclusion_id: str) -> None: """ @@ -152,10 +257,8 @@ class ConclusionScope: Args: conclusion_id: The ID of the conclusion to delete """ - self._client.workspaces.conclusions.delete( - workspace_id=self.workspace_id, - conclusion_id=conclusion_id, - ) + self._honcho._ensure_workspace() + self._honcho._http.delete(routes.conclusion(self.workspace_id, conclusion_id)) def create( self, @@ -179,25 +282,31 @@ class ConclusionScope: ]) ``` """ + self._honcho._ensure_workspace() + conclusion_params = [ + { + "content": c.content + if isinstance(c, ConclusionCreateParams) + else c["content"], + "session_id": c.session_id + if isinstance(c, ConclusionCreateParams) + else c["session_id"], + "observer_id": self.observer, + "observed_id": self.observed, + } + for c in conclusions + ] - return self._client.workspaces.conclusions.create( - workspace_id=self.workspace_id, - conclusions=[ - conclusion_create_params.Conclusion( - content=conclusion.content - if isinstance(conclusion, ConclusionCreateParams) - else conclusion["content"], - session_id=conclusion.session_id - if isinstance(conclusion, ConclusionCreateParams) - else conclusion["session_id"], - observer_id=self.observer, - observed_id=self.observed, - ) - for conclusion in conclusions - ], + data = self._honcho._http.post( + routes.conclusions(self.workspace_id), + body={"conclusions": conclusion_params}, ) + return [ + Conclusion.from_api_response(ConclusionResponse.model_validate(item)) + for item in data + ] - def get_representation( + def representation( self, search_query: str | None = None, search_top_k: int | None = None, @@ -221,23 +330,24 @@ class ConclusionScope: Returns: A Representation string """ - from honcho_core._types import omit + self._honcho._ensure_workspace() + body: dict[str, Any] = {"target": self.observed} + if search_query is not None: + body["search_query"] = search_query + if search_top_k is not None: + body["search_top_k"] = search_top_k + if search_max_distance is not None: + body["search_max_distance"] = search_max_distance + if include_most_frequent is not None: + body["include_most_frequent"] = include_most_frequent + if max_conclusions is not None: + body["max_conclusions"] = max_conclusions - response = self._client.workspaces.peers.representation( - peer_id=self.observer, - workspace_id=self.workspace_id, - target=self.observed, - search_query=search_query if search_query is not None else omit, - search_top_k=search_top_k if search_top_k is not None else omit, - search_max_distance=search_max_distance - if search_max_distance is not None - else omit, - include_most_frequent=include_most_frequent - if include_most_frequent is not None - else omit, - max_conclusions=max_conclusions if max_conclusions is not None else omit, + data = self._honcho._http.post( + routes.peer_representation(self.workspace_id, self.observer), + body=body, ) - + response = RepresentationResponse.model_validate(data) return response.representation def __repr__(self) -> str: @@ -245,219 +355,3 @@ class ConclusionScope: f"ConclusionScope(workspace_id={self.workspace_id!r}, " f"observer={self.observer!r}, observed={self.observed!r})" ) - - -class AsyncConclusionScope: - """ - Async scoped access to conclusions for a specific observer/observed relationship. - - This class provides convenient async methods to list, query, create, and delete conclusions - that are automatically scoped to a specific observer/observed pair. - - Typically accessed via `peer.conclusions` (for self-conclusions) or - `peer.conclusions_of(target)` (for conclusions about another peer). - - Example: - ```python - # Get self-conclusions - conclusions = peer.conclusions - obs_list = await conclusions.list() - search_results = await conclusions.query("preferences") - - # Get conclusions about another peer - bob_conclusions = peer.conclusions_of("bob") - bob_list = await bob_conclusions.list() - ``` - """ - - _client: AsyncHonchoCore = PrivateAttr() - workspace_id: str - observer: str - observed: str - - def __init__( - self, - client: AsyncHonchoCore, - workspace_id: str, - observer: str, - observed: str, - ): - """ - Initialize an AsyncConclusionScope. - - Args: - client: The AsyncHoncho client instance - workspace_id: The workspace ID - observer: The observer peer ID - observed: The observed peer ID - """ - self._client = client - self.workspace_id = workspace_id - self.observer = observer - self.observed = observed - - async def list( - self, - page: int = 1, - size: int = 50, - session: str | SessionBase | None = None, - ) -> AsyncPage[Conclusion]: - """ - List conclusions in this scope. - - Args: - page: Page number (1-indexed) - size: Number of results per page - session: Optional session (ID string or AsyncSession object) to filter by - - Returns: - Paginated response containing Conclusion objects - """ - resolved_session_id = ( - None - if session is None - else (session if isinstance(session, str) else session.id) - ) - filters: dict[str, Any] = { - "observer": self.observer, - "observed": self.observed, - } - if resolved_session_id: - filters["session_id"] = resolved_session_id - - return await self._client.workspaces.conclusions.list( - workspace_id=self.workspace_id, - filters=filters, - page=page, - size=size, - ) - - async def query( - self, - query: str, - top_k: int = 10, - distance: float | None = None, - ) -> list[Conclusion]: - """ - Semantic search for conclusions in this scope. - - Args: - query: The search query string - top_k: Maximum number of results to return - distance: Maximum cosine distance threshold (0.0-1.0) - - Returns: - List of matching Conclusion objects - """ - filters: dict[str, Any] = { - "observer": self.observer, - "observed": self.observed, - } - - return await self._client.workspaces.conclusions.query( - workspace_id=self.workspace_id, - query=query, - top_k=top_k, - distance=distance, - filters=filters, - ) - - async def delete(self, conclusion_id: str) -> None: - """ - Delete a conclusion by ID. - - Args: - conclusion_id: The ID of the conclusion to delete - """ - await self._client.workspaces.conclusions.delete( - workspace_id=self.workspace_id, - conclusion_id=conclusion_id, - ) - - async def create( - self, - conclusions: list[ConclusionCreateParams | dict[str, Any]], - ) -> list[Conclusion]: - """ - Create conclusions in this scope. - - Args: - conclusions: List of conclusions to create. - Each conclusion can be a ConclusionCreateParams object or a dictionary with 'content' and 'session_id' keys. - - Returns: - List of created Conclusion objects - - Example: - ```python - conclusions = await peer.conclusions.create([ - {"content": "User prefers dark mode", "session_id": "session1"}, - {"content": "User is interested in AI", "session_id": "session1"}, - ]) - ``` - """ - return await self._client.workspaces.conclusions.create( - workspace_id=self.workspace_id, - conclusions=[ - conclusion_create_params.Conclusion( - content=conclusion.content - if isinstance(conclusion, ConclusionCreateParams) - else conclusion["content"], - session_id=conclusion.session_id - if isinstance(conclusion, ConclusionCreateParams) - else conclusion["session_id"], - observer_id=self.observer, - observed_id=self.observed, - ) - for conclusion in conclusions - ], - ) - - async def get_representation( - self, - search_query: str | None = None, - search_top_k: int | None = None, - search_max_distance: float | None = None, - include_most_frequent: bool | None = None, - max_conclusions: int | None = None, - ) -> str: - """ - Get the computed representation for this scope. - - This returns the working representation (narrative) built from the - conclusions in this scope. - - Args: - search_query: Optional semantic search query to curate the representation - search_top_k: Number of semantically relevant facts to return - search_max_distance: Maximum semantic distance for search results (0.0-1.0) - include_most_frequent: Whether to include the most frequent conclusions - max_conclusions: Maximum number of conclusions to include - - Returns: - A Representation string - """ - from honcho_core._types import omit - - response = await self._client.workspaces.peers.representation( - peer_id=self.observer, - workspace_id=self.workspace_id, - target=self.observed, - search_query=search_query if search_query is not None else omit, - search_top_k=search_top_k if search_top_k is not None else omit, - search_max_distance=search_max_distance - if search_max_distance is not None - else omit, - include_most_frequent=include_most_frequent - if include_most_frequent is not None - else omit, - max_conclusions=max_conclusions if max_conclusions is not None else omit, - ) - - return response.representation - - def __repr__(self) -> str: - return ( - f"AsyncConclusionScope(workspace_id={self.workspace_id!r}, " - f"observer={self.observer!r}, observed={self.observed!r})" - ) diff --git a/sdks/python/src/honcho/http/__init__.py b/sdks/python/src/honcho/http/__init__.py new file mode 100644 index 00000000..dffdd2ef --- /dev/null +++ b/sdks/python/src/honcho/http/__init__.py @@ -0,0 +1,37 @@ +"""HTTP client module for Honcho SDK.""" + +from .async_client import AsyncHonchoHTTPClient +from .client import HonchoHTTPClient +from .exceptions import ( + APIError, + AuthenticationError, + BadRequestError, + ConflictError, + ConnectionError, + HonchoError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + ServerError, + TimeoutError, + UnprocessableEntityError, +) + +__all__ = [ + # Errors + "HonchoError", + "APIError", + "BadRequestError", + "AuthenticationError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitError", + "ServerError", + "TimeoutError", + "ConnectionError", + # Clients + "HonchoHTTPClient", + "AsyncHonchoHTTPClient", +] diff --git a/sdks/python/src/honcho/http/async_client.py b/sdks/python/src/honcho/http/async_client.py new file mode 100644 index 00000000..0db853b8 --- /dev/null +++ b/sdks/python/src/honcho/http/async_client.py @@ -0,0 +1,386 @@ +"""Async HTTP client for Honcho SDK.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from typing import Any, cast + +import httpx + +from .exceptions import ( + ConnectionError, + RateLimitError, + ServerError, + TimeoutError, + create_error_from_response, +) + +DEFAULT_TIMEOUT = 60.0 # 60 seconds +DEFAULT_MAX_RETRIES = 2 +RETRY_STATUS_CODES = {429, 500, 502, 503, 504} +INITIAL_RETRY_DELAY = 0.5 # 500ms + + +class AsyncHonchoHTTPClient: + """Async HTTP client for the Honcho API with retry logic and timeout support.""" + + base_url: str + api_key: str | None + timeout: float + max_retries: int + default_headers: dict[str, str] + default_query: dict[str, Any] | None + _owns_client: bool + _client: httpx.AsyncClient + + def __init__( + self, + *, + base_url: str, + api_key: str | None = None, + timeout: float = DEFAULT_TIMEOUT, + max_retries: int = DEFAULT_MAX_RETRIES, + default_headers: dict[str, str] | None = None, + default_query: dict[str, Any] | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + # Remove trailing slash from base_url + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + self.max_retries = max_retries + self.default_headers = { + "Content-Type": "application/json", + **(default_headers or {}), + } + self.default_query = default_query + self._owns_client = http_client is None + self._client = http_client or httpx.AsyncClient( # nosec B113 + base_url=self.base_url, + timeout=httpx.Timeout(timeout), + ) + + async def close(self) -> None: + """Close the HTTP client if we own it.""" + if self._owns_client: + await self._client.aclose() + + async def __aenter__(self) -> "AsyncHonchoHTTPClient": + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close() + + async def request( + self, + method: str, + path: str, + *, + body: Any = None, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Any: + """Make an HTTP request with automatic retries and timeout handling.""" + url = self._build_url(path) + request_headers = self._build_headers(headers) + request_timeout = timeout if timeout is not None else self.timeout + merged_query = {**(self.default_query or {}), **(query or {})} or None + + last_error: Exception | None = None + attempt = 0 + + while attempt <= self.max_retries: + try: + response = await self._client.request( + method, + url, + json=body if body is not None else None, + params=self._clean_query_params(merged_query), + headers=request_headers, + timeout=request_timeout, + ) + + if response.is_success: + # Handle empty responses + text = response.text + if not text: + return None + return response.json() + + # Handle error responses + error_body = self._parse_error_body(response) + retry_after = self._parse_retry_after(response) + error = create_error_from_response( + response.status_code, + error_body.get("message") or f"HTTP {response.status_code}", + body=error_body, + retry_after=retry_after, + ) + + # Only retry on specific status codes + if ( + response.status_code in RETRY_STATUS_CODES + and attempt < self.max_retries + ): + last_error = error + await asyncio.sleep(self._get_retry_delay(attempt, retry_after)) + attempt += 1 + continue + + 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}") + if attempt < self.max_retries: + last_error = error + await asyncio.sleep(self._get_retry_delay(attempt)) + attempt += 1 + continue + raise error from e + + except (TimeoutError, ConnectionError, RateLimitError, ServerError): + raise + + except Exception as e: + # Re-raise API errors + if hasattr(e, "status"): + raise + raise ConnectionError(str(e)) from e + + # If we exhausted retries, raise the last error + if last_error: + raise last_error + raise ConnectionError("Request failed after retries") + + async def get( + self, + path: str, + *, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Any: + """Make a GET request.""" + return await self.request( + "GET", path, query=query, headers=headers, timeout=timeout + ) + + async def post( + self, + path: str, + *, + body: Any = None, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Any: + """Make a POST request.""" + return await self.request( + "POST", path, body=body, query=query, headers=headers, timeout=timeout + ) + + async def put( + self, + path: str, + *, + body: Any = None, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Any: + """Make a PUT request.""" + return await self.request( + "PUT", path, body=body, query=query, headers=headers, timeout=timeout + ) + + async def patch( + self, + path: str, + *, + body: Any = None, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Any: + """Make a PATCH request.""" + return await self.request( + "PATCH", path, body=body, query=query, headers=headers, timeout=timeout + ) + + async def delete( + self, + path: str, + *, + body: Any = None, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Any: + """Make a DELETE request.""" + return await self.request( + "DELETE", path, body=body, query=query, headers=headers, timeout=timeout + ) + + async def stream( + self, + method: str, + path: str, + *, + body: Any = None, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> AsyncIterator[bytes]: + """Make a streaming request that yields raw bytes for SSE parsing.""" + url = self._build_url(path) + request_headers = { + **self._build_headers(headers), + "Accept": "text/event-stream", + } + request_timeout = timeout if timeout is not None else self.timeout + merged_query = {**(self.default_query or {}), **(query or {})} or None + + async with self._client.stream( + method, + url, + json=body if body is not None else None, + params=self._clean_query_params(merged_query), + headers=request_headers, + timeout=request_timeout, + ) as response: + if not response.is_success: + # Read error body + await response.aread() + error_body = self._parse_error_body(response) + raise create_error_from_response( + response.status_code, + error_body.get("message") or f"HTTP {response.status_code}", + body=error_body, + ) + + async for chunk in response.aiter_bytes(): + yield chunk + + async def upload( + self, + path: str, + *, + files: dict[str, Any], + data: dict[str, Any] | None = None, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Any: + """Make a multipart form data request (for file uploads).""" + url = self._build_url(path) + # Don't set Content-Type for multipart - httpx will set it with boundary + request_headers = self._build_headers(headers) + request_headers.pop("Content-Type", None) + request_timeout = timeout if timeout is not None else self.timeout + merged_query = {**(self.default_query or {}), **(query or {})} or None + + response = await self._client.post( + url, + files=files, + data=data, + params=self._clean_query_params(merged_query), + headers=request_headers, + timeout=request_timeout, + ) + + if not response.is_success: + error_body = self._parse_error_body(response) + raise create_error_from_response( + response.status_code, + error_body.get("message") or f"HTTP {response.status_code}", + body=error_body, + ) + + text = response.text + if not text: + return None + return response.json() + + def _build_url(self, path: str) -> str: + """Build the full URL from path.""" + if path.startswith("/"): + return f"{self.base_url}{path}" + return f"{self.base_url}/{path}" + + def _build_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]: + """Build request headers including auth.""" + headers = {**self.default_headers} + + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + if extra: + headers.update(extra) + + return headers + + def _clean_query_params( + self, params: dict[str, Any] | None + ) -> dict[str, Any] | None: + """Remove None values from query params.""" + if params is None: + return None + return {k: v for k, v in params.items() if v is not None} + + def _parse_error_body(self, response: httpx.Response) -> dict[str, Any]: + """Parse error body from response.""" + try: + body: Any = response.json() + if isinstance(body, dict): + body_dict: dict[str, Any] = cast(dict[str, Any], body) + return { + "message": body_dict.get("detail") + or body_dict.get("message") + or body_dict.get("error"), + **body_dict, + } + return {"message": str(body)} + except Exception: + return {"message": f"HTTP {response.status_code}"} + + def _parse_retry_after(self, response: httpx.Response) -> float | None: + """Parse Retry-After header.""" + header = response.headers.get("Retry-After") + if not header: + return None + + try: + # Try parsing as seconds + return float(header) + except ValueError: + pass + + try: + # Try parsing as HTTP date + import time + from datetime import datetime + from email.utils import parsedate_to_datetime + + dt: datetime = cast(datetime, parsedate_to_datetime(header)) + timestamp: float = dt.timestamp() + return max(0.0, timestamp - time.time()) + except Exception: + return None + + def _get_retry_delay(self, attempt: int, retry_after: float | None = None) -> float: + """Calculate delay before next retry.""" + if retry_after is not None: + return retry_after + # Exponential backoff: 0.5s, 1s, 2s, etc. + return INITIAL_RETRY_DELAY * (2**attempt) diff --git a/sdks/python/src/honcho/http/client.py b/sdks/python/src/honcho/http/client.py new file mode 100644 index 00000000..dd406953 --- /dev/null +++ b/sdks/python/src/honcho/http/client.py @@ -0,0 +1,383 @@ +"""Sync HTTP client for Honcho SDK.""" + +from __future__ import annotations + +import time +from collections.abc import Iterator +from typing import Any, cast + +import httpx + +from .exceptions import ( + ConnectionError, + RateLimitError, + ServerError, + TimeoutError, + create_error_from_response, +) + +DEFAULT_TIMEOUT = 60.0 # 60 seconds +DEFAULT_MAX_RETRIES = 2 +RETRY_STATUS_CODES = {429, 500, 502, 503, 504} +INITIAL_RETRY_DELAY = 0.5 # 500ms + + +class HonchoHTTPClient: + """Sync HTTP client for the Honcho API with retry logic and timeout support.""" + + base_url: str + api_key: str | None + timeout: float + max_retries: int + default_headers: dict[str, str] + default_query: dict[str, Any] | None + _owns_client: bool + _client: httpx.Client + + def __init__( + self, + *, + base_url: str, + api_key: str | None = None, + timeout: float = DEFAULT_TIMEOUT, + max_retries: int = DEFAULT_MAX_RETRIES, + default_headers: dict[str, str] | None = None, + default_query: dict[str, Any] | None = None, + http_client: httpx.Client | None = None, + ) -> None: + # Remove trailing slash from base_url + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + self.max_retries = max_retries + self.default_headers = { + "Content-Type": "application/json", + **(default_headers or {}), + } + self.default_query = default_query + self._owns_client = http_client is None + self._client = http_client or httpx.Client( # nosec B113 + base_url=self.base_url, + timeout=httpx.Timeout(timeout), + ) + + def close(self) -> None: + """Close the HTTP client if we own it.""" + if self._owns_client: + self._client.close() + + def __enter__(self) -> "HonchoHTTPClient": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + def request( + self, + method: str, + path: str, + *, + body: Any = None, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Any: + """Make an HTTP request with automatic retries and timeout handling.""" + url = self._build_url(path) + request_headers = self._build_headers(headers) + request_timeout = timeout if timeout is not None else self.timeout + merged_query = {**(self.default_query or {}), **(query or {})} or None + + last_error: Exception | None = None + attempt = 0 + + while attempt <= self.max_retries: + try: + response = self._client.request( + method, + url, + json=body if body is not None else None, + params=self._clean_query_params(merged_query), + headers=request_headers, + timeout=request_timeout, + ) + + if response.is_success: + # Handle empty responses + text = response.text + if not text: + return None + return response.json() + + # Handle error responses + error_body = self._parse_error_body(response) + retry_after = self._parse_retry_after(response) + error = create_error_from_response( + response.status_code, + error_body.get("message") or f"HTTP {response.status_code}", + body=error_body, + retry_after=retry_after, + ) + + # Only retry on specific status codes + if ( + response.status_code in RETRY_STATUS_CODES + and attempt < self.max_retries + ): + last_error = error + time.sleep(self._get_retry_delay(attempt, retry_after)) + attempt += 1 + continue + + 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}") + if attempt < self.max_retries: + last_error = error + time.sleep(self._get_retry_delay(attempt)) + attempt += 1 + continue + raise error from e + + except (TimeoutError, ConnectionError, RateLimitError, ServerError): + raise + + except Exception as e: + # Re-raise API errors + if hasattr(e, "status"): + raise + raise ConnectionError(str(e)) from e + + # If we exhausted retries, raise the last error + if last_error: + raise last_error + raise ConnectionError("Request failed after retries") + + def get( + self, + path: str, + *, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Any: + """Make a GET request.""" + return self.request("GET", path, query=query, headers=headers, timeout=timeout) + + def post( + self, + path: str, + *, + body: Any = None, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Any: + """Make a POST request.""" + return self.request( + "POST", path, body=body, query=query, headers=headers, timeout=timeout + ) + + def put( + self, + path: str, + *, + body: Any = None, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Any: + """Make a PUT request.""" + return self.request( + "PUT", path, body=body, query=query, headers=headers, timeout=timeout + ) + + def patch( + self, + path: str, + *, + body: Any = None, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Any: + """Make a PATCH request.""" + return self.request( + "PATCH", path, body=body, query=query, headers=headers, timeout=timeout + ) + + def delete( + self, + path: str, + *, + body: Any = None, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Any: + """Make a DELETE request.""" + return self.request( + "DELETE", path, body=body, query=query, headers=headers, timeout=timeout + ) + + def stream( + self, + method: str, + path: str, + *, + body: Any = None, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Iterator[bytes]: + """Make a streaming request that yields raw bytes for SSE parsing.""" + url = self._build_url(path) + request_headers = { + **self._build_headers(headers), + "Accept": "text/event-stream", + } + request_timeout = timeout if timeout is not None else self.timeout + merged_query = {**(self.default_query or {}), **(query or {})} or None + + with self._client.stream( + method, + url, + json=body if body is not None else None, + params=self._clean_query_params(merged_query), + headers=request_headers, + timeout=request_timeout, + ) as response: + if not response.is_success: + # Read error body + response.read() + error_body = self._parse_error_body(response) + raise create_error_from_response( + response.status_code, + error_body.get("message") or f"HTTP {response.status_code}", + body=error_body, + ) + + for chunk in response.iter_bytes(): + yield chunk + + def upload( + self, + path: str, + *, + files: dict[str, Any], + data: dict[str, Any] | None = None, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Any: + """Make a multipart form data request (for file uploads).""" + url = self._build_url(path) + # Don't set Content-Type for multipart - httpx will set it with boundary + request_headers = self._build_headers(headers) + request_headers.pop("Content-Type", None) + request_timeout = timeout if timeout is not None else self.timeout + merged_query = {**(self.default_query or {}), **(query or {})} or None + + response = self._client.post( + url, + files=files, + data=data, + params=self._clean_query_params(merged_query), + headers=request_headers, + timeout=request_timeout, + ) + + if not response.is_success: + error_body = self._parse_error_body(response) + raise create_error_from_response( + response.status_code, + error_body.get("message") or f"HTTP {response.status_code}", + body=error_body, + ) + + text = response.text + if not text: + return None + return response.json() + + def _build_url(self, path: str) -> str: + """Build the full URL from path.""" + if path.startswith("/"): + return f"{self.base_url}{path}" + return f"{self.base_url}/{path}" + + def _build_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]: + """Build request headers including auth.""" + headers = {**self.default_headers} + + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + if extra: + headers.update(extra) + + return headers + + def _clean_query_params( + self, params: dict[str, Any] | None + ) -> dict[str, Any] | None: + """Remove None values from query params.""" + if params is None: + return None + return {k: v for k, v in params.items() if v is not None} + + def _parse_error_body(self, response: httpx.Response) -> dict[str, Any]: + """Parse error body from response.""" + try: + body: Any = response.json() + if isinstance(body, dict): + body_dict: dict[str, Any] = cast(dict[str, Any], body) + return { + "message": body_dict.get("detail") + or body_dict.get("message") + or body_dict.get("error"), + **body_dict, + } + return {"message": str(body)} + except Exception: + return {"message": f"HTTP {response.status_code}"} + + def _parse_retry_after(self, response: httpx.Response) -> float | None: + """Parse Retry-After header.""" + header = response.headers.get("Retry-After") + if not header: + return None + + try: + # Try parsing as seconds + return float(header) + except ValueError: + pass + + try: + # Try parsing as HTTP date + from datetime import datetime + from email.utils import parsedate_to_datetime + + dt: datetime = cast(datetime, parsedate_to_datetime(header)) + timestamp: float = dt.timestamp() + return max(0.0, timestamp - time.time()) + except Exception: + return None + + def _get_retry_delay(self, attempt: int, retry_after: float | None = None) -> float: + """Calculate delay before next retry.""" + if retry_after is not None: + return retry_after + # Exponential backoff: 0.5s, 1s, 2s, etc. + return INITIAL_RETRY_DELAY * (2**attempt) diff --git a/sdks/python/src/honcho/http/exceptions.py b/sdks/python/src/honcho/http/exceptions.py new file mode 100644 index 00000000..7eba5adf --- /dev/null +++ b/sdks/python/src/honcho/http/exceptions.py @@ -0,0 +1,158 @@ +"""Exception classes for Honcho SDK errors.""" + +from __future__ import annotations + +from typing import Any + + +class HonchoError(Exception): + """Base error class for all Honcho SDK errors.""" + + message: str + status: int + code: str | None + body: Any + + def __init__( + self, + message: str, + *, + status: int = 0, + code: str | None = None, + body: Any = None, + ) -> None: + super().__init__(message) + self.message = message + self.status = status + self.code = code + self.body = body + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(message={self.message!r}, status={self.status})" + ) + + +class APIError(HonchoError): + """Error from an API response with HTTP status code.""" + + def __init__( + self, + message: str, + *, + status: int, + body: Any = None, + ) -> None: + super().__init__(message, status=status, code="api_error", body=body) + + +class BadRequestError(APIError): + """Error thrown when request validation fails (400).""" + + def __init__(self, message: str = "Bad request", body: Any = None) -> None: + super().__init__(message, status=400, body=body) + self.code = "bad_request" # pyright: ignore[reportUnannotatedClassAttribute] + + +class AuthenticationError(APIError): + """Error thrown when authentication fails (401).""" + + def __init__(self, message: str = "Authentication failed") -> None: + super().__init__(message, status=401) + self.code = "authentication_error" # pyright: ignore[reportUnannotatedClassAttribute] + + +class PermissionDeniedError(APIError): + """Error thrown when the user lacks permission (403).""" + + def __init__(self, message: str = "Permission denied") -> None: + super().__init__(message, status=403) + self.code = "permission_denied" # pyright: ignore[reportUnannotatedClassAttribute] + + +class NotFoundError(APIError): + """Error thrown when a resource is not found (404).""" + + def __init__(self, message: str = "Resource not found") -> None: + super().__init__(message, status=404) + self.code = "not_found" # pyright: ignore[reportUnannotatedClassAttribute] + + +class ConflictError(APIError): + """Error thrown on resource conflict (409).""" + + def __init__(self, message: str = "Resource conflict", body: Any = None) -> None: + super().__init__(message, status=409, body=body) + self.code = "conflict" # pyright: ignore[reportUnannotatedClassAttribute] + + +class UnprocessableEntityError(APIError): + """Error thrown when entity cannot be processed (422).""" + + def __init__(self, message: str = "Unprocessable entity", body: Any = None) -> None: + super().__init__(message, status=422, body=body) + self.code = "unprocessable_entity" # pyright: ignore[reportUnannotatedClassAttribute] + + +class RateLimitError(APIError): + """Error thrown when rate limited (429).""" + + retry_after: float | None = None + + def __init__( + self, + message: str = "Rate limit exceeded", + retry_after: float | None = None, + ) -> None: + super().__init__(message, status=429) + self.code = "rate_limit_exceeded" # pyright: ignore[reportUnannotatedClassAttribute] + self.retry_after = retry_after + + +class ServerError(APIError): + """Error thrown on server errors (5xx).""" + + def __init__(self, message: str = "Server error", status: int = 500) -> None: + super().__init__(message, status=status) + self.code = "server_error" # pyright: ignore[reportUnannotatedClassAttribute] + + +class TimeoutError(HonchoError): + """Error thrown when a request times out.""" + + def __init__(self, message: str = "Request timed out") -> None: + super().__init__(message, code="timeout") + + +class ConnectionError(HonchoError): + """Error thrown when a connection fails.""" + + def __init__(self, message: str = "Connection failed") -> None: + super().__init__(message, code="connection_error") + + +def create_error_from_response( + status: int, + message: str, + body: Any = None, + retry_after: float | None = None, +) -> HonchoError: + """Create the appropriate error type based on HTTP status code.""" + if status == 400: + return BadRequestError(message, body=body) + elif status == 401: + return AuthenticationError(message) + elif status == 403: + return PermissionDeniedError(message) + elif status == 404: + return NotFoundError(message) + elif status == 409: + return ConflictError(message, body=body) + elif status == 422: + return UnprocessableEntityError(message, body=body) + elif status == 429: + return RateLimitError(message, retry_after=retry_after) + elif status >= 500: + return ServerError(message, status=status) + else: + return APIError(message, status=status, body=body) diff --git a/sdks/python/src/honcho/http/routes.py b/sdks/python/src/honcho/http/routes.py new file mode 100644 index 00000000..3fdbd677 --- /dev/null +++ b/sdks/python/src/honcho/http/routes.py @@ -0,0 +1,138 @@ +"""API route constants for Honcho SDK.""" + +API_VERSION = "v3" + + +# Workspace routes +def workspaces() -> str: + return f"/{API_VERSION}/workspaces" + + +def workspaces_list() -> str: + return f"/{API_VERSION}/workspaces/list" + + +def workspace(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}" + + +def workspace_search(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/search" + + +def workspace_queue_status(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/queue/status" + + +def workspace_schedule_dream(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/schedule_dream" + + +# Peer routes +def peers(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/peers" + + +def peers_list(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/peers/list" + + +def peer(workspace_id: str, peer_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/peers/{peer_id}" + + +def peer_chat(workspace_id: str, peer_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/peers/{peer_id}/chat" + + +def peer_representation(workspace_id: str, peer_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/peers/{peer_id}/representation" + + +def peer_card(workspace_id: str, peer_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/peers/{peer_id}/card" + + +def peer_context(workspace_id: str, peer_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/peers/{peer_id}/context" + + +def peer_search(workspace_id: str, peer_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/peers/{peer_id}/search" + + +def peer_sessions_list(workspace_id: str, peer_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/peers/{peer_id}/sessions" + + +# Session routes +def sessions(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/sessions" + + +def sessions_list(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/list" + + +def session(workspace_id: str, session_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}" + + +def session_clone(workspace_id: str, session_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/clone" + + +def session_context(workspace_id: str, session_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/context" + + +def session_summaries(workspace_id: str, session_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/summaries" + + +def session_search(workspace_id: str, session_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/search" + + +def session_peers(workspace_id: str, session_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/peers" + + +def session_peer_config(workspace_id: str, session_id: str, peer_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config" + + +# Message routes +def messages(workspace_id: str, session_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/messages" + + +def messages_list(workspace_id: str, session_id: str) -> str: + return ( + f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/messages/list" + ) + + +def message(workspace_id: str, session_id: str, message_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/messages/{message_id}" + + +def messages_upload(workspace_id: str, session_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/messages/upload" + + +# Conclusion routes +def conclusions(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/conclusions" + + +def conclusions_list(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/conclusions/list" + + +def conclusions_query(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/conclusions/query" + + +def conclusion(workspace_id: str, conclusion_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/conclusions/{conclusion_id}" diff --git a/sdks/python/src/honcho/message.py b/sdks/python/src/honcho/message.py new file mode 100644 index 00000000..335d6855 --- /dev/null +++ b/sdks/python/src/honcho/message.py @@ -0,0 +1,81 @@ +"""Message class for Honcho SDK.""" + +from __future__ import annotations + +import datetime +from typing import Any + +from .api_types import MessageResponse + + +class Message: + """ + A message in a Honcho session. + + Messages represent communication between peers within a session. + This class wraps the API response with convenient attribute access. + + Attributes: + id: Unique identifier for this message + content: The message content + peer_id: The peer ID who authored this message + session_id: The session ID this message belongs to + workspace_id: The workspace ID this message belongs to + metadata: Metadata associated with this message + created_at: Timestamp for when the message was created + token_count: Number of tokens in this message + """ + + id: str + content: str + peer_id: str + session_id: str + workspace_id: str + metadata: dict[str, Any] + created_at: datetime.datetime + token_count: int + + def __init__( + self, + id: str, + content: str, + peer_id: str, + session_id: str, + workspace_id: str, + metadata: dict[str, Any], + created_at: datetime.datetime, + token_count: int, + ) -> None: + self.id = id + self.content = content + self.peer_id = peer_id + self.session_id = session_id + self.workspace_id = workspace_id + self.metadata = metadata + self.created_at = created_at + self.token_count = token_count + + @classmethod + def from_api_response(cls, data: MessageResponse) -> "Message": + """Create a Message from an API response.""" + return cls( + id=data.id, + content=data.content, + peer_id=data.peer_id, + session_id=data.session_id, + workspace_id=data.workspace_id, + metadata=data.metadata, + created_at=data.created_at, + token_count=data.token_count, + ) + + def __repr__(self) -> str: + truncated = ( + f"{self.content[:50]}..." if len(self.content) > 50 else self.content + ) + return ( + f"Message(id='{self.id}', peer_id='{self.peer_id}', content='{truncated}')" + ) + + def __str__(self) -> str: + return self.content diff --git a/sdks/python/src/honcho/mixins.py b/sdks/python/src/honcho/mixins.py new file mode 100644 index 00000000..45b72cd8 --- /dev/null +++ b/sdks/python/src/honcho/mixins.py @@ -0,0 +1,248 @@ +"""Mixins for common SDK functionality.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from pydantic import Field, validate_call + + +class MetadataConfigMixin(ABC): + """ + Mixin providing get/set/refresh methods for metadata and configuration. + + Classes using this mixin must implement: + - _get_http_client() -> HTTPClientProtocol + - _get_fetch_route() -> str + - _get_update_route() -> str + - _get_fetch_body() -> dict[str, Any] + - _parse_response(data: dict[str, Any]) -> tuple[dict, dict] + + And must have these attributes: + - _metadata: dict[str, object] | None + - _configuration: dict[str, object] | None + """ + + _metadata: dict[str, object] | None + _configuration: dict[str, object] | None + + @abstractmethod + def _get_http_client(self) -> Any: + """Get the HTTP client for making requests.""" + ... + + @abstractmethod + def _get_fetch_route(self) -> str: + """Get the route for fetching metadata/configuration.""" + ... + + @abstractmethod + def _get_update_route(self) -> str: + """Get the route for updating metadata/configuration.""" + ... + + @abstractmethod + def _get_fetch_body(self) -> dict[str, Any]: + """Get the body for fetching metadata/configuration.""" + ... + + @abstractmethod + def _parse_response( + self, data: dict[str, Any] + ) -> tuple[dict[str, object], dict[str, object]]: + """Parse the response to extract metadata and configuration.""" + ... + + 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. + """ + data = self._get_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + metadata, configuration = self._parse_response(data) + self._metadata = metadata + self._configuration = configuration + return self._metadata + + @validate_call + def set_metadata( + self, + metadata: dict[str, object] = Field( + ..., description="Metadata dictionary to set" + ), + ) -> None: + """ + Set metadata on the server and update the cache. + + Args: + metadata: A dictionary of metadata to set. + Keys must be strings, values can be any JSON-serializable type. + """ + self._get_http_client().put( + self._get_update_route(), + body={"metadata": metadata}, + ) + self._metadata = metadata + + def get_configuration(self) -> dict[str, object]: + """ + Get configuration from the server and update the cache. + + Returns: + A dictionary containing the configuration. Returns an empty dictionary + if no configuration is set. + """ + data = self._get_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + metadata, configuration = self._parse_response(data) + self._metadata = metadata + self._configuration = configuration + return self._configuration + + @validate_call + def set_configuration( + self, + configuration: dict[str, object] = Field( + ..., description="Configuration dictionary to set" + ), + ) -> None: + """ + Set configuration on the server and update the cache. + + Args: + configuration: A dictionary of configuration to set. + Keys must be strings, values can be any JSON-serializable type. + """ + self._get_http_client().put( + self._get_update_route(), + body={"configuration": configuration}, + ) + self._configuration = configuration + + def refresh(self) -> None: + """ + Refresh cached metadata and configuration from the server. + + Makes a single API call to retrieve the latest metadata and configuration + and updates the cached attributes. + """ + data = self._get_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + metadata, configuration = self._parse_response(data) + self._metadata = metadata + self._configuration = configuration + + +class AsyncMetadataConfigMixin(ABC): + """ + Async mixin providing get/set/refresh methods for metadata and configuration. + + Classes using this mixin must implement: + - _get_async_http_client() -> AsyncHTTPClientProtocol + - _get_fetch_route() -> str + - _get_update_route() -> str + - _get_fetch_body() -> dict[str, Any] + - _parse_response(data: dict[str, Any]) -> tuple[dict, dict] + - _set_metadata(metadata: dict[str, object]) -> None + - _set_configuration(configuration: dict[str, object]) -> None + """ + + @abstractmethod + def _get_async_http_client(self) -> Any: + """Get the async HTTP client for making requests.""" + ... + + @abstractmethod + def _get_fetch_route(self) -> str: + """Get the route for fetching metadata/configuration.""" + ... + + @abstractmethod + def _get_update_route(self) -> str: + """Get the route for updating metadata/configuration.""" + ... + + @abstractmethod + def _get_fetch_body(self) -> dict[str, Any]: + """Get the body for fetching metadata/configuration.""" + ... + + @abstractmethod + def _parse_response( + self, data: dict[str, Any] + ) -> tuple[dict[str, object], dict[str, object]]: + """Parse the response to extract metadata and configuration.""" + ... + + @abstractmethod + def _set_metadata(self, metadata: dict[str, object]) -> None: + """Set metadata on the parent object.""" + ... + + @abstractmethod + def _set_configuration(self, configuration: dict[str, object]) -> None: + """Set configuration on the parent object.""" + ... + + @abstractmethod + def _get_metadata(self) -> dict[str, object]: + """Get cached metadata from the parent object.""" + ... + + @abstractmethod + def _get_configuration(self) -> dict[str, object]: + """Get cached configuration from the parent object.""" + ... + + async def get_metadata(self) -> dict[str, object]: + """Get metadata from the server asynchronously.""" + data = await self._get_async_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + metadata, configuration = self._parse_response(data) + self._set_metadata(metadata) + self._set_configuration(configuration) + return self._get_metadata() + + async def set_metadata(self, metadata: dict[str, object]) -> None: + """Set metadata on the server asynchronously.""" + await self._get_async_http_client().put( + self._get_update_route(), + body={"metadata": metadata}, + ) + self._set_metadata(metadata) + + async def get_configuration(self) -> dict[str, object]: + """Get configuration from the server asynchronously.""" + data = await self._get_async_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + metadata, configuration = self._parse_response(data) + self._set_metadata(metadata) + self._set_configuration(configuration) + return self._get_configuration() + + async def set_configuration(self, configuration: dict[str, object]) -> None: + """Set configuration on the server asynchronously.""" + await self._get_async_http_client().put( + self._get_update_route(), + body={"configuration": configuration}, + ) + self._set_configuration(configuration) + + async def refresh(self) -> None: + """Refresh cached metadata and configuration asynchronously.""" + data = await self._get_async_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + metadata, configuration = self._parse_response(data) + self._set_metadata(metadata) + self._set_configuration(configuration) diff --git a/sdks/python/src/honcho/pagination.py b/sdks/python/src/honcho/pagination.py index 10c7cd09..32c2c05a 100644 --- a/sdks/python/src/honcho/pagination.py +++ b/sdks/python/src/honcho/pagination.py @@ -1,104 +1,250 @@ -from collections.abc import Callable, Iterator -from typing import Generic +"""Pagination wrapper for Honcho SDK.""" -from honcho_core.pagination import SyncPage as SyncPageCore +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable, Iterator +from typing import Any, Generic + +from pydantic import BaseModel from typing_extensions import TypeVar -T = TypeVar("T") +T = TypeVar("T", bound=BaseModel) U = TypeVar("U", default=T) +__all__ = ["SyncPage", "AsyncPage"] + class SyncPage(Generic[T, U]): """ Paginated result wrapper that transforms objects from type T to type U. - Provides iteration and transformation capabilities while preserving - pagination functionality from the underlying core SyncPage. + Provides iteration and transformation capabilities for paginated API responses. """ - _original_page: SyncPageCore[T] - _transform_func: Callable[[T], U] | None - def __init__( self, - original_page: SyncPageCore[T], + data: dict[str, Any], + item_type: type[T], transform_func: Callable[[T], U] | None = None, + fetch_next: Callable[[int], "SyncPage[T, U]"] | None = None, ) -> None: """ - Initialize the transformed page. + Initialize the page. Args: - original_page: The original SyncPage to wrap + data: Raw paginated response data with items, page, size, total, pages + item_type: Type to parse items as transform_func: Optional function to transform objects from type T to type U. If None, objects are passed through unchanged. + fetch_next: Optional callback to fetch the next page. Takes page number. """ - self._original_page = original_page - self._transform_func = transform_func + self._data: dict[str, Any] = data + self._item_type: type[T] = item_type + self._transform_func: Callable[[T], U] | None = transform_func + self._fetch_next: Callable[[int], "SyncPage[T, U]"] | None = fetch_next + + # Parse items + raw_items = data.get("items", []) + self._raw_items: list[T] = [ + item_type.model_validate(item) for item in raw_items + ] def __iter__(self) -> Iterator[U] | Iterator[T]: - """Iterate over all transformed items across all pages.""" - for item in self._original_page: - if self._transform_func is not None: - yield self._transform_func(item) - else: - yield item + """ + Iterate over all transformed items across all pages. + + Warning: + This iterator automatically fetches ALL subsequent pages as you iterate. + For large datasets, this may result in many API calls. If you only need + the current page, use the `items` property instead. + """ + page: SyncPage[T, U] | None = self + while page is not None: + for item in page._raw_items: + if self._transform_func is not None: + yield self._transform_func(item) + else: + yield item + page = page.get_next_page() def __getitem__(self, index: int) -> U | T: """Get a transformed item by index on the current page.""" - items = self._original_page.items or [] - item = items[index] + item = self._raw_items[index] if self._transform_func is not None: return self._transform_func(item) return item def __len__(self) -> int: """Get the number of items on the current page.""" - items = self._original_page.items or [] - return len(items) + return len(self._raw_items) @property def items(self) -> list[U] | list[T]: """Get all transformed items on the current page.""" - items = self._original_page.items or [] if self._transform_func is not None: - return [self._transform_func(item) for item in items] - return items + return [self._transform_func(item) for item in self._raw_items] + return list(self._raw_items) @property def total(self) -> int | None: """Get the total number of items across all pages.""" - return self._original_page.total + return self._data.get("total") @property def page(self) -> int | None: """Get the current page number.""" - return self._original_page.page + return self._data.get("page") @property def size(self) -> int | None: """Get the page size.""" - return self._original_page.size + return self._data.get("size") @property def pages(self) -> int | None: """Get the total number of pages.""" - return self._original_page.pages + return self._data.get("pages") def has_next_page(self) -> bool: """Check if there's a next page.""" - return self._original_page.has_next_page() + current_page = self.page + total_pages = self.pages + if current_page is None or total_pages is None: + return False + return current_page < total_pages def get_next_page(self) -> "SyncPage[T, U] | None": """ Fetch the next page of results. - Returns None if there are no more pages. + Returns None if there are no more pages or no fetch callback. """ - if not hasattr(self._original_page, "get_next_page"): + if not self.has_next_page(): + return None + if self._fetch_next is None: return None - next_original_page = self._original_page.get_next_page() - if not next_original_page: + current_page = self.page + if current_page is None: return None - return SyncPage(next_original_page, self._transform_func) + return self._fetch_next(current_page + 1) + + +class AsyncPage(Generic[T, U]): + """ + Async paginated result wrapper that transforms objects from type T to type U. + + Provides async iteration and transformation capabilities for paginated API responses. + """ + + def __init__( + self, + data: dict[str, Any], + item_type: type[T], + transform_func: Callable[[T], U] | None = None, + fetch_next: Callable[[int], Awaitable["AsyncPage[T, U]"]] | None = None, + ) -> None: + """ + Initialize the async page. + + Args: + data: Raw paginated response data with items, page, size, total, pages + item_type: Type to parse items as + transform_func: Optional function to transform objects from type T to type U. + If None, objects are passed through unchanged. + fetch_next: Optional async callback to fetch the next page. Takes page number. + """ + self._data: dict[str, Any] = data + self._item_type: type[T] = item_type + self._transform_func: Callable[[T], U] | None = transform_func + self._fetch_next: Callable[[int], Awaitable["AsyncPage[T, U]"]] | None = ( + fetch_next + ) + + # Parse items + raw_items = data.get("items", []) + self._raw_items: list[T] = [ + item_type.model_validate(item) for item in raw_items + ] + + async def __aiter__(self) -> AsyncIterator[U] | AsyncIterator[T]: + """ + Async iterate over all transformed items across all pages. + + Warning: + This iterator automatically fetches ALL subsequent pages as you iterate. + For large datasets, this may result in many API calls. If you only need + the current page, use the `items` property instead. + """ + page: AsyncPage[T, U] | None = self + while page is not None: + for item in page._raw_items: + if self._transform_func is not None: + yield self._transform_func(item) + else: + yield item + page = await page.get_next_page() + + def __getitem__(self, index: int) -> U | T: + """Get a transformed item by index on the current page.""" + item = self._raw_items[index] + if self._transform_func is not None: + return self._transform_func(item) + return item + + def __len__(self) -> int: + """Get the number of items on the current page.""" + return len(self._raw_items) + + @property + def items(self) -> list[U] | list[T]: + """Get all transformed items on the current page.""" + if self._transform_func is not None: + return [self._transform_func(item) for item in self._raw_items] + return list(self._raw_items) + + @property + def total(self) -> int | None: + """Get the total number of items across all pages.""" + return self._data.get("total") + + @property + def page(self) -> int | None: + """Get the current page number.""" + return self._data.get("page") + + @property + def size(self) -> int | None: + """Get the page size.""" + return self._data.get("size") + + @property + def pages(self) -> int | None: + """Get the total number of pages.""" + return self._data.get("pages") + + def has_next_page(self) -> bool: + """Check if there's a next page.""" + current_page = self.page + total_pages = self.pages + if current_page is None or total_pages is None: + return False + return current_page < total_pages + + async def get_next_page(self) -> "AsyncPage[T, U] | None": + """ + Fetch the next page of results. + + Returns None if there are no more pages or no fetch callback. + """ + if not self.has_next_page(): + return None + if self._fetch_next is None: + return None + + current_page = self.page + if current_page is None: + return None + + return await self._fetch_next(current_page + 1) diff --git a/sdks/python/src/honcho/peer.py b/sdks/python/src/honcho/peer.py index 84372788..ea71fc79 100644 --- a/sdks/python/src/honcho/peer.py +++ b/sdks/python/src/honcho/peer.py @@ -1,32 +1,43 @@ +# pyright: reportPrivateUsage=false +"""Sync Peer class for Honcho SDK.""" + from __future__ import annotations import datetime +import logging from collections.abc import Generator -from typing import Literal +from typing import TYPE_CHECKING, Any, Literal -from honcho_core import Honcho as HonchoCore -from honcho_core._types import omit -from honcho_core.types.workspaces import PeerCardResponse -from honcho_core.types.workspaces.peer_context_response import ( - PeerContextResponse, -) -from honcho_core.types.workspaces.peer_representation_response import ( - PeerRepresentationResponse, -) -from honcho_core.types.workspaces.session import Session as SessionCore -from honcho_core.types.workspaces.sessions import MessageCreateParam -from honcho_core.types.workspaces.sessions.message import Message -from honcho_core.types.workspaces.sessions.message_create_param import Configuration from pydantic import ConfigDict, Field, PrivateAttr, validate_call +from .api_types import ( + MessageCreateParams, + MessageResponse, + PeerCardResponse, + PeerConfig, + PeerContextResponse, + PeerResponse, + RepresentationResponse, + SessionResponse, +) from .base import PeerBase, SessionBase from .conclusions import ConclusionScope +from .http import routes +from .message import Message +from .mixins import MetadataConfigMixin from .pagination import SyncPage -from .session import Session from .types import DialecticStreamResponse +from .utils import parse_datetime, parse_sse_stream, resolve_id + +if TYPE_CHECKING: + from .aio import PeerAio + from .client import Honcho + from .session import Session + +logger = logging.getLogger(__name__) -class Peer(PeerBase): +class Peer(PeerBase, MetadataConfigMixin): """ Represents a peer in the Honcho system. @@ -40,12 +51,12 @@ class Peer(PeerBase): metadata: Cached metadata for this peer. May be stale if not recently fetched. Call get_metadata() for fresh data. configuration: Cached configuration for this peer. May be stale if not - recently fetched. Call get_config() for fresh data. + recently fetched. Call get_configuration() for fresh data. """ _metadata: dict[str, object] | None = PrivateAttr(default=None) - _configuration: dict[str, object] | None = PrivateAttr(default=None) - _client: HonchoCore = PrivateAttr() + _configuration: PeerConfig | None = PrivateAttr(default=None) + _honcho: "Honcho" = PrivateAttr() @property def metadata(self) -> dict[str, object] | None: @@ -53,10 +64,86 @@ class Peer(PeerBase): return self._metadata @property - def configuration(self) -> dict[str, object] | None: - """Cached configuration for this peer. May be stale. Use get_config() for fresh data.""" + def configuration(self) -> PeerConfig | None: + """Cached configuration for this peer. May be stale. Use get_configuration() for fresh data.""" return self._configuration + # MetadataConfigMixin implementation + def _get_http_client(self): + self._honcho._ensure_workspace() + return self._honcho._http + + def _get_fetch_route(self) -> str: + return routes.peers(self.workspace_id) + + def _get_update_route(self) -> str: + return routes.peer(self.workspace_id, self.id) + + def _get_fetch_body(self) -> dict[str, Any]: + return {"id": self.id} + + def _parse_response( + self, data: dict[str, Any] + ) -> tuple[dict[str, object], dict[str, object]]: + peer = PeerResponse.model_validate(data) + # Return configuration as dict for mixin compatibility + return peer.metadata or {}, peer.configuration.model_dump(exclude_none=True) + + def get_configuration(self) -> PeerConfig: # pyright: ignore[reportIncompatibleMethodOverride] + """ + Get configuration from the server and update the cache. + + Returns: + A PeerConfig object containing the configuration settings. + """ + 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._metadata = peer.metadata or {} + self._configuration = peer.configuration + return self._configuration + + @validate_call + def set_configuration( # pyright: ignore[reportIncompatibleMethodOverride] + self, + configuration: PeerConfig = Field(..., description="Configuration to set"), + ) -> None: + """ + Set configuration on the server and update the cache. + + Args: + configuration: A PeerConfig object with configuration settings. + """ + self._get_http_client().put( + self._get_update_route(), + body={"configuration": configuration.model_dump(exclude_none=True)}, + ) + self._configuration = configuration + + @property + def aio(self) -> "PeerAio": + """ + Access async versions of all Peer methods. + + Returns a PeerAio view that provides async versions of all methods + while sharing state with this Peer instance. + + Example: + ```python + peer = honcho.peer("user-123") + + # Async operations + await peer.aio.chat("query") + await peer.aio.get_metadata() + ``` + """ + # Import here to avoid circular import (aio.py imports Peer) + from .aio import PeerAio + + return PeerAio(self) + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def __init__( self, @@ -65,18 +152,13 @@ class Peer(PeerBase): min_length=1, description="Unique identifier for this peer within the workspace", ), - workspace_id: str = Field( - ..., min_length=1, description="Workspace ID for scoping operations" - ), - client: HonchoCore = Field( - ..., description="Reference to the parent Honcho client instance" - ), + honcho: Any = Field(..., description="Honcho client instance"), *, metadata: dict[str, object] | None = Field( None, description="Optional metadata dictionary to associate with this peer. If set, will get/create peer immediately with metadata.", ), - config: dict[str, object] | None = Field( + configuration: PeerConfig | None = Field( None, description="Optional configuration to set for this peer. If set, will get/create peer immediately with flags.", ), @@ -89,42 +171,44 @@ class Peer(PeerBase): Args: peer_id: Unique identifier for this peer within the workspace - workspace_id: Workspace ID for scoping operations - client: Reference to the parent Honcho client instance + honcho: Honcho client instance metadata: Optional metadata dictionary to associate with this peer. - If set, will get/create peer immediately with metadata. - config: Optional configuration to set for this peer. - If set, will get/create peer immediately with flags. + 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. """ super().__init__( id=peer_id, - workspace_id=workspace_id, + workspace_id=honcho.workspace_id, ) - self._client = client + self._honcho = honcho self._metadata = metadata - self._configuration = config + self._configuration = configuration - if config is not None or metadata is not None: - peer_data = self._client.workspaces.peers.get_or_create( - workspace_id=workspace_id, - id=peer_id, - configuration=config if config is not None else omit, - metadata=metadata if metadata is not None else omit, - ) + 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 + self._configuration = peer_data.configuration # pyright: ignore[reportIncompatibleVariableOverride] + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def chat( self, - query: str, + query: str = Field(..., min_length=1, description="The natural language query"), *, - stream: bool = False, target: str | PeerBase | None = None, session: str | SessionBase | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, - ) -> str | DialecticStreamResponse | None: + ) -> str | None: """ Query the peer's representation with a natural language question. @@ -134,7 +218,6 @@ class Peer(PeerBase): Args: query: The natural language question to ask. - stream: Whether to stream the response target: Optional target peer for local representation query. If provided, queries what this peer knows about the target peer rather than querying the peer's global representation. Can be a peer ID string @@ -146,71 +229,87 @@ class Peer(PeerBase): "high", or "max". Defaults to "low" if not provided. Returns: - For non-streaming: Response string containing the answer, or None if no relevant information - For streaming: DialecticStreamResponse object that can be iterated over and provides final response + Response string containing the answer, or None if no relevant information """ - # Extract IDs from objects if needed - target_id = ( - None - if target is None - else (target if isinstance(target, str) else target.id) + self._honcho._ensure_workspace() + target_id = resolve_id(target) + resolved_session_id = resolve_id(session) + + body: dict[str, Any] = {"query": query, "stream": False} + if target_id: + body["target"] = target_id + if resolved_session_id: + body["session_id"] = resolved_session_id + if reasoning_level: + body["reasoning_level"] = reasoning_level + + data = self._honcho._http.post( + routes.peer_chat(self.workspace_id, self.id), + body=body, ) - resolved_session_id = ( - None - if session is None - else (session if isinstance(session, str) else session.id) - ) - - if stream: - - def stream_response() -> Generator[str, None, None]: - import json - - # Use core SDK with_streaming_response - with self._client.workspaces.peers.with_streaming_response.chat( - peer_id=self.id, - workspace_id=self.workspace_id, - query=query, - stream=True, - target=target_id, - session_id=resolved_session_id, - reasoning_level=reasoning_level - if reasoning_level is not None - else omit, - ) as response: - response.http_response.raise_for_status() - for line in response.iter_lines(): - if line.startswith("data: "): - json_str = line[6:] # Remove "data: " prefix - try: - chunk_data = json.loads(json_str) - if chunk_data.get("done"): - break - delta_obj = chunk_data.get("delta", {}) - content = delta_obj.get("content") - if content: - yield content - except json.JSONDecodeError: - continue - - return DialecticStreamResponse(stream_response()) - - response = self._client.workspaces.peers.chat( - peer_id=self.id, - workspace_id=self.workspace_id, - query=query, - stream=stream, - target=target_id, - session_id=resolved_session_id, - reasoning_level=reasoning_level if reasoning_level is not None else omit, - ) - if response.content in ("", None, "None"): + content = data.get("content") + if not content: return None - return response.content + return content - def get_sessions( + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + def chat_stream( + self, + query: str = Field(..., min_length=1, description="The natural language query"), + *, + target: str | PeerBase | None = None, + session: str | SessionBase | None = None, + reasoning_level: Literal["minimal", "low", "medium", "high", "max"] + | None = None, + ) -> DialecticStreamResponse: + """ + Query the peer's representation with a natural language question, streaming the response. + + Makes an API call to the Honcho dialectic endpoint to query either the peer's + global representation (all content associated with this peer) or their local + representation of another peer (what this peer knows about the target peer). + + Args: + query: The natural language question to ask. + target: Optional target peer for local representation query. If provided, + queries what this peer knows about the target peer rather than + querying the peer's global representation. Can be a peer ID string + or a Peer object. + session: Optional session to scope the query to. If provided, only + information from that session is considered. Can be a session + ID string or a Session object. + reasoning_level: Optional reasoning level for the query: "minimal", "low", "medium", + "high", or "max". Defaults to "low" if not provided. + + Returns: + DialecticStreamResponse object that can be iterated over and provides final response + """ + self._honcho._ensure_workspace() + target_id = resolve_id(target) + resolved_session_id = resolve_id(session) + + body: dict[str, Any] = {"query": query, "stream": True} + if target_id: + body["target"] = target_id + if resolved_session_id: + body["session_id"] = resolved_session_id + if reasoning_level: + body["reasoning_level"] = reasoning_level + + def stream_response() -> Generator[str, None, None]: + yield from parse_sse_stream( + self._honcho._http.stream( + "POST", + routes.peer_chat(self.workspace_id, self.id), + body=body, + ) + ) + + return DialecticStreamResponse(stream_response()) + + def sessions( self, filters: dict[str, object] | None = None - ) -> SyncPage[SessionCore, Session]: + ) -> SyncPage[SessionResponse, "Session"]: """ Get all sessions this peer is a member of. @@ -221,29 +320,39 @@ class Peer(PeerBase): A paginated list of Session objects this peer belongs to. Returns an empty list if the peer is not a member of any sessions """ + self._honcho._ensure_workspace() + # Import here to avoid circular import (session.py imports Peer) from .session import Session - sessions_page = self._client.workspaces.peers.sessions.list( - peer_id=self.id, - workspace_id=self.workspace_id, - filters=filters, - ) - return SyncPage( - sessions_page, - lambda session: Session(session.id, self.workspace_id, self._client), + data = self._honcho._http.post( + routes.peer_sessions_list(self.workspace_id, self.id), + body={"filters": filters} if filters else None, ) + def transform(session: SessionResponse) -> Session: + return Session(session.id, self._honcho) + + def fetch_next(page: int) -> SyncPage[SessionResponse, Session]: + 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}, + ) + return SyncPage(next_data, SessionResponse, transform, fetch_next) + + return SyncPage(data, SessionResponse, transform, fetch_next) + @validate_call def message( self, content: str = Field( - ..., min_length=1, description="The text content for the message" + ..., min_length=0, description="The text content for the message" ), *, metadata: dict[str, object] | None = Field( None, description="Optional metadata dictionary" ), - config: Configuration | None = Field( + configuration: dict[str, Any] | None = Field( None, description="Optional configuration dictionary to associate with the message", ), @@ -251,167 +360,56 @@ class Peer(PeerBase): None, description="Optional created-at timestamp for the message. Accepts a datetime which will be converted to an ISO 8601 string, or a preformatted string.", ), - ) -> MessageCreateParam: + ) -> MessageCreateParams: """ - Create a MessageCreateParam object attributed to this peer. + Build a message object attributed to this peer (synchronous, no API call). - This is a convenience method for creating MessageCreateParam objects with this peer's ID. - The created MessageCreateParam can then be added to sessions or used in other operations. + This is a convenience method for creating message objects with this peer's ID + already set. The returned object can then be passed to `session.add_messages()`. + + Note: + This method is synchronous and does NOT send the message to Honcho. + To actually create the message on the server, pass the returned object to + `session.add_messages()`. Args: content: The text content for the message metadata: Optional metadata dictionary to associate with the message + configuration: Optional configuration dictionary (e.g., reasoning settings) + created_at: Optional created-at timestamp Returns: - A new MessageCreateParam object with this peer's ID and the provided content - """ - created_at_str: str | None - if isinstance(created_at, datetime.datetime): - created_at_str = created_at.isoformat() - else: - created_at_str = created_at + A MessageCreateParams object ready to be passed to `session.add_messages()` - return MessageCreateParam( + Example: + ```python + msg = peer.message("Hello!") + await session.add_messages(msg) + + # Or batch multiple messages: + await session.add_messages([ + alice.message("Hi Bob"), + bob.message("Hey Alice!"), + ]) + ``` + """ + from .api_types import MessageConfiguration + + if content != "" and content.strip() == "": + raise ValueError("Message content cannot be only whitespace") + + created_at_dt = parse_datetime(created_at) + + config_obj = MessageConfiguration(**configuration) if configuration else None + + return MessageCreateParams( peer_id=self.id, content=content, - configuration=config, + configuration=config_obj, metadata=metadata, - created_at=created_at_str, + created_at=created_at_dt, ) - def get_metadata(self) -> dict[str, object]: - """ - Get the current metadata for this peer. - - Makes an API call to retrieve metadata associated with this peer. Metadata - can include custom attributes, settings, or any other key-value data - associated with the peer. This method also updates the cached metadata attribute. - - Returns: - A dictionary containing the peer's metadata. Returns an empty dictionary - if no metadata is set - """ - peer = self._client.workspaces.peers.get_or_create( - workspace_id=self.workspace_id, - id=self.id, - ) - self._metadata = peer.metadata or {} - return self._metadata - - @validate_call - def set_metadata( - self, - metadata: dict[str, object] = Field( - ..., description="Metadata dictionary to associate with this peer" - ), - ) -> None: - """ - Set the metadata for this peer. - - Makes an API call to update the metadata associated with this peer. - This will overwrite any existing metadata with the provided values. - This method also updates the cached metadata attribute. - - Args: - metadata: A dictionary of metadata to associate with this peer. - Keys must be strings, values can be any JSON-serializable type - """ - self._client.workspaces.peers.update( - peer_id=self.id, - workspace_id=self.workspace_id, - metadata=metadata, - ) - self._metadata = metadata - - def get_config(self) -> dict[str, object]: - """ - Get the current workspace-level configuration for this peer. - - Makes an API call to retrieve configuration associated with this peer. - Configuration currently includes one optional flag, `observe_me`. - This method also updates the cached configuration attribute. - - Returns: - A dictionary containing the peer's configuration - """ - peer = self._client.workspaces.peers.get_or_create( - workspace_id=self.workspace_id, - id=self.id, - ) - self._configuration = peer.configuration or {} - return self._configuration - - @validate_call - def set_config( - self, - config: dict[str, object] = Field( - ..., description="Configuration dictionary to associate with this peer" - ), - ) -> None: - """ - Set the configuration for this peer. Currently the only supported config - value is the `observe_me` flag, which controls whether derivation tasks - should be created for this peer's global representation. Default is True. - - Makes an API call to update the configuration associated with this peer. - This will overwrite any existing configuration with the provided values. - This method also updates the cached configuration attribute. - - Args: - config: A dictionary of configuration to associate with this peer. - Keys must be strings, values can be any JSON-serializable type - """ - self._client.workspaces.peers.update( - peer_id=self.id, - workspace_id=self.workspace_id, - configuration=config, - ) - self._configuration = config - - def get_peer_config(self) -> dict[str, object]: - """ - Get the current workspace-level configuration for this peer. - - .. deprecated:: - Use :meth:`get_config` instead. - - Returns: - A dictionary containing the peer's configuration - """ - return self.get_config() - - @validate_call - def set_peer_config( - self, - config: dict[str, object] = Field( - ..., description="Configuration dictionary to associate with this peer" - ), - ) -> None: - """ - Set the configuration for this peer. - - .. deprecated:: - Use :meth:`set_config` instead. - - Args: - config: A dictionary of configuration to associate with this peer - """ - return self.set_config(config) - - def refresh(self) -> None: - """ - Refresh cached metadata and configuration for this peer. - - Makes a single API call to retrieve the latest metadata and configuration - associated with this peer and updates the cached attributes. - """ - peer = self._client.workspaces.peers.get_or_create( - workspace_id=self.workspace_id, - id=self.id, - ) - self._metadata = peer.metadata or {} - self._configuration = peer.configuration or {} - @validate_call def search( self, @@ -430,25 +428,28 @@ class Peer(PeerBase): Args: query: The search query to use - filters: Filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v2/guides/using-filters). + filters: Filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). limit: Number of results to return (1-100, default: 10) Returns: A list of Message objects representing the search results. Returns an empty list if no messages are found. """ - return self._client.workspaces.peers.search( - self.id, - workspace_id=self.workspace_id, - query=query, - filters=filters, - limit=limit, + self._honcho._ensure_workspace() + data = self._honcho._http.post( + routes.peer_search(self.workspace_id, self.id), + body={"query": query, "filters": filters, "limit": limit}, ) + return [ + Message.from_api_response(MessageResponse.model_validate(item)) + for item in data + ] + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def card( self, target: str | PeerBase | None = None, - ) -> str: + ) -> list[str] | None: """ Get the peer card for this peer. @@ -461,38 +462,30 @@ class Peer(PeerBase): peer's card of the target peer. Can be a Peer object or peer ID string. Returns: - A string containing the peer card joined with newlines, or an empty string if none is available + A list of strings representing the peer card, or None if none is available """ - # Validate target parameter - if isinstance(target, str) and len(target.strip()) == 0: - raise ValueError("target string cannot be empty") + self._honcho._ensure_workspace() + target_id = resolve_id(target) - target_id = ( - None - if target is None - else (target if isinstance(target, str) else target.id) + query = {"target": target_id} if target_id else None + data = self._honcho._http.get( + routes.peer_card(self.workspace_id, self.id), + query=query, ) - response: PeerCardResponse = self._client.workspaces.peers.card( - peer_id=self.id, - workspace_id=self.workspace_id, - target=target_id, - ) - if response.peer_card is None: - return "" + response = PeerCardResponse.model_validate(data) - items: list[str] = response.peer_card + return response.peer_card - return "\n".join(items) - - def get_representation( + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + def representation( self, session: str | SessionBase | None = None, target: str | PeerBase | None = None, search_query: str | None = None, - search_top_k: int | None = None, - search_max_distance: float | None = None, + search_top_k: int | None = Field(None, ge=1, le=100), + search_max_distance: float | None = Field(None, ge=0.0, le=1.0), include_most_frequent: bool | None = None, - max_conclusions: int | None = None, + max_conclusions: int | None = Field(None, ge=1, le=100), ) -> str: """ Get a subset of the representation of the peer. @@ -513,59 +506,56 @@ class Peer(PeerBase): Example: ```python # Get global representation - rep = peer.get_representation() + rep = peer.representation() print(rep) # Get representation scoped to a session - session_rep = peer.get_representation(session='session-123') + session_rep = peer.representation(session='session-123') # Get representation with semantic search - searched_rep = peer.get_representation( + searched_rep = peer.representation( search_query='preferences', search_top_k=10, max_conclusions=50 ) ``` """ + self._honcho._ensure_workspace() + session_id = resolve_id(session) + target_id = resolve_id(target) - session_id = ( - None - if session is None - else session - if isinstance(session, str) - else session.id - ) + body: dict[str, Any] = {} + if session_id: + body["session_id"] = session_id + if target_id: + body["target"] = target_id + if search_query is not None: + body["search_query"] = search_query + if search_top_k is not None: + body["search_top_k"] = search_top_k + if search_max_distance is not None: + body["search_max_distance"] = search_max_distance + if include_most_frequent is not None: + body["include_most_frequent"] = include_most_frequent + if max_conclusions is not None: + body["max_conclusions"] = max_conclusions - target_id = ( - None - if target is None - else (target if isinstance(target, str) else target.id) + data = self._honcho._http.post( + routes.peer_representation(self.workspace_id, self.id), + body=body, ) - data: PeerRepresentationResponse = self._client.workspaces.peers.representation( - peer_id=self.id, - workspace_id=self.workspace_id, - session_id=session_id, - target=target_id, - search_query=search_query if search_query is not None else omit, - search_top_k=search_top_k if search_top_k is not None else omit, - search_max_distance=search_max_distance - if search_max_distance is not None - else omit, - include_most_frequent=include_most_frequent - if include_most_frequent is not None - else omit, - max_conclusions=max_conclusions if max_conclusions is not None else omit, - ) - return data.representation + response = RepresentationResponse.model_validate(data) + return response.representation - def get_context( + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + def context( self, target: str | PeerBase | None = None, search_query: str | None = None, - search_top_k: int | None = None, - search_max_distance: float | None = None, + search_top_k: int | None = Field(None, ge=1, le=100), + search_max_distance: float | None = Field(None, ge=0.0, le=1.0), include_most_frequent: bool | None = None, - max_conclusions: int | None = None, + max_conclusions: int | None = Field(None, ge=1, le=100), ) -> PeerContextResponse: """ Get context for this peer, including representation and peer card. @@ -589,44 +579,45 @@ class Peer(PeerBase): Example: ```python # Get own context - context = peer.get_context() + context = peer.context() print(context.representation) print(context.peer_card) # Get context for another peer - context = peer.get_context(target='other-peer-id') + context = peer.context(target='other-peer-id') # Get context with semantic search - context = peer.get_context( + context = peer.context( search_query='preferences', search_top_k=10 ) ``` """ + self._honcho._ensure_workspace() + target_id = resolve_id(target) - target_id = ( - None - if target is None - else (target if isinstance(target, str) else target.id) - ) + query: dict[str, Any] = {} + if target_id: + query["target"] = target_id + if search_query is not None: + query["search_query"] = search_query + if search_top_k is not None: + query["search_top_k"] = search_top_k + if search_max_distance is not None: + query["search_max_distance"] = search_max_distance + if include_most_frequent is not None: + query["include_most_frequent"] = include_most_frequent + if max_conclusions is not None: + query["max_conclusions"] = max_conclusions - return self._client.workspaces.peers.context( - peer_id=self.id, - workspace_id=self.workspace_id, - target=target_id, - search_query=search_query if search_query is not None else omit, - search_top_k=search_top_k if search_top_k is not None else omit, - search_max_distance=search_max_distance - if search_max_distance is not None - else omit, - include_most_frequent=include_most_frequent - if include_most_frequent is not None - else omit, - max_conclusions=max_conclusions if max_conclusions is not None else omit, + data = self._honcho._http.get( + routes.peer_context(self.workspace_id, self.id), + query=query if query else None, ) + return PeerContextResponse.model_validate(data) @property - def conclusions(self) -> "ConclusionScope": + def conclusions(self) -> ConclusionScope: """ Access this peer's self-conclusions (where observer == observed == self). @@ -648,11 +639,9 @@ class Peer(PeerBase): peer.conclusions.delete("obs-123") ``` """ - from .conclusions import ConclusionScope as _ConclusionScope + return ConclusionScope(self._honcho, self.workspace_id, self.id, self.id) - return _ConclusionScope(self._client, self.workspace_id, self.id, self.id) - - def conclusions_of(self, target: str | PeerBase) -> "ConclusionScope": + def conclusions_of(self, target: str | PeerBase) -> ConclusionScope: """ Access conclusions this peer has made about another peer. @@ -680,10 +669,8 @@ class Peer(PeerBase): rep = bob_conclusions.get_representation() ``` """ - from .conclusions import ConclusionScope as _ConclusionScope - target_id = target.id if isinstance(target, PeerBase) else target - return _ConclusionScope(self._client, self.workspace_id, self.id, target_id) + return ConclusionScope(self._honcho, self.workspace_id, self.id, target_id) def __repr__(self) -> str: """ diff --git a/sdks/python/src/honcho/session.py b/sdks/python/src/honcho/session.py index 4e48edd3..6f1bb77b 100644 --- a/sdks/python/src/honcho/session.py +++ b/sdks/python/src/honcho/session.py @@ -1,45 +1,49 @@ +# pyright: reportPrivateUsage=false +"""Sync Session class for Honcho SDK.""" + from __future__ import annotations import json import logging -import time from datetime import datetime from typing import TYPE_CHECKING, Any -from honcho_core import Honcho as HonchoCore -from honcho_core._types import omit -from honcho_core.types.workspaces import QueueStatusResponse -from honcho_core.types.workspaces.peer_representation_response import ( - PeerRepresentationResponse, -) -from honcho_core.types.workspaces.sessions import MessageCreateParam -from honcho_core.types.workspaces.sessions.message import Message -from honcho_core.types.workspaces.sessions.message_create_param import Configuration -from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call +from pydantic import ConfigDict, Field, PrivateAttr, validate_call +from .api_types import ( + MessageCreateParams, + MessageResponse, + PeerResponse, + QueueStatusResponse, + RepresentationResponse, + SessionConfiguration, + SessionPeerConfig, + SessionResponse, +) from .base import PeerBase, SessionBase +from .http import routes +from .message import Message +from .mixins import MetadataConfigMixin from .pagination import SyncPage +from .peer import Peer from .session_context import SessionContext, SessionSummaries, Summary -from .utils import prepare_file_for_upload +from .utils import ( + datetime_to_iso, + normalize_peers_to_dict, + prepare_file_for_upload, + resolve_id, +) if TYPE_CHECKING: - from .peer import Peer + from .aio import SessionAio + from .client import Honcho logger = logging.getLogger(__name__) - -class SessionPeerConfig(BaseModel): - 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", - ) - observe_me: bool | None = Field( - None, - description="Whether other peers in this session should try to form a session-level theory-of-mind representation of this peer", - ) +__all__ = ["Session", "SessionPeerConfig"] -class Session(SessionBase): +class Session(SessionBase, MetadataConfigMixin): """ Represents a session in Honcho. @@ -53,12 +57,12 @@ class Session(SessionBase): metadata: Cached metadata for this session. May be stale if not recently fetched. Call get_metadata() for fresh data. configuration: Cached configuration for this session. May be stale if not - recently fetched. Call get_config() for fresh data. + recently fetched. Call get_configuration() for fresh data. """ _metadata: dict[str, object] | None = PrivateAttr(default=None) - _configuration: dict[str, object] | None = PrivateAttr(default=None) - _client: HonchoCore = PrivateAttr() + _configuration: SessionConfiguration | None = PrivateAttr(default=None) + _honcho: "Honcho" = PrivateAttr() @property def metadata(self) -> dict[str, object] | None: @@ -66,28 +70,104 @@ class Session(SessionBase): return self._metadata @property - def configuration(self) -> dict[str, object] | None: - """Cached configuration for this session. May be stale. Use get_config() for fresh data.""" + def configuration(self) -> SessionConfiguration | None: + """Cached configuration for this session. May be stale. Use get_configuration() for fresh data.""" return self._configuration + # MetadataConfigMixin implementation + def _get_http_client(self): + self._honcho._ensure_workspace() + return self._honcho._http + + def _get_fetch_route(self) -> str: + return routes.sessions(self.workspace_id) + + def _get_update_route(self) -> str: + return routes.session(self.workspace_id, self.id) + + def _get_fetch_body(self) -> dict[str, Any]: + return {"id": self.id} + + def _parse_response( + self, data: dict[str, Any] + ) -> tuple[dict[str, object], dict[str, object]]: + session = SessionResponse.model_validate(data) + # Return configuration as dict for mixin compatibility + return session.metadata or {}, session.configuration.model_dump( + exclude_none=True + ) + + def get_configuration(self) -> SessionConfiguration: # pyright: ignore[reportIncompatibleMethodOverride] + """ + Get configuration from the server and update the cache. + + Returns: + A SessionConfiguration object containing the configuration settings. + """ + 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._metadata = session.metadata or {} + self._configuration = session.configuration + return self._configuration + + @validate_call + def set_configuration( # pyright: ignore[reportIncompatibleMethodOverride] + self, + configuration: SessionConfiguration = Field( + ..., description="Configuration to set" + ), + ) -> None: + """ + Set configuration on the server and update the cache. + + Args: + configuration: A SessionConfiguration object with configuration settings. + """ + self._get_http_client().put( + self._get_update_route(), + body={"configuration": configuration.model_dump(exclude_none=True)}, + ) + self._configuration = configuration + + @property + def aio(self) -> "SessionAio": + """ + Access async versions of all Session methods. + + Returns a SessionAio view that provides async versions of all methods + while sharing state with this Session instance. + + Example: + ```python + session = honcho.session("session-123") + + # Async operations + await session.aio.add_messages(peer.message("Hello")) + async for msg in session.aio.messages(): + print(msg.content) + ``` + """ + # Import here to avoid circular import (aio.py imports Session) + from .aio import SessionAio + + return SessionAio(self) + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def __init__( self, session_id: str = Field( ..., min_length=1, description="Unique identifier for this session" ), - workspace_id: str = Field( - ..., min_length=1, description="Workspace ID for scoping operations" - ), - client: HonchoCore = Field( - ..., description="Reference to the parent Honcho client instance" - ), + honcho: Any = Field(..., description="Honcho client instance"), *, metadata: dict[str, object] | None = Field( None, description="Optional metadata dictionary to associate with this session. If set, will get/create session immediately with metadata.", ), - config: dict[str, object] | None = Field( + configuration: SessionConfiguration | None = Field( None, description="Optional configuration to set for this session. If set, will get/create session immediately with flags.", ), @@ -100,31 +180,33 @@ class Session(SessionBase): Args: session_id: Unique identifier for this session within the workspace - workspace_id: Workspace ID for scoping operations - client: Reference to the parent Honcho client instance + honcho: Honcho client instance metadata: Optional metadata dictionary to associate with this session. - If set, will get/create session immediately with metadata. - config: Optional configuration to set for this session. - If set, will get/create session immediately with flags. + 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. """ super().__init__( id=session_id, - workspace_id=workspace_id, + workspace_id=honcho.workspace_id, ) - self._client = client + self._honcho = honcho self._metadata = metadata - self._configuration = config + self._configuration = configuration - if config is not None or metadata is not None: - session_data = self._client.workspaces.sessions.get_or_create( - workspace_id=workspace_id, - id=session_id, - configuration=config if config is not None else omit, - metadata=metadata if metadata is not None else omit, - ) + 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 + self._configuration = session_data.configuration # pyright: ignore[reportIncompatibleVariableOverride] def add_peers( self, @@ -141,7 +223,7 @@ class Session(SessionBase): """ Add peers to this session. - Makes an async API call to add one or more peers to this session. Adding peers + Makes an API call to add one or more peers to this session. Adding peers creates bidirectional relationships and allows them to participate in the session's conversations. @@ -155,25 +237,10 @@ class Session(SessionBase): - List[tuple[Union[Peer, str], SessionPeerConfig]]: List of Peer objects and/or peer IDs and SessionPeerConfig - Mixed lists with peers and tuples/lists containing peer+config combinations """ - if not isinstance(peers, list): - peers = [peers] - - peer_dict: dict[str, Any] = {} - for peer in peers: - if isinstance(peer, tuple): - # Handle tuple[str/Peer, SessionPeerConfig] - peer_id = peer[0] if isinstance(peer[0], str) else peer[0].id - peer_config = peer[1] - peer_dict[peer_id] = peer_config.model_dump(exclude_none=True) - else: - # Handle direct str or Peer - peer_id = peer if isinstance(peer, str) else peer.id - peer_dict[peer_id] = {} - - self._client.workspaces.sessions.peers.add( - session_id=self.id, - workspace_id=self.workspace_id, - body=peer_dict, + self._honcho._ensure_workspace() + self._honcho._http.post( + routes.session_peers(self.workspace_id, self.id), + body=normalize_peers_to_dict(peers), ) def set_peers( @@ -204,25 +271,10 @@ class Session(SessionBase): - List[tuple[Union[Peer, str], SessionPeerConfig]]: List of Peer objects and/or peer IDs and SessionPeerConfig - Mixed lists with peers and tuples/lists containing peer+config combinations """ - if not isinstance(peers, list): - peers = [peers] - - peer_dict: dict[str, Any] = {} - for peer in peers: - if isinstance(peer, tuple): - # Handle tuple[str/Peer, SessionPeerConfig] - peer_id = peer[0] if isinstance(peer[0], str) else peer[0].id - peer_config = peer[1] - peer_dict[peer_id] = peer_config.model_dump(exclude_none=True) - else: - # Handle direct str or Peer - peer_id = peer if isinstance(peer, str) else peer.id - peer_dict[peer_id] = {} - - self._client.workspaces.sessions.peers.set( - session_id=self.id, - workspace_id=self.workspace_id, - body=peer_dict, + self._honcho._ensure_workspace() + self._honcho._http.put( + routes.session_peers(self.workspace_id, self.id), + body=normalize_peers_to_dict(peers), ) def remove_peers( @@ -244,18 +296,18 @@ class Session(SessionBase): - Peer: Single Peer object - List[Union[Peer, str]]: List of Peer objects and/or peer IDs """ + self._honcho._ensure_workspace() if not isinstance(peers, list): peers = [peers] peer_ids = [peer if isinstance(peer, str) else peer.id for peer in peers] - self._client.workspaces.sessions.peers.remove( - session_id=self.id, - workspace_id=self.workspace_id, + self._honcho._http.delete( + routes.session_peers(self.workspace_id, self.id), body=peer_ids, ) - def get_peers(self) -> list[Peer]: + def peers(self) -> list[Peer]: """ Get all peers in this session. @@ -266,50 +318,54 @@ class Session(SessionBase): Returns: A list of Peer objects that are members of this session """ - from .peer import Peer - - peers_page = self._client.workspaces.sessions.peers.list( - session_id=self.id, - workspace_id=self.workspace_id, + self._honcho._ensure_workspace() + data: dict[str, Any] = self._honcho._http.get( + routes.session_peers(self.workspace_id, self.id) ) + + peers_data: list[Any] = data.get("items", []) return [ - Peer(peer.id, self.workspace_id, self._client) for peer in peers_page.items + Peer(PeerResponse.model_validate(peer).id, self._honcho) + for peer in peers_data ] - def get_peer_config(self, peer: str | PeerBase) -> SessionPeerConfig: + def get_peer_configuration(self, peer: str | PeerBase) -> SessionPeerConfig: """ Get the configuration for a peer in this session. """ + self._honcho._ensure_workspace() peer_id = peer if isinstance(peer, str) else peer.id - peer_config_response = self._client.workspaces.sessions.peers.config( - peer_id=peer_id, - workspace_id=self.workspace_id, - session_id=self.id, + data = self._honcho._http.get( + routes.session_peer_config(self.workspace_id, self.id, peer_id) ) return SessionPeerConfig( - observe_others=peer_config_response.observe_others, - observe_me=peer_config_response.observe_me, + observe_others=data.get("observe_others"), + observe_me=data.get("observe_me"), ) - def set_peer_config(self, peer: str | PeerBase, config: SessionPeerConfig) -> None: + def set_peer_configuration( + self, peer: str | PeerBase, configuration: SessionPeerConfig + ) -> None: """ Set the configuration for a peer in this session. """ + self._honcho._ensure_workspace() peer_id = peer if isinstance(peer, str) else peer.id - self._client.workspaces.sessions.peers.set_config( - peer_id=peer_id, - workspace_id=self.workspace_id, - session_id=self.id, - observe_others=omit - if config.observe_others is None - else config.observe_others, - observe_me=omit if config.observe_me is None else config.observe_me, + body: dict[str, Any] = {} + if configuration.observe_others is not None: + body["observe_others"] = configuration.observe_others + if configuration.observe_me is not None: + body["observe_me"] = configuration.observe_me + + self._honcho._http.put( + routes.session_peer_config(self.workspace_id, self.id, peer_id), + body=body, ) @validate_call def add_messages( self, - messages: MessageCreateParam | list[MessageCreateParam] = Field( + messages: MessageCreateParams | list[MessageCreateParams] = Field( ..., description="Messages to add to the session" ), ) -> list[Message]: @@ -322,26 +378,34 @@ class Session(SessionBase): Args: messages: Messages to add to the session. Can be: - - MessageCreateParam: Single MessageCreateParam object - - List[MessageCreateParam]: List of MessageCreateParam objects + - MessageCreateParams: Single MessageCreateParams object + - List[MessageCreateParams]: List of MessageCreateParams objects """ + self._honcho._ensure_workspace() if not isinstance(messages, list): messages = [messages] - return self._client.workspaces.sessions.messages.create( - session_id=self.id, - workspace_id=self.workspace_id, - messages=[MessageCreateParam(**message) for message in messages], + messages_data = [ + msg.model_dump(mode="json", exclude_none=True) for msg in messages + ] + + data = self._honcho._http.post( + routes.messages(self.workspace_id, self.id), + body={"messages": messages_data}, ) + return [ + Message.from_api_response(MessageResponse.model_validate(msg)) + for msg in data + ] @validate_call - def get_messages( + def messages( self, *, filters: dict[str, object] | None = Field( None, description="Dictionary of filter criteria" ), - ) -> SyncPage[Message]: + ) -> SyncPage[MessageResponse, Message]: """ Get messages from this session with optional filtering. @@ -359,31 +423,24 @@ class Session(SessionBase): A list of Message objects matching the specified criteria, ordered by creation time (most recent first) """ - messages_page = self._client.workspaces.sessions.messages.list( - session_id=self.id, - workspace_id=self.workspace_id, - filters=filters, + self._honcho._ensure_workspace() + data = self._honcho._http.post( + routes.messages_list(self.workspace_id, self.id), + body={"filters": filters} if filters else None, ) - return SyncPage(messages_page) - def get_metadata(self) -> dict[str, object]: - """ - Get metadata for this session. + def transform(response: MessageResponse) -> Message: + return Message.from_api_response(response) - Makes an API call to retrieve the current metadata associated with this session. - Metadata can include custom attributes, settings, or any other key-value data. - This method also updates the cached metadata attribute. + def fetch_next(page: int) -> SyncPage[MessageResponse, Message]: + next_data = self._honcho._http.post( + routes.messages_list(self.workspace_id, self.id), + body={"filters": filters} if filters else None, + query={"page": page}, + ) + return SyncPage(next_data, MessageResponse, transform, fetch_next) - Returns: - A dictionary containing the session's metadata. Returns an empty dictionary - if no metadata is set - """ - session_data = self._client.workspaces.sessions.get_or_create( - workspace_id=self.workspace_id, - id=self.id, - ) - self._metadata = session_data.metadata or {} - return self._metadata + return SyncPage(data, MessageResponse, transform, fetch_next) def delete(self) -> None: """ @@ -398,10 +455,8 @@ class Session(SessionBase): This action cannot be undone. """ - self._client.workspaces.sessions.delete( - session_id=self.id, - workspace_id=self.workspace_id, - ) + self._honcho._ensure_workspace() + self._honcho._http.delete(routes.session(self.workspace_id, self.id)) def clone( self, @@ -433,107 +488,25 @@ class Session(SessionBase): cloned = session.clone(message_id="msg_abc123") ``` """ - # Make the API call using the core SDK's clone method - cloned_session_data = self._client.workspaces.sessions.clone( - session_id=self.id, - workspace_id=self.workspace_id, - message_id=message_id if message_id is not None else omit, - ) + self._honcho._ensure_workspace() + query: dict[str, Any] = {} + if message_id is not None: + query["message_id"] = message_id - # Return a new Session object with the cloned session's data + data = self._honcho._http.post( + routes.session_clone(self.workspace_id, self.id), + query=query if query else None, + ) + cloned = SessionResponse.model_validate(data) return Session( - cloned_session_data.id, - self.workspace_id, - self._client, - metadata=cloned_session_data.metadata, - config=cloned_session_data.configuration, + cloned.id, + self._honcho, + metadata=cloned.metadata, + configuration=cloned.configuration, ) - @validate_call - def set_metadata( - self, - metadata: dict[str, object] = Field( - ..., description="Metadata dictionary to associate with this session" - ), - ) -> None: - """ - Set metadata for this session. - - Makes an API call to update the metadata associated with this session. - This will overwrite any existing metadata with the provided values. - This method also updates the cached metadata attribute. - - Args: - metadata: A dictionary of metadata to associate with this session. - Keys must be strings, values can be any JSON-serializable type - """ - self._client.workspaces.sessions.update( - session_id=self.id, - workspace_id=self.workspace_id, - metadata=metadata, - ) - self._metadata = metadata - - def get_config(self) -> dict[str, object]: - """ - Get configuration for this session. - - Makes an API call to retrieve the current configuration associated with this session. - Configuration includes settings that control session behavior. - This method also updates the cached configuration attribute. - - Returns: - A dictionary containing the session's configuration. Returns an empty dictionary - if no configuration is set - """ - session_data = self._client.workspaces.sessions.get_or_create( - workspace_id=self.workspace_id, - id=self.id, - ) - self._configuration = session_data.configuration or {} - return self._configuration - - @validate_call - def set_config( - self, - configuration: dict[str, object] = Field( - ..., description="Configuration dictionary to associate with this session" - ), - ) -> None: - """ - Set configuration for this session. - - Makes an API call to update the configuration associated with this session. - This will overwrite any existing configuration with the provided values. - This method also updates the cached configuration attribute. - - Args: - configuration: A dictionary of configuration to associate with this session. - Keys must be strings, values can be any JSON-serializable type - """ - self._client.workspaces.sessions.update( - session_id=self.id, - workspace_id=self.workspace_id, - configuration=configuration, - ) - self._configuration = configuration - - def refresh(self) -> None: - """ - Refresh cached metadata and configuration for this session. - - Makes a single API call to retrieve the latest metadata and configuration - associated with this session and updates the cached attributes. - """ - session_data = self._client.workspaces.sessions.get_or_create( - workspace_id=self.workspace_id, - id=self.id, - ) - self._metadata = session_data.metadata or {} - self._configuration = session_data.configuration or {} - - @validate_call - def get_context( + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + def context( self, *, summary: bool = True, @@ -546,7 +519,7 @@ class Session(SessionBase): ), last_user_message: str | Message | None = Field( None, - description="The most recent message (string or Message object), used to fetch semantically relevant conclusions and returned as part of the context object. Use this alongside `peer_target` to get a more focused context -- does nothing if `peer_target` is not provided.", + description="The most recent message text (string or Message object), used to fetch semantically relevant conclusions. Use this alongside `peer_target` to get a more focused context -- does nothing if `peer_target` is not provided.", ), peer_perspective: str | None = Field( None, @@ -591,14 +564,14 @@ class Session(SessionBase): summary: Whether to include summary information tokens: Maximum number of tokens to include in the context. Will default to Honcho server configuration if not provided. - peer_target: A peer ID to get context for. If given *without* `peer_perspective`, a representation and peer card will be included from the omniscient Honcho-level view of `peer_target`. If given *with* `peer_perspective`, will get the representation and card for `peer_target` *from the perspective of `peer_perspective`*. - last_user_message: The most recent message (string or Message object), used to fetch semantically relevant conclusions and returned as part of the context object. Use this alongside `peer_target` to get a more focused context -- does nothing if `peer_target` is not provided. - peer_perspective: A peer ID to get context *from the perspective of*. If given, response will attempt to include representation and card from the perspective of `peer_perspective`. Must be provided with `peer_target`. - limit_to_session: Whether to limit the representation to this session only. If True, only conclusions from this session will be included. - search_top_k: Number of semantically relevant facts to return when searching with `last_user_message`. - search_max_distance: Maximum semantic distance for search results (0.0-1.0) when searching with `last_user_message`. - include_most_frequent: Whether to include the most frequent conclusions in the representation. - max_conclusions: Maximum number of conclusions to include in the representation. + peer_target: A peer ID to get context for. + last_user_message: The most recent message for semantic search. + peer_perspective: A peer ID to get context from the perspective of. + limit_to_session: Whether to limit the representation to this session only. + search_top_k: Number of semantically relevant facts to return. + search_max_distance: Maximum semantic distance for search results. + include_most_frequent: Whether to include the most frequent conclusions. + max_conclusions: Maximum number of conclusions to include. Returns: A SessionContext object containing the optimized message history and @@ -609,6 +582,7 @@ class Session(SessionBase): Token counting is performed using tiktoken. For models using different tokenizers, you may need to adjust the token limit accordingly. """ + self._honcho._ensure_workspace() if peer_target is None and peer_perspective is not None: raise ValueError( @@ -620,54 +594,66 @@ class Session(SessionBase): "You must provide a `peer_target` when `last_user_message` is provided" ) - last_user_message_id = ( - last_user_message.id + last_user_message_text = ( + last_user_message.content if isinstance(last_user_message, Message) else last_user_message ) - context = self._client.workspaces.sessions.context( - session_id=self.id, - workspace_id=self.workspace_id, - tokens=tokens if tokens is not None else omit, - summary=summary, - last_message=last_user_message_id - if last_user_message_id is not None - else omit, - peer_target=peer_target if peer_target is not None else omit, - peer_perspective=peer_perspective if peer_perspective is not None else omit, - limit_to_session=limit_to_session, - search_top_k=search_top_k if search_top_k is not None else omit, - search_max_distance=search_max_distance - if search_max_distance is not None - else omit, - include_most_frequent=include_most_frequent - if include_most_frequent is not None - else omit, - max_conclusions=max_conclusions if max_conclusions is not None else omit, + + query: dict[str, Any] = { + "summary": summary, + "limit_to_session": limit_to_session, + } + if tokens is not None: + query["tokens"] = tokens + if last_user_message_text is not None: + query["last_message"] = last_user_message_text + if peer_target is not None: + query["peer_target"] = peer_target + if peer_perspective is not None: + query["peer_perspective"] = peer_perspective + if search_top_k is not None: + query["search_top_k"] = search_top_k + if search_max_distance is not None: + query["search_max_distance"] = search_max_distance + if include_most_frequent is not None: + query["include_most_frequent"] = include_most_frequent + if max_conclusions is not None: + query["max_conclusions"] = max_conclusions + + data = self._honcho._http.get( + routes.session_context(self.workspace_id, self.id), + query=query, ) - # Convert the honcho_core summary to our Summary if it exists + # Convert summary if present session_summary = None - if context.summary: + if data.get("summary"): + s = data["summary"] session_summary = Summary( - content=context.summary.content, - message_id=context.summary.message_id, - summary_type=context.summary.summary_type, - created_at=context.summary.created_at, - token_count=context.summary.token_count, + content=s["content"], + message_id=s["message_id"], + summary_type=s["summary_type"], + created_at=s["created_at"], + token_count=s["token_count"], ) + messages = [ + Message.from_api_response(MessageResponse.model_validate(msg)) + for msg in data.get("messages", []) + ] + return SessionContext( session_id=self.id, - messages=context.messages, + messages=messages, summary=session_summary, - peer_representation=str(context.peer_representation) - if context.peer_representation + peer_representation=str(data.get("peer_representation")) + if data.get("peer_representation") else None, - peer_card=context.peer_card, + peer_card=data.get("peer_card"), ) - def get_summaries(self) -> SessionSummaries: + def summaries(self) -> SessionSummaries: """ Get available summaries for this session. @@ -687,35 +673,35 @@ class Session(SessionBase): - The summary generation is still in progress - Summary generation is disabled for this session """ - # Use the honcho_core client to get summaries - response = self._client.workspaces.sessions.summaries( - session_id=self.id, - workspace_id=self.workspace_id, + self._honcho._ensure_workspace() + data = self._honcho._http.get( + routes.session_summaries(self.workspace_id, self.id) ) - # Create Summary objects from the response data short_summary = None - if response.short_summary: + if data.get("short_summary"): + s = data["short_summary"] short_summary = Summary( - content=response.short_summary.content, - message_id=response.short_summary.message_id, - summary_type=response.short_summary.summary_type, - created_at=response.short_summary.created_at, - token_count=response.short_summary.token_count, + content=s["content"], + message_id=s["message_id"], + summary_type=s["summary_type"], + created_at=s["created_at"], + token_count=s["token_count"], ) long_summary = None - if response.long_summary: + if data.get("long_summary"): + s = data["long_summary"] long_summary = Summary( - content=response.long_summary.content, - message_id=response.long_summary.message_id, - summary_type=response.long_summary.summary_type, - created_at=response.long_summary.created_at, - token_count=response.long_summary.token_count, + content=s["content"], + message_id=s["message_id"], + summary_type=s["summary_type"], + created_at=s["created_at"], + token_count=s["token_count"], ) return SessionSummaries( - id=response.id or self.id, + id=data.get("id") or self.id, short_summary=short_summary, long_summary=long_summary, ) @@ -738,20 +724,22 @@ class Session(SessionBase): Args: query: The search query to use - filters: Filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v2/guides/using-filters). + filters: Filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). limit: Number of results to return (1-100, default: 10) Returns: A list of Message objects representing the search results. Returns an empty list if no messages are found. """ - return self._client.workspaces.sessions.search( - self.id, - workspace_id=self.workspace_id, - query=query, - filters=filters, - limit=limit, + self._honcho._ensure_workspace() + data = self._honcho._http.post( + routes.session_search(self.workspace_id, self.id), + body={"query": query, "filters": filters, "limit": limit}, ) + return [ + Message.from_api_response(MessageResponse.model_validate(msg)) + for msg in data + ] @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def upload_file( @@ -767,7 +755,7 @@ class Session(SessionBase): None, description="Optional metadata dictionary to associate with the messages", ), - configuration: Configuration | None = Field( + configuration: dict[str, Any] | None = Field( None, description="Optional configuration dictionary to associate with the messages", ), @@ -784,7 +772,7 @@ class Session(SessionBase): - (filename, bytes, content_type) tuples - (filename, fileobj, content_type) tuples - Files are normalized to (filename, fileobj, content_type) tuples for the Stainless client. + Files are normalized to (filename, fileobj, content_type) tuples for the HTTP client. Args: file: File to upload. Can be: @@ -805,6 +793,7 @@ class Session(SessionBase): Large files will be automatically split into multiple messages to fit within message size limits. """ + self._honcho._ensure_workspace() # Prepare file for upload using shared utility filename, content_bytes, content_type = prepare_file_for_upload(file) @@ -812,40 +801,38 @@ class Session(SessionBase): # Extract peer ID from Peer object if needed resolved_peer_id = peer if isinstance(peer, str) else peer.id - # Build extra_body dict with optional fields as JSON strings (backend expects Form fields) - extra_body_data: dict[str, str] = {} + # Build form data + data_dict: dict[str, str] = {"peer_id": resolved_peer_id} if metadata is not None: - extra_body_data["metadata"] = json.dumps(metadata) + data_dict["metadata"] = json.dumps(metadata) if configuration is not None: - extra_body_data["configuration"] = json.dumps(configuration) - if created_at is not None: - # Ensure created_at is a string (ISO format) - if isinstance(created_at, datetime): - extra_body_data["created_at"] = created_at.isoformat() - else: - extra_body_data["created_at"] = created_at + data_dict["configuration"] = json.dumps(configuration) + created_at_iso = datetime_to_iso(created_at) + if created_at_iso is not None: + data_dict["created_at"] = created_at_iso - # Call the upload endpoint with extra_body for the additional form fields - response = self._client.workspaces.sessions.messages.upload( - session_id=self.id, - workspace_id=self.workspace_id, - file=(filename, content_bytes, content_type), - peer_id=resolved_peer_id, - extra_body=extra_body_data if extra_body_data else None, + response = self._honcho._http.upload( + routes.messages_upload(self.workspace_id, self.id), + files={"file": (filename, content_bytes, content_type)}, + data=data_dict, ) - return [Message.model_validate(msg) for msg in response] + return [ + Message.from_api_response(MessageResponse.model_validate(msg)) + for msg in response + ] - def get_representation( + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + def representation( self, peer: str | PeerBase, *, target: str | PeerBase | None = None, search_query: str | None = None, - search_top_k: int | None = None, - search_max_distance: float | None = None, + search_top_k: int | None = Field(None, ge=1, le=100), + search_max_distance: float | None = Field(None, ge=0.0, le=1.0), include_most_frequent: bool | None = None, - max_conclusions: int | None = None, + max_conclusions: int | None = Field(None, ge=1, le=100), ) -> str: """ Get a subset of the representation of the peer in this session. @@ -866,46 +853,47 @@ class Session(SessionBase): Example: ```python # Get peer's representation in this session - rep = session.get_representation('user123') + rep = session.representation('user123') print(rep) # Get what user123 knows about assistant in this session - local_rep = session.get_representation('user123', target='assistant') + local_rep = session.representation('user123', target='assistant') # Get representation with semantic search - searched_rep = session.get_representation( + searched_rep = session.representation( 'user123', search_query='preferences', search_top_k=10 ) ``` """ + self._honcho._ensure_workspace() + peer_id = resolve_id(peer) + target_id = resolve_id(target) - peer_id = peer if isinstance(peer, str) else peer.id - target_id = ( - None - if target is None - else (target if isinstance(target, str) else target.id) + query: dict[str, Any] = {"session_id": self.id} + if target_id: + query["target"] = target_id + if search_query is not None: + query["search_query"] = search_query + if search_top_k is not None: + query["search_top_k"] = search_top_k + if search_max_distance is not None: + query["search_max_distance"] = search_max_distance + if include_most_frequent is not None: + query["include_most_frequent"] = include_most_frequent + if max_conclusions is not None: + query["max_conclusions"] = max_conclusions + + data = self._honcho._http.post( + routes.peer_representation(self.workspace_id, peer_id), + body=query, ) - data: PeerRepresentationResponse = self._client.workspaces.peers.representation( - peer_id, - workspace_id=self.workspace_id, - session_id=self.id, - target=target_id, - search_query=search_query if search_query is not None else omit, - search_top_k=search_top_k if search_top_k is not None else omit, - search_max_distance=search_max_distance - if search_max_distance is not None - else omit, - include_most_frequent=include_most_frequent - if include_most_frequent is not None - else omit, - max_conclusions=max_conclusions if max_conclusions is not None else omit, - ) - return data.representation + response = RepresentationResponse.model_validate(data) + return response.representation @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) - def get_queue_status( + def queue_status( self, observer: str | PeerBase | None = None, sender: str | PeerBase | None = None, @@ -917,101 +905,52 @@ class Session(SessionBase): observer: Optional observer (ID string or Peer object) to scope the status check sender: Optional sender (ID string or Peer object) to scope the status check """ - resolved_observer_id = ( - None - if observer is None - else (observer if isinstance(observer, str) else observer.id) - ) - resolved_sender_id = ( - None - if sender is None - else (sender if isinstance(sender, str) else sender.id) - ) + self._honcho._ensure_workspace() + resolved_observer_id = resolve_id(observer) + resolved_sender_id = resolve_id(sender) - return self._client.workspaces.queue.status( - workspace_id=self.workspace_id, - observer_id=resolved_observer_id, - sender_id=resolved_sender_id, - session_id=self.id, + query: dict[str, Any] = {"session_id": self.id} + if resolved_observer_id: + query["observer_id"] = resolved_observer_id + if resolved_sender_id: + query["sender_id"] = resolved_sender_id + + data = self._honcho._http.get( + routes.workspace_queue_status(self.workspace_id), + query=query, ) + return QueueStatusResponse.model_validate(data) @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) - def poll_queue_status( + def update_message( self, - observer: str | PeerBase | None = None, - sender: str | PeerBase | None = None, - timeout: float = Field( - 300.0, - gt=0, - description="Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).", + message: Message | str = Field( + ..., description="The Message object or message ID to update" ), - ) -> QueueStatusResponse: + metadata: dict[str, object] = Field( + ..., description="The metadata to update for the message" + ), + ) -> Message: """ - Poll get_queue_status until pending_work_units and in_progress_work_units are both 0. - This allows you to guarantee that all messages have been processed by the queue for - use with the dialectic endpoint. + Update the metadata of a message in this session. - The polling estimates sleep time by assuming each work unit takes 1 second. + Makes an API call to update the metadata of a specific message within this session. Args: - observer: Optional observer (ID string or Peer object) to scope the status check - sender: Optional sender (ID string or Peer object) to scope the status check - timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds). + message: Either a Message object or a message ID string + metadata: The metadata to update for the message Returns: - QueueStatusResponse when all work units are complete - - Raises: - TimeoutError: If timeout is exceeded before work units complete - Exception: If get_queue_status fails repeatedly + The updated Message object """ - start_time = time.time() + self._honcho._ensure_workspace() + message_id = message.id if isinstance(message, Message) else message - while True: - try: - status = self.get_queue_status(observer, sender) - except Exception as e: - logger.warning(f"Failed to get queue status: {e}") - # Sleep briefly before retrying - time.sleep(1) - - # Check timeout after error - elapsed_time = time.time() - start_time - if elapsed_time >= timeout: - raise TimeoutError( - f"Polling timeout exceeded after {timeout}s. " - + f"Error during status check: {e}" - ) from e - continue - - if status.pending_work_units == 0 and status.in_progress_work_units == 0: - return status - - # Check timeout before sleeping - elapsed_time = time.time() - start_time - if elapsed_time >= timeout: - raise TimeoutError( - f"Polling timeout exceeded after {timeout}s. " - + f"Current status: {status.pending_work_units} pending, " - + f"{status.in_progress_work_units} in progress work units." - ) - - # Sleep for the expected time to complete all current work units - # Assuming each pending and in-progress work unit takes 1 second - total_work_units = status.pending_work_units + status.in_progress_work_units - sleep_time = max(1, total_work_units) - - # Don't sleep past the timeout - remaining_time = timeout - elapsed_time - sleep_time = min(sleep_time, remaining_time) - if sleep_time <= 0: - raise TimeoutError( - f"Polling timeout exceeded after {timeout}s. " - + f"Current status: {status.pending_work_units} pending, " - + f"{status.in_progress_work_units} in progress work units." - ) - - time.sleep(sleep_time) + data = self._honcho._http.put( + routes.message(self.workspace_id, self.id, message_id), + body={"metadata": metadata}, + ) + return Message.from_api_response(MessageResponse.model_validate(data)) def __repr__(self) -> str: """ diff --git a/sdks/python/src/honcho/session_context.py b/sdks/python/src/honcho/session_context.py index 0c657fe1..dde312aa 100644 --- a/sdks/python/src/honcho/session_context.py +++ b/sdks/python/src/honcho/session_context.py @@ -1,9 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar -from honcho_core.types.workspaces.sessions.message import Message -from pydantic import BaseModel, Field, validate_call +from pydantic import BaseModel, ConfigDict, Field + +from .message import Message if TYPE_CHECKING: from .peer import Peer @@ -49,6 +50,8 @@ class SessionContext(BaseModel): messages: List of Message objects representing the conversation context """ + model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True) + session_id: str = Field( ..., description="ID of the session this context belongs to" ) @@ -67,43 +70,6 @@ class SessionContext(BaseModel): description="The peer card, if context is requested from a specific perspective", ) - @validate_call - def __init__( - self, - session_id: str = Field( - ..., description="ID of the session this context belongs to" - ), - messages: list[Message] = Field( - ..., description="List of Message objects to include in the context" - ), - summary: Summary | None = Field( - None, - description="Summary of the session history prior to the message cutoff", - ), - peer_representation: str | None = Field( - None, - description="The peer representation, if context is requested from a specific perspective", - ), - peer_card: list[str] | None = Field( - None, - description="The peer card, if context is requested from a specific perspective", - ), - ) -> None: - """ - Initialize a new SessionContext. - - Args: - messages: List of Message objects to include in the context - summary: Optional Summary object containing summary information - """ - super().__init__( - session_id=session_id, - messages=messages, - summary=summary, - peer_representation=peer_representation, - peer_card=peer_card, - ) - def to_openai( self, *, diff --git a/sdks/python/src/honcho/types.py b/sdks/python/src/honcho/types.py index 0db73ca2..5d02be18 100644 --- a/sdks/python/src/honcho/types.py +++ b/sdks/python/src/honcho/types.py @@ -3,25 +3,24 @@ from __future__ import annotations from collections.abc import AsyncIterator, Iterator +from typing import Self __all__ = [ "DialecticStreamResponse", + "AsyncDialecticStreamResponse", ] class DialecticStreamResponse: """ - Iterator for streaming dialectic responses with utilities for accessing the final response. + Sync streaming response for dialectic queries. - Similar to OpenAI and Anthropic streaming patterns, this allows you to: - - Iterate over chunks as they arrive - - Access the final accumulated response after streaming completes + Allows iterating over chunks as they arrive and accessing the final + accumulated response after streaming completes. - Works with both sync and async iterators. - - Example (sync): + Example: ```python - stream = peer.chat("Hello", stream=True) + stream = peer.chat_stream("Hello") # Stream chunks for chunk in stream: @@ -31,10 +30,58 @@ class DialecticStreamResponse: final = stream.get_final_response() print(f"\\nFull content: {final['content']}") ``` + """ - Example (async): + _iterator: Iterator[str] + _accumulated_content: list[str] + _is_complete: bool + + def __init__(self, iterator: Iterator[str]) -> None: + self._iterator = iterator + self._accumulated_content = [] + self._is_complete = False + + def __iter__(self) -> Self: + return self + + def __next__(self) -> str: + try: + chunk = next(self._iterator) + self._accumulated_content.append(chunk) + return chunk + except StopIteration: + self._is_complete = True + raise + + def get_final_response(self) -> dict[str, str]: + """ + Get the final accumulated response after streaming completes. + + Returns: + A dictionary with the full content: {"content": "full accumulated text"} + + Note: + This should be called after the stream has been fully consumed. + If called before completion, it returns the content accumulated so far. + """ + return {"content": "".join(self._accumulated_content)} + + @property + def is_complete(self) -> bool: + """Check if the stream has finished.""" + return self._is_complete + + +class AsyncDialecticStreamResponse: + """ + Async streaming response for dialectic queries. + + Allows iterating over chunks as they arrive and accessing the final + accumulated response after streaming completes. + + Example: ```python - stream = await peer.chat("Hello", stream=True) + stream = await peer.aio.chat_stream("Hello") # Stream chunks async for chunk in stream: @@ -46,44 +93,20 @@ class DialecticStreamResponse: ``` """ - _iterator: Iterator[str] | AsyncIterator[str] + _iterator: AsyncIterator[str] _accumulated_content: list[str] _is_complete: bool - def __init__(self, iterator: Iterator[str] | AsyncIterator[str]): + def __init__(self, iterator: AsyncIterator[str]) -> None: self._iterator = iterator self._accumulated_content = [] self._is_complete = False - # Sync iterator protocol - def __iter__(self): - if isinstance(self._iterator, Iterator): - return self - else: - raise TypeError("iterator must be an sync iterator, got async iterator") - - def __next__(self) -> str: - try: - if not isinstance(self._iterator, Iterator): - raise TypeError("iterator must be an sync iterator, got async iterator") - chunk = next(self._iterator) - self._accumulated_content.append(chunk) - return chunk - except StopIteration: - self._is_complete = True - raise - - # Async iterator protocol - def __aiter__(self): - if isinstance(self._iterator, AsyncIterator): - return self - else: - raise TypeError("iterator must be an async iterator, got sync iterator") + def __aiter__(self) -> Self: + return self async def __anext__(self) -> str: try: - if not isinstance(self._iterator, AsyncIterator): - raise TypeError("iterator must be an async iterator, got sync iterator") chunk = await self._iterator.__anext__() self._accumulated_content.append(chunk) return chunk diff --git a/sdks/python/src/honcho/utils/__init__.py b/sdks/python/src/honcho/utils/__init__.py index 88a7da73..aac9221b 100644 --- a/sdks/python/src/honcho/utils/__init__.py +++ b/sdks/python/src/honcho/utils/__init__.py @@ -2,6 +2,21 @@ Utility modules for the Honcho Python SDK. """ +from .datetime import datetime_to_iso, parse_datetime from .file_upload import normalize_file_input, prepare_file_for_upload +from .peers import normalize_peers_to_dict +from .resolve import resolve_id +from .sse import SSEStreamParser, parse_sse_astream, parse_sse_chunk, parse_sse_stream -__all__ = ["normalize_file_input", "prepare_file_for_upload"] +__all__ = [ + "datetime_to_iso", + "parse_datetime", + "normalize_file_input", + "normalize_peers_to_dict", + "SSEStreamParser", + "parse_sse_astream", + "parse_sse_chunk", + "parse_sse_stream", + "prepare_file_for_upload", + "resolve_id", +] diff --git a/sdks/python/src/honcho/utils/datetime.py b/sdks/python/src/honcho/utils/datetime.py new file mode 100644 index 00000000..79ce135e --- /dev/null +++ b/sdks/python/src/honcho/utils/datetime.py @@ -0,0 +1,49 @@ +"""DateTime utilities for the Honcho Python SDK.""" + +from datetime import datetime + + +def datetime_to_iso(value: datetime | str | None) -> str | None: + """ + Convert a datetime value to an ISO 8601 formatted string. + + Args: + value: A datetime object, an ISO 8601 string, or None + + Returns: + An ISO 8601 formatted string, or None if input is None + """ + if value is None: + return None + if isinstance(value, str): + return value + return value.isoformat() + + +def parse_datetime(value: datetime | str | None) -> datetime | None: + """ + Parse an ISO 8601 datetime string into a `datetime` instance. + + This accepts timestamps in the forms commonly produced by APIs and JS runtimes, + including a trailing "Z" UTC designator (e.g. "2024-01-15T10:30:00Z") and + offsets (e.g. "2024-01-15T10:30:00+00:00"). + + Args: + value: A datetime object, an ISO 8601 string, or None. + + Returns: + A `datetime` instance if provided, otherwise None. + + Raises: + ValueError: If the string cannot be parsed as ISO 8601. + """ + if value is None: + return None + if isinstance(value, datetime): + return value + + normalized = value.replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized) + except ValueError as e: + raise ValueError(f"Invalid ISO 8601 datetime: {value!r}") from e diff --git a/sdks/python/src/honcho/utils/peers.py b/sdks/python/src/honcho/utils/peers.py new file mode 100644 index 00000000..2054c6b8 --- /dev/null +++ b/sdks/python/src/honcho/utils/peers.py @@ -0,0 +1,49 @@ +"""Peer-related utility functions.""" + +from __future__ import annotations + +from typing import Any + +from ..api_types import SessionPeerConfig +from ..base import PeerBase + + +def normalize_peers_to_dict( + peers: str + | PeerBase + | tuple[str, SessionPeerConfig] + | tuple[PeerBase, SessionPeerConfig] + | list[PeerBase | str] + | list[tuple[PeerBase | str, SessionPeerConfig]] + | list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]], +) -> dict[str, Any]: + """ + Normalize various peer input formats into a dict mapping peer IDs to configs. + + Accepts: + - str: Single peer ID + - PeerBase: Single Peer object + - tuple[str, SessionPeerConfig]: Single peer ID with config + - tuple[PeerBase, SessionPeerConfig]: Single Peer object with config + - list[PeerBase | str]: List of peers/IDs + - list[tuple[PeerBase | str, SessionPeerConfig]]: List of peers with configs + - Mixed lists combining all of the above + + Returns: + Dict mapping peer IDs to their config dicts (empty dict if no config) + """ + + if not isinstance(peers, list): + peers = [peers] + + peer_dict: dict[str, Any] = {} + for peer in peers: + if isinstance(peer, tuple): + peer_id = peer[0] if isinstance(peer[0], str) else peer[0].id + peer_config = peer[1] + peer_dict[peer_id] = peer_config.model_dump(exclude_none=True) + else: + peer_id = peer if isinstance(peer, str) else peer.id + peer_dict[peer_id] = {} + + return peer_dict diff --git a/sdks/python/src/honcho/utils/resolve.py b/sdks/python/src/honcho/utils/resolve.py new file mode 100644 index 00000000..db70fc01 --- /dev/null +++ b/sdks/python/src/honcho/utils/resolve.py @@ -0,0 +1,50 @@ +"""ID resolution utilities for the Honcho Python SDK.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, overload + +if TYPE_CHECKING: + from ..base import PeerBase, SessionBase + + +@overload +def resolve_id(obj: None) -> None: ... + + +@overload +def resolve_id(obj: str) -> str: ... + + +@overload +def resolve_id(obj: "PeerBase | SessionBase") -> str: ... + + +def resolve_id(obj: "str | PeerBase | SessionBase | None") -> str | None: + """ + Resolve an ID from a string, PeerBase, SessionBase, or None. + + This utility function extracts the ID from an object that may be: + - A string (returned as-is) + - An object with an `id` attribute (the id is extracted) + - None (returns None) + + Args: + obj: A string ID, an object with an `id` attribute (like Peer or Session), or None + + Returns: + The resolved string ID, or None if input is None + + Example: + >>> resolve_id("user-123") + 'user-123' + >>> resolve_id(peer) # where peer.id == "user-123" + 'user-123' + >>> resolve_id(None) + None + """ + if obj is None: + return None + if isinstance(obj, str): + return obj + return obj.id diff --git a/sdks/python/src/honcho/utils/sse.py b/sdks/python/src/honcho/utils/sse.py new file mode 100644 index 00000000..5c24ea64 --- /dev/null +++ b/sdks/python/src/honcho/utils/sse.py @@ -0,0 +1,228 @@ +"""SSE (Server-Sent Events) parsing utilities.""" + +from __future__ import annotations + +import codecs +import json +import logging +from collections.abc import AsyncGenerator, AsyncIterable, Generator, Iterable +from typing import Any, cast + +logger = logging.getLogger(__name__) + + +class SSEStreamParser: + """ + Incrementally parse an SSE byte stream into content strings. + + This parser is designed for real network streaming where byte chunks may split UTF-8 + codepoints and/or split lines arbitrarily. It maintains: + + - An incremental UTF-8 decoder to safely decode across chunk boundaries. + - A text buffer to safely assemble complete lines across chunk boundaries. + + The Honcho streaming format is expected to include lines in the form `data:` or + `data: `. Each data line should contain a JSON object with: + + - `done: true` to indicate stream completion. + - `delta.content` containing incremental text. + + Any JSON decoding failures are logged with the same warning format as the legacy + parser, including a preview of the data payload. + """ + + def __init__(self) -> None: + self._decoder: codecs.IncrementalDecoder = codecs.getincrementaldecoder( + "utf-8" + )(errors="replace") + self._text_buffer: str = "" + self._done: bool = False + + @property + def done(self) -> bool: + """Whether the stream has emitted a `done: true` message.""" + return self._done + + def feed(self, chunk: bytes) -> Generator[str, None, None]: + """ + Feed the next bytes from the SSE stream and yield any newly available content. + + Args: + chunk: Raw bytes from the SSE stream. + + Yields: + Content strings extracted from any complete `data:` lines decoded from this + chunk (and any previously buffered partial data). + """ + if self._done or not chunk: + return + + decoded = self._decoder.decode(chunk, final=False) + if decoded: + self._text_buffer += decoded + + yield from self._drain_complete_lines() + + def finalize(self) -> Generator[str, None, None]: + """ + Finalize the stream and yield any remaining content. + + This should be called once the underlying byte stream is finished to flush any + remaining decoder/buffer state. + """ + if self._done: + return + + decoded = self._decoder.decode(b"", final=True) + if decoded: + self._text_buffer += decoded + + yield from self._drain_complete_lines(flush_partial=True) + + def _drain_complete_lines( + self, *, flush_partial: bool = False + ) -> Generator[str, None, None]: + while not self._done: + line = self._pop_line(flush_partial=flush_partial) + if line is None: + return + yield from self._handle_line(line) + + def _pop_line(self, *, flush_partial: bool) -> str | None: + if not self._text_buffer: + return None + + idx_n = self._text_buffer.find("\n") + idx_r = self._text_buffer.find("\r") + + if idx_n == -1 and idx_r == -1: + if not flush_partial: + return None + line = self._text_buffer + self._text_buffer = "" + return line + + if idx_n == -1: + idx = idx_r + elif idx_r == -1: + idx = idx_n + else: + idx = min(idx_n, idx_r) + + sep = self._text_buffer[idx] + if sep == "\n": + line = self._text_buffer[:idx] + self._text_buffer = self._text_buffer[idx + 1 :] + if line.endswith("\r"): + line = line[:-1] + return line + + if idx == len(self._text_buffer) - 1 and not flush_partial: + return None + + if idx + 1 < len(self._text_buffer) and self._text_buffer[idx + 1] == "\n": + line = self._text_buffer[:idx] + self._text_buffer = self._text_buffer[idx + 2 :] + return line + + line = self._text_buffer[:idx] + self._text_buffer = self._text_buffer[idx + 1 :] + return line + + def _handle_line(self, line: str) -> Generator[str, None, None]: + if not line.startswith("data:"): + return + + json_str = line[len("data:") :].lstrip(" ") + if not json_str: + return + + try: + parsed: object = json.loads(json_str) + if not isinstance(parsed, dict): + return + + chunk_data = cast(dict[str, Any], parsed) + if chunk_data.get("done"): + self._done = True + return + + delta_obj = chunk_data.get("delta", {}) + if not isinstance(delta_obj, dict): + return + + delta_data = cast(dict[str, Any], delta_obj) + content = delta_data.get("content") + if isinstance(content, str) and content: + yield content + except json.JSONDecodeError as e: + logger.warning( + "Failed to decode streaming chunk: %s (data: %s)", + e, + json_str[:100], + ) + + +def parse_sse_chunk( + chunk: bytes, *, parser: SSEStreamParser | None = None +) -> Generator[str, None, None]: + """ + Parse bytes from an SSE stream and yield content strings. + + For correct handling of UTF-8 and line boundaries across network chunks, construct + one `SSEStreamParser` per stream and pass it for each call: + + - `yield from parse_sse_chunk(chunk, parser=parser)` + + Args: + chunk: Raw bytes from the SSE stream. + parser: Optional persistent parser instance. If omitted, a temporary parser is + created and finalized for this single chunk. + + Yields: + Content strings extracted from delta objects. + """ + if parser is None: + tmp = SSEStreamParser() + yield from tmp.feed(chunk) + yield from tmp.finalize() + return + yield from parser.feed(chunk) + + +def parse_sse_stream(chunks: Iterable[bytes]) -> Generator[str, None, None]: + """ + Parse an SSE byte stream and yield content strings. + + Args: + chunks: An iterable of raw byte chunks from an SSE stream. + + Yields: + Content strings extracted from delta objects, in order. + """ + parser = SSEStreamParser() + for chunk in chunks: + yield from parser.feed(chunk) + if parser.done: + return + yield from parser.finalize() + + +async def parse_sse_astream(chunks: AsyncIterable[bytes]) -> AsyncGenerator[str, None]: + """ + Parse an async SSE byte stream and yield content strings. + + Args: + chunks: An async iterable of raw byte chunks from an SSE stream. + + Yields: + Content strings extracted from delta objects, in order. + """ + parser = SSEStreamParser() + async for chunk in chunks: + for content in parser.feed(chunk): + yield content + if parser.done: + return + for content in parser.finalize(): + yield content diff --git a/sdks/typescript/CHANGELOG.md b/sdks/typescript/CHANGELOG.md index 74900a40..e2e05cb8 100644 --- a/sdks/typescript/CHANGELOG.md +++ b/sdks/typescript/CHANGELOG.md @@ -5,6 +5,29 @@ 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.0.0] - 2026-01-13 + +### Added + +- `ConclusionScope` object for CRUD operations on conclusions (renamed from observations) +- Representation configuration support + +### Changed + +- Observations renamed to Conclusions across the SDK +- Major SDK refactoring and cleanup +- Simplified method signatures throughout +- Representation endpoints now return `string` instead of old Representation object + +### Fixed + +- Pagination `this` binding issue + +### Removed + +- Representation object +- Stainless "core" SDK -- this SDK is now standalone + ## [1.6.0] - 2025-12-03 ### Added diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index 39c86314..af79dd4f 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -37,3 +37,20 @@ console.log(response); ``` See `examples/` for more. + +## Development + +### Type checking + +```bash +bun run typecheck +``` + +### Testing + +Tests for the SDK live in `tests/` at the monorepo root and are run via pytest, which orchestrates a test server. Do not run `bun test` directly. + +```bash +# From the monorepo root +uv run pytest tests/ -k typescript +``` diff --git a/sdks/typescript/__tests__/README.md b/sdks/typescript/__tests__/README.md deleted file mode 100644 index 2b788266..00000000 --- a/sdks/typescript/__tests__/README.md +++ /dev/null @@ -1,199 +0,0 @@ -# Honcho TypeScript SDK Test Suite - -This directory contains an exhaustive and idiomatic test suite for the Honcho TypeScript SDK that covers every available endpoint in multiple ways. - -## Test Structure - -### Unit Tests - -Each class has its own dedicated test file with comprehensive coverage: - -- **`client.test.ts`** - Tests for the main `Honcho` client class -- **`peer.test.ts`** - Tests for the `Peer` class -- **`session.test.ts`** - Tests for the `Session` class -- **`session_context.test.ts`** - Tests for the `SessionContext` class -- **`pagination.test.ts`** - Tests for the `Page` class - -### Integration Tests - -- **`integration.test.ts`** - End-to-end workflow tests that demonstrate real-world usage patterns - -### Test Configuration - -- **`setup.ts`** - Global test setup and configuration -- **`jest.config.js`** - Jest configuration -- **`__mocks__/@honcho-ai/core.ts`** - Mock implementation of the core API client - -## Test Coverage - -### Honcho Client (`client.test.ts`) - -- ✅ Constructor with all option variations -- ✅ Environment variable fallbacks -- ✅ Peer creation and validation -- ✅ Session creation and validation -- ✅ Workspace metadata operations -- ✅ Workspace listing -- ✅ Search functionality -- ✅ Error handling for all methods -- ✅ Edge cases and input validation - -### Peer Class (`peer.test.ts`) - -- ✅ Chat functionality with all option combinations -- ✅ Session management -- ✅ Message operations (add, get, create) -- ✅ Metadata operations -- ✅ Search within peer scope -- ✅ Different input types and formats -- ✅ Null/empty response handling -- ✅ Error scenarios - -### Session Class (`session.test.ts`) - -- ✅ Peer management (add, set, remove, list) -- ✅ Message operations with filtering -- ✅ Metadata operations -- ✅ Context retrieval with options -- ✅ Search within session scope -- ✅ Working representation queries -- ✅ Mixed input types (strings vs objects) -- ✅ Constructor options -- ✅ Error handling - -### SessionContext Class (`session_context.test.ts`) - -- ✅ Constructor variations -- ✅ OpenAI format conversion -- ✅ Anthropic format conversion -- ✅ Length and toString methods -- ✅ Empty/null message handling -- ✅ Complex message content -- ✅ Case sensitivity -- ✅ Missing field handling -- ✅ Edge cases and malformed data - -### Page Class (`pagination.test.ts`) - -- ✅ Async iteration -- ✅ Transform functions -- ✅ Data retrieval methods -- ✅ Pagination navigation -- ✅ Different page formats -- ✅ Error handling in transforms -- ✅ Large dataset handling -- ✅ Circular references -- ✅ Complex nested structures - -### Integration Tests (`integration.test.ts`) - -- ✅ Complete chat session workflow -- ✅ Workspace and peer management -- ✅ Multi-scope search functionality -- ✅ Error scenario handling -- ✅ Pagination workflows -- ✅ Working representation queries -- ✅ Type safety verification -- ✅ Empty/null response handling - -## Test Patterns - -### Comprehensive Mocking - -All tests use comprehensive mocks of the underlying `@honcho-ai/core` API client to ensure: - -- Tests run independently of external services -- Predictable and controllable test scenarios -- Fast test execution -- Ability to test error conditions - -### Edge Case Coverage - -Each test suite includes extensive edge case testing: - -- Empty/null inputs and responses -- Invalid input types -- API error conditions -- Boundary conditions -- Malformed data handling - -### Multiple Input Types - -Tests verify that methods handle various input types correctly: - -- String vs object parameters -- Single items vs arrays -- Optional vs required parameters -- Different data structures - -### Error Scenarios - -Comprehensive error testing including: - -- API failures -- Invalid inputs -- Network errors -- Timeout scenarios -- Validation errors - -### Async Operations - -Proper testing of all asynchronous operations: - -- Promise resolution/rejection -- Async iteration -- Concurrent operations -- Error propagation - -## Running Tests - -```bash -# Install dependencies -npm install - -# Run all tests -npm test - -# Run tests with coverage -npm run test:coverage - -# Run tests in watch mode -npm run test:watch - -# Run specific test file -npm test client.test.ts - -# Run integration tests only -npm test integration.test.ts -``` - -## Coverage Goals - -This test suite aims for: - -- **100% function coverage** - Every function is called -- **100% branch coverage** - Every code path is tested -- **100% statement coverage** - Every line is executed -- **Comprehensive edge case coverage** - Every failure mode is tested - -## Test Philosophy - -1. **Exhaustive Testing**: Every public method and property is tested -2. **Multiple Scenarios**: Each method is tested with various inputs and conditions -3. **Real-world Usage**: Integration tests mirror actual usage patterns -4. **Error Resilience**: Extensive error condition testing -5. **Type Safety**: TypeScript types are verified throughout -6. **Performance Awareness**: Tests include large dataset scenarios -7. **Maintainability**: Clear, well-documented test cases - -## Mock Strategy - -The test suite uses a sophisticated mocking strategy: - -1. **Core API Mocking**: The `@honcho-ai/core` module is completely mocked -2. **Flexible Responses**: Mock responses can be configured per test -3. **Error Simulation**: Easy simulation of API errors and edge cases -4. **Isolation**: Each test runs in complete isolation -5. **Deterministic**: Tests produce consistent, reproducible results - -This ensures that the SDK layer is thoroughly tested while remaining independent of the underlying API implementation. diff --git a/sdks/typescript/__tests__/__mocks__/@honcho-ai/core.ts b/sdks/typescript/__tests__/__mocks__/@honcho-ai/core.ts deleted file mode 100644 index 04216493..00000000 --- a/sdks/typescript/__tests__/__mocks__/@honcho-ai/core.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Mock implementation of @honcho-ai/core for testing -export default class MockHonchoCore { - public workspaces = { - peers: { - list: jest.fn(), - chat: jest.fn(), - sessions: { - list: jest.fn(), - }, - messages: { - create: jest.fn(), - list: jest.fn(), - }, - getOrCreate: jest.fn(), - update: jest.fn(), - search: jest.fn(), - getRepresentation: jest.fn(), - }, - sessions: { - list: jest.fn(), - peers: { - add: jest.fn(), - set: jest.fn(), - remove: jest.fn(), - list: jest.fn(), - getConfig: jest.fn(), - setConfig: jest.fn(), - }, - messages: { - create: jest.fn(), - list: jest.fn(), - upload: jest.fn(), - }, - getOrCreate: jest.fn(), - update: jest.fn(), - getContext: jest.fn(), - search: jest.fn(), - }, - getOrCreate: jest.fn().mockResolvedValue({ id: 'test-workspace', metadata: {} }), - update: jest.fn(), - list: jest.fn(), - search: jest.fn(), - deriverStatus: jest.fn(), - }; - - constructor(options?: any) { - // Mock constructor - } -} diff --git a/sdks/typescript/__tests__/client.test.ts b/sdks/typescript/__tests__/client.test.ts index 2708972b..c0e2fb50 100644 --- a/sdks/typescript/__tests__/client.test.ts +++ b/sdks/typescript/__tests__/client.test.ts @@ -1,594 +1,356 @@ -import { Honcho } from '../src/client'; -import { Peer } from '../src/peer'; -import { Session } from '../src/session'; -import { Page } from '../src/pagination'; -import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages'; +/** + * Client Tests + * + * Tests for workspace-level operations via the Honcho client. + * + * Endpoints covered: + * - POST /v3/workspaces (get-or-create workspace) + * - POST /v3/workspaces/list (list workspaces) + * - PUT /v3/workspaces/:workspaceId (update workspace) + * - DELETE /v3/workspaces/:workspaceId (delete workspace) + * - POST /v3/workspaces/:workspaceId/search (search messages) + * - GET /v3/workspaces/:workspaceId/queue/status (queue status) + */ -// Mock the @honcho-ai/core module -jest.mock('@honcho-ai/core', () => { - return jest.fn().mockImplementation(() => ({ - workspaces: { - peers: { - list: jest.fn(), - getOrCreate: jest.fn(), - }, - sessions: { - list: jest.fn(), - getOrCreate: jest.fn(), - }, - queue: { - status: jest.fn(), - }, - getOrCreate: jest.fn().mockResolvedValue({ id: 'test-workspace', metadata: {} }), - update: jest.fn(), - list: jest.fn(), - search: jest.fn(), - }, - })); -}); +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { Honcho } from '../src' +import { + createTestClient, + generateId, + generateWorkspaceId, + requireServer, + TEST_CONFIG, +} from './setup' +import { testMetadata } from './helpers' describe('Honcho Client', () => { - let honcho: Honcho; - let mockClient: any; + let client: Honcho + let cleanup: () => Promise - beforeEach(() => { - // Clear all mocks before each test - jest.clearAllMocks(); + beforeAll(async () => { + await requireServer() + const setup = await createTestClient('client') + client = setup.client + cleanup = setup.cleanup + }) - honcho = new Honcho({ - workspaceId: 'test-workspace', - apiKey: 'test-key', - environment: 'local', - }); + afterAll(async () => { + await cleanup() + }) - mockClient = (honcho as any)._client; - }); + // =========================================================================== + // Workspace Creation and Configuration + // =========================================================================== - describe('constructor', () => { - it('should initialize with provided options', () => { - const client = new Honcho({ - workspaceId: 'custom-workspace', - apiKey: 'custom-key', - environment: 'production', - baseURL: 'https://custom-url.com', - timeout: 5000, - maxRetries: 3, - }); + describe('POST /workspaces (create/get)', () => { + test('client constructor creates workspace on first access', async () => { + // Workspace was created in beforeAll via getMetadata() + expect(client.workspaceId).toBeDefined() + expect(client.workspaceId).toContain('test-client-') + }) - expect(client.workspaceId).toBe('custom-workspace'); - }); + test('getMetadata returns empty object for new workspace', async () => { + const metadata = await client.getMetadata() + expect(metadata).toEqual({}) + }) - it('should use environment variables as fallbacks', () => { - process.env.HONCHO_WORKSPACE_ID = 'env-workspace'; - process.env.HONCHO_API_KEY = 'env-key'; - process.env.HONCHO_URL = 'https://env-url.com'; + test('getConfiguration returns empty object for new workspace', async () => { + const config = await client.getConfiguration() + expect(config).toEqual({}) + }) + }) - const client = new Honcho({}); + describe('PUT /workspaces/:id (update)', () => { + test('setMetadata updates workspace metadata', async () => { + const metadata = testMetadata({ custom: 'value' }) + await client.setMetadata(metadata) - expect(client.workspaceId).toBe('env-workspace'); + const fetched = await client.getMetadata() + expect(fetched).toEqual(metadata) + }) - // Clean up environment variables - delete process.env.HONCHO_WORKSPACE_ID; - delete process.env.HONCHO_API_KEY; - delete process.env.HONCHO_URL; - }); + test('setConfiguration updates workspace configuration', async () => { + const config = { + reasoning: { enabled: true }, + } + await client.setConfiguration(config) - it('should use default workspace ID when none provided', () => { - const client = new Honcho({}); - expect(client.workspaceId).toBe('default'); - }); + const fetched = await client.getConfiguration() + expect(fetched).toEqual(config) + }) - it('should handle all constructor options', () => { - const client = new Honcho({ - workspaceId: 'test', - apiKey: 'key', + test('cached metadata is updated after setMetadata', async () => { + const metadata = testMetadata({ cached: true }) + await client.setMetadata(metadata) + + // Check cached value without API call + expect(client.metadata).toEqual(metadata) + }) + + test('refresh updates both metadata and configuration', async () => { + // Set values + await client.setMetadata({ a: 1 }) + await client.setConfiguration({ reasoning: { enabled: true } }) + + // Create new client with same workspace (simulates stale cache) + const freshClient = new Honcho({ + baseURL: TEST_CONFIG.baseURL, + apiKey: TEST_CONFIG.apiKey, + workspaceId: client.workspaceId, + }) + + // Before refresh, cache is empty + expect(freshClient.metadata).toBeUndefined() + expect(freshClient.configuration).toBeUndefined() + + // After refresh, cache is populated + await freshClient.refresh() + expect(freshClient.metadata).toEqual({ a: 1 }) + expect(freshClient.configuration).toMatchObject({ reasoning: { enabled: true } }) + }) + }) + + // =========================================================================== + // Workspace Listing + // =========================================================================== + + describe('POST /workspaces/list', () => { + test('workspaces returns Page with workspace IDs', async () => { + const page = await client.workspaces() + + expect(Array.isArray(page.items)).toBe(true) + }) + + test('workspaces with filter narrows results', async () => { + // Create a workspace with specific metadata + const uniqueValue = `filter-test-${Date.now()}` + const testClient = new Honcho({ + baseURL: TEST_CONFIG.baseURL, + apiKey: TEST_CONFIG.apiKey, + workspaceId: generateWorkspaceId('filter'), + }) + + try { + await testClient.setMetadata({ filterKey: uniqueValue }) + + // Filter should find this workspace + const page = await client.workspaces({ + metadata: { filterKey: uniqueValue }, + }) + + expect(page.items).toContain(testClient.workspaceId) + } finally { + await testClient.deleteWorkspace(testClient.workspaceId) + } + }) + }) + + // =========================================================================== + // Workspace Deletion + // =========================================================================== + + describe('DELETE /workspaces/:id', () => { + test('deleteWorkspace removes workspace', async () => { + // Create a workspace to delete + const tempWorkspaceId = generateWorkspaceId('delete') + const tempClient = new Honcho({ + baseURL: TEST_CONFIG.baseURL, + apiKey: TEST_CONFIG.apiKey, + workspaceId: tempWorkspaceId, + }) + + // Ensure it exists + await tempClient.getMetadata() + + // Delete it (returns void) + await client.deleteWorkspace(tempWorkspaceId) + + // Verify it's gone from list + const page = await client.workspaces() + expect(page.items).not.toContain(tempWorkspaceId) + }) + }) + + // =========================================================================== + // Peer and Session Access + // =========================================================================== + + describe('Peer access', () => { + test('peer() returns Peer instance without API call', async () => { + const peer = await client.peer('lazy-peer') + + expect(peer.id).toBe('lazy-peer') + expect(peer.workspaceId).toBe(client.workspaceId) + }) + + test('peer() with metadata makes API call', async () => { + const peer = await client.peer('eager-peer', { + metadata: { created: true }, + }) + + expect(peer.id).toBe('eager-peer') + expect(peer.metadata).toEqual({ created: true }) + }) + + test('peers returns paginated list', async () => { + // Create some peers + await client.peer('list-peer-1', { metadata: {} }) + await client.peer('list-peer-2', { metadata: {} }) + + const page = await client.peers() + + expect(page.items.length).toBeGreaterThanOrEqual(2) + expect(page.total).toBeGreaterThanOrEqual(2) + + const ids = page.items.map((p) => p.id) + expect(ids).toContain('list-peer-1') + expect(ids).toContain('list-peer-2') + }) + }) + + describe('Session access', () => { + test('session() returns Session instance without API call', async () => { + const session = await client.session('lazy-session', { metadata: {} }) + + expect(session.id).toBe('lazy-session') + expect(session.workspaceId).toBe(client.workspaceId) + }) + + test('session() with metadata makes API call', async () => { + const session = await client.session('eager-session', { + metadata: { created: true }, + }) + + expect(session.id).toBe('eager-session') + expect(session.metadata).toEqual({ created: true }) + }) + + test('sessions returns paginated list', async () => { + // Create some sessions + await client.session('list-session-1', { metadata: {} }) + await client.session('list-session-2', { metadata: {} }) + + const page = await client.sessions() + + expect(page.items.length).toBeGreaterThanOrEqual(2) + + const ids = page.items.map((s) => s.id) + expect(ids).toContain('list-session-1') + expect(ids).toContain('list-session-2') + }) + }) + + // =========================================================================== + // Search + // =========================================================================== + + describe('POST /workspaces/:id/search', () => { + test('search returns matching messages', async () => { + // Setup: create session with messages + const session = await client.session('search-session', { metadata: {} }) + const peer = await client.peer('search-peer') + + await session.addPeers([peer.id]) + await session.addMessages([ + peer.message('The quick brown fox jumps over the lazy dog'), + peer.message('Hello world, this is a test message'), + ]) + + // Search for content + const results = await client.search('quick brown fox') + + // Note: Vector search may return results based on semantic similarity + expect(Array.isArray(results)).toBe(true) + }) + + test('search with filters scopes results', async () => { + const session = await client.session('search-filtered-session', { metadata: {} }) + const peer = await client.peer('search-filtered-peer') + + await session.addPeers([peer.id]) + await session.addMessages([ + peer.message('Unique searchable content xyz123'), + ]) + + const results = await client.search('unique searchable', { + filters: { session_id: session.id }, + }) + + // All results should be from the specified session + for (const msg of results) { + expect(msg.sessionId).toBe(session.id) + } + }) + + test('search with limit constrains results', async () => { + const results = await client.search('test', { limit: 5 }) + + expect(results.length).toBeLessThanOrEqual(5) + }) + }) + + // =========================================================================== + // Queue Status + // =========================================================================== + + describe('GET /workspaces/:id/queue/status', () => { + test('queueStatus returns status object', async () => { + const status = await client.queueStatus() + + expect(typeof status.totalWorkUnits).toBe('number') + expect(typeof status.completedWorkUnits).toBe('number') + expect(typeof status.inProgressWorkUnits).toBe('number') + expect(typeof status.pendingWorkUnits).toBe('number') + }) + + test('queueStatus with observer filter', async () => { + const peer = await client.peer('queue-observer') + + const status = await client.queueStatus({ + observer: peer, + }) + + expect(typeof status.totalWorkUnits).toBe('number') + }) + + test('queueStatus with session filter', async () => { + const session = await client.session('queue-session', { metadata: {} }) + + const status = await client.queueStatus({ + session: session, + }) + + expect(typeof status.totalWorkUnits).toBe('number') + }) + }) + + // =========================================================================== + // Client Configuration + // =========================================================================== + + describe('Client configuration', () => { + test('baseURL is accessible', () => { + expect(client.baseURL).toBe(TEST_CONFIG.baseURL) + }) + + test('http client is accessible', () => { + expect(client.http).toBeDefined() + expect(client.http.baseURL).toBe(TEST_CONFIG.baseURL) + }) + + test('toString returns readable representation', () => { + const str = client.toString() + expect(str).toContain('Honcho') + expect(str).toContain(client.workspaceId) + }) + + test('environment option sets local URL', () => { + const localClient = new Honcho({ environment: 'local', - baseURL: 'https://example.com', - timeout: 10000, - maxRetries: 5, - defaultHeaders: { 'X-Custom': 'header' }, - defaultQuery: { param: 'value' }, - }); + workspaceId: 'test', + }) - expect(client.workspaceId).toBe('test'); - }); - }); - - describe('peer', () => { - it('should create a new Peer instance', async () => { - const peer = await honcho.peer('test-peer'); - - expect(peer).toBeInstanceOf(Peer); - expect(peer.id).toBe('test-peer'); - }); - - it('should create peer with metadata and config', async () => { - const metadata = { name: 'Test Peer' }; - const config = { observe_me: false }; - - mockClient.workspaces.peers.getOrCreate.mockResolvedValue({ - id: 'test-peer', - metadata: metadata, - configuration: config, - }); - - await honcho.peer('test-peer', { metadata, config }); - - expect(mockClient.workspaces.peers.getOrCreate).toHaveBeenCalledWith( - 'test-workspace', - { id: 'test-peer', metadata: metadata, configuration: config } - ); - }); - - it('should throw error for empty peer ID', async () => { - await expect(honcho.peer('')).rejects.toThrow(); - }); - - it('should throw error for non-string peer ID', async () => { - await expect(honcho.peer(null as any)).rejects.toThrow(); - await expect(honcho.peer(undefined as any)).rejects.toThrow(); - await expect(honcho.peer(123 as any)).rejects.toThrow(); - }); - }); - - describe('getPeers', () => { - it('should return a Page of Peer instances', async () => { - const mockPeersData = { - items: [ - { id: 'peer1', metadata: {} }, - { id: 'peer2', metadata: {} }, - ], - total: 2, - size: 2, - hasNextPage: () => false, - }; - mockClient.workspaces.peers.list.mockResolvedValue(mockPeersData); - - const peersPage = await honcho.getPeers(); - - expect(peersPage).toBeInstanceOf(Page); - expect(mockClient.workspaces.peers.list).toHaveBeenCalledWith('test-workspace', { filters: undefined }); - }); - - it('should handle empty peers list', async () => { - const mockPeersData = { - items: [], - total: 0, - size: 0, - hasNextPage: () => false, - }; - mockClient.workspaces.peers.list.mockResolvedValue(mockPeersData); - - const peersPage = await honcho.getPeers(); - - expect(peersPage).toBeInstanceOf(Page); - expect(mockClient.workspaces.peers.list).toHaveBeenCalledWith('test-workspace', { filters: undefined }); - }); - - it('should handle API errors', async () => { - mockClient.workspaces.peers.list.mockRejectedValue(new Error('API Error')); - - await expect(honcho.getPeers()).rejects.toThrow(); - }); - }); - - describe('session', () => { - it('should create a new Session instance', async () => { - const session = await honcho.session('test-session'); - - expect(session).toBeInstanceOf(Session); - expect(session.id).toBe('test-session'); - }); - - it('should create session with metadata and config', async () => { - const metadata = { name: 'Test Session' }; - const config = { anonymous: true }; - - mockClient.workspaces.sessions.getOrCreate.mockResolvedValue({ - id: 'test-session', - metadata: metadata, - configuration: config, - }); - - await honcho.session('test-session', { metadata, config }); - - expect(mockClient.workspaces.sessions.getOrCreate).toHaveBeenCalledWith( - 'test-workspace', - { id: 'test-session', metadata: metadata, configuration: config } - ); - }); - - it('should throw error for empty session ID', async () => { - await expect(honcho.session('')).rejects.toThrow(); - }); - - it('should throw error for non-string session ID', async () => { - await expect(honcho.session(null as any)).rejects.toThrow(); - await expect(honcho.session(undefined as any)).rejects.toThrow(); - await expect(honcho.session(123 as any)).rejects.toThrow(); - }); - }); - - describe('getSessions', () => { - it('should return a Page of Session instances', async () => { - const mockSessionsData = { - items: [ - { id: 'session1', metadata: {} }, - { id: 'session2', metadata: {} }, - ], - total: 2, - size: 2, - hasNextPage: () => false, - }; - mockClient.workspaces.sessions.list.mockResolvedValue(mockSessionsData); - - const sessionsPage = await honcho.getSessions(); - - expect(sessionsPage).toBeInstanceOf(Page); - expect(mockClient.workspaces.sessions.list).toHaveBeenCalledWith('test-workspace', { filters: undefined }); - }); - - it('should handle empty sessions list', async () => { - const mockSessionsData = { - items: [], - total: 0, - size: 0, - hasNextPage: () => false, - }; - mockClient.workspaces.sessions.list.mockResolvedValue(mockSessionsData); - - const sessionsPage = await honcho.getSessions(); - - expect(sessionsPage).toBeInstanceOf(Page); - }); - - it('should handle API errors', async () => { - mockClient.workspaces.sessions.list.mockRejectedValue(new Error('API Error')); - - await expect(honcho.getSessions()).rejects.toThrow(); - }); - }); - - describe('getMetadata', () => { - it('should return workspace metadata', async () => { - const mockWorkspace = { - id: 'test-workspace', - metadata: { key: 'value', setting: 'config' }, - }; - mockClient.workspaces.getOrCreate.mockResolvedValue(mockWorkspace); - - const metadata = await honcho.getMetadata(); - - expect(metadata).toEqual({ key: 'value', setting: 'config' }); - expect(mockClient.workspaces.getOrCreate).toHaveBeenCalledWith({ id: 'test-workspace' }); - }); - - it('should return empty object when no metadata exists', async () => { - const mockWorkspace = { - id: 'test-workspace', - metadata: null, - }; - mockClient.workspaces.getOrCreate.mockResolvedValue(mockWorkspace); - - const metadata = await honcho.getMetadata(); - - expect(metadata).toEqual({}); - }); - - it('should handle API errors', async () => { - mockClient.workspaces.getOrCreate.mockRejectedValue(new Error('Workspace not found')); - - await expect(honcho.getMetadata()).rejects.toThrow(); - }); - }); - - describe('setMetadata', () => { - it('should update workspace metadata', async () => { - const metadata = { newKey: 'newValue', updated: true }; - mockClient.workspaces.update.mockResolvedValue({}); - - await honcho.setMetadata(metadata); - - expect(mockClient.workspaces.update).toHaveBeenCalledWith('test-workspace', { metadata }); - }); - - it('should handle empty metadata object', async () => { - mockClient.workspaces.update.mockResolvedValue({}); - - await honcho.setMetadata({}); - - expect(mockClient.workspaces.update).toHaveBeenCalledWith('test-workspace', { metadata: {} }); - }); - - it('should handle complex metadata objects', async () => { - const complexMetadata = { - nested: { object: { with: 'values' } }, - array: [1, 2, 3], - boolean: true, - number: 42, - string: 'test', - }; - mockClient.workspaces.update.mockResolvedValue({}); - - await honcho.setMetadata(complexMetadata); - - expect(mockClient.workspaces.update).toHaveBeenCalledWith('test-workspace', { metadata: complexMetadata }); - }); - - it('should handle API errors', async () => { - mockClient.workspaces.update.mockRejectedValue(new Error('Update failed')); - - await expect(honcho.setMetadata({ key: 'value' })).rejects.toThrow(); - }); - }); - - describe('getWorkspaces', () => { - it('should return array of workspace IDs', async () => { - const mockWorkspacesPage = { - [Symbol.asyncIterator]: async function* () { - yield { id: 'workspace1' }; - yield { id: 'workspace2' }; - yield { id: 'workspace3' }; - }, - }; - mockClient.workspaces.list.mockResolvedValue(mockWorkspacesPage); - - const workspaces = await honcho.getWorkspaces(); - - expect(workspaces).toEqual(['workspace1', 'workspace2', 'workspace3']); - expect(mockClient.workspaces.list).toHaveBeenCalled(); - }); - - it('should handle empty workspaces list', async () => { - const mockWorkspacesPage = { - [Symbol.asyncIterator]: async function* () { - // Empty iterator - }, - }; - mockClient.workspaces.list.mockResolvedValue(mockWorkspacesPage); - - const workspaces = await honcho.getWorkspaces(); - - expect(workspaces).toEqual([]); - }); - - it('should handle API errors', async () => { - mockClient.workspaces.list.mockRejectedValue(new Error('Failed to list workspaces')); - - await expect(honcho.getWorkspaces()).rejects.toThrow(); - }); - }); - - describe('search', () => { - it('should search for messages and return Page', async () => { - const mockSearchResults = [ - { id: 'msg1', content: 'Hello world', peer_id: 'peer1' }, - { id: 'msg2', content: 'Hello there', peer_id: 'peer2' }, - ]; - mockClient.workspaces.search.mockResolvedValue(mockSearchResults); - - const results = await honcho.search('hello'); - - expect(Array.isArray(results)).toBe(true); - expect(mockClient.workspaces.search).toHaveBeenCalledWith('test-workspace', { query: 'hello', limit: undefined }); - }); - - it('should handle empty search results', async () => { - const mockSearchResults: any[] = []; - mockClient.workspaces.search.mockResolvedValue(mockSearchResults); - - const results = await honcho.search('nonexistent'); - - expect(Array.isArray(results)).toBe(true); - }); - - it('should throw error for empty query', async () => { - await expect(honcho.search('')).rejects.toThrow(); - await expect(honcho.search(' ')).rejects.toThrow(); - }); - - it('should throw error for non-string query', async () => { - await expect(honcho.search(null as any)).rejects.toThrow(); - await expect(honcho.search(undefined as any)).rejects.toThrow(); - await expect(honcho.search(123 as any)).rejects.toThrow(); - }); - - it('should handle complex search queries', async () => { - const mockSearchResults: any[] = []; - mockClient.workspaces.search.mockResolvedValue(mockSearchResults); - - const complexQuery = 'complex query with "quotes" and special characters!@#$%'; - await honcho.search(complexQuery); - - expect(mockClient.workspaces.search).toHaveBeenCalledWith('test-workspace', { query: complexQuery, limit: undefined }); - }); - - it('should handle API errors', async () => { - mockClient.workspaces.search.mockRejectedValue(new Error('Search failed')); - - await expect(honcho.search('test')).rejects.toThrow(); - }); - }); - - describe('getQueueStatus', () => { - it('should return queue status without options', async () => { - const mockStatus = { - total_work_units: 10, - completed_work_units: 5, - in_progress_work_units: 3, - pending_work_units: 2, - sessions: { 'session1': { status: 'active' } }, - }; - mockClient.workspaces.queue.status.mockResolvedValue(mockStatus); - - const status = await honcho.getQueueStatus(); - - expect(status).toEqual({ - totalWorkUnits: 10, - completedWorkUnits: 5, - inProgressWorkUnits: 3, - pendingWorkUnits: 2, - sessions: { 'session1': { status: 'active' } }, - }); - expect(mockClient.workspaces.queue.status).toHaveBeenCalledWith( - 'test-workspace', - {} - ); - }); - - it('should return queue status with options', async () => { - const mockStatus = { - total_work_units: 5, - completed_work_units: 3, - in_progress_work_units: 1, - pending_work_units: 1, - }; - mockClient.workspaces.queue.status.mockResolvedValue(mockStatus); - - const status = await honcho.getQueueStatus({ - observer: 'observer1', - sender: 'sender1', - session: 'session1', - }); - - expect(status).toEqual({ - totalWorkUnits: 5, - completedWorkUnits: 3, - inProgressWorkUnits: 1, - pendingWorkUnits: 1, - sessions: undefined, - }); - expect(mockClient.workspaces.queue.status).toHaveBeenCalledWith( - 'test-workspace', - { - observer_id: 'observer1', - sender_id: 'sender1', - session_id: 'session1', - } - ); - }); - }); - - describe('pollQueueStatus', () => { - it('should poll until processing is complete', async () => { - const mockStatusComplete = { - total_work_units: 5, - completed_work_units: 5, - in_progress_work_units: 0, - pending_work_units: 0, - }; - mockClient.workspaces.queue.status.mockResolvedValue(mockStatusComplete); - - const status = await honcho.pollQueueStatus(); - - expect(status).toEqual({ - totalWorkUnits: 5, - completedWorkUnits: 5, - inProgressWorkUnits: 0, - pendingWorkUnits: 0, - sessions: undefined, - }); - }); - - it('should timeout if processing takes too long', async () => { - const mockStatusPending = { - total_work_units: 5, - completed_work_units: 2, - in_progress_work_units: 2, - pending_work_units: 1, - }; - mockClient.workspaces.queue.status.mockResolvedValue(mockStatusPending); - - await expect(honcho.pollQueueStatus({ timeoutMs: 0 })).rejects.toThrow(); - }); - }); - - describe('updateMessage', () => { - beforeEach(() => { - mockClient.workspaces.sessions = { - messages: { - update: jest.fn(), - }, - }; - }); - - it('should update message metadata using Message object', async () => { - const mockMessage: Message = { - id: 'msg-123', - session_id: 'session-456', - content: 'Test message', - peer_id: 'peer-789', - created_at: '2024-01-01T00:00:00Z', - token_count: 10, - workspace_id: 'test-workspace', - }; - const metadata = { updated: true, importance: 'high' }; - const mockUpdatedMessage = { ...mockMessage, metadata }; - - mockClient.workspaces.sessions.messages.update.mockResolvedValue(mockUpdatedMessage); - - const result = await honcho.updateMessage(mockMessage, metadata); - - expect(result).toEqual(mockUpdatedMessage); - expect(mockClient.workspaces.sessions.messages.update).toHaveBeenCalledWith( - 'test-workspace', - 'session-456', - 'msg-123', - { metadata } - ); - }); - - it('should update message metadata using message ID and session ID', async () => { - const messageId = 'msg-123'; - const sessionId = 'session-456'; - const metadata = { updated: true, importance: 'high' }; - const mockUpdatedMessage = { - id: messageId, - session_id: sessionId, - content: 'Test message', - peer_id: 'peer-789', - metadata, - }; - - mockClient.workspaces.sessions.messages.update.mockResolvedValue(mockUpdatedMessage); - - const result = await honcho.updateMessage(messageId, metadata, sessionId); - - expect(result).toEqual(mockUpdatedMessage); - expect(mockClient.workspaces.sessions.messages.update).toHaveBeenCalledWith( - 'test-workspace', - sessionId, - messageId, - { metadata } - ); - }); - - it('should throw error when message is string ID but session ID is not provided', async () => { - const messageId = 'msg-123'; - const metadata = { updated: true }; - - await expect(honcho.updateMessage(messageId, metadata)).rejects.toThrow( - 'session is required when message is a string ID' - ); - }); - - it('should handle API errors', async () => { - const mockMessage: Message = { - id: 'msg-123', - session_id: 'session-456', - content: 'Test message', - peer_id: 'peer-789', - created_at: '2024-01-01T00:00:00Z', - token_count: 10, - workspace_id: 'test-workspace', - }; - const metadata = { updated: true }; - - mockClient.workspaces.sessions.messages.update.mockRejectedValue( - new Error('Update failed') - ); - - await expect(honcho.updateMessage(mockMessage, metadata)).rejects.toThrow('Update failed'); - }); - }); -}); + expect(localClient.baseURL).toBe('http://localhost:8000') + }) + }) +}) diff --git a/sdks/typescript/__tests__/conclusions.test.ts b/sdks/typescript/__tests__/conclusions.test.ts new file mode 100644 index 00000000..68051b13 --- /dev/null +++ b/sdks/typescript/__tests__/conclusions.test.ts @@ -0,0 +1,426 @@ +/** + * Conclusions Tests + * + * Tests for Conclusion operations via ConclusionScope. + * + * Endpoints covered: + * - POST /v3/workspaces/:workspaceId/conclusions (create conclusions) + * - POST /v3/workspaces/:workspaceId/conclusions/list (list conclusions) + * - POST /v3/workspaces/:workspaceId/conclusions/query (semantic search) + * - DELETE /v3/workspaces/:workspaceId/conclusions/:conclusionId (delete conclusion) + */ + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { Honcho, Conclusion, ConclusionScope } from '../src' +import { createTestClient, requireServer } from './setup' +import { assertConclusionShape } from './helpers' + +describe('Conclusions', () => { + let client: Honcho + let cleanup: () => Promise + + beforeAll(async () => { + await requireServer() + const setup = await createTestClient('conclusions') + client = setup.client + cleanup = setup.cleanup + }) + + afterAll(async () => { + await cleanup() + }) + + // =========================================================================== + // ConclusionScope Access + // =========================================================================== + + describe('ConclusionScope access', () => { + test('peer.conclusions returns self-scope', async () => { + const peer = await client.peer('self-scope-peer') + + const scope = peer.conclusions + + expect(scope).toBeInstanceOf(ConclusionScope) + expect(scope.observer).toBe(peer.id) + expect(scope.observed).toBe(peer.id) + expect(scope.workspaceId).toBe(client.workspaceId) + }) + + test('peer.conclusionsOf returns target scope', async () => { + const observer = await client.peer('observer-peer') + const target = await client.peer('target-peer') + + const scope = observer.conclusionsOf(target) + + expect(scope.observer).toBe(observer.id) + expect(scope.observed).toBe(target.id) + }) + + test('conclusionsOf with string ID', async () => { + const peer = await client.peer('string-target-peer') + + const scope = peer.conclusionsOf('some-target-id') + + expect(scope.observed).toBe('some-target-id') + }) + }) + + // =========================================================================== + // Conclusion Creation (POST /conclusions) + // =========================================================================== + + describe('POST /conclusions (create)', () => { + test('create single conclusion', async () => { + // Pass metadata to ensure peer is created on server + const peer = await client.peer('create-single-conclusion-peer', { metadata: {} }) + const session = await client.session('create-single-conclusion-session', { metadata: {} }) + + const conclusions = await peer.conclusions.create({ + content: 'User prefers dark mode', + sessionId: session.id, + }) + + expect(conclusions.length).toBe(1) + expect(conclusions[0]).toBeInstanceOf(Conclusion) + expect(conclusions[0].content).toBe('User prefers dark mode') + expect(conclusions[0].observerId).toBe(peer.id) + expect(conclusions[0].observedId).toBe(peer.id) + }) + + test('create multiple conclusions', async () => { + const peer = await client.peer('create-multi-conclusion-peer', { metadata: {} }) + const session = await client.session('create-multi-conclusion-session', { metadata: {} }) + + const conclusions = await peer.conclusions.create([ + { content: 'Likes TypeScript', sessionId: session }, + { content: 'Uses VS Code', sessionId: session.id }, + { content: 'Prefers tabs over spaces', sessionId: session }, + ]) + + expect(conclusions.length).toBe(3) + expect(conclusions[0].content).toBe('Likes TypeScript') + expect(conclusions[1].content).toBe('Uses VS Code') + expect(conclusions[2].content).toBe('Prefers tabs over spaces') + }) + + test('create conclusion for target peer', async () => { + const observer = await client.peer('conclusion-observer', { metadata: {} }) + const observed = await client.peer('conclusion-observed', { metadata: {} }) + const session = await client.session('conclusion-target-session', { metadata: {} }) + + const scope = observer.conclusionsOf(observed) + const conclusions = await scope.create({ + content: 'Observed peer likes coffee', + sessionId: session, + }) + + expect(conclusions[0].observerId).toBe(observer.id) + expect(conclusions[0].observedId).toBe(observed.id) + }) + }) + + // =========================================================================== + // Conclusion Listing (POST /conclusions/list) + // =========================================================================== + + describe('POST /conclusions/list', () => { + test('list returns conclusions in scope', async () => { + const peer = await client.peer('list-conclusion-peer', { metadata: {} }) + const session = await client.session('list-conclusion-session', { metadata: {} }) + + // Create some conclusions first + await peer.conclusions.create([ + { content: 'Conclusion A', sessionId: session }, + { content: 'Conclusion B', sessionId: session }, + ]) + + const page = await peer.conclusions.list() + const conclusions = page.items + + expect(conclusions.length).toBeGreaterThanOrEqual(2) + // All should be self-conclusions + for (const c of conclusions) { + expect(c.observerId).toBe(peer.id) + expect(c.observedId).toBe(peer.id) + } + }) + + test('list with pagination', async () => { + const peer = await client.peer('paginate-conclusion-peer', { metadata: {} }) + const session = await client.session('paginate-conclusion-session', { metadata: {} }) + + // Create several conclusions + await peer.conclusions.create( + Array.from({ length: 5 }, (_, i) => ({ + content: `Paginated conclusion ${i + 1}`, + sessionId: session, + })) + ) + + // Get with small page size using options object + const page1 = await peer.conclusions.list({ page: 1, size: 2 }) + + expect(page1.items.length).toBeLessThanOrEqual(2) + }) + + test('list scoped to session', async () => { + const peer = await client.peer('session-scope-conclusion-peer', { metadata: {} }) + const session1 = await client.session('conclusion-session-1', { metadata: {} }) + const session2 = await client.session('conclusion-session-2', { metadata: {} }) + + await peer.conclusions.create([ + { content: 'Session 1 conclusion', sessionId: session1 }, + { content: 'Session 2 conclusion', sessionId: session2 }, + ]) + + const page = await peer.conclusions.list({ page: 1, size: 50, session: session1 }) + const conclusions = page.items + + // All results should be from session1 + for (const c of conclusions) { + expect(c.sessionId).toBe(session1.id) + } + }) + + test('list for target peer scope', async () => { + const observer = await client.peer('list-target-observer', { metadata: {} }) + const target = await client.peer('list-target-target', { metadata: {} }) + const session = await client.session('list-target-session', { metadata: {} }) + + await observer.conclusionsOf(target).create({ + content: 'About the target', + sessionId: session, + }) + + const page = await observer.conclusionsOf(target).list() + const conclusions = page.items + + expect(conclusions.length).toBeGreaterThanOrEqual(1) + for (const c of conclusions) { + expect(c.observerId).toBe(observer.id) + expect(c.observedId).toBe(target.id) + } + }) + }) + + // =========================================================================== + // Conclusion Query (POST /conclusions/query) + // =========================================================================== + + describe('POST /conclusions/query', () => { + test('query returns semantically similar conclusions', async () => { + const peer = await client.peer('query-conclusion-peer', { metadata: {} }) + const session = await client.session('query-conclusion-session', { metadata: {} }) + + // Create conclusions with distinct topics + await peer.conclusions.create([ + { content: 'User enjoys programming in Python', sessionId: session }, + { content: 'User likes hiking in mountains', sessionId: session }, + { content: 'User prefers tea over coffee', sessionId: session }, + ]) + + const results = await peer.conclusions.query('programming languages') + + expect(Array.isArray(results)).toBe(true) + // Results should be semantically relevant to the query + }) + + test('query with topK limit', async () => { + const peer = await client.peer('topk-conclusion-peer', { metadata: {} }) + const session = await client.session('topk-conclusion-session', { metadata: {} }) + + await peer.conclusions.create( + Array.from({ length: 10 }, (_, i) => ({ + content: `Conclusion about topic ${i}`, + sessionId: session, + })) + ) + + const results = await peer.conclusions.query('topic', 3) + + expect(results.length).toBeLessThanOrEqual(3) + }) + + test('query with distance threshold', async () => { + const peer = await client.peer('distance-conclusion-peer', { metadata: {} }) + const session = await client.session('distance-conclusion-session', { metadata: {} }) + + await peer.conclusions.create({ + content: 'Very specific unique content xyz123', + sessionId: session, + }) + + const results = await peer.conclusions.query( + 'specific unique xyz123', + 10, + 0.5 // Strict distance threshold + ) + + expect(Array.isArray(results)).toBe(true) + }) + + test('query scoped to target peer', async () => { + const observer = await client.peer('query-target-observer', { metadata: {} }) + const target = await client.peer('query-target-target', { metadata: {} }) + const session = await client.session('query-target-session', { metadata: {} }) + + await observer.conclusionsOf(target).create({ + content: 'Target likes machine learning', + sessionId: session, + }) + + const results = await observer.conclusionsOf(target).query('ML AI') + + for (const c of results) { + expect(c.observerId).toBe(observer.id) + expect(c.observedId).toBe(target.id) + } + }) + }) + + // =========================================================================== + // Conclusion Deletion (DELETE /conclusions/:id) + // =========================================================================== + + describe('DELETE /conclusions/:id', () => { + test('delete removes conclusion', async () => { + const peer = await client.peer('delete-conclusion-peer', { metadata: {} }) + const session = await client.session('delete-conclusion-session', { metadata: {} }) + + const [conclusion] = await peer.conclusions.create({ + content: 'To be deleted', + sessionId: session, + }) + + // Delete it + await peer.conclusions.delete(conclusion.id) + + // Should not appear in list + const page = await peer.conclusions.list() + const ids = page.items.map((c) => c.id) + expect(ids).not.toContain(conclusion.id) + }) + }) + + // =========================================================================== + // Representation from Scope + // =========================================================================== + + describe('representation from scope', () => { + test('returns representation for self-scope', async () => { + const peer = await client.peer('repr-self-scope-peer', { metadata: {} }) + const session = await client.session('repr-self-scope-session', { metadata: {} }) + + await peer.conclusions.create({ + content: 'User is a software engineer', + sessionId: session, + }) + + const representation = await peer.conclusions.representation() + + expect(typeof representation).toBe('string') + }) + + test('returns representation for target scope', async () => { + const observer = await client.peer('repr-target-scope-observer', { metadata: {} }) + const target = await client.peer('repr-target-scope-target', { metadata: {} }) + const session = await client.session('repr-target-scope-session', { metadata: {} }) + + await observer.conclusionsOf(target).create({ + content: 'Target is friendly', + sessionId: session, + }) + + const representation = await observer + .conclusionsOf(target) + .representation() + + expect(typeof representation).toBe('string') + }) + + test('representation with options', async () => { + const peer = await client.peer('repr-options-scope-peer', { metadata: {} }) + + const representation = await peer.conclusions.representation({ + searchQuery: 'preferences', + searchTopK: 5, + maxConclusions: 20, + }) + + expect(typeof representation).toBe('string') + }) + }) + + // =========================================================================== + // Conclusion Class + // =========================================================================== + + describe('Conclusion class', () => { + test('fromApiResponse creates instance', () => { + const response = { + id: 'test-id', + content: 'Test content', + observer_id: 'observer', + observed_id: 'observed', + session_id: 'session', + created_at: '2024-01-15T10:00:00Z', + } + + const conclusion = Conclusion.fromApiResponse(response) + + expect(conclusion.id).toBe('test-id') + expect(conclusion.content).toBe('Test content') + expect(conclusion.observerId).toBe('observer') + expect(conclusion.observedId).toBe('observed') + expect(conclusion.sessionId).toBe('session') + expect(conclusion.createdAt).toBe('2024-01-15T10:00:00Z') + }) + + test('toString returns readable format', async () => { + const peer = await client.peer('tostring-conclusion-peer', { metadata: {} }) + const session = await client.session('tostring-conclusion-session', { metadata: {} }) + + const [conclusion] = await peer.conclusions.create({ + content: 'A longer conclusion that should be truncated in toString', + sessionId: session, + }) + + const str = conclusion.toString() + + expect(str).toContain('Conclusion') + expect(str).toContain(conclusion.id) + }) + + test('toString truncates long content', () => { + const conclusion = new Conclusion( + 'id', + 'A'.repeat(100), + 'observer', + 'observed', + 'session', + '2024-01-01' + ) + + const str = conclusion.toString() + + expect(str).toContain('...') + expect(str.length).toBeLessThan(100) + }) + }) + + // =========================================================================== + // ConclusionScope toString + // =========================================================================== + + describe('ConclusionScope toString', () => { + test('returns readable format', async () => { + const peer = await client.peer('scope-tostring-peer') + + const str = peer.conclusions.toString() + + expect(str).toContain('ConclusionScope') + expect(str).toContain(peer.id) + expect(str).toContain(client.workspaceId) + }) + }) +}) diff --git a/sdks/typescript/__tests__/errors.test.ts b/sdks/typescript/__tests__/errors.test.ts new file mode 100644 index 00000000..581d4b99 --- /dev/null +++ b/sdks/typescript/__tests__/errors.test.ts @@ -0,0 +1,326 @@ +/** + * Error Handling Tests + * + * Tests for SDK error handling and error types. + * + * This file is split into: + * - Unit tests: Test error classes directly (no server required) + * - Integration tests: Test error scenarios against live server + */ + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { Honcho, Peer } from '../src' +import { + HonchoError, + BadRequestError, + AuthenticationError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + ServerError, + TimeoutError, + ConnectionError, + createErrorFromResponse, +} from '../src/http/errors' +import { + PeerIdSchema, + SessionIdSchema, + SearchQuerySchema, + LimitSchema, +} from '../src/validation' +import { createTestClient, requireServer, TEST_CONFIG } from './setup' + +// ============================================================================= +// Unit Tests (no server required) +// ============================================================================= + +describe('Error types (unit)', () => { + test('HonchoError is base class', () => { + const error = new HonchoError('Test error', 500) + + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(HonchoError) + expect(error.message).toBe('Test error') + expect(error.status).toBe(500) + expect(error.name).toBe('HonchoError') + }) + + test('BadRequestError has status 400', () => { + const error = new BadRequestError('Invalid input', { field: 'name' }) + + expect(error).toBeInstanceOf(HonchoError) + expect(error.status).toBe(400) + expect(error.code).toBe('bad_request') + expect(error.body).toEqual({ field: 'name' }) + }) + + test('AuthenticationError has status 401', () => { + const error = new AuthenticationError('Invalid token') + + expect(error).toBeInstanceOf(HonchoError) + expect(error.status).toBe(401) + expect(error.code).toBe('authentication_error') + }) + + test('PermissionDeniedError has status 403', () => { + const error = new PermissionDeniedError('Access denied') + + expect(error).toBeInstanceOf(HonchoError) + expect(error.status).toBe(403) + expect(error.code).toBe('permission_denied') + }) + + test('NotFoundError has status 404', () => { + const error = new NotFoundError('Workspace not found') + + expect(error).toBeInstanceOf(HonchoError) + expect(error.status).toBe(404) + expect(error.code).toBe('not_found') + }) + + test('RateLimitError has status 429', () => { + const error = new RateLimitError('Too many requests', 5000) + + expect(error).toBeInstanceOf(HonchoError) + expect(error.status).toBe(429) + expect(error.code).toBe('rate_limit_exceeded') + expect(error.retryAfter).toBe(5000) + }) + + test('ServerError has status 5xx', () => { + const error = new ServerError('Internal error', 503) + + expect(error).toBeInstanceOf(HonchoError) + expect(error.status).toBe(503) + expect(error.code).toBe('server_error') + }) + + test('TimeoutError has status 0', () => { + const error = new TimeoutError('Request timed out') + + expect(error).toBeInstanceOf(HonchoError) + expect(error.status).toBe(0) + expect(error.code).toBe('timeout') + }) + + test('ConnectionError has status 0', () => { + const error = new ConnectionError('Network error') + + expect(error).toBeInstanceOf(HonchoError) + expect(error.status).toBe(0) + expect(error.code).toBe('connection_error') + }) +}) + +describe('createErrorFromResponse (unit)', () => { + test('400 creates BadRequestError', () => { + const error = createErrorFromResponse(400, 'Bad request', { field: 'x' }) + + expect(error).toBeInstanceOf(BadRequestError) + expect(error.body).toEqual({ field: 'x' }) + }) + + test('401 creates AuthenticationError', () => { + const error = createErrorFromResponse(401, 'Unauthorized') + + expect(error).toBeInstanceOf(AuthenticationError) + }) + + test('403 creates PermissionDeniedError', () => { + const error = createErrorFromResponse(403, 'Forbidden') + + expect(error).toBeInstanceOf(PermissionDeniedError) + }) + + test('404 creates NotFoundError', () => { + const error = createErrorFromResponse(404, 'Not found') + + expect(error).toBeInstanceOf(NotFoundError) + }) + + test('429 creates RateLimitError with retryAfter', () => { + const error = createErrorFromResponse(429, 'Rate limited', {}, 3000) + + expect(error).toBeInstanceOf(RateLimitError) + expect((error as RateLimitError).retryAfter).toBe(3000) + }) + + test('500 creates ServerError', () => { + const error = createErrorFromResponse(500, 'Server error') + + expect(error).toBeInstanceOf(ServerError) + }) + + test('503 creates ServerError', () => { + const error = createErrorFromResponse(503, 'Service unavailable') + + expect(error).toBeInstanceOf(ServerError) + expect(error.status).toBe(503) + }) + + test('unknown status creates HonchoError', () => { + const error = createErrorFromResponse(418, "I'm a teapot") + + expect(error).toBeInstanceOf(HonchoError) + expect(error.status).toBe(418) + }) +}) + +describe('Error messages (unit)', () => { + test('HonchoError includes status in output', () => { + const error = new HonchoError('Something went wrong', 500) + + expect(error.message).toBe('Something went wrong') + expect(error.status).toBe(500) + }) + + test('BadRequestError preserves body', () => { + const body = { + detail: [ + { loc: ['body', 'name'], msg: 'field required', type: 'value_error' }, + ], + } + const error = new BadRequestError('Validation failed', body) + + expect(error.body).toEqual(body) + }) + + test('default error messages are sensible', () => { + expect(new AuthenticationError().message).toBe('Authentication failed') + expect(new PermissionDeniedError().message).toBe('Permission denied') + expect(new NotFoundError().message).toBe('Resource not found') + expect(new RateLimitError().message).toBe('Rate limit exceeded') + expect(new ServerError().message).toBe('Server error') + expect(new TimeoutError().message).toBe('Request timed out') + expect(new ConnectionError().message).toBe('Connection failed') + }) + + test('instanceof checks work correctly', () => { + const validation = new BadRequestError('test') + const auth = new AuthenticationError('test') + const notFound = new NotFoundError('test') + + // All are HonchoError + expect(validation instanceof HonchoError).toBe(true) + expect(auth instanceof HonchoError).toBe(true) + expect(notFound instanceof HonchoError).toBe(true) + + // But distinct types + expect(validation instanceof AuthenticationError).toBe(false) + expect(auth instanceof NotFoundError).toBe(false) + }) +}) + +describe('Client-side validation (unit)', () => { + // Test Zod schemas directly to avoid Honcho constructor side effects + + test('empty peer ID throws', () => { + expect(() => PeerIdSchema.parse('')).toThrow() + }) + + test('valid peer ID passes', () => { + expect(PeerIdSchema.parse('valid-peer')).toBe('valid-peer') + }) + + test('empty session ID throws', () => { + expect(() => SessionIdSchema.parse('')).toThrow() + }) + + test('valid session ID passes', () => { + expect(SessionIdSchema.parse('valid-session')).toBe('valid-session') + }) + + test('empty search query throws', () => { + expect(() => SearchQuerySchema.parse('')).toThrow() + }) + + test('valid search query passes', () => { + expect(SearchQuerySchema.parse('find this')).toBe('find this') + }) + + test('limit of 0 throws', () => { + expect(() => LimitSchema.parse(0)).toThrow() + }) + + test('limit over 100 throws', () => { + expect(() => LimitSchema.parse(101)).toThrow() + }) + + test('valid limit passes', () => { + expect(LimitSchema.parse(50)).toBe(50) + }) + + test('peer card with empty string throws', async () => { + // Create a peer directly without HTTP client to test validation + const peer = new Peer('test-peer', 'workspace', {} as never) + + // Zod validation requires non-empty string (PeerIdSchema) + await expect(peer.card('')).rejects.toThrow() + }) + + test('peer card with invalid target type throws', async () => { + const peer = new Peer('test-peer', 'workspace', {} as never) + + // Zod throws on invalid type + await expect(peer.card(123 as never)).rejects.toThrow() + }) +}) + +describe('Connection scenarios (unit)', () => { + test('unreachable server throws error', async () => { + // Note: Creating Honcho fires a background request, so we test + // the HTTP client directly to avoid side effects + const { HonchoHTTPClient } = await import('../src/http/client') + + const httpClient = new HonchoHTTPClient({ + baseURL: 'http://localhost:59999', + timeout: 1000, + maxRetries: 0, + }) + + await expect(httpClient.get('/test')).rejects.toThrow() + }) +}) + +// ============================================================================= +// Integration Tests (require server) +// ============================================================================= + +describe('Error Handling (integration)', () => { + let client: Honcho + let cleanup: () => Promise + + beforeAll(async () => { + await requireServer() + const setup = await createTestClient('errors') + client = setup.client + cleanup = setup.cleanup + }) + + afterAll(async () => { + await cleanup() + }) + + test('invalid API key returns 401 (if auth enabled)', async () => { + const badClient = new Honcho({ + baseURL: TEST_CONFIG.baseURL, + apiKey: 'invalid-key-12345', + workspaceId: 'test-bad-auth', + }) + + try { + await badClient.getMetadata() + // If we get here, auth is disabled on server - that's OK + } catch (error) { + if (error instanceof AuthenticationError) { + expect(error.status).toBe(401) + } + } + }) + + test('operations work with valid client', async () => { + // Basic sanity check that the client works + const metadata = await client.getMetadata() + expect(typeof metadata).toBe('object') + }) +}) diff --git a/sdks/typescript/__tests__/helpers.ts b/sdks/typescript/__tests__/helpers.ts new file mode 100644 index 00000000..ed66078a --- /dev/null +++ b/sdks/typescript/__tests__/helpers.ts @@ -0,0 +1,223 @@ +/** + * Test Helpers + * + * Shared utilities for test assertions and common patterns. + */ + +import { expect } from 'bun:test' +import type { Message } from '../src/message' +import type { + PeerResponse, + SessionResponse, + WorkspaceResponse, + ConclusionResponse, + PageResponse, +} from '../src/types/api' + +// ============================================================================= +// Response Shape Assertions +// ============================================================================= + +/** + * Assert that a response matches the WorkspaceResponse schema. + */ +export function assertWorkspaceShape(workspace: WorkspaceResponse): void { + expect(workspace).toBeDefined() + expect(typeof workspace.id).toBe('string') + expect(workspace.id.length).toBeGreaterThan(0) + 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() +} + +/** + * Assert that a response matches the PeerResponse schema. + */ +export function assertPeerShape(peer: PeerResponse): void { + expect(peer).toBeDefined() + expect(typeof peer.id).toBe('string') + expect(peer.id.length).toBeGreaterThan(0) + expect(typeof peer.workspace_id).toBe('string') + 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() +} + +/** + * Assert that a response matches the SessionResponse schema. + */ +export function assertSessionShape(session: SessionResponse): void { + expect(session).toBeDefined() + expect(typeof session.id).toBe('string') + expect(session.id.length).toBeGreaterThan(0) + expect(typeof session.workspace_id).toBe('string') + expect(typeof session.is_active).toBe('boolean') + 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() +} + +/** + * Assert that a response matches the Message schema. + */ +export function assertMessageShape(message: Message): void { + expect(message).toBeDefined() + expect(typeof message.id).toBe('string') + expect(message.id.length).toBeGreaterThan(0) + expect(typeof message.content).toBe('string') + expect(typeof message.peerId).toBe('string') + expect(typeof message.sessionId).toBe('string') + expect(typeof message.workspaceId).toBe('string') + expect(typeof message.metadata).toBe('object') + expect(typeof message.createdAt).toBe('string') + expect(typeof message.tokenCount).toBe('number') + expect(message.tokenCount).toBeGreaterThanOrEqual(0) + expect(() => new Date(message.createdAt)).not.toThrow() +} + +/** + * Assert that a response matches the ConclusionResponse schema. + */ +export function assertConclusionShape(conclusion: ConclusionResponse): void { + expect(conclusion).toBeDefined() + expect(typeof conclusion.id).toBe('string') + expect(conclusion.id.length).toBeGreaterThan(0) + expect(typeof conclusion.content).toBe('string') + expect(typeof conclusion.observer_id).toBe('string') + 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() +} + +/** + * Assert that a response matches the PageResponse schema. + */ +export function assertPageShape( + page: PageResponse, + itemAssertion?: (item: T) => void +): void { + expect(page).toBeDefined() + expect(Array.isArray(page.items)).toBe(true) + expect(typeof page.page).toBe('number') + expect(page.page).toBeGreaterThanOrEqual(1) + expect(typeof page.size).toBe('number') + expect(page.size).toBeGreaterThan(0) + expect(typeof page.total).toBe('number') + expect(page.total).toBeGreaterThanOrEqual(0) + expect(typeof page.pages).toBe('number') + expect(page.pages).toBeGreaterThanOrEqual(0) + + if (itemAssertion) { + for (const item of page.items) { + itemAssertion(item) + } + } +} + +// ============================================================================= +// Test Data Generators +// ============================================================================= + +/** + * Generate test message content with optional index. + */ +export function testMessage(index?: number): string { + const suffix = index !== undefined ? ` #${index}` : '' + return `Test message content${suffix} - ${Date.now()}` +} + +/** + * Generate test metadata. + */ +export function testMetadata(extra?: Record): Record { + return { + test: true, + timestamp: Date.now(), + ...extra, + } +} + +// ============================================================================= +// Async Helpers +// ============================================================================= + +/** + * Wait for a condition to be true, with timeout. + */ +export async function waitFor( + condition: () => boolean | Promise, + options: { timeout?: number; interval?: number } = {} +): Promise { + const { timeout = 10000, interval = 100 } = options + const start = Date.now() + + while (Date.now() - start < timeout) { + if (await condition()) { + return + } + await new Promise((resolve) => setTimeout(resolve, interval)) + } + + throw new Error(`waitFor timed out after ${timeout}ms`) +} + +/** + * Collect all items from an async iterable (for pagination testing). + */ +export async function collectAll(iterable: AsyncIterable): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} + +/** + * Collect streaming chunks into a single string. + */ +export async function collectStream( + stream: AsyncIterable +): Promise { + const chunks: string[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + return chunks.join('') +} + +// ============================================================================= +// Error Assertion Helpers +// ============================================================================= + +/** + * Assert that an async function throws a specific error type. + */ +export async function expectError( + fn: () => Promise, + errorType: new (...args: unknown[]) => E, + messageMatch?: string | RegExp +): Promise { + try { + await fn() + throw new Error(`Expected ${errorType.name} to be thrown`) + } catch (error) { + if (!(error instanceof errorType)) { + throw new Error( + `Expected ${errorType.name} but got ${error instanceof Error ? error.constructor.name : typeof error}` + ) + } + if (messageMatch) { + if (typeof messageMatch === 'string') { + expect(error.message).toContain(messageMatch) + } else { + expect(error.message).toMatch(messageMatch) + } + } + return error + } +} diff --git a/sdks/typescript/__tests__/http-client.test.ts b/sdks/typescript/__tests__/http-client.test.ts new file mode 100644 index 00000000..06d80230 --- /dev/null +++ b/sdks/typescript/__tests__/http-client.test.ts @@ -0,0 +1,1381 @@ +/** + * HTTP Client Unit Tests + * + * Tests for the standalone HTTP client with: + * - Constructor configuration + * - URL and header building + * - Retry logic with exponential backoff + * - Timeout handling + * - Error response parsing + * - Streaming support + * + * These tests mock fetch to verify client behavior without a server. + */ + +import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test' +import { HonchoHTTPClient } from '../src/http/client' +import { + HonchoError, + BadRequestError, + AuthenticationError, + NotFoundError, + RateLimitError, + ServerError, + TimeoutError, + ConnectionError, +} from '../src/http/errors' + +// ============================================================================= +// Test Utilities +// ============================================================================= + +/** + * Create a mock Response object + */ +function mockResponse( + body: unknown, + options: { + status?: number + ok?: boolean + headers?: Record + } = {} +): Response { + const { status = 200, ok = status >= 200 && status < 300, headers = {} } = options + + const responseHeaders = new Headers(headers) + const bodyString = typeof body === 'string' ? body : JSON.stringify(body) + + return new Response(bodyString, { + status, + headers: responseHeaders, + }) +} + +/** + * Create a mock Response that throws on text()/json() + */ +function mockErrorResponse( + status: number, + errorBody: unknown, + headers: Record = {} +): Response { + return mockResponse(errorBody, { status, headers }) +} + +// ============================================================================= +// Constructor Tests +// ============================================================================= + +describe('HonchoHTTPClient constructor', () => { + test('normalizes baseURL by removing trailing slash', () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com/', + }) + + expect(client.baseURL).toBe('https://api.example.com') + }) + + test('preserves baseURL without trailing slash', () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + }) + + expect(client.baseURL).toBe('https://api.example.com') + }) + + test('uses default timeout of 60000ms', () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + }) + + expect(client.timeout).toBe(60000) + }) + + test('uses custom timeout when provided', () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + timeout: 30000, + }) + + expect(client.timeout).toBe(30000) + }) + + test('uses default maxRetries of 2', () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + }) + + expect(client.maxRetries).toBe(2) + }) + + test('uses custom maxRetries when provided', () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 5, + }) + + expect(client.maxRetries).toBe(5) + }) + + test('sets default Content-Type header', () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + }) + + expect(client.defaultHeaders['Content-Type']).toBe('application/json') + }) + + test('merges custom default headers', () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + defaultHeaders: { + 'X-Custom-Header': 'custom-value', + }, + }) + + expect(client.defaultHeaders['Content-Type']).toBe('application/json') + expect(client.defaultHeaders['X-Custom-Header']).toBe('custom-value') + }) + + test('custom headers can override default Content-Type', () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + defaultHeaders: { + 'Content-Type': 'text/plain', + }, + }) + + expect(client.defaultHeaders['Content-Type']).toBe('text/plain') + }) + + test('stores apiKey', () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + apiKey: 'test-api-key', + }) + + expect(client.apiKey).toBe('test-api-key') + }) +}) + +// ============================================================================= +// URL Building Tests +// ============================================================================= + +describe('URL building', () => { + let client: HonchoHTTPClient + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, // Disable retries for URL tests + }) + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('builds URL from path', async () => { + let capturedURL = '' + globalThis.fetch = async (url) => { + capturedURL = url.toString() + return mockResponse({ ok: true }) + } + + await client.get('/v1/test') + + expect(capturedURL).toBe('https://api.example.com/v1/test') + }) + + test('adds query parameters', async () => { + let capturedURL = '' + globalThis.fetch = async (url) => { + capturedURL = url.toString() + return mockResponse({ ok: true }) + } + + await client.get('/v1/test', { + query: { page: 1, limit: 10 }, + }) + + const url = new URL(capturedURL) + expect(url.searchParams.get('page')).toBe('1') + expect(url.searchParams.get('limit')).toBe('10') + }) + + test('handles boolean query parameters', async () => { + let capturedURL = '' + globalThis.fetch = async (url) => { + capturedURL = url.toString() + return mockResponse({ ok: true }) + } + + await client.get('/v1/test', { + query: { active: true, deleted: false }, + }) + + const url = new URL(capturedURL) + expect(url.searchParams.get('active')).toBe('true') + expect(url.searchParams.get('deleted')).toBe('false') + }) + + test('omits undefined query parameters', async () => { + let capturedURL = '' + globalThis.fetch = async (url) => { + capturedURL = url.toString() + return mockResponse({ ok: true }) + } + + await client.get('/v1/test', { + query: { present: 'value', missing: undefined }, + }) + + const url = new URL(capturedURL) + expect(url.searchParams.get('present')).toBe('value') + expect(url.searchParams.has('missing')).toBe(false) + }) +}) + +// ============================================================================= +// Header Building Tests +// ============================================================================= + +describe('Header building', () => { + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('includes default headers', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + + let capturedHeaders: Headers | undefined + globalThis.fetch = async (_, init) => { + capturedHeaders = new Headers(init?.headers as HeadersInit) + return mockResponse({ ok: true }) + } + + await client.get('/test') + + expect(capturedHeaders?.get('Content-Type')).toBe('application/json') + }) + + test('includes Authorization header when apiKey provided', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + apiKey: 'secret-key', + maxRetries: 0, + }) + + let capturedHeaders: Headers | undefined + globalThis.fetch = async (_, init) => { + capturedHeaders = new Headers(init?.headers as HeadersInit) + return mockResponse({ ok: true }) + } + + await client.get('/test') + + expect(capturedHeaders?.get('Authorization')).toBe('Bearer secret-key') + }) + + test('merges request-specific headers', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + + let capturedHeaders: Headers | undefined + globalThis.fetch = async (_, init) => { + capturedHeaders = new Headers(init?.headers as HeadersInit) + return mockResponse({ ok: true }) + } + + await client.get('/test', { + headers: { 'X-Request-ID': 'abc123' }, + }) + + expect(capturedHeaders?.get('Content-Type')).toBe('application/json') + expect(capturedHeaders?.get('X-Request-ID')).toBe('abc123') + }) + + test('request headers override default headers', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + defaultHeaders: { 'X-Custom': 'default' }, + maxRetries: 0, + }) + + let capturedHeaders: Headers | undefined + globalThis.fetch = async (_, init) => { + capturedHeaders = new Headers(init?.headers as HeadersInit) + return mockResponse({ ok: true }) + } + + await client.get('/test', { + headers: { 'X-Custom': 'override' }, + }) + + expect(capturedHeaders?.get('X-Custom')).toBe('override') + }) +}) + +// ============================================================================= +// HTTP Methods Tests +// ============================================================================= + +describe('HTTP methods', () => { + let client: HonchoHTTPClient + let originalFetch: typeof globalThis.fetch + let capturedMethod: string + + beforeEach(() => { + client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + originalFetch = globalThis.fetch + capturedMethod = '' + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('get() uses GET method', async () => { + globalThis.fetch = async (_, init) => { + capturedMethod = init?.method || '' + return mockResponse({ data: 'test' }) + } + + await client.get('/test') + + expect(capturedMethod).toBe('GET') + }) + + test('post() uses POST method', async () => { + globalThis.fetch = async (_, init) => { + capturedMethod = init?.method || '' + return mockResponse({ data: 'test' }) + } + + await client.post('/test', { body: { key: 'value' } }) + + expect(capturedMethod).toBe('POST') + }) + + test('put() uses PUT method', async () => { + globalThis.fetch = async (_, init) => { + capturedMethod = init?.method || '' + return mockResponse({ data: 'test' }) + } + + await client.put('/test', { body: { key: 'value' } }) + + expect(capturedMethod).toBe('PUT') + }) + + test('patch() uses PATCH method', async () => { + globalThis.fetch = async (_, init) => { + capturedMethod = init?.method || '' + return mockResponse({ data: 'test' }) + } + + await client.patch('/test', { body: { key: 'value' } }) + + expect(capturedMethod).toBe('PATCH') + }) + + test('delete() uses DELETE method', async () => { + globalThis.fetch = async (_, init) => { + capturedMethod = init?.method || '' + return mockResponse({ data: 'test' }) + } + + await client.delete('/test') + + expect(capturedMethod).toBe('DELETE') + }) + + test('post() serializes body as JSON', async () => { + let capturedBody: string | undefined + globalThis.fetch = async (_, init) => { + capturedBody = init?.body as string + return mockResponse({ ok: true }) + } + + await client.post('/test', { body: { name: 'test', count: 42 } }) + + expect(capturedBody).toBe('{"name":"test","count":42}') + }) +}) + +// ============================================================================= +// Response Handling Tests +// ============================================================================= + +describe('Response handling', () => { + let client: HonchoHTTPClient + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('parses JSON response body', async () => { + globalThis.fetch = async () => mockResponse({ result: 'success', count: 42 }) + + const data = await client.get<{ result: string; count: number }>('/test') + + expect(data.result).toBe('success') + expect(data.count).toBe(42) + }) + + test('handles empty response body', async () => { + globalThis.fetch = async () => mockResponse('') + + const data = await client.delete('/test') + + expect(data).toBeUndefined() + }) + + test('handles null response body', async () => { + globalThis.fetch = async () => new Response(null, { status: 204 }) + + const data = await client.delete('/test') + + expect(data).toBeUndefined() + }) +}) + +// ============================================================================= +// Error Response Tests +// ============================================================================= + +describe('Error responses', () => { + let client: HonchoHTTPClient + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, // Disable retries to test error handling directly + }) + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('400 throws BadRequestError', async () => { + globalThis.fetch = async () => + mockErrorResponse(400, { detail: 'Invalid input' }) + + await expect(client.get('/test')).rejects.toBeInstanceOf(BadRequestError) + }) + + test('401 throws AuthenticationError', async () => { + globalThis.fetch = async () => + mockErrorResponse(401, { detail: 'Invalid token' }) + + await expect(client.get('/test')).rejects.toBeInstanceOf(AuthenticationError) + }) + + test('404 throws NotFoundError', async () => { + globalThis.fetch = async () => + mockErrorResponse(404, { detail: 'Resource not found' }) + + await expect(client.get('/test')).rejects.toBeInstanceOf(NotFoundError) + }) + + test('429 throws RateLimitError', async () => { + globalThis.fetch = async () => + mockErrorResponse(429, { detail: 'Too many requests' }) + + await expect(client.get('/test')).rejects.toBeInstanceOf(RateLimitError) + }) + + test('500 throws ServerError', async () => { + globalThis.fetch = async () => + mockErrorResponse(500, { detail: 'Internal server error' }) + + await expect(client.get('/test')).rejects.toBeInstanceOf(ServerError) + }) + + test('error includes message from response body', async () => { + globalThis.fetch = async () => + mockErrorResponse(400, { detail: 'Name is required' }) + + try { + await client.get('/test') + throw new Error('Should have thrown') + } catch (error) { + expect(error).toBeInstanceOf(BadRequestError) + expect((error as BadRequestError).message).toBe('Name is required') + } + }) + + test('handles non-JSON error response', async () => { + globalThis.fetch = async () => + new Response('Internal Server Error', { + status: 500, + headers: { 'Content-Type': 'text/plain' }, + }) + + try { + await client.get('/test') + throw new Error('Should have thrown') + } catch (error) { + expect(error).toBeInstanceOf(ServerError) + // Falls back to HTTP status message + expect((error as ServerError).message).toBe('HTTP 500') + } + }) +}) + +// ============================================================================= +// Retry Logic Tests +// ============================================================================= + +describe('Retry logic', () => { + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('retries on 429 status code', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 2, + }) + + let attempts = 0 + globalThis.fetch = async () => { + attempts++ + if (attempts < 3) { + return mockErrorResponse(429, { detail: 'Rate limited' }) + } + return mockResponse({ success: true }) + } + + const result = await client.get<{ success: boolean }>('/test') + + expect(attempts).toBe(3) // Initial + 2 retries + expect(result.success).toBe(true) + }) + + test('retries on 500 status code', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 2, + }) + + let attempts = 0 + globalThis.fetch = async () => { + attempts++ + if (attempts < 3) { + return mockErrorResponse(500, { detail: 'Server error' }) + } + return mockResponse({ success: true }) + } + + const result = await client.get<{ success: boolean }>('/test') + + expect(attempts).toBe(3) + expect(result.success).toBe(true) + }) + + test('retries on 502 status code', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 1, + }) + + let attempts = 0 + globalThis.fetch = async () => { + attempts++ + if (attempts < 2) { + return mockErrorResponse(502, { detail: 'Bad gateway' }) + } + return mockResponse({ success: true }) + } + + const result = await client.get<{ success: boolean }>('/test') + + expect(attempts).toBe(2) + expect(result.success).toBe(true) + }) + + test('retries on 503 status code', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 1, + }) + + let attempts = 0 + globalThis.fetch = async () => { + attempts++ + if (attempts < 2) { + return mockErrorResponse(503, { detail: 'Service unavailable' }) + } + return mockResponse({ success: true }) + } + + const result = await client.get<{ success: boolean }>('/test') + + expect(attempts).toBe(2) + expect(result.success).toBe(true) + }) + + test('retries on 504 status code', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 1, + }) + + let attempts = 0 + globalThis.fetch = async () => { + attempts++ + if (attempts < 2) { + return mockErrorResponse(504, { detail: 'Gateway timeout' }) + } + return mockResponse({ success: true }) + } + + const result = await client.get<{ success: boolean }>('/test') + + expect(attempts).toBe(2) + expect(result.success).toBe(true) + }) + + test('does not retry on 400 status code', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 2, + }) + + let attempts = 0 + globalThis.fetch = async () => { + attempts++ + return mockErrorResponse(400, { detail: 'Bad request' }) + } + + await expect(client.get('/test')).rejects.toBeInstanceOf(BadRequestError) + expect(attempts).toBe(1) // No retries + }) + + test('does not retry on 401 status code', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 2, + }) + + let attempts = 0 + globalThis.fetch = async () => { + attempts++ + return mockErrorResponse(401, { detail: 'Unauthorized' }) + } + + await expect(client.get('/test')).rejects.toBeInstanceOf(AuthenticationError) + expect(attempts).toBe(1) + }) + + test('does not retry on 404 status code', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 2, + }) + + let attempts = 0 + globalThis.fetch = async () => { + attempts++ + return mockErrorResponse(404, { detail: 'Not found' }) + } + + await expect(client.get('/test')).rejects.toBeInstanceOf(NotFoundError) + expect(attempts).toBe(1) + }) + + test('throws after exhausting retries', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 2, + }) + + let attempts = 0 + globalThis.fetch = async () => { + attempts++ + return mockErrorResponse(500, { detail: 'Server error' }) + } + + await expect(client.get('/test')).rejects.toBeInstanceOf(ServerError) + expect(attempts).toBe(3) // Initial + 2 retries + }) + + test('respects maxRetries = 0', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + + let attempts = 0 + globalThis.fetch = async () => { + attempts++ + return mockErrorResponse(500, { detail: 'Server error' }) + } + + await expect(client.get('/test')).rejects.toBeInstanceOf(ServerError) + expect(attempts).toBe(1) // No retries + }) +}) + +// ============================================================================= +// Retry-After Header Tests +// ============================================================================= + +describe('Retry-After header parsing', () => { + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('parses Retry-After as seconds', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + + globalThis.fetch = async () => + mockErrorResponse(429, { detail: 'Rate limited' }, { 'Retry-After': '5' }) + + try { + await client.get('/test') + } catch (error) { + expect(error).toBeInstanceOf(RateLimitError) + expect((error as RateLimitError).retryAfter).toBe(5000) // Converted to ms + } + }) + + test('includes retryAfter in RateLimitError', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + + globalThis.fetch = async () => + mockErrorResponse(429, { detail: 'Rate limited' }, { 'Retry-After': '10' }) + + try { + await client.get('/test') + } catch (error) { + expect(error).toBeInstanceOf(RateLimitError) + expect((error as RateLimitError).retryAfter).toBe(10000) + } + }) +}) + +// ============================================================================= +// Exponential Backoff Tests +// ============================================================================= + +describe('Exponential backoff', () => { + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('uses exponential backoff delays', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 3, + }) + + const delays: number[] = [] + const startTimes: number[] = [] + + globalThis.fetch = async () => { + const now = Date.now() + if (startTimes.length > 0) { + delays.push(now - startTimes[startTimes.length - 1]) + } + startTimes.push(now) + return mockErrorResponse(500, { detail: 'Server error' }) + } + + await expect(client.get('/test')).rejects.toBeInstanceOf(ServerError) + + // Should have 3 delays (between 4 attempts) + expect(delays.length).toBe(3) + + // Delays should be approximately: 500ms, 1000ms, 2000ms + // Allow some tolerance for timing + expect(delays[0]).toBeGreaterThanOrEqual(400) + expect(delays[0]).toBeLessThan(700) + expect(delays[1]).toBeGreaterThanOrEqual(900) + expect(delays[1]).toBeLessThan(1200) + expect(delays[2]).toBeGreaterThanOrEqual(1800) + expect(delays[2]).toBeLessThan(2500) + }) +}) + +// ============================================================================= +// Timeout Tests +// ============================================================================= + +describe('Timeout handling', () => { + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('throws TimeoutError when request exceeds timeout', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + timeout: 100, // 100ms timeout + maxRetries: 0, + }) + + globalThis.fetch = async (_, init) => { + // Create a promise that respects the abort signal + return new Promise((resolve, reject) => { + const signal = init?.signal + + if (signal?.aborted) { + reject(new DOMException('Aborted', 'AbortError')) + return + } + + const timeoutId = setTimeout(() => { + resolve(mockResponse({ success: true })) + }, 200) + + signal?.addEventListener('abort', () => { + clearTimeout(timeoutId) + reject(new DOMException('Aborted', 'AbortError')) + }) + }) + } + + await expect(client.get('/test')).rejects.toBeInstanceOf(TimeoutError) + }) + + test('TimeoutError message includes timeout value', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + timeout: 50, + maxRetries: 0, + }) + + globalThis.fetch = async (_, init) => { + return new Promise((resolve, reject) => { + const signal = init?.signal + + if (signal?.aborted) { + reject(new DOMException('Aborted', 'AbortError')) + return + } + + const timeoutId = setTimeout(() => { + resolve(mockResponse({ success: true })) + }, 100) + + signal?.addEventListener('abort', () => { + clearTimeout(timeoutId) + reject(new DOMException('Aborted', 'AbortError')) + }) + }) + } + + try { + await client.get('/test') + } catch (error) { + expect(error).toBeInstanceOf(TimeoutError) + expect((error as TimeoutError).message).toContain('50ms') + } + }) + + test('respects per-request timeout override', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + timeout: 1000, // Default 1s + maxRetries: 0, + }) + + globalThis.fetch = async (_, init) => { + return new Promise((resolve, reject) => { + const signal = init?.signal + + if (signal?.aborted) { + reject(new DOMException('Aborted', 'AbortError')) + return + } + + const timeoutId = setTimeout(() => { + resolve(mockResponse({ success: true })) + }, 150) + + signal?.addEventListener('abort', () => { + clearTimeout(timeoutId) + reject(new DOMException('Aborted', 'AbortError')) + }) + }) + } + + // Should timeout with 100ms override + await expect(client.get('/test', { timeout: 100 })).rejects.toBeInstanceOf( + TimeoutError + ) + }) + + test('successful request within timeout', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + timeout: 1000, + maxRetries: 0, + }) + + globalThis.fetch = async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return mockResponse({ success: true }) + } + + const result = await client.get<{ success: boolean }>('/test') + expect(result.success).toBe(true) + }) + + test('retries on timeout when retries available', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + timeout: 50, + maxRetries: 2, + }) + + let attempts = 0 + globalThis.fetch = async (_, init) => { + attempts++ + + return new Promise((resolve, reject) => { + const signal = init?.signal + + if (signal?.aborted) { + reject(new DOMException('Aborted', 'AbortError')) + return + } + + if (attempts < 3) { + // First two attempts timeout + const timeoutId = setTimeout(() => { + resolve(mockResponse({ success: false })) + }, 100) + + signal?.addEventListener('abort', () => { + clearTimeout(timeoutId) + reject(new DOMException('Aborted', 'AbortError')) + }) + } else { + // Third attempt succeeds quickly + resolve(mockResponse({ success: true })) + } + }) + } + + const result = await client.get<{ success: boolean }>('/test') + + expect(attempts).toBe(3) + expect(result.success).toBe(true) + }) +}) + +// ============================================================================= +// AbortSignal Tests +// ============================================================================= + +describe('AbortSignal support', () => { + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('respects external AbortSignal', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + + const controller = new AbortController() + + globalThis.fetch = async (_, init) => { + return new Promise((resolve, reject) => { + const signal = init?.signal + + if (signal?.aborted) { + reject(new DOMException('Aborted', 'AbortError')) + return + } + + const timeoutId = setTimeout(() => { + resolve(mockResponse({ success: true })) + }, 100) + + signal?.addEventListener('abort', () => { + clearTimeout(timeoutId) + reject(new DOMException('Aborted', 'AbortError')) + }) + }) + } + + // Abort after 10ms + setTimeout(() => controller.abort(), 10) + + await expect( + client.get('/test', { signal: controller.signal }) + ).rejects.toBeInstanceOf(TimeoutError) + }) + + test('aborted requests trigger abort', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + + const controller = new AbortController() + let abortEventFired = false + + globalThis.fetch = async (_, init) => { + return new Promise((resolve, reject) => { + const signal = init?.signal + + // Check if already aborted + if (signal?.aborted) { + abortEventFired = true + reject(new DOMException('Aborted', 'AbortError')) + return + } + + // Listen for abort + signal?.addEventListener('abort', () => { + abortEventFired = true + reject(new DOMException('Aborted', 'AbortError')) + }) + + // Slow request that will be aborted + setTimeout(() => { + resolve(mockResponse({ success: true })) + }, 200) + }) + } + + // Abort after 20ms + setTimeout(() => controller.abort(), 20) + + await expect( + client.get('/test', { signal: controller.signal }) + ).rejects.toBeInstanceOf(TimeoutError) + + // Verify abort was triggered + expect(abortEventFired).toBe(true) + }) +}) + +// ============================================================================= +// Streaming Tests +// ============================================================================= + +describe('stream() method', () => { + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('returns Response object for successful stream', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + + globalThis.fetch = async () => mockResponse('data: {"chunk": 1}\n') + + const response = await client.stream('POST', '/stream') + + expect(response).toBeInstanceOf(Response) + expect(response.ok).toBe(true) + }) + + test('stream sets Accept header to text/event-stream', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + + let capturedHeaders: Headers | undefined + globalThis.fetch = async (_, init) => { + capturedHeaders = new Headers(init?.headers as HeadersInit) + return mockResponse('data: {"chunk": 1}\n') + } + + await client.stream('POST', '/stream') + + expect(capturedHeaders?.get('Accept')).toBe('text/event-stream') + }) + + test('stream throws on error response', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + + globalThis.fetch = async () => + mockErrorResponse(500, { detail: 'Stream error' }) + + await expect(client.stream('POST', '/stream')).rejects.toBeInstanceOf( + ServerError + ) + }) + + test('stream does not retry on errors', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 3, // Retries are set but shouldn't apply to stream + }) + + let attempts = 0 + globalThis.fetch = async () => { + attempts++ + return mockErrorResponse(500, { detail: 'Stream error' }) + } + + await expect(client.stream('POST', '/stream')).rejects.toBeInstanceOf( + ServerError + ) + + // stream() doesn't implement retry logic + expect(attempts).toBe(1) + }) +}) + +// ============================================================================= +// Upload Tests +// ============================================================================= + +describe('upload() method', () => { + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('sends FormData body', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + + let capturedBody: FormData | undefined + globalThis.fetch = async (_, init) => { + capturedBody = init?.body as FormData + return mockResponse({ uploaded: true }) + } + + const formData = new FormData() + formData.append('file', new Blob(['test content']), 'test.txt') + + const result = await client.upload<{ uploaded: boolean }>('/upload', formData) + + expect(result.uploaded).toBe(true) + expect(capturedBody).toBeInstanceOf(FormData) + }) + + test('does not set Content-Type header (browser sets with boundary)', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + + let capturedHeaders: Record | undefined + globalThis.fetch = async (_, init) => { + capturedHeaders = init?.headers as Record + return mockResponse({ uploaded: true }) + } + + const formData = new FormData() + formData.append('file', new Blob(['test']), 'test.txt') + + await client.upload('/upload', formData) + + // Content-Type should NOT be set (browser handles multipart boundary) + expect(capturedHeaders?.['Content-Type']).toBeUndefined() + }) + + test('includes Authorization header when apiKey provided', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + apiKey: 'upload-key', + maxRetries: 0, + }) + + let capturedHeaders: Record | undefined + globalThis.fetch = async (_, init) => { + capturedHeaders = init?.headers as Record + return mockResponse({ uploaded: true }) + } + + const formData = new FormData() + formData.append('file', new Blob(['test']), 'test.txt') + + await client.upload('/upload', formData) + + expect(capturedHeaders?.['Authorization']).toBe('Bearer upload-key') + }) + + test('upload handles empty response', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + + globalThis.fetch = async () => mockResponse('') + + const formData = new FormData() + formData.append('file', new Blob(['test']), 'test.txt') + + const result = await client.upload('/upload', formData) + + expect(result).toBeUndefined() + }) + + test('upload throws on error response', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + + globalThis.fetch = async () => + mockErrorResponse(400, { detail: 'Invalid file' }) + + const formData = new FormData() + formData.append('file', new Blob(['test']), 'test.txt') + + await expect(client.upload('/upload', formData)).rejects.toBeInstanceOf( + BadRequestError + ) + }) +}) + +// ============================================================================= +// Connection Error Tests +// ============================================================================= + +describe('Connection errors', () => { + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('network failure throws ConnectionError', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 0, + }) + + globalThis.fetch = async () => { + throw new TypeError('fetch failed: Connection refused') + } + + await expect(client.get('/test')).rejects.toBeInstanceOf(ConnectionError) + }) + + test('retries on connection error', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 2, + }) + + let attempts = 0 + globalThis.fetch = async () => { + attempts++ + if (attempts < 3) { + throw new TypeError('fetch failed: Connection refused') + } + return mockResponse({ success: true }) + } + + const result = await client.get<{ success: boolean }>('/test') + + expect(attempts).toBe(3) + expect(result.success).toBe(true) + }) + + test('throws ConnectionError after exhausting retries', async () => { + const client = new HonchoHTTPClient({ + baseURL: 'https://api.example.com', + maxRetries: 2, + }) + + let attempts = 0 + globalThis.fetch = async () => { + attempts++ + throw new TypeError('fetch failed: Network error') + } + + await expect(client.get('/test')).rejects.toBeInstanceOf(ConnectionError) + expect(attempts).toBe(3) + }) +}) diff --git a/sdks/typescript/__tests__/http-streaming.test.ts b/sdks/typescript/__tests__/http-streaming.test.ts new file mode 100644 index 00000000..92838040 --- /dev/null +++ b/sdks/typescript/__tests__/http-streaming.test.ts @@ -0,0 +1,563 @@ +/** + * HTTP Streaming Unit Tests + * + * Tests for SSE parsing and streaming response handling: + * - parseSSE function + * - DialecticStreamResponse class + * - createDialecticStream factory + * + * These tests use mocked ReadableStreams to verify behavior without a server. + */ + +import { describe, test, expect } from 'bun:test' +import { + parseSSE, + DialecticStreamResponse, + createDialecticStream, + type DialecticStreamChunk, +} from '../src/http/streaming' + +// ============================================================================= +// Test Utilities +// ============================================================================= + +/** + * Create a mock Response with a readable stream body + */ +function createMockStreamResponse(chunks: string[]): Response { + const encoder = new TextEncoder() + let chunkIndex = 0 + + const stream = new ReadableStream({ + pull(controller) { + if (chunkIndex < chunks.length) { + controller.enqueue(encoder.encode(chunks[chunkIndex])) + chunkIndex++ + } else { + controller.close() + } + }, + }) + + return new Response(stream, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) +} + +/** + * Create a Response with null body + */ +function createNullBodyResponse(): Response { + return new Response(null) +} + +/** + * Create an async generator from an array + */ +async function* arrayToAsyncGenerator(items: T[]): AsyncGenerator { + for (const item of items) { + yield item + } +} + +/** + * Collect all items from an async generator + */ +async function collectGenerator(gen: AsyncIterable): Promise { + const items: T[] = [] + for await (const item of gen) { + items.push(item) + } + return items +} + +// ============================================================================= +// parseSSE Tests +// ============================================================================= + +describe('parseSSE', () => { + test('parses single SSE event', async () => { + const response = createMockStreamResponse(['data: {"value": 1}\n\n']) + + const events = await collectGenerator(parseSSE<{ value: number }>(response)) + + expect(events).toHaveLength(1) + expect(events[0].value).toBe(1) + }) + + test('parses multiple SSE events', async () => { + const response = createMockStreamResponse([ + 'data: {"value": 1}\n', + 'data: {"value": 2}\n', + 'data: {"value": 3}\n', + ]) + + const events = await collectGenerator(parseSSE<{ value: number }>(response)) + + expect(events).toHaveLength(3) + expect(events[0].value).toBe(1) + expect(events[1].value).toBe(2) + expect(events[2].value).toBe(3) + }) + + test('handles events split across chunks', async () => { + const response = createMockStreamResponse([ + 'data: {"val', + 'ue": 42}\n', + 'data: {"value": 100}\n', + ]) + + const events = await collectGenerator(parseSSE<{ value: number }>(response)) + + expect(events).toHaveLength(2) + expect(events[0].value).toBe(42) + expect(events[1].value).toBe(100) + }) + + test('handles [DONE] signal and stops iteration', async () => { + const response = createMockStreamResponse([ + 'data: {"value": 1}\n', + 'data: [DONE]\n', + 'data: {"value": 2}\n', // Should not be yielded + ]) + + const events = await collectGenerator(parseSSE<{ value: number }>(response)) + + expect(events).toHaveLength(1) + expect(events[0].value).toBe(1) + }) + + test('handles [DONE] with whitespace', async () => { + const response = createMockStreamResponse([ + 'data: {"value": 1}\n', + 'data: [DONE] \n', + ]) + + const events = await collectGenerator(parseSSE<{ value: number }>(response)) + + expect(events).toHaveLength(1) + }) + + test('skips invalid JSON lines', async () => { + const response = createMockStreamResponse([ + 'data: {"value": 1}\n', + 'data: not valid json\n', + 'data: {"value": 3}\n', + ]) + + const events = await collectGenerator(parseSSE<{ value: number }>(response)) + + expect(events).toHaveLength(2) + expect(events[0].value).toBe(1) + expect(events[1].value).toBe(3) + }) + + test('ignores non-data lines', async () => { + const response = createMockStreamResponse([ + 'event: message\n', + 'id: 123\n', + 'data: {"value": 1}\n', + 'retry: 5000\n', + 'data: {"value": 2}\n', + ]) + + const events = await collectGenerator(parseSSE<{ value: number }>(response)) + + expect(events).toHaveLength(2) + expect(events[0].value).toBe(1) + expect(events[1].value).toBe(2) + }) + + test('handles empty chunks', async () => { + const response = createMockStreamResponse([ + '', + 'data: {"value": 1}\n', + '', + 'data: {"value": 2}\n', + '', + ]) + + const events = await collectGenerator(parseSSE<{ value: number }>(response)) + + expect(events).toHaveLength(2) + }) + + test('handles data remaining in buffer after stream ends', async () => { + const response = createMockStreamResponse([ + 'data: {"value": 1}\n', + 'data: {"value": 2}', // No trailing newline + ]) + + const events = await collectGenerator(parseSSE<{ value: number }>(response)) + + expect(events).toHaveLength(2) + expect(events[1].value).toBe(2) + }) + + test('throws on null response body', async () => { + const response = createNullBodyResponse() + + await expect(collectGenerator(parseSSE(response))).rejects.toThrow( + 'Response body is null' + ) + }) + + test('handles complex JSON objects', async () => { + const response = createMockStreamResponse([ + 'data: {"nested": {"key": "value"}, "array": [1, 2, 3]}\n', + ]) + + interface ComplexType { + nested: { key: string } + array: number[] + } + + const events = await collectGenerator(parseSSE(response)) + + expect(events).toHaveLength(1) + expect(events[0].nested.key).toBe('value') + expect(events[0].array).toEqual([1, 2, 3]) + }) + + test('handles empty events', async () => { + const response = createMockStreamResponse([ + 'data: \n', // Empty data + 'data: {"value": 1}\n', + ]) + + const events = await collectGenerator(parseSSE<{ value: number }>(response)) + + // Empty data line should be skipped (JSON.parse('') throws) + expect(events).toHaveLength(1) + expect(events[0].value).toBe(1) + }) + + test('handles multiple newlines between events', async () => { + const response = createMockStreamResponse([ + 'data: {"value": 1}\n', + '\n', + '\n', + 'data: {"value": 2}\n', + ]) + + const events = await collectGenerator(parseSSE<{ value: number }>(response)) + + expect(events).toHaveLength(2) + }) +}) + +// ============================================================================= +// DialecticStreamResponse Tests +// ============================================================================= + +describe('DialecticStreamResponse', () => { + test('iterates over string chunks', async () => { + const generator = arrayToAsyncGenerator(['Hello', ' ', 'World']) + const stream = new DialecticStreamResponse(generator) + + const chunks = await collectGenerator(stream) + + expect(chunks).toEqual(['Hello', ' ', 'World']) + }) + + test('getFinalResponse joins all chunks', async () => { + const generator = arrayToAsyncGenerator(['Hello', ' ', 'World']) + const stream = new DialecticStreamResponse(generator) + + const result = await stream.getFinalResponse() + + expect(result).toBe('Hello World') + }) + + test('toArray returns array of chunks', async () => { + const generator = arrayToAsyncGenerator(['chunk1', 'chunk2', 'chunk3']) + const stream = new DialecticStreamResponse(generator) + + const result = await stream.toArray() + + expect(result).toEqual(['chunk1', 'chunk2', 'chunk3']) + }) + + test('can re-iterate after consumption', async () => { + const generator = arrayToAsyncGenerator(['a', 'b', 'c']) + const stream = new DialecticStreamResponse(generator) + + // First iteration + const first = await collectGenerator(stream) + + // Second iteration (from cache) + const second = await collectGenerator(stream) + + expect(first).toEqual(['a', 'b', 'c']) + expect(second).toEqual(['a', 'b', 'c']) + }) + + test('getFinalResponse works before iteration', async () => { + const generator = arrayToAsyncGenerator(['one', 'two']) + const stream = new DialecticStreamResponse(generator) + + // Call getFinalResponse without iterating first + const result = await stream.getFinalResponse() + + expect(result).toBe('onetwo') + }) + + test('toArray works before iteration', async () => { + const generator = arrayToAsyncGenerator(['x', 'y', 'z']) + const stream = new DialecticStreamResponse(generator) + + const result = await stream.toArray() + + expect(result).toEqual(['x', 'y', 'z']) + }) + + test('getFinalResponse after partial iteration', async () => { + const generator = arrayToAsyncGenerator(['first', 'second', 'third']) + const stream = new DialecticStreamResponse(generator) + + // Partial iteration + const partial: string[] = [] + for await (const chunk of stream) { + partial.push(chunk) + if (partial.length === 2) break + } + + // This will return cached chunks (consumed so far) + // Note: The test depends on implementation - it may consume remaining + const result = await stream.getFinalResponse() + + expect(typeof result).toBe('string') + }) + + test('handles empty generator', async () => { + const generator = arrayToAsyncGenerator([]) + const stream = new DialecticStreamResponse(generator) + + const chunks = await collectGenerator(stream) + const final = await stream.getFinalResponse() + + expect(chunks).toEqual([]) + expect(final).toBe('') + }) + + test('handles single chunk', async () => { + const generator = arrayToAsyncGenerator(['only one']) + const stream = new DialecticStreamResponse(generator) + + const final = await stream.getFinalResponse() + + expect(final).toBe('only one') + }) + + test('iteration caches chunks for re-iteration', async () => { + const generator = arrayToAsyncGenerator(['a', 'b', 'c']) + const stream = new DialecticStreamResponse(generator) + + // Consume via iteration + await collectGenerator(stream) + + // Re-iterate should use cached chunks + const second = await stream.toArray() + + expect(second).toEqual(['a', 'b', 'c']) + }) +}) + +// ============================================================================= +// createDialecticStream Tests +// ============================================================================= + +describe('createDialecticStream', () => { + test('creates DialecticStreamResponse from SSE response', async () => { + const response = createMockStreamResponse([ + 'data: {"done": false, "delta": {"content": "Hello"}}\n', + 'data: {"done": false, "delta": {"content": " World"}}\n', + 'data: {"done": true, "delta": {}}\n', + ]) + + const stream = createDialecticStream(response) + + expect(stream).toBeInstanceOf(DialecticStreamResponse) + }) + + test('yields content from delta', async () => { + const response = createMockStreamResponse([ + 'data: {"done": false, "delta": {"content": "Hello"}}\n', + 'data: {"done": false, "delta": {"content": " World"}}\n', + 'data: {"done": true, "delta": {}}\n', + ]) + + const stream = createDialecticStream(response) + const chunks = await collectGenerator(stream) + + expect(chunks).toEqual(['Hello', ' World']) + }) + + test('stops on done: true', async () => { + const response = createMockStreamResponse([ + 'data: {"done": false, "delta": {"content": "first"}}\n', + 'data: {"done": true, "delta": {}}\n', + 'data: {"done": false, "delta": {"content": "after done"}}\n', + ]) + + const stream = createDialecticStream(response) + const chunks = await collectGenerator(stream) + + expect(chunks).toEqual(['first']) + }) + + test('skips chunks without content', async () => { + const response = createMockStreamResponse([ + 'data: {"done": false, "delta": {"content": "yes"}}\n', + 'data: {"done": false, "delta": {}}\n', + 'data: {"done": false, "delta": {"content": "also yes"}}\n', + 'data: {"done": true, "delta": {}}\n', + ]) + + const stream = createDialecticStream(response) + const chunks = await collectGenerator(stream) + + expect(chunks).toEqual(['yes', 'also yes']) + }) + + test('handles empty content strings', async () => { + const response = createMockStreamResponse([ + 'data: {"done": false, "delta": {"content": "start"}}\n', + 'data: {"done": false, "delta": {"content": ""}}\n', + 'data: {"done": false, "delta": {"content": "end"}}\n', + 'data: {"done": true, "delta": {}}\n', + ]) + + const stream = createDialecticStream(response) + const chunks = await collectGenerator(stream) + + // Empty string is falsy, so it's skipped + expect(chunks).toEqual(['start', 'end']) + }) + + test('getFinalResponse joins all content', async () => { + const response = createMockStreamResponse([ + 'data: {"done": false, "delta": {"content": "The "}}\n', + 'data: {"done": false, "delta": {"content": "answer "}}\n', + 'data: {"done": false, "delta": {"content": "is 42"}}\n', + 'data: {"done": true, "delta": {}}\n', + ]) + + const stream = createDialecticStream(response) + const result = await stream.getFinalResponse() + + expect(result).toBe('The answer is 42') + }) + + test('handles stream with only done message', async () => { + const response = createMockStreamResponse([ + 'data: {"done": true, "delta": {}}\n', + ]) + + const stream = createDialecticStream(response) + const chunks = await collectGenerator(stream) + + expect(chunks).toEqual([]) + }) + + test('handles unicode content', async () => { + const response = createMockStreamResponse([ + 'data: {"done": false, "delta": {"content": "\u4f60\u597d"}}\n', + 'data: {"done": false, "delta": {"content": " \ud83d\udc4b"}}\n', + 'data: {"done": true, "delta": {}}\n', + ]) + + const stream = createDialecticStream(response) + const result = await stream.getFinalResponse() + + expect(result).toBe('\u4f60\u597d \ud83d\udc4b') + }) + + test('handles newlines in content', async () => { + const response = createMockStreamResponse([ + 'data: {"done": false, "delta": {"content": "line1\\nline2"}}\n', + 'data: {"done": true, "delta": {}}\n', + ]) + + const stream = createDialecticStream(response) + const result = await stream.getFinalResponse() + + expect(result).toBe('line1\nline2') + }) +}) + +// ============================================================================= +// Edge Cases and Integration +// ============================================================================= + +describe('Streaming edge cases', () => { + test('parseSSE handles very long lines', async () => { + const longContent = 'x'.repeat(10000) + const response = createMockStreamResponse([ + `data: {"content": "${longContent}"}\n`, + ]) + + const events = await collectGenerator( + parseSSE<{ content: string }>(response) + ) + + expect(events).toHaveLength(1) + expect(events[0].content.length).toBe(10000) + }) + + test('parseSSE handles rapid small chunks', async () => { + // Simulate byte-by-byte delivery + const fullMessage = 'data: {"v": 1}\n' + const chunks = fullMessage.split('').map((c) => c) + + const response = createMockStreamResponse(chunks) + + const events = await collectGenerator(parseSSE<{ v: number }>(response)) + + expect(events).toHaveLength(1) + expect(events[0].v).toBe(1) + }) + + test('DialecticStreamResponse handles many chunks', async () => { + const manyChunks = Array.from({ length: 1000 }, (_, i) => `chunk${i}`) + const generator = arrayToAsyncGenerator(manyChunks) + const stream = new DialecticStreamResponse(generator) + + const result = await stream.toArray() + + expect(result.length).toBe(1000) + expect(result[0]).toBe('chunk0') + expect(result[999]).toBe('chunk999') + }) + + test('full pipeline: SSE response to final content', async () => { + // Simulate a complete dialectic streaming response + const response = createMockStreamResponse([ + 'data: {"done": false, "delta": {"content": "Based on "}}\n', + 'data: {"done": false, "delta": {"content": "the conversation, "}}\n', + 'data: {"done": false, "delta": {"content": "the user enjoys "}}\n', + 'data: {"done": false, "delta": {"content": "programming."}}\n', + 'data: {"done": true, "delta": {}}\n', + ]) + + const stream = createDialecticStream(response) + + // Test iteration + const chunks: string[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks).toEqual([ + 'Based on ', + 'the conversation, ', + 'the user enjoys ', + 'programming.', + ]) + + // Test final response (from cache) + const final = await stream.getFinalResponse() + expect(final).toBe('Based on the conversation, the user enjoys programming.') + }) +}) diff --git a/sdks/typescript/__tests__/integration.test.ts b/sdks/typescript/__tests__/integration.test.ts deleted file mode 100644 index f5d3c6e9..00000000 --- a/sdks/typescript/__tests__/integration.test.ts +++ /dev/null @@ -1,496 +0,0 @@ -import { Honcho } from '../src/client' -import { Peer } from '../src/peer' -import { Session } from '../src/session' -import { SessionContext } from '../src/session_context' -import { Page } from '../src/pagination' - -// Mock the @honcho-ai/core module -let mockWorkspacesApi: any - -jest.mock('@honcho-ai/core', () => { - return jest.fn().mockImplementation(() => mockWorkspacesApi) -}) - -describe('Honcho SDK Integration Tests', () => { - let honcho: Honcho - - beforeEach(() => { - mockWorkspacesApi = { - workspaces: { - peers: { - list: jest.fn(), - chat: jest.fn(), - sessions: { list: jest.fn() }, - messages: { create: jest.fn(), list: jest.fn() }, - getOrCreate: jest.fn(), - update: jest.fn(), - search: jest.fn(), - representation: jest.fn(), - }, - sessions: { - list: jest.fn(), - peers: { - add: jest.fn(), - set: jest.fn(), - remove: jest.fn(), - list: jest.fn(), - }, - messages: { create: jest.fn(), list: jest.fn() }, - getOrCreate: jest.fn(), - update: jest.fn(), - context: jest.fn(), - search: jest.fn(), - }, - getOrCreate: jest.fn(), - update: jest.fn(), - list: jest.fn(), - search: jest.fn(), - }, - } - - jest.clearAllMocks() - - honcho = new Honcho({ - workspaceId: 'integration-test-workspace', - apiKey: 'test-api-key', - environment: 'local', - }) - }) - - describe('Complete Workflow Integration', () => { - it('should handle complete chat session workflow', async () => { - // Setup mock responses - const mockPeerData = { id: 'assistant', metadata: { role: 'ai' } } - const mockSessionData = { - id: 'chat-session', - metadata: { topic: 'general' }, - } - const mockMessages = [ - { id: 'msg1', content: 'Hello', peer_id: 'user' }, - { id: 'msg2', content: 'Hi there!', peer_id: 'assistant' }, - ] - const mockContextData = { - messages: mockMessages, - summary: { - content: 'Friendly greeting', - message_id: 5, - summary_type: 'short', - created_at: '2024-01-01T00:00:00Z', - token_count: 50, - }, - } - - mockWorkspacesApi.workspaces.peers.getOrCreate.mockResolvedValue( - mockPeerData - ) - mockWorkspacesApi.workspaces.sessions.getOrCreate.mockResolvedValue( - mockSessionData - ) - mockWorkspacesApi.workspaces.sessions.peers.add.mockResolvedValue({}) - mockWorkspacesApi.workspaces.sessions.messages.create.mockResolvedValue( - {} - ) - mockWorkspacesApi.workspaces.sessions.context.mockResolvedValue( - mockContextData - ) - mockWorkspacesApi.workspaces.peers.chat.mockResolvedValue({ - content: 'AI response', - }) - - // Step 1: Create peers - const user = await honcho.peer('user') - const assistant = await honcho.peer('assistant') - - expect(user).toBeInstanceOf(Peer) - expect(assistant).toBeInstanceOf(Peer) - expect(user.id).toBe('user') - expect(assistant.id).toBe('assistant') - - // Step 2: Create session - const session = await honcho.session('chat-session') - expect(session).toBeInstanceOf(Session) - expect(session.id).toBe('chat-session') - - // Step 3: Add peers to session - await session.addPeers([user, assistant]) - expect( - mockWorkspacesApi.workspaces.sessions.peers.add - ).toHaveBeenCalledWith('integration-test-workspace', 'chat-session', { - user: {}, - assistant: {}, - }) - - // Step 4: Add messages to session - const userMessage = user.message('Hello') - const assistantMessage = assistant.message('Hi there!') - - await session.addMessages([userMessage, assistantMessage]) - expect( - mockWorkspacesApi.workspaces.sessions.messages.create - ).toHaveBeenCalledWith('integration-test-workspace', 'chat-session', { - messages: [ - { peer_id: 'user', content: 'Hello', metadata: undefined }, - { peer_id: 'assistant', content: 'Hi there!', metadata: undefined }, - ], - }) - - // Step 5: Get session context - const context = await session.getContext() - expect(context).toBeInstanceOf(SessionContext) - expect(context.sessionId).toBe('chat-session') - expect(context.messages).toEqual(mockMessages) - expect(context.summary?.content).toBe('Friendly greeting') - - // Step 6: Convert context to different formats - const openAIFormat = context.toOpenAI('assistant') - const anthropicFormat = context.toAnthropic('assistant') - - expect(openAIFormat).toEqual([ - { role: 'system', content: 'Friendly greeting' }, - { role: 'user', content: 'Hello', name: 'user' }, - { role: 'assistant', content: 'Hi there!', name: 'assistant' }, - ]) - - expect(anthropicFormat).toEqual([ - { role: 'user', content: 'Friendly greeting' }, - { role: 'user', content: 'user: Hello' }, - { role: 'assistant', content: 'Hi there!' }, - ]) - - // Step 7: Query assistant - const response = await assistant.chat('How are you?') - expect(response).toBe('AI response') - expect(mockWorkspacesApi.workspaces.peers.chat).toHaveBeenCalledWith( - 'integration-test-workspace', - 'assistant', - { - query: 'How are you?', - stream: false, - target: undefined, - session_id: undefined, - } - ) - }) - - it('should handle workspace and peer management workflow', async () => { - // Setup mock responses - const mockWorkspaceMetadata = { name: 'Test Workspace', version: '1.0' } - const mockPeersList = { - items: [ - { id: 'peer1', metadata: { role: 'user' } }, - { id: 'peer2', metadata: { role: 'assistant' } }, - ], - total: 2, - size: 2, - hasNextPage: () => false, - [Symbol.asyncIterator]: async function*() { - for (const item of this.items) { - yield item - } - }, - } - - mockWorkspacesApi.workspaces.getOrCreate.mockResolvedValue({ - id: 'integration-test-workspace', - metadata: mockWorkspaceMetadata, - }) - mockWorkspacesApi.workspaces.update.mockResolvedValue({}) - mockWorkspacesApi.workspaces.peers.list.mockResolvedValue(mockPeersList) - - // Step 1: Get workspace metadata - const metadata = await honcho.getMetadata() - expect(metadata).toEqual(mockWorkspaceMetadata) - - // Step 2: Update workspace metadata - const newMetadata = { ...mockWorkspaceMetadata, updated: true } - await honcho.setMetadata(newMetadata) - expect(mockWorkspacesApi.workspaces.update).toHaveBeenCalledWith( - 'integration-test-workspace', - { metadata: newMetadata } - ) - - // Step 3: Get all peers - const peersPage = await honcho.getPeers() - expect(peersPage).toBeInstanceOf(Page) - - // Step 4: Iterate through peers - const peersList: Peer[] = [] - for await (const peer of peersPage) { - peersList.push(peer) - } - - expect(peersList).toHaveLength(2) - expect(peersList[0]).toBeInstanceOf(Peer) - expect(peersList[1]).toBeInstanceOf(Peer) - expect(peersList[0].id).toBe('peer1') - expect(peersList[1].id).toBe('peer2') - }) - - it('should handle search functionality across different scopes', async () => { - // Setup mock responses - const mockWorkspaceSearchResults = [ - { id: 'msg1', content: 'workspace message', peer_id: 'peer1' }, - ] - - const mockPeerSearchResults = [ - { id: 'msg2', content: 'peer message', peer_id: 'peer1' }, - ] - - const mockSessionSearchResults = [ - { id: 'msg3', content: 'session message', peer_id: 'peer1' }, - ] - - mockWorkspacesApi.workspaces.search.mockResolvedValue( - mockWorkspaceSearchResults - ) - mockWorkspacesApi.workspaces.peers.search.mockResolvedValue( - mockPeerSearchResults - ) - mockWorkspacesApi.workspaces.sessions.search.mockResolvedValue( - mockSessionSearchResults - ) - - // Step 1: Search workspace - const workspaceResults = await honcho.search('test query') - expect(Array.isArray(workspaceResults)).toBe(true) - expect(mockWorkspacesApi.workspaces.search).toHaveBeenCalledWith( - 'integration-test-workspace', - { query: 'test query', limit: undefined } - ) - - // Step 2: Search peer - const peer = await honcho.peer('test-peer') - const peerResults = await peer.search('peer query') - expect(Array.isArray(peerResults)).toBe(true) - expect(mockWorkspacesApi.workspaces.peers.search).toHaveBeenCalledWith( - 'integration-test-workspace', - 'test-peer', - { query: 'peer query', limit: undefined } - ) - - // Step 3: Search session - const session = await honcho.session('test-session') - const sessionResults = await session.search('session query') - expect(Array.isArray(sessionResults)).toBe(true) - expect(mockWorkspacesApi.workspaces.sessions.search).toHaveBeenCalledWith( - 'integration-test-workspace', - 'test-session', - { query: 'session query', limit: undefined } - ) - }) - - it('should handle error scenarios gracefully', async () => { - // Setup error scenarios - mockWorkspacesApi.workspaces.peers.chat.mockRejectedValue( - new Error('Chat API failed') - ) - mockWorkspacesApi.workspaces.sessions.context.mockRejectedValue( - new Error('Context API failed') - ) - - const assistant = await honcho.peer('assistant') - const session = await honcho.session('error-session') - - // Test error handling in chat - await expect(assistant.chat('Hello')).rejects.toThrow() - - // Test error handling in context - await expect(session.getContext()).rejects.toThrow() - }) - - it('should handle pagination correctly', async () => { - // Setup paginated response - const firstPageData = { - items: [ - { id: 'peer1', metadata: {} }, - { id: 'peer2', metadata: {} }, - ], - total: 4, - size: 2, - page: 1, - pages: 2, - hasNextPage: () => true, - getNextPage: jest.fn(), - [Symbol.asyncIterator]: async function*() { - for (const item of this.items) { - yield item - } - }, - } - - const secondPageData = { - items: [ - { id: 'peer3', metadata: {} }, - { id: 'peer4', metadata: {} }, - ], - total: 4, - size: 2, - page: 2, - pages: 2, - hasNextPage: () => false, - getNextPage: jest.fn().mockResolvedValue(null), - [Symbol.asyncIterator]: async function*() { - for (const item of this.items) { - yield item - } - }, - } - - firstPageData.getNextPage.mockResolvedValue(secondPageData) - mockWorkspacesApi.workspaces.peers.list.mockResolvedValue(firstPageData) - - // Step 1: Get first page - const firstPage = await honcho.getPeers() - expect(firstPage.total).toBe(4) - expect(firstPage.size).toBe(2) - expect(firstPage.hasNextPage).toBe(true) - - // Step 2: Get data from first page - const firstPageData_ = firstPage.items - expect(firstPageData_).toHaveLength(2) - expect(firstPageData_[0]).toBeInstanceOf(Peer) - expect(firstPageData_[0].id).toBe('peer1') - - // Step 3: Get next page - const secondPage = await firstPage.getNextPage() - expect(secondPage).not.toBeNull() - expect(secondPage!.hasNextPage).toBe(false) - expect(secondPage!.page).toBe(2) - - // Step 4: Get data from second page - const secondPageData_ = secondPage!.items - expect(secondPageData_).toHaveLength(2) - expect(secondPageData_[0]).toBeInstanceOf(Peer) - expect(secondPageData_[0].id).toBe('peer3') - - // Step 5: Verify no more pages - const thirdPage = await secondPage!.getNextPage() - expect(thirdPage).toBeNull() - }) - - it('should handle working representation queries', async () => { - const mockRepresentation = - 'Alice likes coffee\nAlice works as a developer\nAlice is a coffee-drinking developer' - - mockWorkspacesApi.workspaces.peers.representation.mockResolvedValue({ - representation: mockRepresentation, - }) - - const session = await honcho.session('working-rep-session') - const alice = await honcho.peer('alice') - const bob = await honcho.peer('bob') - - // Test working representation without target - - const globalRep = await session.getRepresentation('alice') - expect(globalRep).toBe(mockRepresentation) - expect( - mockWorkspacesApi.workspaces.peers.representation - ).toHaveBeenCalledWith('integration-test-workspace', 'alice', { - session_id: 'working-rep-session', - target: undefined, - search_query: undefined, - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: undefined, - }) - - // Test working representation with target - const targetRep = await session.getRepresentation(alice, bob) - expect(targetRep).toBe(mockRepresentation) - expect( - mockWorkspacesApi.workspaces.peers.representation - ).toHaveBeenCalledWith('integration-test-workspace', 'alice', { - session_id: 'working-rep-session', - target: 'bob', - search_query: undefined, - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: undefined, - }) - }) - }) - - describe('Edge Cases and Error Handling Integration', () => { - it('should handle empty and null responses gracefully', async () => { - // Setup empty/null responses - mockWorkspacesApi.workspaces.peers.chat.mockResolvedValue({ - content: null, - }) - mockWorkspacesApi.workspaces.peers.list.mockResolvedValue({ - items: [], - total: 0, - hasNextPage: () => false, - }) - mockWorkspacesApi.workspaces.sessions.context.mockResolvedValue({ - messages: [], - }) - - const peer = await honcho.peer('empty-peer') - const session = await honcho.session('empty-session') - - // Test null chat response - const chatResult = await peer.chat('Hello') - expect(chatResult).toBeNull() - - // Test empty peers list - const peersPage = await honcho.getPeers() - const peersList = peersPage.items - expect(peersList).toEqual([]) - - // Test empty context - const context = await session.getContext() - expect(context.messages).toEqual([]) - expect(context.length).toBe(0) - }) - - it('should maintain type safety throughout the workflow', async () => { - // This test verifies TypeScript types are maintained correctly - const peer: Peer = await honcho.peer('typed-peer') - const session: Session = await honcho.session('typed-session') - - expect(typeof peer.id).toBe('string') - expect(typeof session.id).toBe('string') - - const message = peer.message('typed message', { - metadata: { type: 'test' }, - }) - expect(typeof message.peer_id).toBe('string') - expect(typeof message.content).toBe('string') - expect(typeof message.metadata).toBe('object') - - // Mock successful operations - mockWorkspacesApi.workspaces.sessions.context.mockResolvedValue({ - messages: [{ id: 'msg1', content: 'Hello', peer_id: 'typed-peer' }], - summary: { - content: 'Test summary', - message_id: 1, - summary_type: 'short', - created_at: '2024-01-01T00:00:00Z', - token_count: 20, - }, - }) - - const context: SessionContext = await session.getContext() - expect(typeof context.sessionId).toBe('string') - expect(Array.isArray(context.messages)).toBe(true) - expect(context.summary).not.toBeNull() - expect(typeof context.summary?.content).toBe('string') - expect(typeof context.length).toBe('number') - expect(typeof context.toString()).toBe('string') - - const openAI = context.toOpenAI(peer) - const anthropic = context.toAnthropic('assistant') - - expect(Array.isArray(openAI)).toBe(true) - expect(Array.isArray(anthropic)).toBe(true) - - if (openAI.length > 0) { - expect(typeof openAI[0].role).toBe('string') - expect(typeof openAI[0].content).toBe('string') - } - }) - }) -}) diff --git a/sdks/typescript/__tests__/messages.test.ts b/sdks/typescript/__tests__/messages.test.ts new file mode 100644 index 00000000..210c90c4 --- /dev/null +++ b/sdks/typescript/__tests__/messages.test.ts @@ -0,0 +1,406 @@ +/** + * Messages Tests + * + * Tests for Message operations. + * + * Endpoints covered: + * - POST /v3/workspaces/:workspaceId/sessions/:sessionId/messages (create messages - batch) + * - POST /v3/workspaces/:workspaceId/sessions/:sessionId/messages/list (list messages) + * - GET /v3/workspaces/:workspaceId/sessions/:sessionId/messages/:messageId (get single message) + * - PUT /v3/workspaces/:workspaceId/sessions/:sessionId/messages/:messageId (update message) + * - POST /v3/workspaces/:workspaceId/sessions/:sessionId/messages/upload (file upload) + */ + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { Honcho } from '../src' +import { createTestClient, requireServer } from './setup' +import { assertMessageShape, collectAll } from './helpers' + +describe('Messages', () => { + let client: Honcho + let cleanup: () => Promise + + beforeAll(async () => { + await requireServer() + const setup = await createTestClient('messages') + client = setup.client + cleanup = setup.cleanup + }) + + afterAll(async () => { + await cleanup() + }) + + // =========================================================================== + // Message Creation (POST /messages) + // =========================================================================== + + describe('POST /messages (create)', () => { + test('creates single message', async () => { + const session = await client.session('single-msg-session', { metadata: {} }) + const peer = await client.peer('single-msg-peer') + await session.addPeers([peer.id]) + + const messages = await session.addMessages(peer.message('Hello world')) + + expect(messages.length).toBe(1) + assertMessageShape(messages[0]) + expect(messages[0].content).toBe('Hello world') + expect(messages[0].peerId).toBe(peer.id) + expect(messages[0].sessionId).toBe(session.id) + }) + + test('creates batch of messages', async () => { + const session = await client.session('batch-msg-session', { metadata: {} }) + const peer = await client.peer('batch-msg-peer') + await session.addPeers([peer.id]) + + const messages = await session.addMessages([ + peer.message('First'), + peer.message('Second'), + peer.message('Third'), + ]) + + expect(messages.length).toBe(3) + expect(messages[0].content).toBe('First') + expect(messages[1].content).toBe('Second') + expect(messages[2].content).toBe('Third') + }) + + test('message with metadata', async () => { + const session = await client.session('meta-msg-session', { metadata: {} }) + const peer = await client.peer('meta-msg-peer') + await session.addPeers([peer.id]) + + const messages = await session.addMessages( + peer.message('With metadata', { metadata: { key: 'value', count: 42 } }) + ) + + expect(messages[0].metadata).toEqual({ key: 'value', count: 42 }) + }) + + test('message with configuration', async () => { + const session = await client.session('config-msg-session', { metadata: {} }) + const peer = await client.peer('config-msg-peer') + await session.addPeers([peer.id]) + + const messages = await session.addMessages( + peer.message('With config', { + configuration: { reasoning: { enabled: true } }, + }) + ) + + expect(messages[0]).toBeDefined() + // Configuration is used server-side, may not be returned + }) + + test('message with custom createdAt', async () => { + const session = await client.session('timestamp-msg-session', { metadata: {} }) + const peer = await client.peer('timestamp-msg-peer') + await session.addPeers([peer.id]) + + const customDate = new Date('2024-01-15T10:30:00Z') + const messages = await session.addMessages( + peer.message('Custom timestamp', { createdAt: customDate }) + ) + + // Server should use our timestamp + expect(new Date(messages[0].createdAt).toISOString()).toBe( + customDate.toISOString() + ) + }) + + test('messages from multiple peers', async () => { + const session = await client.session('multi-peer-msg-session', { metadata: {} }) + const alice = await client.peer('alice') + const bob = await client.peer('bob') + await session.addPeers([alice.id, bob.id]) + + const messages = await session.addMessages([ + alice.message('Hello from Alice'), + bob.message('Hello from Bob'), + alice.message('Nice to meet you'), + ]) + + expect(messages[0].peerId).toBe(alice.id) + expect(messages[1].peerId).toBe(bob.id) + expect(messages[2].peerId).toBe(alice.id) + }) + + test('batch up to 100 messages', async () => { + const session = await client.session('large-batch-session', { metadata: {} }) + const peer = await client.peer('large-batch-peer') + await session.addPeers([peer.id]) + + const batch = Array.from({ length: 50 }, (_, i) => + peer.message(`Message ${i + 1}`) + ) + + const messages = await session.addMessages(batch) + + expect(messages.length).toBe(50) + expect(messages[0].content).toBe('Message 1') + expect(messages[49].content).toBe('Message 50') + }) + + test('tokenCount is calculated', async () => { + const session = await client.session('token-count-session', { metadata: {} }) + const peer = await client.peer('token-count-peer') + await session.addPeers([peer.id]) + + const messages = await session.addMessages( + peer.message('This is a test message with several words') + ) + + expect(messages[0].tokenCount).toBeGreaterThan(0) + }) + }) + + // =========================================================================== + // Message Listing (POST /messages/list) + // =========================================================================== + + describe('POST /messages/list', () => { + test('returns paginated list', async () => { + const session = await client.session('list-msg-session', { metadata: {} }) + const peer = await client.peer('list-msg-peer') + await session.addPeers([peer.id]) + await session.addMessages([ + peer.message('One'), + peer.message('Two'), + peer.message('Three'), + ]) + + const page = await session.messages() + + expect(page.items.length).toBe(3) + expect(page.page).toBe(1) + expect(page.total).toBe(3) + }) + + test('pagination works', async () => { + const session = await client.session('paginate-msg-session', { metadata: {} }) + const peer = await client.peer('paginate-msg-peer') + await session.addPeers([peer.id]) + + // Create 15 messages + const batch = Array.from({ length: 15 }, (_, i) => + peer.message(`Msg ${i + 1}`) + ) + await session.addMessages(batch) + + // Get first page with small size + const page = await session.messages() + + expect(page.total).toBe(15) + }) + + test('Page is async iterable', async () => { + const session = await client.session('iter-msg-session', { metadata: {} }) + const peer = await client.peer('iter-msg-peer') + await session.addPeers([peer.id]) + await session.addMessages([ + peer.message('A'), + peer.message('B'), + peer.message('C'), + ]) + + const page = await session.messages() + const contents = await collectAll(page) + + expect(contents.map((m) => m.content)).toContain('A') + expect(contents.map((m) => m.content)).toContain('B') + expect(contents.map((m) => m.content)).toContain('C') + }) + + test('filter by peer', async () => { + const session = await client.session('filter-peer-msg-session', { metadata: {} }) + const alice = await client.peer('filter-alice') + const bob = await client.peer('filter-bob') + await session.addPeers([alice.id, bob.id]) + await session.addMessages([ + alice.message('From Alice'), + bob.message('From Bob'), + ]) + + const page = await session.messages({ peer_id: alice.id }) + + expect(page.items.length).toBe(1) + expect(page.items[0].peerId).toBe(alice.id) + }) + + test('filter by metadata', async () => { + const session = await client.session('filter-meta-msg-session', { metadata: {} }) + const peer = await client.peer('filter-meta-peer') + await session.addPeers([peer.id]) + await session.addMessages([ + peer.message('Tagged', { metadata: { category: 'important' } }), + peer.message('Untagged'), + ]) + + const page = await session.messages({ metadata: { category: 'important' } }) + + expect(page.items.length).toBe(1) + expect(page.items[0].metadata.category).toBe('important') + }) + }) + + // =========================================================================== + // Message Update (PUT /messages/:id) + // =========================================================================== + + describe('PUT /messages/:id (update)', () => { + test('updateMessage with MessageResponse', async () => { + const session = await client.session('update-msg-response-session', { metadata: {} }) + const peer = await client.peer('update-msg-response-peer') + await session.addPeers([peer.id]) + + const [message] = await session.addMessages(peer.message('Original')) + + const updated = await session.updateMessage(message, { + status: 'reviewed', + reviewedAt: Date.now(), + }) + + expect(updated.metadata.status).toBe('reviewed') + expect(updated.metadata.reviewedAt).toBeDefined() + }) + + test('updateMessage with string ID', async () => { + const session = await client.session('update-msg-string-session', { metadata: {} }) + const peer = await client.peer('update-msg-string-peer') + await session.addPeers([peer.id]) + + const [message] = await session.addMessages(peer.message('To update')) + + const updated = await session.updateMessage(message.id, { flag: true }) + + expect(updated.metadata.flag).toBe(true) + }) + + test('updateMessage replaces metadata entirely', async () => { + const session = await client.session('update-msg-replace-session', { metadata: {} }) + const peer = await client.peer('update-msg-replace-peer') + await session.addPeers([peer.id]) + + const [message] = await session.addMessages( + peer.message('With meta', { metadata: { old: 'value' } }) + ) + + const updated = await session.updateMessage(message, { new: 'value' }) + + expect(updated.metadata).toEqual({ new: 'value' }) + expect(updated.metadata.old).toBeUndefined() + }) + }) + + // =========================================================================== + // File Upload (POST /messages/upload) + // =========================================================================== + + describe('POST /messages/upload', () => { + test('upload text file creates messages', async () => { + const session = await client.session('upload-session', { metadata: {} }) + const peer = await client.peer('upload-peer') + await session.addPeers([peer.id]) + + const fileContent = 'Line 1\nLine 2\nLine 3' + const file = new Blob([fileContent], { type: 'text/plain' }) + + const messages = await session.uploadFile(file, peer) + + expect(messages.length).toBeGreaterThan(0) + assertMessageShape(messages[0]) + }) + + test('upload with metadata', async () => { + const session = await client.session('upload-meta-session', { metadata: {} }) + const peer = await client.peer('upload-meta-peer') + await session.addPeers([peer.id]) + + const file = new Blob(['Test content'], { type: 'text/plain' }) + + const messages = await session.uploadFile(file, peer, { + metadata: { source: 'upload-test' }, + }) + + expect(messages.length).toBeGreaterThan(0) + }) + + test('upload with buffer-style object', async () => { + const session = await client.session('upload-buffer-session', { metadata: {} }) + const peer = await client.peer('upload-buffer-peer') + await session.addPeers([peer.id]) + + const content = new TextEncoder().encode('Buffer content here') + + const messages = await session.uploadFile( + { + filename: 'test.txt', + content: content, + content_type: 'text/plain', + }, + peer + ) + + expect(messages.length).toBeGreaterThan(0) + }) + + test('upload with peer ID string', async () => { + const session = await client.session('upload-peer-string-session', { metadata: {} }) + await session.addPeers(['upload-string-peer']) + + const file = new Blob(['Content'], { type: 'text/plain' }) + + const messages = await session.uploadFile(file, 'upload-string-peer') + + expect(messages.length).toBeGreaterThan(0) + }) + }) + + // =========================================================================== + // Message Shape Validation + // =========================================================================== + + describe('Response shape validation', () => { + test('message has all required fields', async () => { + const session = await client.session('shape-session', { metadata: {} }) + const peer = await client.peer('shape-peer') + await session.addPeers([peer.id]) + + const [message] = await session.addMessages(peer.message('Shape test')) + + // Validate all fields exist and have correct types + expect(typeof message.id).toBe('string') + expect(message.id.length).toBeGreaterThan(0) + expect(typeof message.content).toBe('string') + expect(typeof message.peerId).toBe('string') + expect(typeof message.sessionId).toBe('string') + expect(typeof message.workspaceId).toBe('string') + expect(typeof message.metadata).toBe('object') + expect(typeof message.createdAt).toBe('string') + expect(typeof message.tokenCount).toBe('number') + + // Validate date format + expect(() => new Date(message.createdAt)).not.toThrow() + const date = new Date(message.createdAt) + expect(date.getTime()).toBeGreaterThan(0) + }) + + test('message IDs are unique', async () => { + const session = await client.session('unique-id-session', { metadata: {} }) + const peer = await client.peer('unique-id-peer') + await session.addPeers([peer.id]) + + const messages = await session.addMessages([ + peer.message('One'), + peer.message('Two'), + peer.message('Three'), + ]) + + const ids = messages.map((m) => m.id) + const uniqueIds = new Set(ids) + expect(uniqueIds.size).toBe(ids.length) + }) + }) +}) diff --git a/sdks/typescript/__tests__/metadata_caching.test.ts b/sdks/typescript/__tests__/metadata_caching.test.ts deleted file mode 100644 index fd44eb6f..00000000 --- a/sdks/typescript/__tests__/metadata_caching.test.ts +++ /dev/null @@ -1,565 +0,0 @@ -import { Honcho } from '../src/client'; -import { Peer } from '../src/peer'; -import { Session } from '../src/session'; - -// Mock the @honcho-ai/core module -jest.mock('@honcho-ai/core', () => { - return jest.fn().mockImplementation(() => ({ - workspaces: { - peers: { - list: jest.fn(), - getOrCreate: jest.fn(), - update: jest.fn(), - }, - sessions: { - list: jest.fn(), - getOrCreate: jest.fn(), - update: jest.fn(), - }, - getOrCreate: jest.fn(), - update: jest.fn(), - }, - })); -}); - -describe('Metadata and Configuration Caching', () => { - let honcho: Honcho; - let mockClient: any; - - beforeEach(() => { - jest.clearAllMocks(); - - honcho = new Honcho({ - workspaceId: 'test-workspace', - apiKey: 'test-key', - environment: 'local', - }); - - mockClient = (honcho as any)._client; - }); - - describe('Workspace Metadata Caching', () => { - it('should initialize with undefined metadata', () => { - expect(honcho.metadata).toBeUndefined(); - }); - - it('should cache metadata after getMetadata call', async () => { - const mockWorkspace = { - id: 'test-workspace', - metadata: { theme: 'dark', version: '1.0' }, - }; - mockClient.workspaces.getOrCreate.mockResolvedValue(mockWorkspace); - - const metadata = await honcho.getMetadata(); - - expect(metadata).toEqual({ theme: 'dark', version: '1.0' }); - expect(honcho.metadata).toEqual({ theme: 'dark', version: '1.0' }); - }); - - it('should cache empty object when metadata is null', async () => { - const mockWorkspace = { - id: 'test-workspace', - metadata: null, - }; - mockClient.workspaces.getOrCreate.mockResolvedValue(mockWorkspace); - - const metadata = await honcho.getMetadata(); - - expect(metadata).toEqual({}); - expect(honcho.metadata).toEqual({}); - }); - - it('should update cached metadata after setMetadata call', async () => { - mockClient.workspaces.update.mockResolvedValue({}); - - const newMetadata = { theme: 'light', version: '2.0' }; - await honcho.setMetadata(newMetadata); - - expect(honcho.metadata).toEqual(newMetadata); - }); - - it('should maintain cached value across multiple calls', async () => { - const mockWorkspace = { - id: 'test-workspace', - metadata: { count: 1 }, - }; - mockClient.workspaces.getOrCreate.mockResolvedValue(mockWorkspace); - - await honcho.getMetadata(); - expect(honcho.metadata).toEqual({ count: 1 }); - - // Update cache - mockClient.workspaces.update.mockResolvedValue({}); - await honcho.setMetadata({ count: 2 }); - expect(honcho.metadata).toEqual({ count: 2 }); - - // Verify cache persists - expect(honcho.metadata).toEqual({ count: 2 }); - }); - }); - - describe('Peer Metadata and Configuration Caching', () => { - describe('Peer Constructor with metadata/config', () => { - it('should initialize peer with provided metadata and config', async () => { - const metadata = { name: 'Test Peer', role: 'assistant' }; - const config = { observe_me: false }; - - mockClient.workspaces.peers.getOrCreate.mockResolvedValue({ - id: 'peer1', - metadata: metadata, - configuration: config, - }); - - const peer = await honcho.peer('peer1', { metadata, config }); - - expect(peer.metadata).toEqual(metadata); - expect(peer.configuration).toEqual(config); - expect(mockClient.workspaces.peers.getOrCreate).toHaveBeenCalledWith( - 'test-workspace', - { id: 'peer1', metadata, configuration: config } - ); - }); - - it('should initialize peer without metadata/config', async () => { - const peer = await honcho.peer('peer1'); - - expect(peer.metadata).toBeUndefined(); - expect(peer.configuration).toBeUndefined(); - expect(mockClient.workspaces.peers.getOrCreate).not.toHaveBeenCalled(); - }); - }); - - describe('Peer Metadata Caching', () => { - let peer: Peer; - - beforeEach(() => { - peer = new Peer('test-peer', 'test-workspace', mockClient); - }); - - it('should cache metadata after getMetadata call', async () => { - const mockPeer = { - id: 'test-peer', - metadata: { name: 'Alice', role: 'user' }, - }; - mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer); - - const metadata = await peer.getMetadata(); - - expect(metadata).toEqual({ name: 'Alice', role: 'user' }); - expect(peer.metadata).toEqual({ name: 'Alice', role: 'user' }); - }); - - it('should cache empty object when metadata is null', async () => { - const mockPeer = { - id: 'test-peer', - metadata: null, - }; - mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer); - - const metadata = await peer.getMetadata(); - - expect(metadata).toEqual({}); - expect(peer.metadata).toEqual({}); - }); - - it('should update cached metadata after setMetadata call', async () => { - mockClient.workspaces.peers.update.mockResolvedValue({}); - - const newMetadata = { name: 'Bob', role: 'admin' }; - await peer.setMetadata(newMetadata); - - expect(peer.metadata).toEqual(newMetadata); - }); - - it('should maintain cached value across operations', async () => { - mockClient.workspaces.peers.getOrCreate.mockResolvedValue({ - id: 'test-peer', - metadata: { score: 100 }, - }); - mockClient.workspaces.peers.update.mockResolvedValue({}); - - // Get initial metadata - await peer.getMetadata(); - expect(peer.metadata).toEqual({ score: 100 }); - - // Update metadata - await peer.setMetadata({ score: 200 }); - expect(peer.metadata).toEqual({ score: 200 }); - - // Verify cache persists - expect(peer.metadata).toEqual({ score: 200 }); - }); - }); - - describe('Peer Configuration Caching', () => { - let peer: Peer; - - beforeEach(() => { - peer = new Peer('test-peer', 'test-workspace', mockClient); - }); - - it('should cache configuration after getConfig call', async () => { - const mockPeer = { - id: 'test-peer', - configuration: { observe_me: true, observe_others: false }, - }; - mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer); - - const config = await peer.getConfig(); - - expect(config).toEqual({ observe_me: true, observe_others: false }); - expect(peer.configuration).toEqual({ observe_me: true, observe_others: false }); - }); - - it('should cache empty object when configuration is null', async () => { - const mockPeer = { - id: 'test-peer', - configuration: null, - }; - mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer); - - const config = await peer.getConfig(); - - expect(config).toEqual({}); - expect(peer.configuration).toEqual({}); - }); - - it('should update cached configuration after setConfig call', async () => { - mockClient.workspaces.peers.update.mockResolvedValue({}); - - const newConfig = { observe_me: false, observe_others: true }; - await peer.setConfig(newConfig); - - expect(peer.configuration).toEqual(newConfig); - }); - - it('should support deprecated getPeerConfig method', async () => { - const mockPeer = { - id: 'test-peer', - configuration: { observe_me: true }, - }; - mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer); - - const config = await peer.getPeerConfig(); - - expect(config).toEqual({ observe_me: true }); - expect(peer.configuration).toEqual({ observe_me: true }); - }); - - it('should support deprecated setPeerConfig method', async () => { - mockClient.workspaces.peers.update.mockResolvedValue({}); - - const newConfig = { observe_me: false }; - await peer.setPeerConfig(newConfig); - - expect(peer.configuration).toEqual(newConfig); - }); - }); - - describe('Peer List with Cached Data', () => { - it('should populate metadata and config when listing peers', async () => { - const mockPeersData = { - items: [ - { - id: 'peer1', - metadata: { name: 'Alice' }, - configuration: { observe_me: true }, - }, - { - id: 'peer2', - metadata: { name: 'Bob' }, - configuration: { observe_me: false }, - }, - ], - total: 2, - size: 2, - hasNextPage: () => false, - }; - mockClient.workspaces.peers.list.mockResolvedValue(mockPeersData); - - const peersPage = await honcho.getPeers(); - const peers = peersPage.items; - - expect(peers[0].metadata).toEqual({ name: 'Alice' }); - expect(peers[0].configuration).toEqual({ observe_me: true }); - expect(peers[1].metadata).toEqual({ name: 'Bob' }); - expect(peers[1].configuration).toEqual({ observe_me: false }); - }); - - it('should handle null metadata and config in peer list', async () => { - const mockPeersData = { - items: [ - { - id: 'peer1', - metadata: null, - configuration: null, - }, - ], - total: 1, - size: 1, - hasNextPage: () => false, - }; - mockClient.workspaces.peers.list.mockResolvedValue(mockPeersData); - - const peersPage = await honcho.getPeers(); - const peers = peersPage.items; - - expect(peers[0].metadata).toBeUndefined(); - expect(peers[0].configuration).toBeUndefined(); - }); - }); - }); - - describe('Session Metadata and Configuration Caching', () => { - describe('Session Constructor with metadata/config', () => { - it('should initialize session with provided metadata and config', async () => { - const metadata = { title: 'Test Session', tags: ['important'] }; - const config = { anonymous: false }; - - mockClient.workspaces.sessions.getOrCreate.mockResolvedValue({ - id: 'session1', - metadata: metadata, - configuration: config, - }); - - const session = await honcho.session('session1', { metadata, config }); - - expect(session.metadata).toEqual(metadata); - expect(session.configuration).toEqual(config); - expect(mockClient.workspaces.sessions.getOrCreate).toHaveBeenCalledWith( - 'test-workspace', - { id: 'session1', metadata, configuration: config } - ); - }); - - it('should initialize session without metadata/config', async () => { - const session = await honcho.session('session1'); - - expect(session.metadata).toBeUndefined(); - expect(session.configuration).toBeUndefined(); - expect(mockClient.workspaces.sessions.getOrCreate).not.toHaveBeenCalled(); - }); - }); - - describe('Session Metadata Caching', () => { - let session: Session; - - beforeEach(() => { - session = new Session('test-session', 'test-workspace', mockClient); - }); - - it('should cache metadata after getMetadata call', async () => { - const mockSession = { - id: 'test-session', - metadata: { title: 'Chat Session', active: true }, - }; - mockClient.workspaces.sessions.getOrCreate.mockResolvedValue(mockSession); - - const metadata = await session.getMetadata(); - - expect(metadata).toEqual({ title: 'Chat Session', active: true }); - expect(session.metadata).toEqual({ title: 'Chat Session', active: true }); - }); - - it('should cache empty object when metadata is null', async () => { - const mockSession = { - id: 'test-session', - metadata: null, - }; - mockClient.workspaces.sessions.getOrCreate.mockResolvedValue(mockSession); - - const metadata = await session.getMetadata(); - - expect(metadata).toEqual({}); - expect(session.metadata).toEqual({}); - }); - - it('should update cached metadata after setMetadata call', async () => { - mockClient.workspaces.sessions.update.mockResolvedValue({}); - - const newMetadata = { title: 'Updated Session', active: false }; - await session.setMetadata(newMetadata); - - expect(session.metadata).toEqual(newMetadata); - }); - }); - - describe('Session Configuration Caching', () => { - let session: Session; - - beforeEach(() => { - session = new Session('test-session', 'test-workspace', mockClient); - }); - - it('should cache configuration after getConfig call', async () => { - const mockSession = { - id: 'test-session', - configuration: { anonymous: true, summarize: false }, - }; - mockClient.workspaces.sessions.getOrCreate.mockResolvedValue(mockSession); - - const config = await session.getConfig(); - - expect(config).toEqual({ anonymous: true, summarize: false }); - expect(session.configuration).toEqual({ anonymous: true, summarize: false }); - }); - - it('should cache empty object when configuration is null', async () => { - const mockSession = { - id: 'test-session', - configuration: null, - }; - mockClient.workspaces.sessions.getOrCreate.mockResolvedValue(mockSession); - - const config = await session.getConfig(); - - expect(config).toEqual({}); - expect(session.configuration).toEqual({}); - }); - - it('should update cached configuration after setConfig call', async () => { - mockClient.workspaces.sessions.update.mockResolvedValue({}); - - const newConfig = { anonymous: false, summarize: true }; - await session.setConfig(newConfig); - - expect(session.configuration).toEqual(newConfig); - }); - }); - - describe('Session List with Cached Data', () => { - it('should populate metadata and config when listing sessions', async () => { - const mockSessionsData = { - items: [ - { - id: 'session1', - metadata: { title: 'Session 1' }, - configuration: { anonymous: true }, - }, - { - id: 'session2', - metadata: { title: 'Session 2' }, - configuration: { anonymous: false }, - }, - ], - total: 2, - size: 2, - hasNextPage: () => false, - }; - mockClient.workspaces.sessions.list.mockResolvedValue(mockSessionsData); - - const sessionsPage = await honcho.getSessions(); - const sessions = sessionsPage.items; - - expect(sessions[0].metadata).toEqual({ title: 'Session 1' }); - expect(sessions[0].configuration).toEqual({ anonymous: true }); - expect(sessions[1].metadata).toEqual({ title: 'Session 2' }); - expect(sessions[1].configuration).toEqual({ anonymous: false }); - }); - - it('should handle null metadata and config in session list', async () => { - const mockSessionsData = { - items: [ - { - id: 'session1', - metadata: null, - configuration: null, - }, - ], - total: 1, - size: 1, - hasNextPage: () => false, - }; - mockClient.workspaces.sessions.list.mockResolvedValue(mockSessionsData); - - const sessionsPage = await honcho.getSessions(); - const sessions = sessionsPage.items; - - expect(sessions[0].metadata).toBeUndefined(); - expect(sessions[0].configuration).toBeUndefined(); - }); - }); - }); - - describe('Integration: Combined Metadata and Configuration Operations', () => { - it('should cache both metadata and config for peers independently', async () => { - const peer = new Peer('test-peer', 'test-workspace', mockClient); - - // Set up mocks - mockClient.workspaces.peers.getOrCreate.mockResolvedValue({ - id: 'test-peer', - metadata: { name: 'Test' }, - configuration: { observe_me: true }, - }); - mockClient.workspaces.peers.update.mockResolvedValue({}); - - // Get both metadata and config - await peer.getMetadata(); - await peer.getConfig(); - - expect(peer.metadata).toEqual({ name: 'Test' }); - expect(peer.configuration).toEqual({ observe_me: true }); - - // Update metadata only - await peer.setMetadata({ name: 'Updated' }); - - expect(peer.metadata).toEqual({ name: 'Updated' }); - expect(peer.configuration).toEqual({ observe_me: true }); // Should remain unchanged - - // Update config only - await peer.setConfig({ observe_me: false }); - - expect(peer.metadata).toEqual({ name: 'Updated' }); // Should remain unchanged - expect(peer.configuration).toEqual({ observe_me: false }); - }); - - it('should cache both metadata and config for sessions independently', async () => { - const session = new Session('test-session', 'test-workspace', mockClient); - - // Set up mocks - mockClient.workspaces.sessions.getOrCreate.mockResolvedValue({ - id: 'test-session', - metadata: { title: 'Test' }, - configuration: { anonymous: true }, - }); - mockClient.workspaces.sessions.update.mockResolvedValue({}); - - // Get both metadata and config - await session.getMetadata(); - await session.getConfig(); - - expect(session.metadata).toEqual({ title: 'Test' }); - expect(session.configuration).toEqual({ anonymous: true }); - - // Update metadata only - await session.setMetadata({ title: 'Updated' }); - - expect(session.metadata).toEqual({ title: 'Updated' }); - expect(session.configuration).toEqual({ anonymous: true }); // Should remain unchanged - - // Update config only - await session.setConfig({ anonymous: false }); - - expect(session.metadata).toEqual({ title: 'Updated' }); // Should remain unchanged - expect(session.configuration).toEqual({ anonymous: false }); - }); - - it('should reduce API calls by using cached values', async () => { - const peer = new Peer('test-peer', 'test-workspace', mockClient); - - // Initial fetch - mockClient.workspaces.peers.getOrCreate.mockResolvedValue({ - id: 'test-peer', - metadata: { name: 'Test' }, - }); - - await peer.getMetadata(); - expect(mockClient.workspaces.peers.getOrCreate).toHaveBeenCalledTimes(1); - - // Access cached value directly (without API call) - const cachedMetadata = peer.metadata; - expect(cachedMetadata).toEqual({ name: 'Test' }); - expect(mockClient.workspaces.peers.getOrCreate).toHaveBeenCalledTimes(1); // Still only 1 call - }); - }); -}); diff --git a/sdks/typescript/__tests__/pagination.test.ts b/sdks/typescript/__tests__/pagination.test.ts deleted file mode 100644 index be69d6ea..00000000 --- a/sdks/typescript/__tests__/pagination.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { Page } from '../src/pagination' - -describe('Page', () => { - let mockOriginalPage: any - let mockItems: any[] - - beforeEach(() => { - mockItems = [ - { id: 'item1', name: 'Item 1' }, - { id: 'item2', name: 'Item 2' }, - { id: 'item3', name: 'Item 3' }, - ] - - mockOriginalPage = { - items: mockItems, - size: 3, - total: 10, - page: 1, - pages: 4, - hasNextPage: () => true, - [Symbol.asyncIterator]: async function*() { - for (const item of mockItems) { - yield item - } - }, - } as any - }) - - describe('constructor', () => { - it('should initialize with original page', () => { - const page = new Page(mockOriginalPage) - - expect(page['_originalPage']).toBe(mockOriginalPage) - expect(page['_transformFunc']).toBeUndefined() - }) - - it('should initialize with transform function', () => { - const transformFunc = (item: any) => ({ ...item, transformed: true }) - const page = new Page(mockOriginalPage, transformFunc) - - expect(page['_originalPage']).toBe(mockOriginalPage) - expect(page['_transformFunc']).toBe(transformFunc) - }) - }) - - describe('Symbol.asyncIterator', () => { - it('should iterate through items without transform', async () => { - const page = new Page(mockOriginalPage) - const items: any[] = [] - - for await (const item of page) { - items.push(item) - } - - expect(items).toEqual(mockItems) - }) - - it('should iterate through items with transform', async () => { - const transformFunc = (item: any) => ({ ...item, transformed: true }) - const page = new Page(mockOriginalPage, transformFunc) - const items: any[] = [] - - for await (const item of page) { - items.push(item) - } - - expect(items).toEqual([ - { id: 'item1', name: 'Item 1', transformed: true }, - { id: 'item2', name: 'Item 2', transformed: true }, - { id: 'item3', name: 'Item 3', transformed: true }, - ]) - }) - - it('should handle transform function that throws error', async () => { - const errorTransform = () => { - throw new Error('Transform error') - } - const page = new Page(mockOriginalPage, errorTransform) - - const iterate = async () => { - for await (const item of page) { - // This should throw - } - } - - await expect(iterate()).rejects.toThrow('Transform error') - }) - }) - - describe('get', () => { - it('should get item by index without transform', () => { - const page = new Page(mockOriginalPage) - - const item = page.get(1) - - expect(item).toEqual(mockItems[1]) - }) - - it('should get item by index with transform', () => { - const transformFunc = (item: any) => ({ ...item, transformed: true }) - const page = new Page(mockOriginalPage, transformFunc) - - const item = page.get(1) - - expect(item).toEqual({ ...mockItems[1], transformed: true }) - }) - - it('should handle out of bounds index', () => { - const page = new Page(mockOriginalPage) - - expect(() => page.get(999)).toThrow( - 'Index 999 is out of bounds for page with 3 items' - ) - }) - }) - - describe('length getter', () => { - it('should return length of items array', () => { - const page = new Page(mockOriginalPage) - - expect(page.length).toBe(3) - }) - - it('should handle empty items array', () => { - const emptyPage = { ...mockOriginalPage, items: [] } - const page = new Page(emptyPage) - - expect(page.length).toBe(0) - }) - - it('should handle undefined items', () => { - const noItemsPage = { ...mockOriginalPage, items: undefined } - const page = new Page(noItemsPage) - - expect(page.length).toBe(0) - }) - }) - - describe('items getter', () => { - it('should return items array without transform', () => { - const page = new Page(mockOriginalPage) - - const data = page.items - - expect(data).toEqual(mockItems) - }) - - it('should return items array with transform', () => { - const transformFunc = (item: any) => ({ ...item, transformed: true }) - const page = new Page(mockOriginalPage, transformFunc) - - const data = page.items - - expect(data).toEqual([ - { id: 'item1', name: 'Item 1', transformed: true }, - { id: 'item2', name: 'Item 2', transformed: true }, - { id: 'item3', name: 'Item 3', transformed: true }, - ]) - }) - - it('should handle transform function returning null', () => { - const nullTransform = () => null - const page = new Page(mockOriginalPage, nullTransform) - - const data = page.items - - expect(data).toEqual([null, null, null]) - }) - }) - - describe('pagination metadata getters', () => { - it('should return total from original page', () => { - const page = new Page(mockOriginalPage) - - expect(page.total).toBe(10) - }) - - it('should return page number from original page', () => { - const page = new Page(mockOriginalPage) - - expect(page.page).toBe(1) - }) - - it('should return size from original page', () => { - const page = new Page(mockOriginalPage) - - expect(page.size).toBe(3) - }) - - it('should return pages from original page', () => { - const page = new Page(mockOriginalPage) - - expect(page.pages).toBe(4) - }) - - it('should handle undefined metadata', () => { - const minimalPage = { items: mockItems } - const page = new Page(minimalPage as any) - - expect(page.total).toBeUndefined() - expect(page.page).toBeUndefined() - expect(page.size).toBeUndefined() - expect(page.pages).toBeUndefined() - }) - }) - - describe('hasNextPage getter', () => { - it('should return true when hasNextPage is true', () => { - const page = new Page(mockOriginalPage) - - expect(page.hasNextPage).toBe(true) - }) - - it('should return false when hasNextPage function returns false', () => { - const lastPage = { ...mockOriginalPage, hasNextPage: () => false } - - const page = new Page(lastPage) - - expect(page.hasNextPage).toBe(false) - }) - - it('should call hasNextPage function if it is a function', () => { - const hasNextPageFn = jest.fn(() => true) - const pageWithFn = { ...mockOriginalPage, hasNextPage: hasNextPageFn } - const page = new Page(pageWithFn) - - const result = page.hasNextPage - - expect(result).toBe(true) - expect(hasNextPageFn).toHaveBeenCalled() - }) - - it('should return false when hasNextPage is undefined', () => { - const noNextPage = { ...mockOriginalPage } - delete noNextPage.hasNextPage - const page = new Page(noNextPage) - - expect(page.hasNextPage).toBe(false) - }) - }) - - describe('getNextPage', () => { - it('should return next page with same transform function', async () => { - const nextPageData = { - items: [{ id: 'item4', name: 'Item 4' }], - size: 1, - total: 10, - page: 2, - pages: 4, - hasNextPage: () => false, - [Symbol.asyncIterator]: async function*() { - for (const item of this.items) { - yield item - } - }, - } - const transformFunc = (item: any) => ({ ...item, transformed: true }) - mockOriginalPage.getNextPage = jest.fn().mockResolvedValue(nextPageData) - const page = new Page(mockOriginalPage, transformFunc) - - const nextPage = await page.getNextPage() - - expect(nextPage).toBeInstanceOf(Page) - expect(nextPage!['_transformFunc']).toBe(transformFunc) - expect(mockOriginalPage.getNextPage).toHaveBeenCalled() - }) - - it('should return null when no next page', async () => { - mockOriginalPage.getNextPage = jest.fn().mockResolvedValue(null) - const page = new Page(mockOriginalPage) - - const nextPage = await page.getNextPage() - - expect(nextPage).toBeNull() - }) - - it('should return null when getNextPage returns undefined', async () => { - mockOriginalPage.getNextPage = jest.fn().mockResolvedValue(undefined) - const page = new Page(mockOriginalPage) - - const nextPage = await page.getNextPage() - - expect(nextPage).toBeNull() - }) - - it('should return null when getNextPage method does not exist', async () => { - const pageWithoutNext = { ...mockOriginalPage } - delete pageWithoutNext.getNextPage - const page = new Page(pageWithoutNext) - - const nextPage = await page.getNextPage() - - expect(nextPage).toBeNull() - }) - - it('should propagate transform function to next page', async () => { - const nextPageData = { - items: [{ id: 'item4', name: 'Item 4' }], - [Symbol.asyncIterator]: async function*() { - for (const item of this.items) { - yield item - } - }, - } - const transformFunc = (item: any) => ({ ...item, count: 999 }) - mockOriginalPage.getNextPage = jest.fn().mockResolvedValue(nextPageData) - const page = new Page(mockOriginalPage, transformFunc) - - const nextPage = await page.getNextPage() - - expect(nextPage).not.toBeNull() - const transformedItem = nextPage!.get(0) - expect(transformedItem).toEqual({ - id: 'item4', - name: 'Item 4', - count: 999, - }) - }) - - it('should handle error from getNextPage', async () => { - mockOriginalPage.getNextPage = jest - .fn() - .mockRejectedValue(new Error('Failed to get next page')) - const page = new Page(mockOriginalPage) - - await expect(page.getNextPage()).rejects.toThrow( - 'Failed to get next page' - ) - }) - }) -}) diff --git a/sdks/typescript/__tests__/peer.test.ts b/sdks/typescript/__tests__/peer.test.ts index ccc16f6e..9a3b3b61 100644 --- a/sdks/typescript/__tests__/peer.test.ts +++ b/sdks/typescript/__tests__/peer.test.ts @@ -1,821 +1,579 @@ -import { Peer } from '../src/peer'; -import { Session } from '../src/session'; -import { Page } from '../src/pagination'; -import { Honcho } from '../src/client'; +/** + * Peer Tests + * + * Comprehensive tests for Peer operations. + * + * Endpoints covered: + * - POST /v3/workspaces/:workspaceId/peers (get-or-create peer) + * - POST /v3/workspaces/:workspaceId/peers/list (list peers) + * - PUT /v3/workspaces/:workspaceId/peers/:peerId (update peer) + * - POST /v3/workspaces/:workspaceId/peers/:peerId/sessions (list peer sessions) + * - POST /v3/workspaces/:workspaceId/peers/:peerId/chat (dialectic chat) + * - POST /v3/workspaces/:workspaceId/peers/:peerId/representation (get representation) + * - GET /v3/workspaces/:workspaceId/peers/:peerId/card (get peer card) + * - GET /v3/workspaces/:workspaceId/peers/:peerId/context (get peer context) + * - POST /v3/workspaces/:workspaceId/peers/:peerId/search (search peer messages) + */ -// Mock the @honcho-ai/core module -jest.mock('@honcho-ai/core', () => { - return jest.fn().mockImplementation(() => ({ - workspaces: { - peers: { - chat: jest.fn(), - sessions: { - list: jest.fn(), - }, - messages: { - create: jest.fn(), - list: jest.fn(), - }, - getOrCreate: jest.fn(), - update: jest.fn(), - search: jest.fn(), - }, - getOrCreate: jest.fn().mockResolvedValue({ id: 'test-workspace', metadata: {} }), - update: jest.fn(), - list: jest.fn(), - search: jest.fn(), - }, - })); -}); +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { Honcho, Peer } from '../src' +import { createTestClient, generateId, requireServer } from './setup' +import { + assertMessageShape, + assertPeerShape, + collectStream, + testMessage, + testMetadata, +} from './helpers' describe('Peer', () => { - let honcho: Honcho; - let peer: Peer; - let mockClient: any; - - beforeEach(() => { - jest.clearAllMocks(); - - honcho = new Honcho({ - workspaceId: 'test-workspace', - apiKey: 'test-key', - environment: 'local', - }); - - peer = new Peer('test-peer', 'test-workspace', (honcho as any)._client); - mockClient = (honcho as any)._client; - }); - - describe('constructor', () => { - it('should initialize with correct properties', () => { - const newPeer = new Peer('peer-id', 'test-workspace', mockClient); - - expect(newPeer.id).toBe('peer-id'); - expect(newPeer.workspaceId).toBe('test-workspace'); - expect(newPeer['_client']).toBe(mockClient); - }); - }); - - describe('chat', () => { - it('should query peer representation and return response', async () => { - const mockResponse = { content: 'Hello, I am a peer response' }; - mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse); - - const result = await peer.chat('Hello'); - - expect(result).toBe('Hello, I am a peer response'); - expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith( - 'test-workspace', - 'test-peer', - { query: 'Hello', stream: false, target: undefined, session_id: undefined } - ); - }); - - it('should return null for None content', async () => { - const mockResponse = { content: 'None' }; - mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse); - - const result = await peer.chat('Hello'); - - expect(result).toBeNull(); - }); - - it('should return null for empty content', async () => { - const mockResponse = { content: null }; - mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse); - - const result = await peer.chat('Hello'); - - expect(result).toBeNull(); - }); - - it.skip('should handle chat with streaming option', async () => { - // Skipped: streaming now uses fetch API directly, not the mocked client - // TODO: Add proper streaming tests with fetch mocking when needed - }); - - it('should handle chat with target peer', async () => { - const targetPeer = new Peer('target-peer', 'test-workspace', mockClient); - const mockResponse = { content: 'Targeted response' }; - mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse); - - await peer.chat('Hello', { target: targetPeer }); - - expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith( - 'test-workspace', - 'test-peer', - { query: 'Hello', stream: false, target: 'target-peer', session_id: undefined } - ); - }); - - it('should handle chat with target as string', async () => { - const mockResponse = { content: 'Targeted response' }; - mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse); - - await peer.chat('Hello', { target: 'string-target' }); - - expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith( - 'test-workspace', - 'test-peer', - { query: 'Hello', stream: false, target: 'string-target', session_id: undefined } - ); - }); - - it('should handle chat with session ID', async () => { - const mockResponse = { content: 'Session-specific response' }; - mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse); - - await peer.chat('Hello', { session: 'session-123' }); - - expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith( - 'test-workspace', - 'test-peer', - { query: 'Hello', stream: false, target: undefined, session_id: 'session-123' } - ); - }); - - // TODO: Re-enable after regenerating Stainless SDK with streaming support - // it('should handle all options together', async () => { - // const targetPeer = new Peer('target-peer', 'test-workspace', mockClient); - // const mockResponse = { content: 'Full options response' }; - // mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse); - - // await peer.chat('Hello', { stream: true, target: targetPeer, sessionId: 'session-456' }); - - // expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith( - // 'test-workspace', - // 'test-peer', - // { query: 'Hello', stream: true, target: 'target-peer', session_id: 'session-456' } - // ); - // }); - - it('should handle API errors', async () => { - mockClient.workspaces.peers.chat.mockRejectedValue(new Error('Chat failed')); - - await expect(peer.chat('Hello')).rejects.toThrow(); - }); - }); - - describe('getSessions', () => { - it('should return Page of Session instances', async () => { - const mockSessionsData = { - items: [ - { id: 'session1', metadata: {} }, - { id: 'session2', metadata: {} }, - ], - total: 2, - size: 2, - hasNextPage: () => false, - }; - mockClient.workspaces.peers.sessions.list.mockResolvedValue(mockSessionsData); - - const sessionsPage = await peer.getSessions(); - - expect(sessionsPage).toBeInstanceOf(Page); - expect(mockClient.workspaces.peers.sessions.list).toHaveBeenCalledWith( - 'test-workspace', - 'test-peer', - { filters: undefined } - ); - }); - - it('should handle empty sessions list', async () => { - const mockSessionsData = { - items: [], - total: 0, - size: 0, - hasNextPage: () => false, - }; - mockClient.workspaces.peers.sessions.list.mockResolvedValue(mockSessionsData); - - const sessionsPage = await peer.getSessions(); - - expect(sessionsPage).toBeInstanceOf(Page); - }); - - it('should handle API errors', async () => { - mockClient.workspaces.peers.sessions.list.mockRejectedValue(new Error('Failed to get sessions')); - - await expect(peer.getSessions()).rejects.toThrow(); - }); - }); - - describe('message', () => { - it('should create message object without metadata', () => { - const message = peer.message('Test content'); - - expect(message).toEqual({ - peer_id: 'test-peer', - content: 'Test content', - metadata: undefined, - configuration: undefined, - created_at: undefined, - }); - }); - - it('should create message object with metadata', () => { - const metadata = { importance: 'high', category: 'greeting' }; - const message = peer.message('Hello there', { metadata }); - - expect(message).toEqual({ - peer_id: 'test-peer', - content: 'Hello there', - metadata: { importance: 'high', category: 'greeting' }, - configuration: undefined, - created_at: undefined, - }); - }); - - it('should create message object with configuration', () => { - const configuration = { deriver: { enabled: false } }; - const message = peer.message('Test content', { configuration }); - - expect(message).toEqual({ - peer_id: 'test-peer', - content: 'Test content', - metadata: undefined, - configuration: { deriver: { enabled: false } }, - created_at: undefined, - }); - }); - - it('should create message object with metadata, configuration, and timestamp', () => { - const metadata = { importance: 'high' }; - const configuration = { deriver: { enabled: false }, peer_card: { create: false } }; - const message = peer.message('Full options test', { - metadata, - configuration, - created_at: '2024-01-15T10:30:00Z', - }); - - expect(message).toEqual({ - peer_id: 'test-peer', - content: 'Full options test', - metadata: { importance: 'high' }, - configuration: { deriver: { enabled: false }, peer_card: { create: false } }, - created_at: '2024-01-15T10:30:00Z', - }); - }); - - it('should handle empty content', () => { - const message = peer.message(''); - - expect(message).toEqual({ - peer_id: 'test-peer', - content: '', - metadata: undefined, - configuration: undefined, - created_at: undefined, - }); - }); - }); - - describe('getMetadata', () => { - it('should return peer metadata', async () => { - const mockPeer = { - id: 'test-peer', - metadata: { name: 'Test Peer', role: 'assistant' }, - }; - mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer); - - const metadata = await peer.getMetadata(); - - expect(metadata).toEqual({ name: 'Test Peer', role: 'assistant' }); - expect(mockClient.workspaces.peers.getOrCreate).toHaveBeenCalledWith( - 'test-workspace', - { id: 'test-peer' } - ); - }); - - it('should return empty object when no metadata exists', async () => { - const mockPeer = { - id: 'test-peer', - metadata: null, - }; - mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer); - - const metadata = await peer.getMetadata(); - - expect(metadata).toEqual({}); - }); - - it('should handle API errors', async () => { - mockClient.workspaces.peers.getOrCreate.mockRejectedValue(new Error('Peer not found')); - - await expect(peer.getMetadata()).rejects.toThrow(); - }); - }); - - describe('setMetadata', () => { - it('should update peer metadata', async () => { - const metadata = { name: 'Updated Peer', status: 'active' }; - mockClient.workspaces.peers.update.mockResolvedValue({}); - - await peer.setMetadata(metadata); - - expect(mockClient.workspaces.peers.update).toHaveBeenCalledWith( - 'test-workspace', - 'test-peer', - { metadata } - ); - }); - - it('should handle empty metadata', async () => { - mockClient.workspaces.peers.update.mockResolvedValue({}); - - await peer.setMetadata({}); - - expect(mockClient.workspaces.peers.update).toHaveBeenCalledWith( - 'test-workspace', - 'test-peer', - { metadata: {} } - ); - }); - - it('should handle complex metadata objects', async () => { - const complexMetadata = { - profile: { name: 'Complex Peer', age: 25 }, - settings: { theme: 'dark', notifications: true }, - tags: ['ai', 'assistant', 'helpful'], - }; - mockClient.workspaces.peers.update.mockResolvedValue({}); - - await peer.setMetadata(complexMetadata); - - expect(mockClient.workspaces.peers.update).toHaveBeenCalledWith( - 'test-workspace', - 'test-peer', - { metadata: complexMetadata } - ); - }); - - it('should handle API errors', async () => { - mockClient.workspaces.peers.update.mockRejectedValue(new Error('Update failed')); - - await expect(peer.setMetadata({ key: 'value' })).rejects.toThrow(); - }); - }); - - describe('getPeerConfig', () => { - it('should return peer configuration', async () => { - const mockPeer = { - id: 'test-peer', - configuration: { observe_me: true, observe_others: false }, - }; - mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer); - - const config = await peer.getPeerConfig(); - - expect(config).toEqual({ observe_me: true, observe_others: false }); - expect(mockClient.workspaces.peers.getOrCreate).toHaveBeenCalledWith( - 'test-workspace', - { id: 'test-peer' } - ); - }); - - it('should return empty object when no configuration exists', async () => { - const mockPeer = { - id: 'test-peer', - configuration: null, - }; - mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer); - - const config = await peer.getPeerConfig(); - - expect(config).toEqual({}); - }); - }); - - describe('setPeerConfig', () => { - it('should update peer configuration', async () => { - const config = { observe_me: false, observe_others: true }; - mockClient.workspaces.peers.update.mockResolvedValue({}); - - await peer.setPeerConfig(config); - - expect(mockClient.workspaces.peers.update).toHaveBeenCalledWith( - 'test-workspace', - 'test-peer', - { configuration: config } - ); - }); - }); - - describe('search', () => { - it('should search peer messages and return array', async () => { - const mockSearchResults = [ - { id: 'msg1', content: 'Hello world', peer_id: 'test-peer' }, - { id: 'msg2', content: 'Hello there', peer_id: 'test-peer' }, - ]; - mockClient.workspaces.peers.search.mockResolvedValue(mockSearchResults); - - const results = await peer.search('hello'); - - expect(Array.isArray(results)).toBe(true); - expect(mockClient.workspaces.peers.search).toHaveBeenCalledWith( - 'test-workspace', - 'test-peer', - { query: 'hello' } - ); - }); - - it('should handle empty search results', async () => { - const mockSearchResults: any[] = []; - mockClient.workspaces.peers.search.mockResolvedValue(mockSearchResults); - - const results = await peer.search('nonexistent'); - - expect(Array.isArray(results)).toBe(true); - }); - - it('should throw error for empty query', async () => { - await expect(peer.search('')).rejects.toThrow(); - await expect(peer.search(' ')).rejects.toThrow(); - }); - - it('should throw error for non-string query', async () => { - await expect(peer.search(null as any)).rejects.toThrow(); - await expect(peer.search(undefined as any)).rejects.toThrow(); - await expect(peer.search(123 as any)).rejects.toThrow(); - }); - - it('should handle complex search queries', async () => { - const mockSearchResults: any[] = []; - mockClient.workspaces.peers.search.mockResolvedValue(mockSearchResults); - - const complexQuery = 'complex query with "quotes" and special characters!@#$%'; - await peer.search(complexQuery); - - expect(mockClient.workspaces.peers.search).toHaveBeenCalledWith( - 'test-workspace', - 'test-peer', - { query: complexQuery } - ); - }); - - it('should handle API errors', async () => { - mockClient.workspaces.peers.search.mockRejectedValue(new Error('Search failed')); - - await expect(peer.search('test')).rejects.toThrow(); - }); - }); - - describe('card', () => { - beforeEach(() => { - mockClient.workspaces.peers.card = jest.fn(); - }); - - it('should get peer card without target', async () => { - const mockCardResponse = { - peer_card: ['Fact 1 about peer', 'Fact 2 about peer', 'Fact 3 about peer'], - }; - mockClient.workspaces.peers.card.mockResolvedValue(mockCardResponse); - - const result = await peer.card(); - - expect(result).toBe('Fact 1 about peer\nFact 2 about peer\nFact 3 about peer'); - expect(mockClient.workspaces.peers.card).toHaveBeenCalledWith( - 'test-workspace', - 'test-peer', - { target: undefined } - ); - }); - - it('should get peer card with target as string', async () => { - const mockCardResponse = { - peer_card: ['What peer knows about target'], - }; - mockClient.workspaces.peers.card.mockResolvedValue(mockCardResponse); - - const result = await peer.card('target-peer'); - - expect(result).toBe('What peer knows about target'); - expect(mockClient.workspaces.peers.card).toHaveBeenCalledWith( - 'test-workspace', - 'test-peer', - { target: 'target-peer' } - ); - }); - - it('should get peer card with target as Peer object', async () => { - const targetPeer = new Peer('target-peer', 'test-workspace', mockClient); - const mockCardResponse = { - peer_card: ['What peer knows about target peer'], - }; - mockClient.workspaces.peers.card.mockResolvedValue(mockCardResponse); - - const result = await peer.card(targetPeer); - - expect(result).toBe('What peer knows about target peer'); - expect(mockClient.workspaces.peers.card).toHaveBeenCalledWith( - 'test-workspace', - 'test-peer', - { target: 'target-peer' } - ); - }); - - it('should return empty string when peer_card is null', async () => { - const mockCardResponse = { - peer_card: null, - }; - mockClient.workspaces.peers.card.mockResolvedValue(mockCardResponse); - - const result = await peer.card(); - - expect(result).toBe(''); - }); - - it('should return empty string when peer_card is undefined', async () => { - const mockCardResponse = {}; - mockClient.workspaces.peers.card.mockResolvedValue(mockCardResponse); - - const result = await peer.card(); - - expect(result).toBe(''); - }); - - it('should throw error for empty string target', async () => { - await expect(peer.card('')).rejects.toThrow('target string cannot be empty'); - await expect(peer.card(' ')).rejects.toThrow('target string cannot be empty'); - }); - - it('should throw error for invalid target type', async () => { - await expect(peer.card(123 as any)).rejects.toThrow('target must be string, Peer, or undefined'); - await expect(peer.card(null as any)).rejects.toThrow('target must be string, Peer, or undefined'); - await expect(peer.card({} as any)).rejects.toThrow('target must be string, Peer, or undefined'); - }); - - it('should handle API errors', async () => { - mockClient.workspaces.peers.card.mockRejectedValue(new Error('Card fetch failed')); - - await expect(peer.card()).rejects.toThrow('Card fetch failed'); - }); - }); - - describe('getRepresentation', () => { - beforeEach(() => { - mockClient.workspaces.peers.representation = jest.fn(); - }); - - it('should get working representation with no parameters', async () => { - const mockRepresentation = 'Observation 1\nObservation 2\nConclusion 1'; - mockClient.workspaces.peers.representation.mockResolvedValue({ - representation: mockRepresentation, - }); - - const result = await peer.getRepresentation(); - - expect(result).toBe(mockRepresentation); - expect( - mockClient.workspaces.peers.representation - ).toHaveBeenCalledWith('test-workspace', 'test-peer', { - session_id: undefined, - target: undefined, - search_query: undefined, - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: undefined, - }); - }); - - it('should get working representation with session as string', async () => { - const mockRepresentation = 'Session-scoped observation'; - mockClient.workspaces.peers.representation.mockResolvedValue({ - representation: mockRepresentation, - }); - - const result = await peer.getRepresentation('session-123'); - - expect(result).toBe(mockRepresentation); - expect( - mockClient.workspaces.peers.representation - ).toHaveBeenCalledWith('test-workspace', 'test-peer', { - session_id: 'session-123', - target: undefined, - search_query: undefined, - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: undefined, - }); - }); - - it('should get working representation with session as Session object', async () => { - const session = new Session('session-123', 'test-workspace', mockClient); - const mockRepresentation = 'Session object observation'; - mockClient.workspaces.peers.representation.mockResolvedValue({ - representation: mockRepresentation, - }); - - const result = await peer.getRepresentation(session); - - expect(result).toBe(mockRepresentation); - expect( - mockClient.workspaces.peers.representation - ).toHaveBeenCalledWith('test-workspace', 'test-peer', { - session_id: 'session-123', - target: undefined, - search_query: undefined, - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: undefined, - }); - }); - - it('should get working representation with target as string', async () => { - const mockRepresentation = "Observer's view of target"; - mockClient.workspaces.peers.representation.mockResolvedValue({ - representation: mockRepresentation, - }); - - const result = await peer.getRepresentation(undefined, 'target-peer'); - - expect(result).toBe(mockRepresentation); - expect( - mockClient.workspaces.peers.representation - ).toHaveBeenCalledWith('test-workspace', 'test-peer', { - session_id: undefined, - target: 'target-peer', - search_query: undefined, - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: undefined, - }); - }); - - it('should get working representation with target as Peer object', async () => { - const targetPeer = new Peer('target-peer', 'test-workspace', mockClient); - const mockRepresentation = "Observer's view of target peer object"; - mockClient.workspaces.peers.representation.mockResolvedValue({ - representation: mockRepresentation, - }); - - const result = await peer.getRepresentation(undefined, targetPeer); - - expect(result).toBe(mockRepresentation); - expect( - mockClient.workspaces.peers.representation - ).toHaveBeenCalledWith('test-workspace', 'test-peer', { - session_id: undefined, - target: 'target-peer', - search_query: undefined, - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: undefined, - }); - }); - - it('should get working representation with search query', async () => { - const mockRepresentation = 'Query-curated observation'; - mockClient.workspaces.peers.representation.mockResolvedValue({ - representation: mockRepresentation, - }); - - const result = await peer.getRepresentation( - undefined, - undefined, - { searchQuery: 'programming' } - ); - - expect(result).toBe(mockRepresentation); - expect( - mockClient.workspaces.peers.representation - ).toHaveBeenCalledWith('test-workspace', 'test-peer', { - session_id: undefined, - target: undefined, - search_query: 'programming', - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: undefined, - }); - }); - - it('should get working representation with custom size', async () => { - const mockRepresentation = 'Limited observations'; - mockClient.workspaces.peers.representation.mockResolvedValue({ - representation: mockRepresentation, - }); - - const result = await peer.getRepresentation(undefined, undefined, { maxConclusions: 10 }); - - expect(result).toBe(mockRepresentation); - expect( - mockClient.workspaces.peers.representation - ).toHaveBeenCalledWith('test-workspace', 'test-peer', { - session_id: undefined, - target: undefined, - search_query: undefined, - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: 10, - }); - }); - - it('should get working representation with all parameters', async () => { - const session = new Session('session-123', 'test-workspace', mockClient); - const targetPeer = new Peer('target-peer', 'test-workspace', mockClient); - const mockRepresentation = 'Fully parameterized observation\nConclusion with all params'; - mockClient.workspaces.peers.representation.mockResolvedValue({ - representation: mockRepresentation, - }); - - const result = await peer.getRepresentation( - session, - targetPeer, - { searchQuery: 'Python programming', maxConclusions: 25 } - ); - - expect(result).toBe(mockRepresentation); - expect( - mockClient.workspaces.peers.representation - ).toHaveBeenCalledWith('test-workspace', 'test-peer', { - session_id: 'session-123', - target: 'target-peer', - search_query: 'Python programming', - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: 25, - }); - }); - - it('should get working representation with string session and string target', async () => { - const mockRepresentation = 'String params observation'; - mockClient.workspaces.peers.representation.mockResolvedValue({ - representation: mockRepresentation, - }); - - const result = await peer.getRepresentation( - 'session-456', - 'target-peer-123', - { searchQuery: 'machine learning', maxConclusions: 50 } - ); - - expect(result).toBe(mockRepresentation); - expect( - mockClient.workspaces.peers.representation - ).toHaveBeenCalledWith('test-workspace', 'test-peer', { - session_id: 'session-456', - target: 'target-peer-123', - search_query: 'machine learning', - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: 50, - }); - }); - - it('should handle boundary size values', async () => { - const mockRepresentationString = 'Boundary test representation'; - mockClient.workspaces.peers.representation.mockResolvedValue({ - representation: mockRepresentationString, - }); - - // Test size = 1 - const result1 = await peer.getRepresentation(undefined, undefined, { maxConclusions: 1 }); - expect(result1).toBe(mockRepresentationString); - expect( - mockClient.workspaces.peers.representation - ).toHaveBeenLastCalledWith('test-workspace', 'test-peer', { - session_id: undefined, - target: undefined, - search_query: undefined, - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: 1, - }); - - // Test size = 100 - const result2 = await peer.getRepresentation(undefined, undefined, { maxConclusions: 100 }); - expect(result2).toBe(mockRepresentationString); - expect( - mockClient.workspaces.peers.representation - ).toHaveBeenLastCalledWith('test-workspace', 'test-peer', { - session_id: undefined, - target: undefined, - search_query: undefined, - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: 100, - }); - }); - - it('should handle API errors', async () => { - mockClient.workspaces.peers.representation.mockRejectedValue( - new Error('Working representation fetch failed') - ); - - await expect(peer.getRepresentation()).rejects.toThrow( - 'Working representation fetch failed' - ); - }); - }); -}); + let client: Honcho + let cleanup: () => Promise + + beforeAll(async () => { + await requireServer() + const setup = await createTestClient('peer') + client = setup.client + cleanup = setup.cleanup + }) + + afterAll(async () => { + await cleanup() + }) + + // =========================================================================== + // Peer Creation (POST /peers) + // =========================================================================== + + describe('POST /peers (create/get)', () => { + test('creates peer with just ID', async () => { + const peer = await client.peer('simple-peer') + + expect(peer).toBeInstanceOf(Peer) + expect(peer.id).toBe('simple-peer') + expect(peer.workspaceId).toBe(client.workspaceId) + }) + + test('creates peer with metadata', async () => { + const metadata = testMetadata({ name: 'Alice' }) + const peer = await client.peer('peer-with-meta', { metadata }) + + expect(peer.id).toBe('peer-with-meta') + expect(peer.metadata).toEqual(metadata) + }) + + test('creates peer with configuration', async () => { + const config = { observeMe: false } + const peer = await client.peer('peer-with-config', { configuration: config }) + + expect(peer.id).toBe('peer-with-config') + expect(peer.configuration).toEqual(config) + }) + + test('creates peer with both metadata and configuration', async () => { + const metadata = { role: 'user' } + const config = { observeMe: true } + const peer = await client.peer('peer-with-both', { metadata, configuration: config }) + + expect(peer.metadata).toEqual(metadata) + expect(peer.configuration).toEqual(config) + }) + + test('get-or-create is idempotent', async () => { + const peer1 = await client.peer('idempotent-peer', { + metadata: { first: true }, + }) + const peer2 = await client.peer('idempotent-peer', { + metadata: { second: true }, + }) + + expect(peer1.id).toBe(peer2.id) + // Second call overwrites metadata + expect(peer2.metadata).toEqual({ second: true }) + }) + }) + + // =========================================================================== + // Peer Listing (POST /peers/list) + // =========================================================================== + + describe('POST /peers/list', () => { + test('peers returns Page with items', async () => { + // Create some peers first + await client.peer('list-peer-a', { metadata: {} }) + await client.peer('list-peer-b', { metadata: {} }) + + const page = await client.peers() + + expect(page.items.length).toBeGreaterThanOrEqual(2) + expect(page.page).toBe(1) + expect(page.total).toBeGreaterThanOrEqual(2) + }) + + test('peers with filter narrows results', async () => { + const uniqueTag = `tag-${Date.now()}` + await client.peer('filtered-peer', { + metadata: { uniqueTag }, + }) + + const page = await client.peers({ metadata: { uniqueTag } }) + + expect(page.items.length).toBe(1) + expect(page.items[0].id).toBe('filtered-peer') + }) + + test('Page is async iterable', async () => { + await client.peer('iter-peer-1', { metadata: {} }) + await client.peer('iter-peer-2', { metadata: {} }) + + const page = await client.peers() + const ids: string[] = [] + + for await (const peer of page) { + ids.push(peer.id) + } + + expect(ids).toContain('iter-peer-1') + expect(ids).toContain('iter-peer-2') + }) + }) + + // =========================================================================== + // Peer Update (PUT /peers/:id) + // =========================================================================== + + describe('PUT /peers/:id (update)', () => { + test('setMetadata updates peer metadata', async () => { + const peer = await client.peer('meta-update-peer') + + await peer.setMetadata({ updated: true, count: 42 }) + const metadata = await peer.getMetadata() + + expect(metadata).toEqual({ updated: true, count: 42 }) + }) + + test('setConfiguration updates peer configuration', async () => { + const peer = await client.peer('config-update-peer') + + await peer.setConfiguration({ observeMe: false }) + const config = await peer.getConfiguration() + + expect(config).toEqual({ observeMe: false }) + }) + + test('refresh updates cached values', async () => { + const peer = await client.peer('refresh-peer', { + metadata: { initial: true }, + }) + + // Modify via another reference + const peer2 = await client.peer('refresh-peer') + await peer2.setMetadata({ modified: true }) + + // Original peer has stale cache + expect(peer.metadata).toEqual({ initial: true }) + + // After refresh, cache is updated + await peer.refresh() + expect(peer.metadata).toEqual({ modified: true }) + }) + }) + + // =========================================================================== + // Peer Sessions (POST /peers/:id/sessions/list) + // =========================================================================== + + describe('POST /peers/:id/sessions/list', () => { + test('sessions returns sessions peer is in', async () => { + const peer = await client.peer('session-member-peer') + const session = await client.session('peer-sessions-test', { metadata: {} }) + + await session.addPeers([peer.id]) + + const sessions = await peer.sessions() + + expect(sessions.items.length).toBeGreaterThanOrEqual(1) + const sessionIds = sessions.items.map((s) => s.id) + expect(sessionIds).toContain('peer-sessions-test') + }) + + test('sessions returns empty for peer in no sessions', async () => { + const peer = await client.peer('lonely-peer') + + const sessions = await peer.sessions() + + // Peer exists but not in any sessions + expect(Array.isArray(sessions.items)).toBe(true) + }) + + test('sessions with filters', async () => { + const peer = await client.peer('filter-sessions-peer') + const session = await client.session('filterable-session', { + metadata: { category: 'special' }, + }) + await session.addPeers([peer.id]) + + const sessions = await peer.sessions({ metadata: { category: 'special' } }) + + expect(sessions.items.length).toBeGreaterThanOrEqual(1) + }) + }) + + // =========================================================================== + // Message Creation Helper + // =========================================================================== + + describe('message() helper', () => { + test('creates message object with peer ID', () => { + const peer = new Peer('msg-peer', 'workspace', {} as never) + + const msg = peer.message('Hello world') + + expect(msg.peerId).toBe('msg-peer') + expect(msg.content).toBe('Hello world') + }) + + test('message with metadata', () => { + const peer = new Peer('msg-peer', 'workspace', {} as never) + + const msg = peer.message('Hello', { metadata: { key: 'value' } }) + + expect(msg.metadata).toEqual({ key: 'value' }) + }) + + test('message with configuration', () => { + const peer = new Peer('msg-peer', 'workspace', {} as never) + + const msg = peer.message('Hello', { + configuration: { reasoning: { enabled: true } }, + }) + + expect(msg.configuration).toEqual({ reasoning: { enabled: true } }) + }) + + test('message with createdAt string', () => { + const peer = new Peer('msg-peer', 'workspace', {} as never) + const timestamp = '2024-01-15T10:30:00Z' + + const msg = peer.message('Hello', { createdAt: timestamp }) + + expect(msg.createdAt).toBe(timestamp) + }) + + test('message with createdAt Date', () => { + const peer = new Peer('msg-peer', 'workspace', {} as never) + const date = new Date('2024-01-15T10:30:00Z') + + const msg = peer.message('Hello', { createdAt: date }) + + expect(msg.createdAt).toBe(date.toISOString()) + }) + }) + + // =========================================================================== + // Search (POST /peers/:id/search) + // =========================================================================== + + describe('POST /peers/:id/search', () => { + test('search finds messages from this peer', async () => { + const peer = await client.peer('search-author-peer') + const session = await client.session('search-author-session', { metadata: {} }) + + await session.addPeers([peer.id]) + await session.addMessages([ + peer.message('Unique content for searching xyz789'), + ]) + + const results = await peer.search('unique content searching') + + expect(Array.isArray(results)).toBe(true) + // Results should be from this peer + for (const msg of results) { + expect(msg.peerId).toBe(peer.id) + } + }) + + test('search with filters', async () => { + const peer = await client.peer('search-filter-peer') + const session = await client.session('search-filter-session', { metadata: {} }) + + await session.addPeers([peer.id]) + await session.addMessages([ + peer.message('Searchable content abc'), + ]) + + const results = await peer.search('searchable', { + filters: { session_id: session.id }, + }) + + for (const msg of results) { + expect(msg.sessionId).toBe(session.id) + } + }) + + test('search with limit', async () => { + const peer = await client.peer('search-limit-peer') + + const results = await peer.search('test', { limit: 3 }) + + expect(results.length).toBeLessThanOrEqual(3) + }) + }) + + // =========================================================================== + // Representation (POST /peers/:id/representation) + // =========================================================================== + + describe('POST /peers/:id/representation', () => { + test('representation returns string', async () => { + const peer = await client.peer('repr-peer') + const session = await client.session('repr-session', { metadata: {} }) + + await session.addPeers([peer.id]) + await session.addMessages([ + peer.message('I love programming in TypeScript'), + peer.message('My favorite color is blue'), + ]) + + const representation = await peer.representation() + + expect(typeof representation).toBe('string') + }) + + test('representation scoped to session', async () => { + const peer = await client.peer('repr-session-peer') + const session = await client.session('repr-session-scoped', { metadata: {} }) + + await session.addPeers([peer.id]) + await session.addMessages([peer.message('Session-specific content')]) + + const representation = await peer.representation({ session }) + + expect(typeof representation).toBe('string') + }) + + test('representation with target peer', async () => { + const observer = await client.peer('repr-observer') + const observed = await client.peer('repr-observed') + const session = await client.session('repr-target-session', { metadata: {} }) + + await session.addPeers([observer.id, observed.id]) + await session.addMessages([ + observed.message('I am being observed'), + ]) + + const representation = await observer.representation({ + target: observed, + }) + + expect(typeof representation).toBe('string') + }) + + test('representation with options', async () => { + const peer = await client.peer('repr-options-peer') + + const representation = await peer.representation({ + searchQuery: 'preferences', + searchTopK: 5, + maxConclusions: 20, + }) + + expect(typeof representation).toBe('string') + }) + }) + + // =========================================================================== + // Peer Card (POST /peers/:id/card) + // =========================================================================== + + describe('POST /peers/:id/card', () => { + test('card returns string array or null', async () => { + const peer = await client.peer('card-peer') + + const card = await peer.card() + + // card() returns string[] | null + expect(card === null || Array.isArray(card)).toBe(true) + }) + + test('card with target peer', async () => { + const observer = await client.peer('card-observer') + const observed = await client.peer('card-observed') + + const card = await observer.card(observed) + + // card() returns string[] | null + expect(card === null || Array.isArray(card)).toBe(true) + }) + + test('card with target ID string', async () => { + const peer = await client.peer('card-string-peer') + + const card = await peer.card('some-target-id') + + // card() returns string[] | null + expect(card === null || Array.isArray(card)).toBe(true) + }) + + test('card throws on invalid target type', async () => { + const peer = await client.peer('card-invalid-peer') + + // Zod throws on invalid type + await expect(peer.card(123 as never)).rejects.toThrow() + }) + + test('card throws on empty target string', async () => { + const peer = await client.peer('card-empty-peer') + + // Zod validation requires non-empty string + await expect(peer.card('')).rejects.toThrow() + }) + }) + + // =========================================================================== + // Peer Context (POST /peers/:id/context) + // =========================================================================== + + describe('POST /peers/:id/context', () => { + test('context returns representation and card', async () => { + const peer = await client.peer('context-peer') + + const context = await peer.context() + + expect(context).toBeDefined() + expect(context.peerId).toBe(peer.id) + expect(context.targetId).toBe(peer.id) // Self-context + expect('representation' in context).toBe(true) + expect('peerCard' in context).toBe(true) + }) + + test('context with target peer', async () => { + const observer = await client.peer('context-observer') + const observed = await client.peer('context-observed') + + const context = await observer.context({ target: observed }) + + expect(context.peerId).toBe(observer.id) + expect(context.targetId).toBe(observed.id) + }) + + test('context with options', async () => { + const peer = await client.peer('context-options-peer') + + const context = await peer.context({ + searchQuery: 'interests', + searchTopK: 10, + maxConclusions: 25, + }) + + expect(context).toBeDefined() + }) + }) + + // =========================================================================== + // Chat / Dialectic (POST /peers/:id/chat) + // =========================================================================== + + describe('POST /peers/:id/chat', () => { + test('chat returns string response', async () => { + const peer = await client.peer('chat-peer') + const session = await client.session('chat-session', { metadata: {} }) + + await session.addPeers([peer.id]) + await session.addMessages([ + peer.message('I enjoy hiking in the mountains'), + ]) + + const response = await peer.chat('What activities does this user enjoy?') + + // Response is string or null + expect(response === null || typeof response === 'string').toBe(true) + }) + + test('chat with session scope', async () => { + const peer = await client.peer('chat-session-peer') + const session = await client.session('chat-scoped-session', { metadata: {} }) + + await session.addPeers([peer.id]) + await session.addMessages([peer.message('Session specific info')]) + + const response = await peer.chat('What do you know?', { + session: session, + }) + + expect(response === null || typeof response === 'string').toBe(true) + }) + + test('chat with target peer', async () => { + const observer = await client.peer('chat-observer') + const target = await client.peer('chat-target') + + const response = await observer.chat('What do you know about this user?', { + target: target, + }) + + expect(response === null || typeof response === 'string').toBe(true) + }) + + test('chat with reasoning level', async () => { + const peer = await client.peer('chat-reasoning-peer') + + const response = await peer.chat('Analyze this user', { + reasoningLevel: 'high', + }) + + expect(response === null || typeof response === 'string').toBe(true) + }) + + // Streaming tests are in streaming.test.ts + }) + + // =========================================================================== + // Conclusions Scope + // =========================================================================== + + describe('Conclusion scope access', () => { + test('conclusions property returns ConclusionScope for self', async () => { + const peer = await client.peer('self-conclusions-peer') + + const scope = peer.conclusions + + expect(scope.observer).toBe(peer.id) + expect(scope.observed).toBe(peer.id) + expect(scope.workspaceId).toBe(client.workspaceId) + }) + + test('conclusionsOf returns ConclusionScope for target', async () => { + const observer = await client.peer('obs-conclusions-peer') + const target = await client.peer('target-conclusions-peer') + + const scope = observer.conclusionsOf(target) + + expect(scope.observer).toBe(observer.id) + expect(scope.observed).toBe(target.id) + }) + + test('conclusionsOf with string ID', async () => { + const peer = await client.peer('string-conclusions-peer') + + const scope = peer.conclusionsOf('some-target-id') + + expect(scope.observer).toBe(peer.id) + expect(scope.observed).toBe('some-target-id') + }) + }) + + // =========================================================================== + // String Representation + // =========================================================================== + + describe('toString', () => { + test('returns readable format', async () => { + const peer = await client.peer('tostring-peer') + + const str = peer.toString() + + expect(str).toBe("Peer(id='tostring-peer')") + }) + }) +}) diff --git a/sdks/typescript/__tests__/preload.ts b/sdks/typescript/__tests__/preload.ts new file mode 100644 index 00000000..1f33a024 --- /dev/null +++ b/sdks/typescript/__tests__/preload.ts @@ -0,0 +1,24 @@ +/** + * Test preload script - runs before any tests + * + * This script checks if tests are being run via pytest (which sets HONCHO_TEST_URL) + * and fails fast with a helpful message if not. + */ + +if (!process.env.HONCHO_TEST_URL) { + console.error(` +╔══════════════════════════════════════════════════════════════════╗ +║ ERROR: Do not run \`bun test\` directly! ║ +║ ║ +║ These tests require a running server with database and Redis. ║ +║ The infrastructure is set up automatically by pytest. ║ +║ ║ +║ Run tests from the monorepo root: ║ +║ ║ +║ cd /path/to/honcho ║ +║ uv run pytest tests/ -k typescript ║ +║ ║ +╚══════════════════════════════════════════════════════════════════╝ +`) + process.exit(1) +} diff --git a/sdks/typescript/__tests__/session.test.ts b/sdks/typescript/__tests__/session.test.ts index 5bfd7916..5562785f 100644 --- a/sdks/typescript/__tests__/session.test.ts +++ b/sdks/typescript/__tests__/session.test.ts @@ -1,1200 +1,613 @@ -import { Session } from '../src/session' -import { Peer } from '../src/peer' -import { Page } from '../src/pagination' -import { SessionContext } from '../src/session_context' -import { Honcho } from '../src/client' +/** + * Session Tests + * + * Tests for Session operations. + * + * Endpoints covered: + * - POST /v3/workspaces/:workspaceId/sessions (get-or-create session) + * - POST /v3/workspaces/:workspaceId/sessions/list (list sessions) + * - PUT /v3/workspaces/:workspaceId/sessions/:sessionId (update session) + * - DELETE /v3/workspaces/:workspaceId/sessions/:sessionId (delete session) + * - POST /v3/workspaces/:workspaceId/sessions/:sessionId/clone (clone session) + * - POST /v3/workspaces/:workspaceId/sessions/:sessionId/peers (add peers) + * - PUT /v3/workspaces/:workspaceId/sessions/:sessionId/peers (set peers) + * - DELETE /v3/workspaces/:workspaceId/sessions/:sessionId/peers (remove peers) + * - GET /v3/workspaces/:workspaceId/sessions/:sessionId/peers (list peers) + * - GET /v3/workspaces/:workspaceId/sessions/:sessionId/peers/:peerId/config (get peer config) + * - PUT /v3/workspaces/:workspaceId/sessions/:sessionId/peers/:peerId/config (set peer config) + * - POST /v3/workspaces/:workspaceId/sessions/:sessionId/messages (add messages) + * - PUT /v3/workspaces/:workspaceId/sessions/:sessionId/messages/:messageId (update message) + * - POST /v3/workspaces/:workspaceId/sessions/:sessionId/messages/list (list messages) + * - GET /v3/workspaces/:workspaceId/sessions/:sessionId/context (get context) + * - GET /v3/workspaces/:workspaceId/sessions/:sessionId/summaries (get summaries) + * - POST /v3/workspaces/:workspaceId/sessions/:sessionId/search (search) + */ -// Mock the @honcho-ai/core module -jest.mock('@honcho-ai/core', () => { - return jest.fn().mockImplementation(() => ({ - workspaces: { - sessions: { - peers: { - add: jest.fn(), - set: jest.fn(), - remove: jest.fn(), - list: jest.fn(), - config: jest.fn(), - setConfig: jest.fn(), - }, - messages: { - create: jest.fn(), - list: jest.fn(), - upload: jest.fn(), - }, - getOrCreate: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - clone: jest.fn(), - context: jest.fn(), - search: jest.fn(), - }, - peers: { - representation: jest.fn(), - }, - queue: { - status: jest.fn(), - }, - getOrCreate: jest.fn(), - update: jest.fn(), - list: jest.fn(), - search: jest.fn(), - }, - })) -}) +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { Honcho, Session, SessionPeerConfig } from '../src' +import { createTestClient, generateId, requireServer } from './setup' +import { assertMessageShape, testMetadata } from './helpers' describe('Session', () => { - let honcho: Honcho - let session: Session - let mockClient: any + let client: Honcho + let cleanup: () => Promise - beforeEach(() => { - jest.clearAllMocks() - - honcho = new Honcho({ - workspaceId: 'test-workspace', - apiKey: 'test-key', - environment: 'local', - }) - - session = new Session( - 'test-session', - 'test-workspace', - (honcho as any)._client - ) - mockClient = (honcho as any)._client + beforeAll(async () => { + await requireServer() + const setup = await createTestClient('session') + client = setup.client + cleanup = setup.cleanup }) - describe('constructor', () => { - it('should initialize with correct properties', () => { - const newSession = new Session('session-id', 'test-workspace', mockClient) - - expect(newSession.id).toBe('session-id') - expect(newSession.workspaceId).toBe('test-workspace') - expect(newSession['_client']).toBe(mockClient) - }) + afterAll(async () => { + await cleanup() }) - describe('addPeers', () => { - it('should add single peer by string ID', async () => { - mockClient.workspaces.sessions.peers.add.mockResolvedValue({}) + // =========================================================================== + // Session Creation (POST /sessions) + // =========================================================================== - await session.addPeers('peer1') + describe('POST /sessions (create/get)', () => { + test('creates session with just ID', async () => { + const session = await client.session('simple-session', { metadata: {} }) - expect(mockClient.workspaces.sessions.peers.add).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - { peer1: {} } - ) + expect(session).toBeInstanceOf(Session) + expect(session.id).toBe('simple-session') + expect(session.workspaceId).toBe(client.workspaceId) }) - it('should add single peer by Peer object', async () => { - const peer = new Peer('peer1', 'test-workspace', mockClient) - mockClient.workspaces.sessions.peers.add.mockResolvedValue({}) + test('creates session with metadata', async () => { + const metadata = testMetadata({ topic: 'testing' }) + const session = await client.session('session-with-meta', { metadata }) - await session.addPeers(peer) - - expect(mockClient.workspaces.sessions.peers.add).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - { peer1: {} } - ) + expect(session.metadata).toEqual(metadata) }) - it('should add array of peer strings', async () => { - mockClient.workspaces.sessions.peers.add.mockResolvedValue({}) + test('creates session with configuration', async () => { + const config = { summary: { enabled: true } } + const session = await client.session('session-with-config', { configuration: config }) - await session.addPeers(['peer1', 'peer2', 'peer3']) - - expect(mockClient.workspaces.sessions.peers.add).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - { - peer1: {}, - peer2: {}, - peer3: {}, - } - ) + expect(session.configuration).toEqual(config) }) - it('should add array of Peer objects', async () => { - const peers = [ - new Peer('peer1', 'test-workspace', mockClient), - new Peer('peer2', 'test-workspace', mockClient), - new Peer('peer3', 'test-workspace', mockClient), - ] - mockClient.workspaces.sessions.peers.add.mockResolvedValue({}) - - await session.addPeers(peers) - - expect(mockClient.workspaces.sessions.peers.add).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - { - peer1: {}, - peer2: {}, - peer3: {}, - } - ) - }) - - it('should add mixed array of strings and Peer objects', async () => { - const peers = [ - 'string-peer', - new Peer('object-peer', 'test-workspace', mockClient), - ] - mockClient.workspaces.sessions.peers.add.mockResolvedValue({}) - - await session.addPeers(peers) - - expect(mockClient.workspaces.sessions.peers.add).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - { - 'string-peer': {}, - 'object-peer': {}, - } - ) - }) - - it('should add peer with SessionPeerConfig', async () => { - const { SessionPeerConfig } = require('../src/session') - const config = new SessionPeerConfig(false, true) - mockClient.workspaces.sessions.peers.add.mockResolvedValue({}) - - await session.addPeers([['peer1', config]]) - - expect(mockClient.workspaces.sessions.peers.add).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - { - peer1: { observe_me: false, observe_others: true }, - } - ) - }) - - it('should handle API errors', async () => { - mockClient.workspaces.sessions.peers.add.mockRejectedValue( - new Error('Failed to add peers') - ) - - await expect(session.addPeers('peer1')).rejects.toThrow() - }) - }) - - describe('setPeers', () => { - it('should set single peer by string ID', async () => { - mockClient.workspaces.sessions.peers.set.mockResolvedValue({}) - - await session.setPeers('peer1') - - expect(mockClient.workspaces.sessions.peers.set).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - { peer1: {} } - ) - }) - - it('should set single peer by Peer object', async () => { - const peer = new Peer('peer1', 'test-workspace', mockClient) - mockClient.workspaces.sessions.peers.set.mockResolvedValue({}) - - await session.setPeers(peer) - - expect(mockClient.workspaces.sessions.peers.set).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - { peer1: {} } - ) - }) - - it('should set array of peers', async () => { - const peers = ['peer1', new Peer('peer2', 'test-workspace', mockClient)] - mockClient.workspaces.sessions.peers.set.mockResolvedValue({}) - - await session.setPeers(peers) - - expect(mockClient.workspaces.sessions.peers.set).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - { - peer1: {}, - peer2: {}, - } - ) - }) - - it('should handle API errors', async () => { - mockClient.workspaces.sessions.peers.set.mockRejectedValue( - new Error('Failed to set peers') - ) - - await expect(session.setPeers(['peer1'])).rejects.toThrow() - }) - }) - - describe('removePeers', () => { - it('should remove single peer by string ID', async () => { - mockClient.workspaces.sessions.peers.remove.mockResolvedValue({}) - - await session.removePeers('peer1') - - expect(mockClient.workspaces.sessions.peers.remove).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - ['peer1'] - ) - }) - - it('should remove single peer by Peer object', async () => { - const peer = new Peer('peer1', 'test-workspace', mockClient) - mockClient.workspaces.sessions.peers.remove.mockResolvedValue({}) - - await session.removePeers(peer) - - expect(mockClient.workspaces.sessions.peers.remove).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - ['peer1'] - ) - }) - - it('should remove array of peers', async () => { - const peers = ['peer1', new Peer('peer2', 'test-workspace', mockClient)] - mockClient.workspaces.sessions.peers.remove.mockResolvedValue({}) - - await session.removePeers(peers) - - expect(mockClient.workspaces.sessions.peers.remove).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - ['peer1', 'peer2'] - ) - }) - - it('should handle API errors', async () => { - mockClient.workspaces.sessions.peers.remove.mockRejectedValue( - new Error('Failed to remove peers') - ) - - await expect(session.removePeers(['peer1'])).rejects.toThrow() - }) - }) - - describe('getPeers', () => { - it('should return array of Peer instances', async () => { - const mockPeersData = { - items: [ - { id: 'peer1', metadata: {} }, - { id: 'peer2', metadata: {} }, - ], - total: 2, - size: 2, - hasNextPage: () => false, - } - mockClient.workspaces.sessions.peers.list.mockResolvedValue(mockPeersData) - - const peers = await session.getPeers() - - expect(peers).toBeInstanceOf(Array) - expect(peers).toHaveLength(2) - expect(peers[0]).toBeInstanceOf(Peer) - expect(peers[1]).toBeInstanceOf(Peer) - expect(mockClient.workspaces.sessions.peers.list).toHaveBeenCalledWith( - 'test-workspace', - 'test-session' - ) - }) - - it('should handle empty peers list', async () => { - const mockPeersData = { - items: [], - total: 0, - size: 0, - hasNextPage: () => false, - } - mockClient.workspaces.sessions.peers.list.mockResolvedValue(mockPeersData) - - const peers = await session.getPeers() - - expect(peers).toBeInstanceOf(Array) - expect(peers.length).toBe(0) - }) - - it('should handle API errors', async () => { - mockClient.workspaces.sessions.peers.list.mockRejectedValue( - new Error('Failed to get peers') - ) - - await expect(session.getPeers()).rejects.toThrow() - }) - }) - - describe('getPeerConfig', () => { - it('should return peer configuration', async () => { - const mockConfig = { observe_me: true, observe_others: false } - mockClient.workspaces.sessions.peers.config.mockResolvedValue( - mockConfig - ) - - const config = await session.getPeerConfig('peer1') - - expect(config).toEqual(mockConfig) - expect( - mockClient.workspaces.sessions.peers.config - ).toHaveBeenCalledWith('test-workspace', 'test-session', 'peer1') - }) - - it('should handle Peer object input', async () => { - const peer = new Peer('peer1', 'test-workspace', mockClient) - const mockConfig = { observe_me: false, observe_others: true } - mockClient.workspaces.sessions.peers.config.mockResolvedValue( - mockConfig - ) - - const config = await session.getPeerConfig(peer) - - expect(config).toEqual(mockConfig) - expect( - mockClient.workspaces.sessions.peers.config - ).toHaveBeenCalledWith('test-workspace', 'test-session', 'peer1') - }) - }) - - describe('setPeerConfig', () => { - it('should set peer configuration', async () => { - const { SessionPeerConfig } = require('../src/session') - const config = new SessionPeerConfig(false, true) - mockClient.workspaces.sessions.peers.setConfig.mockResolvedValue({}) - - await session.setPeerConfig('peer1', config) - - expect( - mockClient.workspaces.sessions.peers.setConfig - ).toHaveBeenCalledWith('test-workspace', 'test-session', 'peer1', { - observe_me: false, - observe_others: true, + test('get-or-create is idempotent', async () => { + const session1 = await client.session('idempotent-session', { + metadata: { version: 1 }, }) - }) - - it('should handle Peer object input', async () => { - const peer = new Peer('peer1', 'test-workspace', mockClient) - const { SessionPeerConfig } = require('../src/session') - const config = new SessionPeerConfig(true, false) - mockClient.workspaces.sessions.peers.setConfig.mockResolvedValue({}) - - await session.setPeerConfig(peer, config) - - expect( - mockClient.workspaces.sessions.peers.setConfig - ).toHaveBeenCalledWith('test-workspace', 'test-session', 'peer1', { - observe_me: true, - observe_others: false, + const session2 = await client.session('idempotent-session', { + metadata: { version: 2 }, }) + + expect(session1.id).toBe(session2.id) + expect(session2.metadata).toEqual({ version: 2 }) }) }) - describe('addMessages', () => { - it('should add single message', async () => { - const message = { - peer_id: 'peer1', - content: 'Hello world', - metadata: { type: 'greeting' }, - } - mockClient.workspaces.sessions.messages.create.mockResolvedValue({}) + // =========================================================================== + // Session Listing (POST /sessions/list) + // =========================================================================== - await session.addMessages(message) + describe('POST /sessions/list', () => { + test('sessions returns paginated list', async () => { + await client.session('list-session-a', { metadata: {} }) + await client.session('list-session-b', { metadata: {} }) - expect( - mockClient.workspaces.sessions.messages.create - ).toHaveBeenCalledWith('test-workspace', 'test-session', { - messages: [ - { - peer_id: 'peer1', - content: 'Hello world', - metadata: { type: 'greeting' }, - }, - ], - }) + const page = await client.sessions() + + expect(page.items.length).toBeGreaterThanOrEqual(2) + const ids = page.items.map((s) => s.id) + expect(ids).toContain('list-session-a') + expect(ids).toContain('list-session-b') }) - it('should add array of messages', async () => { - const messages = [ - { peer_id: 'peer1', content: 'Message 1', metadata: { order: 1 } }, - { peer_id: 'peer2', content: 'Message 2', metadata: { order: 2 } }, - ] - mockClient.workspaces.sessions.messages.create.mockResolvedValue({}) + test('sessions with filter', async () => { + const tag = `tag-${Date.now()}` + await client.session('filtered-session', { metadata: { tag } }) - await session.addMessages(messages) + const page = await client.sessions({ metadata: { tag } }) - expect( - mockClient.workspaces.sessions.messages.create - ).toHaveBeenCalledWith('test-workspace', 'test-session', { - messages: [ - { peer_id: 'peer1', content: 'Message 1', metadata: { order: 1 } }, - { peer_id: 'peer2', content: 'Message 2', metadata: { order: 2 } }, - ], - }) - }) - - it('should handle messages without metadata', async () => { - const message = { - peer_id: 'peer1', - content: 'Simple message', - } - mockClient.workspaces.sessions.messages.create.mockResolvedValue({}) - - await session.addMessages(message) - - expect( - mockClient.workspaces.sessions.messages.create - ).toHaveBeenCalledWith('test-workspace', 'test-session', { - messages: [{ peer_id: 'peer1', content: 'Simple message' }], - }) - }) - - it('should handle empty array', async () => { - mockClient.workspaces.sessions.messages.create.mockResolvedValue({}) - - await session.addMessages([]) - - expect( - mockClient.workspaces.sessions.messages.create - ).toHaveBeenCalledWith('test-workspace', 'test-session', { messages: [] }) - }) - - it('should handle API errors', async () => { - mockClient.workspaces.sessions.messages.create.mockRejectedValue( - new Error('Failed to add messages') - ) - - await expect( - session.addMessages({ peer_id: 'peer1', content: 'test' }) - ).rejects.toThrow() - }) - - it('should add message with custom timestamp', async () => { - const message = { - peer_id: 'peer1', - content: 'Message with timestamp', - created_at: '2023-01-01T12:00:00Z', - metadata: { test: 'timestamp' }, - } - mockClient.workspaces.sessions.messages.create.mockResolvedValue({}) - - await session.addMessages(message) - - expect( - mockClient.workspaces.sessions.messages.create - ).toHaveBeenCalledWith('test-workspace', 'test-session', { - messages: [ - { - peer_id: 'peer1', - content: 'Message with timestamp', - created_at: '2023-01-01T12:00:00Z', - metadata: { test: 'timestamp' }, - }, - ], - }) - }) - - it('should add message with null timestamp', async () => { - const message = { - peer_id: 'peer1', - content: 'Message without timestamp', - created_at: null, - metadata: { test: 'no_timestamp' }, - } - mockClient.workspaces.sessions.messages.create.mockResolvedValue({}) - - await session.addMessages(message) - - expect( - mockClient.workspaces.sessions.messages.create - ).toHaveBeenCalledWith('test-workspace', 'test-session', { - messages: [ - { - peer_id: 'peer1', - content: 'Message without timestamp', - created_at: null, - metadata: { test: 'no_timestamp' }, - }, - ], - }) - }) - - it('should add mixed messages with and without timestamps', async () => { - const messages = [ - { - peer_id: 'peer1', - content: 'Message with timestamp', - created_at: '2023-01-01T12:00:00Z', - metadata: { type: 'historical' }, - }, - { - peer_id: 'peer2', - content: 'Message without timestamp', - metadata: { type: 'current' }, - }, - { - peer_id: 'peer3', - content: 'Message with null timestamp', - created_at: null, - metadata: { type: 'default' }, - }, - ] - mockClient.workspaces.sessions.messages.create.mockResolvedValue({}) - - await session.addMessages(messages) - - expect( - mockClient.workspaces.sessions.messages.create - ).toHaveBeenCalledWith('test-workspace', 'test-session', { - messages: [ - { - peer_id: 'peer1', - content: 'Message with timestamp', - created_at: '2023-01-01T12:00:00Z', - metadata: { type: 'historical' }, - }, - { - peer_id: 'peer2', - content: 'Message without timestamp', - metadata: { type: 'current' }, - }, - { - peer_id: 'peer3', - content: 'Message with null timestamp', - created_at: null, - metadata: { type: 'default' }, - }, - ], - }) - }) - - it('should return list of messages when created', async () => { - const mockMessages = [ - { - id: 'msg1', - peer_id: 'peer1', - content: 'Hello', - created_at: '2023-01-01T00:00:00Z', - metadata: {}, - }, - { - id: 'msg2', - peer_id: 'peer2', - content: 'Hi there', - created_at: '2023-01-01T00:00:01Z', - metadata: {}, - }, - ] - mockClient.workspaces.sessions.messages.create.mockResolvedValue( - mockMessages - ) - - const messages = [ - { peer_id: 'peer1', content: 'Hello' }, - { peer_id: 'peer2', content: 'Hi there' }, - ] - const result = await session.addMessages(messages) - - expect(result).toEqual(mockMessages) - expect(result).toHaveLength(2) - expect(result[0].id).toBe('msg1') - expect(result[0].content).toBe('Hello') - expect(result[1].id).toBe('msg2') - expect(result[1].content).toBe('Hi there') - }) - - it('should return single message when adding single message', async () => { - const mockMessage = { - id: 'msg1', - peer_id: 'peer1', - content: 'Hello', - created_at: '2023-01-01T00:00:00Z', - metadata: {}, - } - mockClient.workspaces.sessions.messages.create.mockResolvedValue([ - mockMessage, - ]) - - const message = { peer_id: 'peer1', content: 'Hello' } - const result = await session.addMessages(message) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual(mockMessage) - expect(result[0].id).toBe('msg1') - expect(result[0].content).toBe('Hello') + expect(page.items.length).toBe(1) + expect(page.items[0].id).toBe('filtered-session') }) }) - describe('getMessages', () => { - it('should get messages without filter', async () => { - const mockMessagesData = { - items: [ - { id: 'msg1', content: 'Message 1', peer_id: 'peer1' }, - { id: 'msg2', content: 'Message 2', peer_id: 'peer2' }, - ], - total: 2, - size: 2, - hasNextPage: () => false, - } - mockClient.workspaces.sessions.messages.list.mockResolvedValue( - mockMessagesData - ) + // =========================================================================== + // Session Update (PUT /sessions/:id) + // =========================================================================== - const messagesPage = await session.getMessages() - - expect(messagesPage).toBeInstanceOf(Page) - expect(mockClient.workspaces.sessions.messages.list).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - undefined - ) - }) - - it('should get messages with filter options', async () => { - const mockMessagesData = { - items: [], - total: 0, - size: 0, - hasNextPage: () => false, - } - mockClient.workspaces.sessions.messages.list.mockResolvedValue( - mockMessagesData - ) - - const filter = { - peer_id: { value: 'peer1' }, - type: { value: 'important' }, - } - await session.getMessages(filter) - - expect(mockClient.workspaces.sessions.messages.list).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - { peer_id: { value: 'peer1' }, type: { value: 'important' } } - ) - }) - - it('should handle API errors', async () => { - mockClient.workspaces.sessions.messages.list.mockRejectedValue( - new Error('Failed to get messages') - ) - - await expect(session.getMessages()).rejects.toThrow() - }) - }) - - describe('getMetadata', () => { - it('should return session metadata', async () => { - const mockSession = { - id: 'test-session', - metadata: { name: 'Test Session', active: true }, - } - mockClient.workspaces.sessions.getOrCreate.mockResolvedValue(mockSession) + describe('PUT /sessions/:id (update)', () => { + test('setMetadata updates session metadata', async () => { + const session = await client.session('update-meta-session', { metadata: {} }) + await session.setMetadata({ updated: true, step: 2 }) const metadata = await session.getMetadata() - expect(metadata).toEqual({ name: 'Test Session', active: true }) - expect(mockClient.workspaces.sessions.getOrCreate).toHaveBeenCalledWith( - 'test-workspace', - { id: 'test-session' } - ) + expect(metadata).toEqual({ updated: true, step: 2 }) }) - it('should return empty object when no metadata exists', async () => { - const mockSession = { - id: 'test-session', - metadata: null, - } - mockClient.workspaces.sessions.getOrCreate.mockResolvedValue(mockSession) + test('setConfiguration updates session configuration', async () => { + const session = await client.session('update-config-session', { metadata: {} }) - const metadata = await session.getMetadata() + await session.setConfiguration({ summary: { enabled: false } }) + const config = await session.getConfiguration() - expect(metadata).toEqual({}) + expect(config).toEqual({ summary: { enabled: false } }) }) - it('should handle API errors', async () => { - mockClient.workspaces.sessions.getOrCreate.mockRejectedValue( - new Error('Session not found') - ) + test('refresh updates cached values', async () => { + const session = await client.session('refresh-session', { + metadata: { initial: true }, + }) - await expect(session.getMetadata()).rejects.toThrow() + // Modify via another reference + const session2 = await client.session('refresh-session', { metadata: {} }) + await session2.setMetadata({ modified: true }) + + // Original has stale cache + expect(session.metadata).toEqual({ initial: true }) + + // After refresh + await session.refresh() + expect(session.metadata).toEqual({ modified: true }) }) }) - describe('setMetadata', () => { - it('should update session metadata', async () => { - const metadata = { name: 'Updated Session', status: 'active' } - mockClient.workspaces.sessions.update.mockResolvedValue({}) + // =========================================================================== + // Session Deletion (DELETE /sessions/:id) + // =========================================================================== - await session.setMetadata(metadata) - - expect(mockClient.workspaces.sessions.update).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - { metadata } - ) - }) - - it('should handle empty metadata', async () => { - mockClient.workspaces.sessions.update.mockResolvedValue({}) - - await session.setMetadata({}) - - expect(mockClient.workspaces.sessions.update).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - { metadata: {} } - ) - }) - - it('should handle API errors', async () => { - mockClient.workspaces.sessions.update.mockRejectedValue( - new Error('Update failed') - ) - - await expect(session.setMetadata({ key: 'value' })).rejects.toThrow() - }) - }) - - describe('getContext', () => { - it('should get session context without options', async () => { - const mockContext = { - messages: [ - { id: 'msg1', content: 'Hello', peer_id: 'peer1' }, - { id: 'msg2', content: 'Hi there', peer_id: 'peer2' }, - ], - summary: { - content: 'Conversation summary', - message_id: 10, - summary_type: 'short', - created_at: '2024-01-01T00:00:00Z', - token_count: 100, - }, - } - mockClient.workspaces.sessions.context.mockResolvedValue(mockContext) - - const context = await session.getContext() - - expect(context).toBeInstanceOf(SessionContext) - expect(context.sessionId).toBe('test-session') - expect(context.messages).toEqual(mockContext.messages) - expect(context.summary?.content).toBe('Conversation summary') - expect(mockClient.workspaces.sessions.context).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - { tokens: undefined, summary: undefined } - ) - }) - - it('should get session context with options', async () => { - const mockContext = { - messages: [{ id: 'msg1', content: 'Hello', peer_id: 'peer1' }], - summary: { - content: 'Brief summary', - message_id: 5, - summary_type: 'short', - created_at: '2024-01-01T00:00:00Z', - token_count: 50, - }, - } - mockClient.workspaces.sessions.context.mockResolvedValue(mockContext) - - const context = await session.getContext({ summary: true, tokens: 1000 }) - - expect(context).toBeInstanceOf(SessionContext) - expect(mockClient.workspaces.sessions.context).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - { tokens: 1000, summary: true } - ) - }) - - it('should handle context without summary', async () => { - const mockContext = { - messages: [{ id: 'msg1', content: 'Hello', peer_id: 'peer1' }], - } - mockClient.workspaces.sessions.context.mockResolvedValue(mockContext) - - const context = await session.getContext() - - expect(context.summary).toBeNull() - }) - - it('should handle API errors', async () => { - mockClient.workspaces.sessions.context.mockRejectedValue( - new Error('Failed to get context') - ) - - await expect(session.getContext()).rejects.toThrow() - }) - }) - - describe('search', () => { - it('should search session messages and return Page', async () => { - const mockSearchResults = [ - { id: 'msg1', content: 'Hello world', peer_id: 'peer1' }, - { id: 'msg2', content: 'Hello there', peer_id: 'peer2' }, - ] - mockClient.workspaces.sessions.search.mockResolvedValue(mockSearchResults) - - const results = await session.search('hello') - - expect(Array.isArray(results)).toBe(true) - expect(mockClient.workspaces.sessions.search).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - { query: 'hello', limit: undefined } - ) - }) - - it('should handle empty search results', async () => { - const mockSearchResults: any[] = [] - mockClient.workspaces.sessions.search.mockResolvedValue(mockSearchResults) - - const results = await session.search('nonexistent') - - expect(Array.isArray(results)).toBe(true) - }) - - it('should throw error for empty query', async () => { - await expect(session.search('')).rejects.toThrow() - await expect(session.search(' ')).rejects.toThrow() - }) - - it('should throw error for non-string query', async () => { - await expect(session.search(null as any)).rejects.toThrow() - await expect(session.search(undefined as any)).rejects.toThrow() - await expect(session.search(123 as any)).rejects.toThrow() - }) - - it('should handle API errors', async () => { - mockClient.workspaces.sessions.search.mockRejectedValue( - new Error('Search failed') - ) - - await expect(session.search('test')).rejects.toThrow() - }) - }) - - describe('uploadFile', () => { - it('should upload file and return messages', async () => { - const mockFile = new File(['test content'], 'test.txt', { - type: 'text/plain', - }) - const mockMessages = [ - { id: 'msg1', content: 'test content', peer_id: 'peer1' }, - ] - mockClient.workspaces.sessions.messages.upload.mockResolvedValue( - mockMessages - ) - - const messages = await session.uploadFile(mockFile, 'peer1') - - expect(messages).toEqual(mockMessages) - expect( - mockClient.workspaces.sessions.messages.upload - ).toHaveBeenCalledWith('test-workspace', 'test-session', { - file: mockFile, - peer_id: 'peer1', - }) - }) - }) - - describe('getRepresentation', () => { - it('should get working representation with peer string', async () => { - const mockRepresentationString = 'Some knowledge about the peer' - mockClient.workspaces.peers.representation.mockResolvedValue({ - representation: mockRepresentationString, - }) - - const result = await session.getRepresentation('peer1') - - expect(typeof result).toBe('string') - expect(result).toBe(mockRepresentationString) - expect( - mockClient.workspaces.peers.representation - ).toHaveBeenCalledWith('test-workspace', 'peer1', { - session_id: 'test-session', - target: undefined, - search_query: undefined, - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: undefined, - }) - }) - - it('should get working representation with Peer object', async () => { - const peer = new Peer('peer1', 'test-workspace', mockClient) - const mockRepresentationString = 'Some knowledge' - mockClient.workspaces.peers.representation.mockResolvedValue({ - representation: mockRepresentationString, - }) - - const result = await session.getRepresentation(peer) - - expect(typeof result).toBe('string') - expect(result).toBe(mockRepresentationString) - expect( - mockClient.workspaces.peers.representation - ).toHaveBeenCalledWith('test-workspace', 'peer1', { - session_id: 'test-session', - target: undefined, - search_query: undefined, - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: undefined, - }) - }) - - it('should get working representation with target peer string', async () => { - const mockRepresentationString = 'What peer1 knows about target' - mockClient.workspaces.peers.representation.mockResolvedValue({ - representation: mockRepresentationString, - }) - - const result = await session.getRepresentation('peer1', 'target-peer') - - expect(typeof result).toBe('string') - expect(result).toBe(mockRepresentationString) - expect( - mockClient.workspaces.peers.representation - ).toHaveBeenCalledWith('test-workspace', 'peer1', { - session_id: 'test-session', - target: 'target-peer', - search_query: undefined, - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: undefined, - }) - }) - - it('should get working representation with target Peer object', async () => { - const peer = new Peer('peer1', 'test-workspace', mockClient) - const target = new Peer('target-peer', 'test-workspace', mockClient) - const mockRepresentationString = 'What peer1 knows about target' - mockClient.workspaces.peers.representation.mockResolvedValue({ - representation: mockRepresentationString, - }) - - const result = await session.getRepresentation(peer, target) - - expect(typeof result).toBe('string') - expect(result).toBe(mockRepresentationString) - expect( - mockClient.workspaces.peers.representation - ).toHaveBeenCalledWith('test-workspace', 'peer1', { - session_id: 'test-session', - target: 'target-peer', - search_query: undefined, - search_top_k: undefined, - search_max_distance: undefined, - include_most_frequent: undefined, - max_conclusions: undefined, - }) - }) - - it('should handle API errors', async () => { - mockClient.workspaces.peers.representation.mockRejectedValue( - new Error('Failed to get working representation') - ) - - await expect(session.getRepresentation('peer1')).rejects.toThrow() - }) - }) - describe('delete', () => { - it('should delete the session', async () => { - mockClient.workspaces.sessions.delete.mockResolvedValue({}) + describe('DELETE /sessions/:id', () => { + test('delete removes session', async () => { + const session = await client.session('delete-me-session', { metadata: {} }) await session.delete() - expect(mockClient.workspaces.sessions.delete).toHaveBeenCalledWith( - 'test-workspace', - 'test-session' - ) - }) - - it('should handle API errors', async () => { - mockClient.workspaces.sessions.delete.mockRejectedValue( - new Error('Failed to delete session') - ) - - await expect(session.delete()).rejects.toThrow('Failed to delete session') + // Session should not appear in list + const page = await client.sessions() + const ids = page.items.map((s) => s.id) + expect(ids).not.toContain('delete-me-session') }) }) - describe('clone', () => { - it('should clone the session without messageId', async () => { - const mockClonedSession = { - id: 'cloned-session-id', - workspace_id: 'test-workspace', - metadata: { cloned: true }, - configuration: { test: 'config' }, - } - mockClient.workspaces.sessions.clone.mockResolvedValue(mockClonedSession) + // =========================================================================== + // Session Clone (POST /sessions/:id/clone) + // =========================================================================== - const clonedSession = await session.clone() + describe('POST /sessions/:id/clone', () => { + test('clone creates copy of session', async () => { + const original = await client.session('original-session', { + metadata: { original: true }, + }) + const peer = await client.peer('clone-peer') + await original.addPeers([peer.id]) + await original.addMessages([peer.message('Message 1')]) - expect(clonedSession).toBeInstanceOf(Session) - expect(clonedSession.id).toBe('cloned-session-id') - expect(clonedSession.workspaceId).toBe('test-workspace') - expect(clonedSession.metadata).toEqual({ cloned: true }) - expect(clonedSession.configuration).toEqual({ test: 'config' }) - expect(mockClient.workspaces.sessions.clone).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - {} - ) + const cloned = await original.clone() + + expect(cloned.id).not.toBe(original.id) + // Cloned session should have same messages + const messages = await cloned.messages() + expect(messages.items.length).toBe(1) }) - it('should clone the session with messageId cutoff', async () => { - const mockClonedSession = { - id: 'cloned-session-cutoff', - workspace_id: 'test-workspace', - metadata: null, - configuration: null, - } - mockClient.workspaces.sessions.clone.mockResolvedValue(mockClonedSession) + test('clone up to specific message', async () => { + const original = await client.session('clone-partial-session', { metadata: {} }) + const peer = await client.peer('clone-partial-peer') + await original.addPeers([peer.id]) - const clonedSession = await session.clone('msg-123') + const messages = await original.addMessages([ + peer.message('First'), + peer.message('Second'), + peer.message('Third'), + ]) - expect(clonedSession).toBeInstanceOf(Session) - expect(clonedSession.id).toBe('cloned-session-cutoff') - expect(mockClient.workspaces.sessions.clone).toHaveBeenCalledWith( - 'test-workspace', - 'test-session', - { message_id: 'msg-123' } - ) - }) + // Clone up to second message + const cloned = await original.clone(messages[1].id) - it('should handle null metadata and configuration', async () => { - const mockClonedSession = { - id: 'cloned-session-null', - workspace_id: 'test-workspace', - metadata: null, - configuration: null, - } - mockClient.workspaces.sessions.clone.mockResolvedValue(mockClonedSession) - - const clonedSession = await session.clone() - - expect(clonedSession.metadata).toBeUndefined() - expect(clonedSession.configuration).toBeUndefined() - }) - - it('should handle API errors', async () => { - mockClient.workspaces.sessions.clone.mockRejectedValue( - new Error('Failed to clone session') - ) - - await expect(session.clone()).rejects.toThrow('Failed to clone session') + const clonedMessages = await cloned.messages() + expect(clonedMessages.items.length).toBe(2) }) }) - describe('getQueueStatus', () => { - it('should return queue status without options', async () => { - const mockStatus = { - total_work_units: 10, - completed_work_units: 5, - in_progress_work_units: 3, - pending_work_units: 2, - sessions: { session1: { status: 'active' } }, - } - mockClient.workspaces.queue.status.mockResolvedValue(mockStatus) + // =========================================================================== + // Peer Management + // =========================================================================== - const status = await session.getQueueStatus() + describe('Peer management', () => { + describe('POST /sessions/:id/peers/add', () => { + test('addPeers with string array', async () => { + const session = await client.session('add-peers-string', { metadata: {} }) - expect(status).toEqual({ - totalWorkUnits: 10, - completedWorkUnits: 5, - inProgressWorkUnits: 3, - pendingWorkUnits: 2, - sessions: { session1: { status: 'active' } }, + await session.addPeers(['peer-a', 'peer-b']) + + const peers = await session.peers() + const ids = peers.map((p) => p.id) + expect(ids).toContain('peer-a') + expect(ids).toContain('peer-b') + }) + + test('addPeers with Peer objects', async () => { + const session = await client.session('add-peers-objects', { metadata: {} }) + const peerA = await client.peer('obj-peer-a') + const peerB = await client.peer('obj-peer-b') + + await session.addPeers([peerA, peerB]) + + const peers = await session.peers() + const ids = peers.map((p) => p.id) + expect(ids).toContain('obj-peer-a') + expect(ids).toContain('obj-peer-b') + }) + + test('addPeers with config tuples', async () => { + const session = await client.session('add-peers-config', { metadata: {} }) + + await session.addPeers([ + ['config-peer-a', { observeMe: true, observeOthers: false }], + ['config-peer-b', { observeMe: false }], + ]) + + const configA = await session.getPeerConfiguration('config-peer-a') + expect(configA.observeMe).toBe(true) + expect(configA.observeOthers).toBe(false) + }) + + test('addPeers with single peer', async () => { + const session = await client.session('add-single-peer', { metadata: {} }) + + await session.addPeers('single-peer') + + const peers = await session.peers() + expect(peers.map((p) => p.id)).toContain('single-peer') }) - expect(mockClient.workspaces.queue.status).toHaveBeenCalledWith( - 'test-workspace', - { session_id: 'test-session' } - ) }) - it('should return queue status with options', async () => { - const mockStatus = { - total_work_units: 5, - completed_work_units: 3, - in_progress_work_units: 1, - pending_work_units: 1, - } - mockClient.workspaces.queue.status.mockResolvedValue(mockStatus) + describe('POST /sessions/:id/peers/set', () => { + test('setPeers replaces all peers', async () => { + const session = await client.session('set-peers-session', { metadata: {} }) + await session.addPeers(['old-peer-a', 'old-peer-b']) - const status = await session.getQueueStatus({ - observer: 'observer1', - sender: 'sender1', - }) + await session.setPeers(['new-peer-a', 'new-peer-b']) - expect(status).toEqual({ - totalWorkUnits: 5, - completedWorkUnits: 3, - inProgressWorkUnits: 1, - pendingWorkUnits: 1, - sessions: undefined, + const peers = await session.peers() + const ids = peers.map((p) => p.id) + expect(ids).toContain('new-peer-a') + expect(ids).toContain('new-peer-b') + expect(ids).not.toContain('old-peer-a') }) - expect(mockClient.workspaces.queue.status).toHaveBeenCalledWith( - 'test-workspace', - { - observer_id: 'observer1', - sender_id: 'sender1', - session_id: 'test-session', - } - ) }) - describe('pollQueueStatus', () => { - it('should poll until processing is complete', async () => { - const mockStatusComplete = { - total_work_units: 5, - completed_work_units: 5, - in_progress_work_units: 0, - pending_work_units: 0, - } - mockClient.workspaces.queue.status.mockResolvedValue( - mockStatusComplete + describe('POST /sessions/:id/peers/remove', () => { + test('removePeers removes specified peers', async () => { + const session = await client.session('remove-peers-session', { metadata: {} }) + await session.addPeers(['keep-peer', 'remove-peer']) + + await session.removePeers(['remove-peer']) + + const peers = await session.peers() + const ids = peers.map((p) => p.id) + expect(ids).toContain('keep-peer') + expect(ids).not.toContain('remove-peer') + }) + + test('removePeers with Peer objects', async () => { + const session = await client.session('remove-peer-objects', { metadata: {} }) + const peer = await client.peer('peer-to-remove') + await session.addPeers([peer]) + + await session.removePeers([peer]) + + const peers = await session.peers() + expect(peers.map((p) => p.id)).not.toContain('peer-to-remove') + }) + }) + + describe('GET /sessions/:id/peers', () => { + test('peers returns Peer instances', async () => { + const session = await client.session('get-peers-session', { metadata: {} }) + await session.addPeers(['list-peer-1', 'list-peer-2']) + + const peers = await session.peers() + + expect(peers.length).toBe(2) + expect(peers[0].workspaceId).toBe(client.workspaceId) + }) + }) + + describe('GET/PUT /sessions/:id/peers/:id/config', () => { + test('getPeerConfiguration returns configuration', async () => { + const session = await client.session('get-peer-config-session', { metadata: {} }) + await session.addPeers([ + ['peer-with-config', { observeMe: true, observeOthers: false }], + ]) + + const config = await session.getPeerConfiguration('peer-with-config') + + expect(config.observeMe).toBe(true) + expect(config.observeOthers).toBe(false) + }) + + test('setPeerConfiguration updates configuration', async () => { + const session = await client.session('set-peer-config-session', { metadata: {} }) + await session.addPeers(['peer-update-config']) + + await session.setPeerConfiguration( + 'peer-update-config', + { observeMe: false, observeOthers: true } ) - const status = await session.pollQueueStatus() + const config = await session.getPeerConfiguration('peer-update-config') + expect(config.observeMe).toBe(false) + expect(config.observeOthers).toBe(true) + }) - expect(status).toEqual({ - totalWorkUnits: 5, - completedWorkUnits: 5, - inProgressWorkUnits: 0, - pendingWorkUnits: 0, - sessions: undefined, + test('setPeerConfiguration with Peer object', async () => { + const session = await client.session('set-config-peer-obj', { metadata: {} }) + const peer = await client.peer('config-obj-peer') + await session.addPeers([peer]) + + await session.setPeerConfiguration(peer, { observeMe: true }) + + const config = await session.getPeerConfiguration(peer) + expect(config.observeMe).toBe(true) + }) + }) + }) + + // =========================================================================== + // Message Operations + // =========================================================================== + + describe('Message operations', () => { + describe('POST /sessions/:id/messages', () => { + test('addMessages creates messages', async () => { + const session = await client.session('add-messages-session', { metadata: {} }) + const peer = await client.peer('add-messages-peer') + await session.addPeers([peer.id]) + + const messages = await session.addMessages([ + peer.message('Hello'), + peer.message('World'), + ]) + + expect(messages.length).toBe(2) + assertMessageShape(messages[0]) + expect(messages[0].content).toBe('Hello') + }) + + test('addMessages with single message', async () => { + const session = await client.session('single-message-session', { metadata: {} }) + const peer = await client.peer('single-message-peer') + await session.addPeers([peer.id]) + + const messages = await session.addMessages(peer.message('Single')) + + expect(messages.length).toBe(1) + expect(messages[0].content).toBe('Single') + }) + }) + + describe('PUT /sessions/:id/messages/:id', () => { + test('updateMessage updates message metadata', async () => { + const session = await client.session('update-msg-session', { metadata: {} }) + const peer = await client.peer('update-msg-peer') + await session.addPeers([peer.id]) + const [message] = await session.addMessages([ + peer.message('Message to update'), + ]) + + const updated = await session.updateMessage(message, { + updated: true, + timestamp: Date.now(), }) - expect(mockClient.workspaces.queue.status).toHaveBeenCalledWith( - 'test-workspace', - { session_id: 'test-session' } - ) + expect(updated.metadata.updated).toBe(true) + expect(updated.metadata.timestamp).toBeDefined() }) - it('should timeout if processing takes too long', async () => { - const mockStatusPending = { - total_work_units: 5, - completed_work_units: 2, - in_progress_work_units: 2, - pending_work_units: 1, - } - mockClient.workspaces.queue.status.mockResolvedValue(mockStatusPending) + test('updateMessage with string ID', async () => { + const session = await client.session('update-msg-string-session', { + metadata: {}, + }) + const peer = await client.peer('update-msg-string-peer') + await session.addPeers([peer.id]) + const [message] = await session.addMessages([ + peer.message('Another message'), + ]) - await expect( - session.pollQueueStatus({ timeoutMs: 100 }) - ).rejects.toThrow() + const updated = await session.updateMessage(message.id, { fromId: true }) + + expect(updated.metadata.fromId).toBe(true) }) }) + + describe('POST /sessions/:id/messages/list', () => { + test('messages returns paginated list', async () => { + const session = await client.session('list-messages-session', { metadata: {} }) + const peer = await client.peer('list-messages-peer') + await session.addPeers([peer.id]) + await session.addMessages([ + peer.message('Msg 1'), + peer.message('Msg 2'), + ]) + + const page = await session.messages() + + expect(page.items.length).toBe(2) + assertMessageShape(page.items[0]) + }) + + test('messages with filters', async () => { + const session = await client.session('filter-messages-session', { metadata: {} }) + const peer = await client.peer('filter-messages-peer') + await session.addPeers([peer.id]) + await session.addMessages([ + peer.message('Filter me', { metadata: { tag: 'special' } }), + peer.message('Not this one'), + ]) + + const page = await session.messages({ metadata: { tag: 'special' } }) + + expect(page.items.length).toBe(1) + expect(page.items[0].metadata.tag).toBe('special') + }) + }) + }) + + // =========================================================================== + // Context and Summaries + // =========================================================================== + + describe('POST /sessions/:id/context', () => { + test('context returns context object', async () => { + const session = await client.session('context-session', { metadata: {} }) + const peer = await client.peer('context-peer') + await session.addPeers([peer.id]) + await session.addMessages([peer.message('Context content')]) + + const context = await session.context() + + expect(context).toBeDefined() + expect(context.sessionId).toBe(session.id) + expect(Array.isArray(context.messages)).toBe(true) + }) + + test('context with options object', async () => { + const session = await client.session('context-options-session', { metadata: {} }) + const peer = await client.peer('context-options-peer') + await session.addPeers([peer.id]) + await session.addMessages([peer.message('Some content')]) + + const context = await session.context({ + summary: true, + tokens: 1000, + peerTarget: peer.id, + }) + + expect(context).toBeDefined() + }) + + test('context with summary and tokens options', async () => { + const session = await client.session('context-options-2-session', { metadata: {} }) + const peer = await client.peer('context-options-2-peer') + await session.addPeers([peer.id]) + + const context = await session.context({ summary: true, tokens: 500 }) + + expect(context).toBeDefined() + }) + }) + + describe('GET /sessions/:id/summaries', () => { + test('summaries returns summary object', async () => { + const session = await client.session('summaries-session', { metadata: {} }) + + const summaries = await session.summaries() + + expect(summaries).toBeDefined() + expect(summaries.sessionId).toBe(session.id) + // Summaries may be null for sessions without enough messages + expect('shortSummary' in summaries).toBe(true) + expect('longSummary' in summaries).toBe(true) + }) + }) + + // =========================================================================== + // Search + // =========================================================================== + + describe('POST /sessions/:id/search', () => { + test('search returns matching messages', async () => { + const session = await client.session('search-session', { metadata: {} }) + const peer = await client.peer('search-peer') + await session.addPeers([peer.id]) + await session.addMessages([ + peer.message('The quick brown fox'), + peer.message('Jumped over the lazy dog'), + ]) + + const results = await session.search('quick brown') + + expect(Array.isArray(results)).toBe(true) + // Results are from this session + for (const msg of results) { + expect(msg.sessionId).toBe(session.id) + } + }) + + test('search with limit', async () => { + const session = await client.session('search-limit-session', { metadata: {} }) + + const results = await session.search('test', { limit: 5 }) + + expect(results.length).toBeLessThanOrEqual(5) + }) + }) + + // =========================================================================== + // Queue Status + // =========================================================================== + + describe('Queue status', () => { + test('queueStatus returns status for session', async () => { + const session = await client.session('queue-status-session', { metadata: {} }) + + const status = await session.queueStatus() + + expect(typeof status.totalWorkUnits).toBe('number') + expect(typeof status.completedWorkUnits).toBe('number') + expect(typeof status.pendingWorkUnits).toBe('number') + }) + + test('queueStatus with observer filter', async () => { + const session = await client.session('queue-observer-session', { metadata: {} }) + const peer = await client.peer('queue-observer-peer') + + const status = await session.queueStatus({ observer: peer }) + + expect(typeof status.totalWorkUnits).toBe('number') + }) + }) + + // =========================================================================== + // Representation + // =========================================================================== + + describe('POST /peers/:id/representation (session-scoped)', () => { + test('representation returns string', async () => { + const session = await client.session('repr-session', { metadata: {} }) + const peer = await client.peer('repr-peer') + await session.addPeers([peer.id]) + await session.addMessages([peer.message('Learning TypeScript')]) + + const representation = await session.representation(peer) + + expect(typeof representation).toBe('string') + }) + + test('representation with target', async () => { + const session = await client.session('repr-target-session', { metadata: {} }) + const observer = await client.peer('repr-observer') + const target = await client.peer('repr-target') + await session.addPeers([observer.id, target.id]) + + const representation = await session.representation(observer, { target }) + + expect(typeof representation).toBe('string') + }) + }) + + // =========================================================================== + // String Representation + // =========================================================================== + + describe('toString', () => { + test('returns readable format', async () => { + const session = await client.session('tostring-session', { metadata: {} }) + + const str = session.toString() + + expect(str).toBe("Session(id='tostring-session')") + }) }) }) diff --git a/sdks/typescript/__tests__/session_context.test.ts b/sdks/typescript/__tests__/session_context.test.ts deleted file mode 100644 index 203d4f98..00000000 --- a/sdks/typescript/__tests__/session_context.test.ts +++ /dev/null @@ -1,437 +0,0 @@ -import { SessionContext, Summary } from '../src/session_context' -import { Peer } from '../src/peer' - -/** - * Helper function to create a proper Message object for testing - */ -function createTestMessage( - id: string, - content: string, - peer_id: string, - additionalProps: any = {} -): any { - return { - id, - content, - peer_id, - created_at: new Date().toISOString(), - session_id: 'test-session', - token_count: 0, - workspace_id: 'test-workspace', - ...additionalProps, - } -} - -/** - * Helper function to create a test Summary object - */ -function createTestSummary(content: string): Summary { - return new Summary({ - content, - message_id: 'test-message-id', - summary_type: 'short', - created_at: new Date().toISOString(), - token_count: content.length, - }) -} - -describe('SessionContext', () => { - let sessionContext: SessionContext - let mockMessages: any[] - - beforeEach(() => { - mockMessages = [ - createTestMessage('msg1', 'Hello', 'assistant'), - createTestMessage('msg2', 'Hi there', 'user'), - createTestMessage('msg3', 'How are you?', 'user'), - createTestMessage('msg4', 'I am doing well, thank you!', 'assistant'), - ] - - sessionContext = new SessionContext('test-session', mockMessages, null) - }) - - describe('constructor', () => { - it('should initialize with all properties', () => { - expect(sessionContext.sessionId).toBe('test-session') - expect(sessionContext.messages).toEqual(mockMessages) - expect(sessionContext.summary).toBe(null) - }) - - it('should initialize with null summary when not provided', () => { - const context = new SessionContext('session-id', mockMessages) - - expect(context.sessionId).toBe('session-id') - expect(context.messages).toEqual(mockMessages) - expect(context.summary).toBe(null) - }) - - it('should handle empty messages array', () => { - const summary = createTestSummary('No messages') - const context = new SessionContext('session-id', [], summary) - - expect(context.sessionId).toBe('session-id') - expect(context.messages).toEqual([]) - expect(context.summary).toBe(summary) - }) - - it('should handle null/undefined summary', () => { - const context1 = new SessionContext( - 'session-id', - mockMessages, - undefined as any - ) - const context2 = new SessionContext('session-id', mockMessages, null) - - expect(context1.summary).toBe(null) - expect(context2.summary).toBe(null) - }) - }) - - describe('toOpenAI', () => { - it('should convert messages to OpenAI format with string assistant', () => { - const openAIMessages = sessionContext.toOpenAI('assistant') - - expect(openAIMessages).toEqual([ - { role: 'assistant', content: 'Hello', name: 'assistant' }, - { role: 'user', content: 'Hi there', name: 'user' }, - { role: 'user', content: 'How are you?', name: 'user' }, - { - role: 'assistant', - content: 'I am doing well, thank you!', - name: 'assistant', - }, - ]) - }) - - it('should convert messages to OpenAI format with Peer object', () => { - const mockClient = {} as any - const assistantPeer = new Peer('assistant', 'test-workspace', mockClient) - - const openAIMessages = sessionContext.toOpenAI(assistantPeer) - - expect(openAIMessages).toEqual([ - { role: 'assistant', content: 'Hello', name: 'assistant' }, - { role: 'user', content: 'Hi there', name: 'user' }, - { role: 'user', content: 'How are you?', name: 'user' }, - { - role: 'assistant', - content: 'I am doing well, thank you!', - name: 'assistant', - }, - ]) - }) - - it('should handle messages where assistant is different peer', () => { - const openAIMessages = sessionContext.toOpenAI('different-assistant') - - expect(openAIMessages).toEqual([ - { role: 'user', content: 'Hello', name: 'assistant' }, - { role: 'user', content: 'Hi there', name: 'user' }, - { role: 'user', content: 'How are you?', name: 'user' }, - { - role: 'user', - content: 'I am doing well, thank you!', - name: 'assistant', - }, - ]) - }) - - it('should handle empty messages array', () => { - const emptyContext = new SessionContext('session-id', []) - const openAIMessages = emptyContext.toOpenAI('assistant') - - expect(openAIMessages).toEqual([]) - }) - - it('should include summary message when summary exists', () => { - const summary = createTestSummary('This is a summary') - const contextWithSummary = new SessionContext( - 'test-session', - mockMessages, - summary - ) - const openAIMessages = contextWithSummary.toOpenAI('assistant') - - expect(openAIMessages).toEqual([ - { role: 'system', content: 'This is a summary' }, - { role: 'assistant', content: 'Hello', name: 'assistant' }, - { role: 'user', content: 'Hi there', name: 'user' }, - { role: 'user', content: 'How are you?', name: 'user' }, - { - role: 'assistant', - content: 'I am doing well, thank you!', - name: 'assistant', - }, - ]) - }) - - it('should handle messages with missing peer_id', () => { - const messagesWithMissingPeer = [ - createTestMessage('msg1', 'Hello', 'assistant'), - createTestMessage('msg2', 'No peer', ''), // missing peer_id - createTestMessage('msg3', 'Another message', ''), // null peer_id - ] - const context = new SessionContext('test', messagesWithMissingPeer) - - const openAIMessages = context.toOpenAI('assistant') - - expect(openAIMessages).toEqual([ - { role: 'assistant', content: 'Hello', name: 'assistant' }, - { role: 'user', content: 'No peer', name: '' }, - { role: 'user', content: 'Another message', name: '' }, - ]) - }) - - it('should handle complex message content', () => { - const complexMessages = [ - createTestMessage( - 'msg1', - 'Message with\nnewlines and special chars!@#$%', - 'assistant' - ), - createTestMessage('msg2', '', 'user'), // empty content - createTestMessage('msg3', ' whitespace ', 'assistant'), - ] - const context = new SessionContext('test', complexMessages) - - const openAIMessages = context.toOpenAI('assistant') - - expect(openAIMessages).toEqual([ - { - role: 'assistant', - content: 'Message with\nnewlines and special chars!@#$%', - name: 'assistant', - }, - { role: 'user', content: '', name: 'user' }, - { role: 'assistant', content: ' whitespace ', name: 'assistant' }, - ]) - }) - }) - - describe('toAnthropic', () => { - it('should convert messages to Anthropic format with string assistant', () => { - const anthropicMessages = sessionContext.toAnthropic('assistant') - - expect(anthropicMessages).toEqual([ - { role: 'assistant', content: 'Hello' }, - { role: 'user', content: 'user: Hi there' }, - { role: 'user', content: 'user: How are you?' }, - { role: 'assistant', content: 'I am doing well, thank you!' }, - ]) - }) - - it('should convert messages to Anthropic format with Peer object', () => { - const mockClient = {} as any - const assistantPeer = new Peer('assistant', 'test-workspace', mockClient) - - const anthropicMessages = sessionContext.toAnthropic(assistantPeer) - - expect(anthropicMessages).toEqual([ - { role: 'assistant', content: 'Hello' }, - { role: 'user', content: 'user: Hi there' }, - { role: 'user', content: 'user: How are you?' }, - { role: 'assistant', content: 'I am doing well, thank you!' }, - ]) - }) - - it('should handle messages where assistant is different peer', () => { - const anthropicMessages = sessionContext.toAnthropic( - 'different-assistant' - ) - - expect(anthropicMessages).toEqual([ - { role: 'user', content: 'assistant: Hello' }, - { role: 'user', content: 'user: Hi there' }, - { role: 'user', content: 'user: How are you?' }, - { role: 'user', content: 'assistant: I am doing well, thank you!' }, - ]) - }) - - it('should handle empty messages array', () => { - const emptyContext = new SessionContext('session-id', []) - const anthropicMessages = emptyContext.toAnthropic('assistant') - - expect(anthropicMessages).toEqual([]) - }) - - it('should include summary message when summary exists', () => { - const summary = createTestSummary('This is a summary') - const contextWithSummary = new SessionContext( - 'test-session', - mockMessages, - summary - ) - const anthropicMessages = contextWithSummary.toAnthropic('assistant') - - expect(anthropicMessages).toEqual([ - { role: 'user', content: 'This is a summary' }, - { role: 'assistant', content: 'Hello' }, - { role: 'user', content: 'user: Hi there' }, - { role: 'user', content: 'user: How are you?' }, - { role: 'assistant', content: 'I am doing well, thank you!' }, - ]) - }) - - it('should handle messages with missing peer_id', () => { - const messagesWithMissingPeer = [ - createTestMessage('msg1', 'Hello', 'assistant'), - createTestMessage('msg2', 'No peer', ''), // missing peer_id - createTestMessage('msg3', 'Another message', ''), // undefined peer_id - ] - const context = new SessionContext('test', messagesWithMissingPeer) - - const anthropicMessages = context.toAnthropic('assistant') - - expect(anthropicMessages).toEqual([ - { role: 'assistant', content: 'Hello' }, - { role: 'user', content: ': No peer' }, - { role: 'user', content: ': Another message' }, - ]) - }) - }) - - describe('length getter', () => { - it('should return correct message count', () => { - expect(sessionContext.length).toBe(4) - }) - - it('should return zero for empty messages', () => { - const emptyContext = new SessionContext('session-id', []) - expect(emptyContext.length).toBe(0) - }) - - it('should return correct count for single message', () => { - const singleMessageContext = new SessionContext('session-id', [ - mockMessages[0], - ]) - expect(singleMessageContext.length).toBe(1) - }) - }) - - describe('toString', () => { - it('should return correct string representation', () => { - const result = sessionContext.toString() - expect(result).toBe('SessionContext(messages=4, summary=none)') - }) - - it('should handle empty messages', () => { - const emptyContext = new SessionContext('session-id', []) - const result = emptyContext.toString() - expect(result).toBe('SessionContext(messages=0, summary=none)') - }) - - it('should handle large number of messages', () => { - const manyMessages = Array.from({ length: 1000 }, (_, i) => - createTestMessage( - `msg${i}`, - `Message ${i}`, - i % 2 === 0 ? 'assistant' : 'user' - ) - ) - const context = new SessionContext('session-id', manyMessages) - - const result = context.toString() - expect(result).toBe('SessionContext(messages=1000, summary=none)') - }) - }) - - describe('edge cases and error handling', () => { - it('should handle messages with null content', () => { - const messagesWithNullContent = [ - createTestMessage('msg1', null as any, 'assistant'), - createTestMessage('msg2', undefined as any, 'user'), - ] - const context = new SessionContext('test', messagesWithNullContent) - - const openAIMessages = context.toOpenAI('assistant') - - expect(openAIMessages).toEqual([ - { role: 'assistant', content: null, name: 'assistant' }, - { role: 'user', content: undefined, name: 'user' }, - ]) - }) - - it('should handle messages with non-string content', () => { - const messagesWithNonStringContent = [ - createTestMessage('msg1', 123 as any, 'assistant'), - createTestMessage('msg2', { text: 'object content' } as any, 'user'), - createTestMessage('msg3', true as any, 'assistant'), - ] - const context = new SessionContext('test', messagesWithNonStringContent) - - const openAIMessages = context.toOpenAI('assistant') - - expect(openAIMessages).toEqual([ - { role: 'assistant', content: 123, name: 'assistant' }, - { role: 'user', content: { text: 'object content' }, name: 'user' }, - { role: 'assistant', content: true, name: 'assistant' }, - ]) - }) - - it('should handle very long session IDs and summaries', () => { - const longSessionId = 'x'.repeat(1000) - const longSummaryText = 'Very long summary that goes on and on...'.repeat( - 100 - ) - const longSummary = createTestSummary(longSummaryText) - const context = new SessionContext( - longSessionId, - mockMessages, - longSummary - ) - - expect(context.sessionId).toBe(longSessionId) - expect(context.summary).toBe(longSummary) - expect(context.summary?.content).toBe(longSummaryText) - expect(context.length).toBe(5) // 4 messages + 1 summary - }) - - it('should handle messages with additional properties', () => { - const messagesWithExtraProps = [ - createTestMessage('msg1', 'Hello', 'assistant', { - timestamp: '2023-01-01T00:00:00Z', - metadata: { important: true }, - extra_field: 'extra_value', - }), - ] - const context = new SessionContext('test', messagesWithExtraProps) - - const openAIMessages = context.toOpenAI('assistant') - - expect(openAIMessages).toEqual([ - { role: 'assistant', content: 'Hello', name: 'assistant' }, - ]) - }) - - it('should handle case-sensitive peer names', () => { - const caseMessages = [ - createTestMessage('msg1', 'Hello', 'Assistant'), - createTestMessage('msg2', 'Hi', 'ASSISTANT'), - createTestMessage('msg3', 'Hey', 'assistant'), - ] - const context = new SessionContext('test', caseMessages) - - const openAIMessages = context.toOpenAI('assistant') - - expect(openAIMessages).toEqual([ - { role: 'user', content: 'Hello', name: 'Assistant' }, // 'Assistant' != 'assistant' - { role: 'user', content: 'Hi', name: 'ASSISTANT' }, // 'ASSISTANT' != 'assistant' - { role: 'assistant', content: 'Hey', name: 'assistant' }, // exact match - ]) - }) - - it('should handle messages without id field', () => { - const messagesWithoutId = [ - createTestMessage('', 'Message without ID', 'assistant'), - createTestMessage('', 'Another message', 'user'), - ] - const context = new SessionContext('test', messagesWithoutId) - - expect(context.length).toBe(2) - expect(context.toOpenAI('assistant')).toEqual([ - { role: 'assistant', content: 'Message without ID', name: 'assistant' }, - { role: 'user', content: 'Another message', name: 'user' }, - ]) - }) - }) -}) diff --git a/sdks/typescript/__tests__/setup.ts b/sdks/typescript/__tests__/setup.ts index a0a00106..4ab6143f 100644 --- a/sdks/typescript/__tests__/setup.ts +++ b/sdks/typescript/__tests__/setup.ts @@ -1,19 +1,137 @@ -// Global test setup -import 'jest'; +/** + * Test Setup + * + * Provides utilities for running integration tests against a live Honcho server. + * + * ============================================================================ + * 🚨 DO NOT RUN `bun test` DIRECTLY - IT WILL FAIL 🚨 + * + * These tests require a running server with database and Redis. + * The test infrastructure is orchestrated via pytest from the monorepo root. + * + * To run these tests: + * cd /path/to/honcho # monorepo root, NOT sdks/typescript + * uv run pytest tests/ -k typescript + * ============================================================================ + * + * Environment variables (set automatically by pytest): + * - HONCHO_TEST_URL: Base URL of the test server + * - HONCHO_TEST_API_KEY: API key for authentication + */ -// Suppress console warnings during tests unless explicitly testing them -const originalConsoleWarn = console.warn; -const originalConsoleError = console.error; +import { Honcho } from '../src' -beforeAll(() => { - console.warn = jest.fn(); - console.error = jest.fn(); -}); +/** + * Configuration for test runs. + */ +export const TEST_CONFIG = { + baseURL: process.env.HONCHO_TEST_URL || 'http://localhost:8000', + apiKey: process.env.HONCHO_TEST_API_KEY, + timeout: 30000, // 30 seconds for integration tests +} as const -afterAll(() => { - console.warn = originalConsoleWarn; - console.error = originalConsoleError; -}); +/** + * Generate a unique workspace ID for test isolation. + * Each test file should use its own workspace to avoid interference. + */ +export function generateWorkspaceId(prefix: string): string { + const timestamp = Date.now() + const random = Math.random().toString(36).substring(2, 8) + return `test-${prefix}-${timestamp}-${random}` +} -// Mock environment variables for tests -process.env.NODE_ENV = 'test'; +/** + * Generate a unique ID for test entities (peers, sessions, etc.) + */ +export function generateId(prefix: string): string { + const random = Math.random().toString(36).substring(2, 10) + return `${prefix}-${random}` +} + +/** + * Create a test client with an isolated workspace. + * Returns the client and a cleanup function. + */ +export async function createTestClient( + prefix: string +): Promise<{ client: Honcho; cleanup: () => Promise }> { + const workspaceId = generateWorkspaceId(prefix) + + const client = new Honcho({ + baseURL: TEST_CONFIG.baseURL, + apiKey: TEST_CONFIG.apiKey, + workspaceId, + timeout: TEST_CONFIG.timeout, + }) + + // Ensure workspace exists by fetching metadata + await client.getMetadata() + + const cleanup = async () => { + try { + await client.deleteWorkspace(workspaceId) + } catch { + // Workspace may already be deleted or not exist + } + } + + return { client, cleanup } +} + +/** + * Wait for the server to be ready. + * Checks the /docs endpoint since there's no /health endpoint. + */ +export async function waitForServer( + maxAttempts = 10, + delayMs = 1000 +): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + // Try the docs endpoint - it's always available + const response = await fetch(`${TEST_CONFIG.baseURL}/docs`) + if (response.ok) { + // Also verify the API is actually working by creating a test workspace + const apiResponse = await fetch(`${TEST_CONFIG.baseURL}/v3/workspaces`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: `health-check-${Date.now()}` }), + }) + // 200/201 means working, 401 means auth required but server works + if (apiResponse.ok || apiResponse.status === 401) { + return true + } + } + } catch { + // Server not ready yet + } + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + return false +} + +/** + * Skip test if server is not available. + * Use this in beforeAll to gracefully skip integration tests. + */ +export async function requireServer(): Promise { + // Wait up to 15 seconds (30 attempts × 500ms) for the server to be ready + // This accounts for server startup time in pytest fixtures + const ready = await waitForServer(30, 500) + if (!ready) { + throw new Error( + '\n\n' + + '╔══════════════════════════════════════════════════════════════════╗\n' + + '║ ERROR: Cannot run tests - no server available ║\n' + + '║ ║\n' + + '║ You probably ran `bun test` directly. DON\'T DO THAT. ║\n' + + '║ ║\n' + + '║ These tests MUST be run via pytest from the monorepo root: ║\n' + + '║ ║\n' + + '║ cd /path/to/honcho ║\n' + + '║ uv run pytest tests/ -k typescript ║\n' + + '║ ║\n' + + '╚══════════════════════════════════════════════════════════════════╝\n' + ) + } +} diff --git a/sdks/typescript/__tests__/streaming.test.ts b/sdks/typescript/__tests__/streaming.test.ts new file mode 100644 index 00000000..d94a7b0b --- /dev/null +++ b/sdks/typescript/__tests__/streaming.test.ts @@ -0,0 +1,259 @@ +/** + * Streaming Tests + * + * Tests for Server-Sent Events (SSE) streaming responses. + * + * Endpoints covered: + * - POST /v1/workspaces/:id/peers/:id/chat (via chatStream()) + * + * These tests verify: + * - Streaming responses are properly parsed + * - Async iteration works correctly + * - Stream can be consumed chunk by chunk + * - Full response can be collected + */ + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { Honcho } from '../src' +import { createTestClient, requireServer } from './setup' +import { collectStream } from './helpers' + +describe('Streaming', () => { + let client: Honcho + let cleanup: () => Promise + + beforeAll(async () => { + await requireServer() + const setup = await createTestClient('streaming') + client = setup.client + cleanup = setup.cleanup + }) + + afterAll(async () => { + await cleanup() + }) + + // =========================================================================== + // Basic Streaming + // =========================================================================== + + describe('POST /peers/:id/chat (streaming)', () => { + test('chatStream() returns async iterable', async () => { + const peer = await client.peer('stream-basic-peer') + const session = await client.session('stream-basic-session') + await session.addPeers([peer.id]) + await session.addMessages([ + peer.message('I enjoy playing chess and reading mystery novels'), + ]) + + const response = await peer.chatStream("What are this user's hobbies?") + + // Should return an async iterable + expect(response).not.toBeNull() + expect(Symbol.asyncIterator in response).toBe(true) + }) + + test('streaming response yields chunks', async () => { + const peer = await client.peer('stream-chunks-peer') + const session = await client.session('stream-chunks-session') + await session.addPeers([peer.id]) + await session.addMessages([ + peer.message('My favorite programming language is TypeScript'), + ]) + + const response = await peer.chatStream('What programming language?') + + const chunks: string[] = [] + for await (const chunk of response) { + chunks.push(chunk) + } + + // Should have received some chunks + // (The actual content depends on the server/model behavior) + expect(Array.isArray(chunks)).toBe(true) + }) + + test('streaming chunks combine to full response', async () => { + const peer = await client.peer('stream-combine-peer') + const session = await client.session('stream-combine-session') + await session.addPeers([peer.id]) + await session.addMessages([ + peer.message('I live in San Francisco and work as a software engineer'), + ]) + + const response = await peer.chatStream('Where does this user live?') + const fullResponse = await collectStream(response) + + // Should be a non-empty string when combined + expect(typeof fullResponse).toBe('string') + }) + + test('streaming with session scope', async () => { + const peer = await client.peer('stream-scoped-peer') + const session = await client.session('stream-scoped-session') + await session.addPeers([peer.id]) + await session.addMessages([ + peer.message('In this session, I am discussing project planning'), + ]) + + const response = await peer.chatStream('What is being discussed?', { + session: session, + }) + + const chunks: string[] = [] + for await (const chunk of response) { + chunks.push(chunk) + } + expect(Array.isArray(chunks)).toBe(true) + }) + + test('streaming with target peer', async () => { + const observer = await client.peer('stream-observer') + const target = await client.peer('stream-target') + const session = await client.session('stream-target-session') + await session.addPeers([observer.id, target.id]) + await session.addMessages([ + target.message('I am the target peer sharing information'), + ]) + + const response = await observer.chatStream( + 'What do you know about this user?', + { target: target } + ) + + const collected = await collectStream(response) + expect(typeof collected).toBe('string') + }) + + test('streaming with reasoning level', async () => { + const peer = await client.peer('stream-reasoning-peer') + const session = await client.session('stream-reasoning-session') + await session.addPeers([peer.id]) + await session.addMessages([peer.message('Complex information here')]) + + const response = await peer.chatStream('Analyze this user', { + reasoningLevel: 'medium', + }) + + const chunks: string[] = [] + for await (const chunk of response) { + chunks.push(chunk) + } + expect(Array.isArray(chunks)).toBe(true) + }) + }) + + // =========================================================================== + // Stream Consumption Patterns + // =========================================================================== + + describe('Stream consumption patterns', () => { + test('stream can only be consumed once', async () => { + const peer = await client.peer('stream-once-peer') + const session = await client.session('stream-once-session') + await session.addPeers([peer.id]) + await session.addMessages([peer.message('Test content')]) + + const response = await peer.chatStream('Test query') + + // First consumption + const first: string[] = [] + for await (const chunk of response) { + first.push(chunk) + } + + // Second consumption should yield nothing (stream exhausted) + const second: string[] = [] + for await (const chunk of response) { + second.push(chunk) + } + + // Note: Behavior depends on implementation + // Some streams throw, others just return empty + expect(Array.isArray(second)).toBe(true) + }) + + test('early break from stream', async () => { + const peer = await client.peer('stream-break-peer') + const session = await client.session('stream-break-session') + await session.addPeers([peer.id]) + await session.addMessages([peer.message('Long content here')]) + + const response = await peer.chatStream('Query') + + let chunkCount = 0 + for await (const chunk of response) { + chunkCount++ + if (chunkCount >= 2) { + break // Exit early + } + } + + // Should have exited early without error + expect(chunkCount).toBeLessThanOrEqual(2) + }) + }) + + // =========================================================================== + // Non-streaming Comparison + // =========================================================================== + + describe('Streaming vs non-streaming', () => { + test('chat() returns string directly', async () => { + const peer = await client.peer('nonstream-peer') + const session = await client.session('nonstream-session') + await session.addPeers([peer.id]) + await session.addMessages([peer.message('Some user preferences')]) + + const response = await peer.chat('What are the preferences?') + + // Non-streaming returns string or null directly + expect(response === null || typeof response === 'string').toBe(true) + }) + + test('chatStream() returns async iterable', async () => { + const peer = await client.peer('stream-method-peer') + const session = await client.session('stream-method-session') + await session.addPeers([peer.id]) + await session.addMessages([peer.message('Stream method test')]) + + const response = await peer.chatStream('Query with stream method') + + // Streaming returns async iterable + expect(Symbol.asyncIterator in response).toBe(true) + }) + }) + + // =========================================================================== + // Edge Cases + // =========================================================================== + + describe('Edge cases', () => { + test('streaming with minimal data', async () => { + const peer = await client.peer('stream-minimal-peer') + + // No session data, representation will be empty + const response = await peer.chatStream('What do you know?') + + // Should still return an iterable (might yield empty or null-like content) + const chunks: string[] = [] + for await (const chunk of response) { + chunks.push(chunk) + } + expect(Array.isArray(chunks)).toBe(true) + }) + + test('separate methods have distinct return types', async () => { + const peer = await client.peer('stream-typing-peer') + + const streamResponse = await peer.chatStream('Query') + const nonStreamResponse = await peer.chat('Query') + + // chatStream() returns DialecticStreamResponse (async iterable) + expect(Symbol.asyncIterator in streamResponse).toBe(true) + + // chat() returns string | null + expect(nonStreamResponse === null || typeof nonStreamResponse === 'string').toBe(true) + }) + }) +}) diff --git a/sdks/typescript/bunfig.toml b/sdks/typescript/bunfig.toml new file mode 100644 index 00000000..7dccfc52 --- /dev/null +++ b/sdks/typescript/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./__tests__/preload.ts"] diff --git a/sdks/typescript/examples/get_context.ts b/sdks/typescript/examples/get_context.ts index 453ba85e..91f86718 100644 --- a/sdks/typescript/examples/get_context.ts +++ b/sdks/typescript/examples/get_context.ts @@ -1,4 +1,4 @@ -import { Honcho, Message } from '../src'; +import { Honcho, MessageInput } from '../src'; /** * Example demonstrating how to get context from a session with summary and token limits. @@ -27,7 +27,7 @@ async function main() { console.log('Generating random messages...'); // Generate some random messages from alice, bob, and charlie and add them to the session - const messages: Message[] = []; + const messages: MessageInput[] = []; for (let i = 0; i < 10; i++) { const randomPeer = peers[Math.floor(Math.random() * peers.length)]; messages.push( @@ -41,7 +41,7 @@ async function main() { console.log('Getting context with summary and low token limit...'); // Get some context of the session // Set the token limit super low so we only get a few of the tiny messages created - const context = await session.getContext({ summary: true, tokens: 50 }); + const context = await session.context({ summary: true, tokens: 50 }); console.log('Context returned:', context); console.log('Example completed successfully!'); diff --git a/sdks/typescript/examples/get_representation.ts b/sdks/typescript/examples/get_representation.ts index 7b036da7..19cd5a6f 100644 --- a/sdks/typescript/examples/get_representation.ts +++ b/sdks/typescript/examples/get_representation.ts @@ -1,4 +1,4 @@ -import { Honcho, type MessageCreate } from '../src'; +import { Honcho, type MessageInput } from '../src'; /** * Example demonstrating how to get peer representations. @@ -27,7 +27,7 @@ async function main() { console.log('Generating random messages...'); // Generate some random messages from alice, bob, and charlie and add them to the session - const messages: MessageCreate[] = []; + const messages: MessageInput[] = []; for (let i = 0; i < 10; i++) { const randomPeer = peers[Math.floor(Math.random() * peers.length)]; messages.push( @@ -43,12 +43,12 @@ async function main() { console.log('Getting alice\'s working representation in session...'); // Get alice's working representation in the session - const representation = await session.getRepresentation(alice); + const representation = await session.representation(alice); console.log('Representation returned:', representation); console.log('Getting alice\'s working representation *of bob* in session...'); // Get alice's working representation *of bob* in the session - const representationOfBob = await session.getRepresentation(alice, bob); + const representationOfBob = await session.representation(alice, { target: bob }); console.log('Representation returned:', representationOfBob); console.log('Example completed successfully!'); diff --git a/sdks/typescript/examples/get_summaries.ts b/sdks/typescript/examples/get_summaries.ts index 15bcd4a9..138707fb 100644 --- a/sdks/typescript/examples/get_summaries.ts +++ b/sdks/typescript/examples/get_summaries.ts @@ -21,7 +21,7 @@ async function main() { const session = await client.session("my-conversation-session"); // Get summaries for the session - const summaries: SessionSummaries = await session.getSummaries(); + const summaries: SessionSummaries = await session.summaries(); console.log(`Session ID: ${summaries.id}`); console.log("-".repeat(50)); diff --git a/sdks/typescript/jest.config.js b/sdks/typescript/jest.config.js deleted file mode 100644 index 0209f4df..00000000 --- a/sdks/typescript/jest.config.js +++ /dev/null @@ -1,20 +0,0 @@ -export default { - preset: 'ts-jest', - testEnvironment: 'node', - roots: ['/src', '/__tests__'], - testMatch: ['**/__tests__/**/*.test.ts', '**/?(*.)+(spec|test).ts'], - transform: { - '^.+\\.ts$': 'ts-jest', - }, - collectCoverageFrom: [ - 'src/**/*.ts', - '!src/**/*.d.ts', - ], - coverageDirectory: 'coverage', - coverageReporters: ['text', 'lcov', 'html'], - setupFilesAfterEnv: ['/__tests__/setup.ts'], - moduleNameMapper: { - '^@honcho-ai/core$': '/__tests__/__mocks__/@honcho-ai/core.ts', - }, - testTimeout: 10000, -}; diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index fa89528a..956066ed 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@honcho-ai/sdk", - "version": "1.6.0", + "version": "2.0.0", "description": "Official DX Optimized TypeScript SDK for Honcho", "author": "Plastic Labs ", "license": "Apache-2.0", @@ -15,20 +15,15 @@ "lint:fix": "biome check src/ --write", "format": "biome format src/ --write", "typecheck": "tsc --noEmit", - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage" + "test": "echo 'Error: Tests must be run from the monorepo root via pytest. See tests/README.md' && exit 1" }, "dependencies": { - "@honcho-ai/core": "2.2.0", "@types/node": "^24.0.1", "zod": "4.0.0" }, "devDependencies": { "@biomejs/biome": "^2.1.2", - "@types/jest": "^29.5.14", - "jest": "^29.7.0", - "ts-jest": "^29.1.0", + "@types/bun": "latest", "typescript": "^5.0.0" } } diff --git a/sdks/typescript/src/api-version.ts b/sdks/typescript/src/api-version.ts new file mode 100644 index 00000000..5b287be2 --- /dev/null +++ b/sdks/typescript/src/api-version.ts @@ -0,0 +1,4 @@ +/** + * API version used for all Honcho API requests. + */ +export const API_VERSION = 'v3' diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 9485e202..bc818146 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -1,25 +1,33 @@ -import HonchoCore from '@honcho-ai/core' -import type { DefaultQuery } from '@honcho-ai/core/core' -import type { - QueueStatusParams, - QueueStatusResponse, -} from '@honcho-ai/core/resources/workspaces/queue' -import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages' +import { API_VERSION } from './api-version' +import { HonchoHTTPClient } from './http/client' +import { Message } from './message' import { Page } from './pagination' import { Peer } from './peer' import { Session } from './session' +import type { + MessageResponse, + PageResponse, + PeerResponse, + QueueStatus, + QueueStatusParams, + QueueStatusResponse, + SessionResponse, + WorkspaceResponse, +} from './types/api' +import { resolveId, transformQueueStatus } from './utils' import { FilterSchema, type Filters, type HonchoConfig, HonchoConfigSchema, LimitSchema, - MessageMetadataSchema, type PeerConfig, PeerConfigSchema, PeerIdSchema, type PeerMetadata, PeerMetadataSchema, + peerConfigFromApi, + peerConfigToApi, type QueueStatusOptions, SearchQuerySchema, type SessionConfig, @@ -27,12 +35,18 @@ import { SessionIdSchema, type SessionMetadata, SessionMetadataSchema, + sessionConfigFromApi, + sessionConfigToApi, type WorkspaceConfig, WorkspaceConfigSchema, type WorkspaceMetadata, WorkspaceMetadataSchema, + workspaceConfigFromApi, + workspaceConfigToApi, } from './validation' +const DEFAULT_BASE_URL = 'https://api.honcho.dev' + /** * Main client for the Honcho TypeScript SDK. * @@ -40,9 +54,6 @@ import { * from environment variables or explicit parameters. This is the primary entry * point for interacting with the Honcho conversational memory platform. * - * For advanced usage, the underlying @honcho-ai/core client can be accessed via the - * `core` property to use functionality not exposed through this SDK. - * * @example * ```typescript * const honcho = new Honcho({ @@ -60,9 +71,9 @@ export class Honcho { */ readonly workspaceId: string /** - * Reference to the core Honcho client instance. + * Reference to the HTTP client instance. */ - private _client: HonchoCore + private _http: HonchoHTTPClient /** * Private cached metadata for this workspace. */ @@ -70,7 +81,11 @@ export class Honcho { /** * Private cached configuration for this workspace. */ - private _configuration?: Record + private _configuration?: WorkspaceConfig + /** + * Memoized workspace get-or-create call. + */ + private _workspaceReady?: Promise /** * Cached metadata for this workspace. May be stale if the workspace @@ -87,30 +102,27 @@ export class Honcho { * Cached configuration for this workspace. May be stale if the workspace * was not recently fetched from the API. * - * Call getConfig() to get the latest configuration from the server, + * Call getConfiguration() to get the latest configuration from the server, * which will also update this cached value. */ - get configuration(): Record | undefined { + get configuration(): WorkspaceConfig | undefined { return this._configuration } /** - * Access the underlying @honcho-ai/core client. The @honcho-ai/core client is the raw Stainless-generated client, - * allowing users to access functionality that is not exposed through this SDK. + * Access the underlying HTTP client for advanced usage. * - * @returns The underlying HonchoCore client instance - * - * @example - * ```typescript - * import { Honcho } from '@honcho-ai/sdk'; - * - * const client = new Honcho(); - * - * const workspace = await client.core.workspaces.getOrCreate({ id: "custom-workspace-id" }); - * ``` + * @returns The HTTP client instance */ - get core(): InstanceType { - return this._client + get http(): HonchoHTTPClient { + return this._http + } + + /** + * Get the base URL for the API. + */ + get baseURL(): string { + return this._http.baseURL } /** @@ -129,34 +141,215 @@ export class Honcho { * @param options.timeout - Optional custom timeout for the HTTP client * @param options.maxRetries - Optional custom maximum number of retries for the HTTP client * @param options.defaultHeaders - Optional custom default headers for the HTTP client - * @param options.defaultQuery - Optional custom default query parameters for the HTTP client */ - constructor(options: HonchoConfig) { + constructor(options: HonchoConfig = {}) { const validatedOptions = HonchoConfigSchema.parse(options) this.workspaceId = validatedOptions.workspaceId || process.env.HONCHO_WORKSPACE_ID || 'default' - this._client = new HonchoCore({ + + // Resolve base URL + let baseURL = validatedOptions.baseURL || process.env.HONCHO_URL + if (validatedOptions.environment === 'local') { + baseURL = 'http://localhost:8000' + } else if (!baseURL) { + baseURL = DEFAULT_BASE_URL + } + + this._http = new HonchoHTTPClient({ + baseURL, apiKey: validatedOptions.apiKey || process.env.HONCHO_API_KEY, - environment: validatedOptions.environment, - baseURL: validatedOptions.baseURL || process.env.HONCHO_URL, timeout: validatedOptions.timeout, maxRetries: validatedOptions.maxRetries, defaultHeaders: validatedOptions.defaultHeaders, - defaultQuery: validatedOptions.defaultQuery as DefaultQuery, + defaultQuery: validatedOptions.defaultQuery, }) - // Note: Constructor cannot be async, so we can't await here - // The workspace will be created on first use if it doesn't exist - // due to the upsert behavior of the API - this._client.workspaces.getOrCreate({ id: this.workspaceId }) } + // =========================================================================== + // Private API Methods + // =========================================================================== + + private async _getOrCreateWorkspace( + id: string, + params?: { + metadata?: Record + configuration?: WorkspaceConfig + } + ): Promise { + return this._http.post(`/${API_VERSION}/workspaces`, { + body: { + id, + metadata: params?.metadata, + configuration: workspaceConfigToApi(params?.configuration), + }, + }) + } + + private async _ensureWorkspace(): Promise { + /** + * Ensure the workspace exists on the server. + * + * The Honcho API uses get-or-create semantics for workspaces via `POST /v3/workspaces`. + * This SDK performs that call once per client instance (memoized) to guarantee that + * all workspace-scoped operations run against an existing workspace. + */ + if (!this._workspaceReady) { + this._workspaceReady = this._getOrCreateWorkspace(this.workspaceId).then( + () => undefined + ) + } + await this._workspaceReady + } + + private async _updateWorkspace( + workspaceId: string, + params: { + metadata?: Record + configuration?: WorkspaceConfig + } + ): Promise { + return this._http.put( + `/${API_VERSION}/workspaces/${workspaceId}`, + { + body: { + metadata: params.metadata, + configuration: workspaceConfigToApi(params.configuration), + }, + } + ) + } + + private async _deleteWorkspace(workspaceId: string): Promise { + await this._http.delete(`/${API_VERSION}/workspaces/${workspaceId}`) + } + + private async _listWorkspaces(params?: { + filters?: Record + page?: number + size?: number + }): Promise> { + return this._http.post>( + `/${API_VERSION}/workspaces/list`, + { + body: { + filters: params?.filters, + }, + query: { + page: params?.page, + size: params?.size, + }, + } + ) + } + + private async _searchWorkspace( + workspaceId: string, + params: { + query: string + filters?: Record + limit?: number + } + ): Promise { + return this._http.post( + `/${API_VERSION}/workspaces/${workspaceId}/search`, + { body: params } + ) + } + + private async _getQueueStatus( + workspaceId: string, + params?: QueueStatusParams + ): Promise { + const query: Record = {} + if (params?.observer_id) query.observer_id = params.observer_id + if (params?.sender_id) query.sender_id = params.sender_id + if (params?.session_id) query.session_id = params.session_id + + return this._http.get( + `/${API_VERSION}/workspaces/${workspaceId}/queue/status`, + { query } + ) + } + + private async _listPeers( + workspaceId: string, + params?: { + filters?: Record + page?: number + size?: number + } + ): Promise> { + return this._http.post>( + `/${API_VERSION}/workspaces/${workspaceId}/peers/list`, + { + body: { filters: params?.filters }, + query: { page: params?.page, size: params?.size }, + } + ) + } + + private async _getOrCreatePeer( + workspaceId: string, + params: { + id: string + metadata?: Record + configuration?: Record + } + ): Promise { + return this._http.post( + `/${API_VERSION}/workspaces/${workspaceId}/peers`, + { body: params } + ) + } + + private async _listSessions( + workspaceId: string, + params?: { + filters?: Record + page?: number + size?: number + } + ): Promise> { + return this._http.post>( + `/${API_VERSION}/workspaces/${workspaceId}/sessions/list`, + { + body: { filters: params?.filters }, + query: { page: params?.page, size: params?.size }, + } + ) + } + + private async _getOrCreateSession( + workspaceId: string, + params: { + id: string + metadata?: Record + configuration?: SessionConfig + } + ): Promise { + return this._http.post( + `/${API_VERSION}/workspaces/${workspaceId}/sessions`, + { + body: { + id: params.id, + metadata: params.metadata, + configuration: sessionConfigToApi(params.configuration), + }, + } + ) + } + + // =========================================================================== + // Public Methods + // =========================================================================== + /** * Get or create a peer with the given ID. * * Creates a Peer object that can be used to interact with the specified peer. - * If metadata or config is provided, makes an API call to get/create the peer + * If metadata or configuration is provided, makes an API call to get/create the peer * immediately with those values. * * Provided metadata and configuration will overwrite existing data for this peer @@ -166,8 +359,8 @@ export class Honcho { * stable identifier that can be used consistently across sessions. * @param metadata - Optional metadata dictionary to associate with this peer. * If set, will get/create peer immediately with metadata. - * @param config - Optional configuration to set for this peer. - * If set, will get/create peer immediately with flags. + * @param configuration - Optional configuration to set for this peer. + * If set, will get/create peer immediately with flags. * @returns Promise resolving to a Peer object that can be used to send messages, * join sessions, and query the peer's knowledge representations * @throws Error if the peer ID is empty or invalid @@ -176,36 +369,42 @@ export class Honcho { id: string, options?: { metadata?: PeerMetadata - config?: PeerConfig + configuration?: PeerConfig } ): Promise { + await this._ensureWorkspace() const validatedId = PeerIdSchema.parse(id) const validatedMetadata = options?.metadata ? PeerMetadataSchema.parse(options.metadata) : undefined - const validatedConfig = options?.config - ? PeerConfigSchema.parse(options.config) + const validatedConfiguration = options?.configuration + ? PeerConfigSchema.parse(options.configuration) : undefined - if (validatedConfig || validatedMetadata) { - const peerData = await this._client.workspaces.peers.getOrCreate( - this.workspaceId, - { - id: validatedId, - configuration: validatedConfig, - metadata: validatedMetadata, - } - ) + if (validatedConfiguration || validatedMetadata) { + const peerData = await this._getOrCreatePeer(this.workspaceId, { + id: validatedId, + configuration: peerConfigToApi(validatedConfiguration), + metadata: validatedMetadata, + }) return new Peer( validatedId, this.workspaceId, - this._client, + this._http, peerData.metadata ?? undefined, - peerData.configuration ?? undefined + peerConfigFromApi(peerData.configuration) ?? undefined, + () => this._ensureWorkspace() ) } - return new Peer(validatedId, this.workspaceId, this._client) + return new Peer( + validatedId, + this.workspaceId, + this._http, + undefined, + undefined, + () => this._ensureWorkspace() + ) } /** @@ -214,25 +413,39 @@ 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/v2/guides/using-filters). + * @param filters - Optional filter criteria for peers. 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 getPeers(filters?: Filters): Promise> { + async peers(filters?: Filters): Promise> { + await this._ensureWorkspace() const validatedFilter = filters ? FilterSchema.parse(filters) : undefined - const peersPage = await this._client.workspaces.peers.list( - this.workspaceId, - { filters: validatedFilter } - ) + const peersPage = await this._listPeers(this.workspaceId, { + filters: validatedFilter, + }) + + const fetchNextPage = async ( + page: number, + size: number + ): Promise> => { + return this._listPeers(this.workspaceId, { + filters: validatedFilter, + page, + size, + }) + } + return new Page( peersPage, (peer) => new Peer( peer.id, this.workspaceId, - this._client, + this._http, peer.metadata ?? undefined, - peer.configuration ?? undefined - ) + peerConfigFromApi(peer.configuration) ?? undefined, + () => this._ensureWorkspace() + ), + fetchNextPage ) } @@ -240,7 +453,7 @@ export class Honcho { * Get or create a session with the given ID. * * Creates a Session object that can be used to manage conversations between - * multiple peers. If metadata or config is provided, makes an API call to + * multiple peers. If metadata or configuration is provided, makes an API call to * get/create the session immediately with those values. * * Provided metadata and configuration will overwrite existing data for this session @@ -251,8 +464,8 @@ export class Honcho { * same conversation * @param metadata - Optional metadata dictionary to associate with this session. * If set, will get/create session immediately with metadata. - * @param config - Optional configuration to set for this session. - * If set, will get/create session immediately with flags. + * @param configuration - Optional configuration to set for this session. + * If set, will get/create session immediately with flags. * @returns Promise resolving to a Session object that can be used to add peers, * send messages, and manage conversation context * @throws Error if the session ID is empty or invalid @@ -261,36 +474,42 @@ export class Honcho { id: string, options?: { metadata?: SessionMetadata - config?: SessionConfig + configuration?: SessionConfig } ): Promise { + await this._ensureWorkspace() const validatedId = SessionIdSchema.parse(id) const validatedMetadata = options?.metadata ? SessionMetadataSchema.parse(options.metadata) : undefined - const validatedConfig = options?.config - ? SessionConfigSchema.parse(options.config) + const validatedConfiguration = options?.configuration + ? SessionConfigSchema.parse(options.configuration) : undefined - if (validatedConfig || validatedMetadata) { - const sessionData = await this._client.workspaces.sessions.getOrCreate( - this.workspaceId, - { - id: validatedId, - configuration: validatedConfig, - metadata: validatedMetadata, - } - ) + if (validatedConfiguration || validatedMetadata) { + const sessionData = await this._getOrCreateSession(this.workspaceId, { + id: validatedId, + configuration: validatedConfiguration, + metadata: validatedMetadata, + }) return new Session( validatedId, this.workspaceId, - this._client, + this._http, sessionData.metadata ?? undefined, - sessionData.configuration ?? undefined + sessionConfigFromApi(sessionData.configuration) ?? undefined, + () => this._ensureWorkspace() ) } - return new Session(validatedId, this.workspaceId, this._client) + return new Session( + validatedId, + this.workspaceId, + this._http, + undefined, + undefined, + () => this._ensureWorkspace() + ) } /** @@ -299,26 +518,40 @@ 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/v2/guides/using-filters). + * @param filters - Optional filter criteria for sessions. 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 getSessions(filters?: Filters): Promise> { + async sessions(filters?: Filters): Promise> { + await this._ensureWorkspace() const validatedFilter = filters ? FilterSchema.parse(filters) : undefined - const sessionsPage = await this._client.workspaces.sessions.list( - this.workspaceId, - { filters: validatedFilter } - ) + const sessionsPage = await this._listSessions(this.workspaceId, { + filters: validatedFilter, + }) + + const fetchNextPage = async ( + page: number, + size: number + ): Promise> => { + return this._listSessions(this.workspaceId, { + filters: validatedFilter, + page, + size, + }) + } + return new Page( sessionsPage, (session) => new Session( session.id, this.workspaceId, - this._client, + this._http, session.metadata ?? undefined, - session.configuration ?? undefined - ) + sessionConfigFromApi(session.configuration) ?? undefined, + () => this._ensureWorkspace() + ), + fetchNextPage ) } @@ -334,9 +567,8 @@ export class Honcho { * Returns an empty dictionary if no metadata is set */ async getMetadata(): Promise> { - const workspace = await this._client.workspaces.getOrCreate({ - id: this.workspaceId, - }) + await this._ensureWorkspace() + const workspace = await this._getOrCreateWorkspace(this.workspaceId) this._metadata = workspace.metadata || {} return this._metadata } @@ -352,8 +584,9 @@ export class Honcho { * Keys must be strings, values can be any JSON-serializable type */ async setMetadata(metadata: WorkspaceMetadata): Promise { + await this._ensureWorkspace() const validatedMetadata = WorkspaceMetadataSchema.parse(metadata) - await this._client.workspaces.update(this.workspaceId, { + await this._updateWorkspace(this.workspaceId, { metadata: validatedMetadata, }) this._metadata = validatedMetadata @@ -366,14 +599,13 @@ export class Honcho { * Configuration includes settings that control workspace behavior. * This method also updates the cached configuration property. * - * @returns Promise resolving to a dictionary containing the workspace's configuration. - * Returns an empty dictionary if no configuration is set + * @returns Promise resolving to the workspace's configuration. + * Returns an empty object if no configuration is set */ - async getConfig(): Promise> { - const workspace = await this._client.workspaces.getOrCreate({ - id: this.workspaceId, - }) - this._configuration = workspace.configuration || {} + async getConfiguration(): Promise { + await this._ensureWorkspace() + const workspace = await this._getOrCreateWorkspace(this.workspaceId) + this._configuration = workspaceConfigFromApi(workspace.configuration) || {} return this._configuration } @@ -384,12 +616,13 @@ export class Honcho { * This will overwrite any existing configuration with the provided values. * This method also updates the cached configuration property. * - * @param configuration - A dictionary of configuration to associate with the workspace. - * Keys must be strings, values can be any JSON-serializable type + * @param configuration - Configuration to associate with the workspace. + * Includes reasoning, peerCard, summary, and dream settings. */ - async setConfig(configuration: WorkspaceConfig): Promise { + async setConfiguration(configuration: WorkspaceConfig): Promise { + await this._ensureWorkspace() const validatedConfig = WorkspaceConfigSchema.parse(configuration) - await this._client.workspaces.update(this.workspaceId, { + await this._updateWorkspace(this.workspaceId, { configuration: validatedConfig, }) this._configuration = validatedConfig @@ -402,11 +635,10 @@ export class Honcho { * associated with the current workspace and updates the cached properties. */ async refresh(): Promise { - const workspace = await this._client.workspaces.getOrCreate({ - id: this.workspaceId, - }) + await this._ensureWorkspace() + const workspace = await this._getOrCreateWorkspace(this.workspaceId) this._metadata = workspace.metadata || {} - this._configuration = workspace.configuration || {} + this._configuration = workspaceConfigFromApi(workspace.configuration) || {} } /** @@ -415,20 +647,30 @@ 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/v2/guides/using-filters). - * @returns Promise resolving to a list of workspace ID strings. Returns an empty - * list if no workspaces are accessible or none exist + * @param filters - Optional filter criteria for workspaces. 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 getWorkspaces(filters?: Filters): Promise { + async workspaces( + filters?: Filters + ): Promise> { const validatedFilter = filters ? FilterSchema.parse(filters) : undefined - const workspacesPage = await this._client.workspaces.list({ + const workspacesPage = await this._listWorkspaces({ filters: validatedFilter, }) - const ids: string[] = [] - for await (const workspace of workspacesPage) { - ids.push(workspace.id) + + const fetchNextPage = async ( + page: number, + size: number + ): Promise> => { + return this._listWorkspaces({ + filters: validatedFilter, + page, + size, + }) } - return ids + + return new Page(workspacesPage, (workspace) => workspace.id, fetchNextPage) } /** @@ -437,12 +679,10 @@ export class Honcho { * Makes an API call to delete the specified workspace. * * @param workspaceId - The ID of the workspace to delete - * @returns Promise resolving to the deleted Workspace object + * @returns Promise that resolves when the workspace is deleted */ - async deleteWorkspace( - workspaceId: string - ): Promise>> { - return await this._client.workspaces.delete(workspaceId) + async deleteWorkspace(workspaceId: string): Promise { + await this._deleteWorkspace(workspaceId) } /** @@ -451,7 +691,7 @@ export class Honcho { * Makes an API call to search for messages in the current workspace. * * @param query - The search query to use - * @param filters - Optional filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v2/guides/using-filters). + * @param filters - Optional filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). * @param limit - Number of results to return (1-100, default: 10). * @returns Promise resolving to an array of Message objects representing the search results. * Returns an empty array if no messages are found. @@ -464,6 +704,7 @@ export class Honcho { limit?: number } ): Promise { + await this._ensureWorkspace() const validatedQuery = SearchQuerySchema.parse(query) const validatedFilters = options?.filters ? FilterSchema.parse(options.filters) @@ -471,11 +712,12 @@ export class Honcho { const validatedLimit = options?.limit ? LimitSchema.parse(options.limit) : undefined - return await this._client.workspaces.search(this.workspaceId, { + const response = await this._searchWorkspace(this.workspaceId, { query: validatedQuery, filters: validatedFilters, limit: validatedLimit, }) + return response.map(Message.fromApiResponse) } /** @@ -490,7 +732,7 @@ export class Honcho { * @param options.session - Optional session (ID string or Session object) to scope the status to * @returns Promise resolving to the queue status information including work unit counts */ - async getQueueStatus( + async queueStatus( options?: Omit< QueueStatusOptions, 'observerId' | 'senderId' | 'sessionId' @@ -499,150 +741,59 @@ export class Honcho { sender?: string | Peer session?: string | Session } - ): Promise<{ - totalWorkUnits: number - completedWorkUnits: number - inProgressWorkUnits: number - pendingWorkUnits: number - sessions?: Record - }> { - const resolvedObserverId = options?.observer - ? typeof options.observer === 'string' - ? options.observer - : options.observer.id - : undefined - const resolvedSenderId = options?.sender - ? typeof options.sender === 'string' - ? options.sender - : options.sender.id - : undefined - const resolvedSessionId = options?.session - ? typeof options.session === 'string' - ? options.session - : options.session.id + ): Promise { + await this._ensureWorkspace() + const observerId = options?.observer + ? resolveId(options.observer) : undefined + const senderId = options?.sender ? resolveId(options.sender) : undefined + const sessionId = options?.session ? resolveId(options.session) : undefined const queryParams: QueueStatusParams = {} - if (resolvedObserverId) queryParams.observer_id = resolvedObserverId - if (resolvedSenderId) queryParams.sender_id = resolvedSenderId - if (resolvedSessionId) queryParams.session_id = resolvedSessionId + if (observerId) queryParams.observer_id = observerId + if (senderId) queryParams.sender_id = senderId + if (sessionId) queryParams.session_id = sessionId - const status = await this._client.workspaces.queue.status( - this.workspaceId, - queryParams - ) - - return { - totalWorkUnits: status.total_work_units, - completedWorkUnits: status.completed_work_units, - inProgressWorkUnits: status.in_progress_work_units, - pendingWorkUnits: status.pending_work_units, - sessions: status.sessions || undefined, - } + const status = await this._getQueueStatus(this.workspaceId, queryParams) + return transformQueueStatus(status) } /** - * Poll getQueueStatus until pendingWorkUnits and inProgressWorkUnits are both 0. - * This allows you to guarantee that all messages have been processed by the queue for - * use with the dialectic endpoint. + * Schedule a dream task for memory consolidation. * - * The polling estimates sleep time by assuming each work unit takes 1 second. + * Dreams are background processes that consolidate observations into higher-level + * insights and update peer cards. This method schedules a dream task for immediate + * processing. * - * @param options - Configuration options for the status request - * @param options.observer - Optional observer (ID string or Peer object) to scope the status to - * @param options.sender - Optional sender (ID string or Peer object) to scope the status to - * @param options.session - Optional session (ID string or Session object) to scope the status to - * @param options.timeoutMs - Optional timeout in milliseconds (default: 300000 - 5 minutes) - * @returns Promise resolving to the final queue status when processing is complete - * @throws Error if timeout is exceeded before processing completes + * @param options - Configuration options for the dream + * @param options.observer - The observer peer (ID string or Peer object) whose perspective + * to use for the dream + * @param options.session - The session (ID string or Session object) to scope the dream to + * @param options.observed - Optional observed peer (ID string or Peer object). If not provided, + * defaults to the observer (self-reflection) + * @returns Promise that resolves when the dream is scheduled */ - async pollQueueStatus( - options?: Omit< - QueueStatusOptions, - 'observerId' | 'senderId' | 'sessionId' - > & { - observer?: string | Peer - sender?: string | Peer - session?: string | Session - } - ): Promise<{ - totalWorkUnits: number - completedWorkUnits: number - inProgressWorkUnits: number - pendingWorkUnits: number - sessions?: Record - }> { - const timeoutMs = options?.timeoutMs ?? 300000 // Default to 5 minutes - const startTime = Date.now() + async scheduleDream(options: { + observer: string | Peer + session: string | Session + observed?: string | Peer + }): Promise { + await this._ensureWorkspace() + const observerId = resolveId(options.observer) + const sessionId = resolveId(options.session) + const observedId = options.observed + ? resolveId(options.observed) + : observerId - while (true) { - const status = await this.getQueueStatus(options) - if (status.pendingWorkUnits === 0 && status.inProgressWorkUnits === 0) { - return status - } - - // Check if timeout has been exceeded - const elapsedTime = Date.now() - startTime - if (elapsedTime >= timeoutMs) { - throw new Error( - `Polling timeout exceeded after ${timeoutMs}ms. ` + - `Current status: ${status.pendingWorkUnits} pending, ${status.inProgressWorkUnits} in progress work units.` - ) - } - - // Sleep for the expected time to complete all current work units - // Assuming each pending and in-progress work unit takes 1 second - const totalWorkUnits = - status.pendingWorkUnits + status.inProgressWorkUnits - const sleepMs = Math.max(1000, totalWorkUnits * 1000) // Sleep at least 1 second - - // Ensure we don't sleep past the timeout - const remainingTime = timeoutMs - elapsedTime - const actualSleepMs = Math.min(sleepMs, remainingTime) - - if (actualSleepMs > 0) { - await new Promise((resolve) => setTimeout(resolve, actualSleepMs)) - } - } - } - - /** - * Update the metadata of a message. - * - * Makes an API call to update the metadata of a specific message within a session. - * - * @param message - Either a Message object or a message ID string - * @param metadata - The metadata to update for the message - * @param session - The session (ID string or Session object) - required if message is a string ID, ignored if message is a Message object - * @returns Promise resolving to the updated Message object - * @throws Error if message is a string ID but session is not provided - */ - async updateMessage( - message: Message | string, - metadata: Record, - session?: string | Session - ): Promise { - const validatedMetadata = MessageMetadataSchema.parse(metadata) - let messageId: string - let resolvedSessionId: string - - if (typeof message === 'string') { - messageId = message - if (!session) { - throw new Error('session is required when message is a string ID') - } - resolvedSessionId = typeof session === 'string' ? session : session.id - } else { - messageId = message.id - resolvedSessionId = message.session_id - } - - return await this._client.workspaces.sessions.messages.update( - this.workspaceId, - resolvedSessionId, - messageId, + await this._http.post( + `/${API_VERSION}/workspaces/${this.workspaceId}/schedule_dream`, { - metadata: validatedMetadata, + body: { + observer: observerId, + observed: observedId, + session_id: sessionId, + dream_type: 'omni', + }, } ) } @@ -653,6 +804,6 @@ export class Honcho { * @returns A string representation suitable for debugging */ toString(): string { - return `Honcho(workspaceId='${this.workspaceId}', baseURL='${this._client.baseURL}')` + return `Honcho(workspaceId='${this.workspaceId}', baseURL='${this._http.baseURL}')` } } diff --git a/sdks/typescript/src/conclusions.ts b/sdks/typescript/src/conclusions.ts index cfb26dc2..dda19787 100644 --- a/sdks/typescript/src/conclusions.ts +++ b/sdks/typescript/src/conclusions.ts @@ -1,10 +1,23 @@ -import type HonchoCore from '@honcho-ai/core' -import type { RepresentationOptions } from './representation' +import { API_VERSION } from './api-version' +import type { HonchoHTTPClient } from './http/client' +import { Page } from './pagination' import type { Session } from './session' -import type { ConclusionCreateParam } from './types' +import type { + ConclusionResponse, + PageResponse, + RepresentationOptions, + RepresentationResponse, +} from './types/api' -// Re-export for consumers who import from this module -export type { RepresentationOptions, ConclusionCreateParam } +/** + * Parameters for creating a conclusion. + */ +export interface ConclusionCreateParams { + /** The conclusion content/text */ + content: string + /** The session this conclusion relates to (ID string or Session object) */ + sessionId: string | Session +} /** * A conclusion from Honcho's reasoning system. @@ -13,34 +26,11 @@ export type { RepresentationOptions, ConclusionCreateParam } * of a peer. */ export class Conclusion { - /** - * Unique identifier for this conclusion. - */ readonly id: string - - /** - * The conclusion content/text. - */ readonly content: string - - /** - * The peer who made the conclusion. - */ readonly observerId: string - - /** - * The peer being observed. - */ readonly observedId: string - - /** - * The session where this conclusion was made. - */ readonly sessionId: string - - /** - * When the conclusion was created. - */ readonly createdAt: string constructor( @@ -59,26 +49,17 @@ export class Conclusion { this.createdAt = createdAt } - /** - * Create a Conclusion from an API response object. - * - * @param data - API response data - * @returns A new Conclusion instance - */ - static fromApiResponse(data: Record): Conclusion { + static fromApiResponse(data: ConclusionResponse): Conclusion { return new Conclusion( - (data.id as string) ?? '', - (data.content as string) ?? '', - (data.observer_id as string) ?? '', - (data.observed_id as string) ?? '', - (data.session_id as string) ?? '', - (data.created_at as string) ?? '' + data.id, + data.content, + data.observer_id, + data.observed_id, + data.session_id, + data.created_at ) } - /** - * Return a string representation of the Conclusion. - */ toString(): string { const truncatedContent = this.content.length > 50 @@ -90,118 +71,153 @@ export class Conclusion { /** * Scoped access to conclusions for a specific observer/observed relationship. - * - * This class provides convenient methods to list, query, and delete conclusions - * that are automatically scoped to a specific observer/observed pair. - * - * Typically accessed via `peer.conclusions` (for self-conclusions) or - * `peer.conclusionsOf(target)` (for conclusions about another peer). - * - * @example - * ```typescript - * // Get self-conclusions - * const conclusions = peer.conclusions - * const obsList = await conclusions.list() - * const searchResults = await conclusions.query('preferences') - * - * // Get conclusions about another peer - * const bobConclusions = peer.conclusionsOf('bob') - * const bobList = await bobConclusions.list() - * ``` - * - * @note - * This class requires the core Honcho SDK to support conclusion endpoints. - * The conclusion endpoints are: - * - POST /workspaces/{workspace_id}/conclusions/list - * - POST /workspaces/{workspace_id}/conclusions/query - * - DELETE /workspaces/{workspace_id}/conclusions/{conclusion_id} */ export class ConclusionScope { - private _client: HonchoCore - - /** - * The workspace ID. - */ + private _http: HonchoHTTPClient + private _ensureWorkspace: () => Promise readonly workspaceId: string - - /** - * The observer peer ID. - */ readonly observer: string - - /** - * The observed peer ID. - */ readonly observed: string - /** - * Initialize a ConclusionScope. - * - * @param client - The Honcho client instance - * @param workspaceId - The workspace ID - * @param observer - The observer peer ID - * @param observed - The observed peer ID - */ constructor( - client: HonchoCore, + http: HonchoHTTPClient, workspaceId: string, observer: string, - observed: string + observed: string, + ensureWorkspace: () => Promise = async () => undefined ) { - this._client = client + this._http = http this.workspaceId = workspaceId this.observer = observer this.observed = observed + this._ensureWorkspace = ensureWorkspace } + // =========================================================================== + // Private API Methods + // =========================================================================== + + private async _list(params: { + filters?: Record + page?: number + size?: number + }): 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 }, + } + ) + } + + private async _query(params: { + query: string + top_k?: number + distance?: number + filters?: Record + }): Promise { + await this._ensureWorkspace() + return this._http.post( + `/${API_VERSION}/workspaces/${this.workspaceId}/conclusions/query`, + { body: params } + ) + } + + private async _create(params: { + conclusions: Array<{ + content: string + session_id: string + observer_id: string + observed_id: string + }> + }): Promise { + await this._ensureWorkspace() + return this._http.post( + `/${API_VERSION}/workspaces/${this.workspaceId}/conclusions`, + { body: params } + ) + } + + private async _delete(conclusionId: string): Promise { + await this._ensureWorkspace() + await this._http.delete( + `/${API_VERSION}/workspaces/${this.workspaceId}/conclusions/${conclusionId}` + ) + } + + private async _getRepresentation( + peerId: string, + params: { + target?: string + search_query?: string + search_top_k?: number + search_max_distance?: number + include_most_frequent?: boolean + max_conclusions?: number + } + ): Promise { + await this._ensureWorkspace() + return this._http.post( + `/${API_VERSION}/workspaces/${this.workspaceId}/peers/${peerId}/representation`, + { body: params } + ) + } + + // =========================================================================== + // Public Methods + // =========================================================================== + /** * List conclusions in this scope. * - * @param page - Page number (1-indexed) - * @param size - Number of results per page - * @param session - Optional session (ID string or Session object) to filter by - * @returns Promise resolving to list of Conclusion objects + * @param options - Optional configuration for the list request + * @param options.page - Page number (1-indexed, default: 1) + * @param options.size - Number of items per page (default: 50) + * @param options.session - Optional session (ID string or Session object) to filter by + * @returns Promise resolving to a Page of Conclusion objects */ - async list( - page: number = 1, - size: number = 50, + async list(options?: { + page?: number + size?: number session?: string | Session - ): Promise { - const resolvedSessionId = session - ? typeof session === 'string' - ? session - : session.id + }): Promise> { + const resolvedSessionId = options?.session + ? typeof options.session === 'string' + ? options.session + : options.session.id : undefined const filters: Record = { - observer: this.observer, - observed: this.observed, + observer_id: this.observer, + observed_id: this.observed, } if (resolvedSessionId) { filters.session_id = resolvedSessionId } - // biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include conclusions - const response = await (this._client.workspaces as any).conclusions.list( - this.workspaceId, - { - filters, - page, - size, - } - ) + const response = await this._list({ + filters, + page: options?.page ?? 1, + size: options?.size ?? 50, + }) - return (response.items ?? []).map((item: unknown) => - Conclusion.fromApiResponse(item as Record) + const fetchNextPage = async ( + page: number, + size: number + ): Promise> => { + return this._list({ filters, page, size }) + } + + return new Page( + response, + (item) => Conclusion.fromApiResponse(item), + fetchNextPage ) } /** * Semantic search for conclusions in this scope. - * - * @param query - The search query string - * @param topK - Maximum number of results to return - * @param distance - Maximum cosine distance threshold (0.0-1.0) - * @returns Promise resolving to list of matching Conclusion objects */ async query( query: string, @@ -209,68 +225,37 @@ export class ConclusionScope { distance?: number ): Promise { const filters: Record = { - observer: this.observer, - observed: this.observed, + observer_id: this.observer, + observed_id: this.observed, } - // biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include conclusions - const response = await (this._client.workspaces as any).conclusions.query( - this.workspaceId, - { - query, - top_k: topK, - distance, - filters, - } - ) + const response = await this._query({ + query, + top_k: topK, + distance, + filters, + }) - return (response ?? []).map((item: unknown) => - Conclusion.fromApiResponse(item as Record) - ) + return (response ?? []).map((item) => Conclusion.fromApiResponse(item)) } /** * Delete a conclusion by ID. - * - * @param conclusionId - The ID of the conclusion to delete */ async delete(conclusionId: string): Promise { - // biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include conclusions - await (this._client.workspaces as any).conclusions.delete( - this.workspaceId, - conclusionId - ) + await this._delete(conclusionId) } /** * Create conclusions in this scope. - * - * @param conclusions - Single conclusion or array of conclusions with content and sessionId - * @returns Promise resolving to list of created Conclusion objects - * - * @example - * ```typescript - * // Create a single conclusion - * const conclusions = await peer.conclusions.create( - * { content: 'User prefers dark mode', sessionId: 'session1' } - * ) - * - * // Create multiple conclusions - * const conclusions = await peer.conclusions.create([ - * { content: 'User prefers dark mode', sessionId: 'session1' }, - * { content: 'User is interested in AI', sessionId: 'session1' }, - * ]) - * ``` */ async create( - conclusions: ConclusionCreateParam | ConclusionCreateParam[] + conclusions: ConclusionCreateParams | ConclusionCreateParams[] ): Promise { - // Normalize to array const conclusionArray = Array.isArray(conclusions) ? conclusions : [conclusions] - // Build the request body with observer/observed from scope const requestConclusions = conclusionArray.map((obs) => ({ content: obs.content, session_id: @@ -279,45 +264,26 @@ export class ConclusionScope { observed_id: this.observed, })) - // biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include conclusions - const response = await (this._client.workspaces as any).conclusions.create( - this.workspaceId, - { conclusions: requestConclusions } - ) + const response = await this._create({ conclusions: requestConclusions }) - return (response ?? []).map((item: unknown) => - Conclusion.fromApiResponse(item as Record) - ) + return (response ?? []).map((item) => Conclusion.fromApiResponse(item)) } /** * Get the computed representation for this scope. - * - * This returns the working representation (narrative) built from the - * conclusions in this scope. - * - * @param options - Optional options to configure the representation - * @returns Promise resolving to a string of the representation */ - async getRepresentation(options?: RepresentationOptions): Promise { - const response = await this._client.workspaces.peers.representation( - this.workspaceId, - 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, - } - ) + async representation(options?: RepresentationOptions): Promise { + 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, + }) return response.representation } - /** - * Return a string representation of the ConclusionScope. - */ toString(): string { return `ConclusionScope(workspaceId='${this.workspaceId}', observer='${this.observer}', observed='${this.observed}')` } diff --git a/sdks/typescript/src/http/client.ts b/sdks/typescript/src/http/client.ts new file mode 100644 index 00000000..a3ff2c1f --- /dev/null +++ b/sdks/typescript/src/http/client.ts @@ -0,0 +1,394 @@ +import { + ConnectionError, + createErrorFromResponse, + RateLimitError, + ServerError, + TimeoutError, +} from './errors' + +export interface HonchoHTTPClientConfig { + baseURL: string + apiKey?: string + timeout?: number + maxRetries?: number + defaultHeaders?: Record + defaultQuery?: Record +} + +export interface RequestOptions { + body?: unknown + query?: Record + headers?: Record + timeout?: number + signal?: AbortSignal +} + +const DEFAULT_TIMEOUT = 60000 // 60 seconds +const DEFAULT_MAX_RETRIES = 2 +const RETRY_STATUS_CODES = [429, 500, 502, 503, 504] +const INITIAL_RETRY_DELAY = 500 // 500ms + +/** + * Minimal HTTP client for the Honcho API with retry logic and timeout support. + */ +export class HonchoHTTPClient { + readonly baseURL: string + readonly apiKey?: string + readonly timeout: number + readonly maxRetries: number + readonly defaultHeaders: Record + readonly defaultQuery?: Record + + constructor(config: HonchoHTTPClientConfig) { + // Remove trailing slash from baseURL + this.baseURL = config.baseURL.replace(/\/$/, '') + this.apiKey = config.apiKey + this.timeout = config.timeout ?? DEFAULT_TIMEOUT + this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES + this.defaultHeaders = { + 'Content-Type': 'application/json', + ...config.defaultHeaders, + } + this.defaultQuery = config.defaultQuery + } + + /** + * Make an HTTP request with automatic retries and timeout handling. + */ + async request( + method: string, + path: string, + options: RequestOptions = {} + ): Promise { + const url = this.buildURL(path, options.query) + const headers = this.buildHeaders(options.headers) + const timeout = options.timeout ?? this.timeout + + let lastError: Error | undefined + let attempt = 0 + + while (attempt <= this.maxRetries) { + try { + const response = await this.fetchWithTimeout( + url, + { + method, + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + signal: options.signal, + }, + timeout + ) + + if (response.ok) { + const text = await response.text() + if (!text) { + // Empty responses (204 No Content, etc.) - valid for DELETE and some PUT/POST + // Callers using void as T will get undefined which is correct + // Callers expecting data from an endpoint that returns empty will get undefined + return undefined as T + } + return JSON.parse(text) as T + } + + // Handle error responses + const errorBody = await this.parseErrorBody(response) + const retryAfter = this.parseRetryAfter(response) + const error = createErrorFromResponse( + response.status, + errorBody.message || `HTTP ${response.status}`, + errorBody, + retryAfter + ) + + // Only retry on specific status codes + if ( + RETRY_STATUS_CODES.includes(response.status) && + attempt < this.maxRetries + ) { + lastError = error + await this.sleep(this.getRetryDelay(attempt, retryAfter)) + attempt++ + continue + } + + throw error + } catch (error) { + if (error instanceof TimeoutError || error instanceof ConnectionError) { + // Retry on network errors + if (attempt < this.maxRetries) { + lastError = error + await this.sleep(this.getRetryDelay(attempt)) + attempt++ + continue + } + } + + // Don't retry on other errors, just throw + if ( + error instanceof RateLimitError || + error instanceof ServerError || + error instanceof TimeoutError || + error instanceof ConnectionError + ) { + 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 + } + } + + // If we exhausted retries, throw the last error + throw lastError || new Error('Request failed after retries') + } + + /** + * Make a GET request. + */ + async get( + path: string, + options?: Omit + ): Promise { + return this.request('GET', path, options) + } + + /** + * Make a POST request. + */ + async post(path: string, options?: RequestOptions): Promise { + return this.request('POST', path, options) + } + + /** + * Make a PUT request. + */ + async put(path: string, options?: RequestOptions): Promise { + return this.request('PUT', path, options) + } + + /** + * Make a PATCH request. + */ + async patch(path: string, options?: RequestOptions): Promise { + return this.request('PATCH', path, options) + } + + /** + * Make a DELETE request. + * Most DELETE endpoints return no content (204), so the default return type is void. + * For endpoints that return data, specify the type parameter explicitly. + */ + async delete(path: string, options?: RequestOptions): Promise + async delete(path: string, options?: RequestOptions): Promise + async delete( + path: string, + options?: RequestOptions + ): Promise { + return this.request('DELETE', path, options) + } + + /** + * Make a streaming request that returns a Response object for SSE parsing. + */ + async stream( + method: string, + path: string, + options: RequestOptions = {} + ): Promise { + const url = this.buildURL(path, options.query) + const headers = { + ...this.buildHeaders(options.headers), + Accept: 'text/event-stream', + } + const timeout = options.timeout ?? this.timeout + + const response = await this.fetchWithTimeout( + url, + { + method, + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + signal: options.signal, + }, + timeout + ) + + if (!response.ok) { + const errorBody = await this.parseErrorBody(response) + throw createErrorFromResponse( + response.status, + errorBody.message || `HTTP ${response.status}`, + errorBody + ) + } + + return response + } + + /** + * Make a multipart form data request (for file uploads). + */ + async upload( + path: string, + formData: FormData, + options: Omit = {} + ): Promise { + const url = this.buildURL(path, options.query) + // Don't set Content-Type for FormData - browser will set it with boundary + const headers: Record = {} + if (this.apiKey) { + headers.Authorization = `Bearer ${this.apiKey}` + } + if (options.headers) { + Object.assign(headers, options.headers) + } + + const timeout = options.timeout ?? this.timeout + + const response = await this.fetchWithTimeout( + url, + { + method: 'POST', + headers, + body: formData, + signal: options.signal, + }, + timeout + ) + + if (!response.ok) { + const errorBody = await this.parseErrorBody(response) + throw createErrorFromResponse( + response.status, + errorBody.message || `HTTP ${response.status}`, + errorBody + ) + } + + const text = await response.text() + if (!text) { + // Empty upload responses are unusual but valid for some endpoints + return undefined as T + } + return JSON.parse(text) as T + } + + private buildURL( + path: string, + query?: Record + ): string { + const url = new URL(path, this.baseURL) + + const mergedQuery: Record = { + ...(this.defaultQuery ?? {}), + ...(query ?? {}), + } + + for (const [key, value] of Object.entries(mergedQuery)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)) + } + } + + return url.toString() + } + + private buildHeaders(extra?: Record): Record { + const headers: Record = { ...this.defaultHeaders } + + if (this.apiKey) { + headers.Authorization = `Bearer ${this.apiKey}` + } + + if (extra) { + Object.assign(headers, extra) + } + + return headers + } + + private async fetchWithTimeout( + url: string, + init: RequestInit, + timeout: number + ): Promise { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeout) + + // Combine with any existing signal + if (init.signal) { + init.signal.addEventListener('abort', () => controller.abort()) + } + + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + }) + return response + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new TimeoutError(`Request timed out after ${timeout}ms`) + } + throw error + } finally { + clearTimeout(timeoutId) + } + } + + private async parseErrorBody( + response: Response + ): Promise<{ message?: string; detail?: string }> { + try { + const body = await response.json() + return { + message: body.detail || body.message || body.error, + ...body, + } + } catch { + return { message: `HTTP ${response.status}` } + } + } + + private parseRetryAfter(response: Response): number | undefined { + const header = response.headers.get('Retry-After') + if (!header) return undefined + + const seconds = Number.parseInt(header, 10) + if (!Number.isNaN(seconds)) { + return seconds * 1000 // Convert to milliseconds + } + + // Try parsing as date + const date = Date.parse(header) + if (!Number.isNaN(date)) { + return Math.max(0, date - Date.now()) + } + + return undefined + } + + private getRetryDelay(attempt: number, retryAfter?: number): number { + if (retryAfter) { + return retryAfter + } + // Exponential backoff: 500ms, 1000ms, 2000ms, etc. + return INITIAL_RETRY_DELAY * 2 ** attempt + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) + } +} diff --git a/sdks/typescript/src/http/errors.ts b/sdks/typescript/src/http/errors.ts new file mode 100644 index 00000000..e1a8ab93 --- /dev/null +++ b/sdks/typescript/src/http/errors.ts @@ -0,0 +1,155 @@ +/** + * Base error class for all Honcho SDK errors. + */ +export class HonchoError extends Error { + readonly status: number + readonly code?: string + readonly body?: unknown + + constructor( + message: string, + status: number, + options?: { code?: string; body?: unknown } + ) { + super(message) + this.name = 'HonchoError' + this.status = status + this.code = options?.code + this.body = options?.body + } +} + +/** + * Error thrown when request validation fails (400). + */ +export class BadRequestError extends HonchoError { + constructor(message: string, body?: unknown) { + super(message, 400, { code: 'bad_request', body }) + this.name = 'BadRequestError' + } +} + +/** + * Error thrown when authentication fails (401). + */ +export class AuthenticationError extends HonchoError { + constructor(message = 'Authentication failed') { + super(message, 401, { code: 'authentication_error' }) + this.name = 'AuthenticationError' + } +} + +/** + * Error thrown when the user lacks permission (403). + */ +export class PermissionDeniedError extends HonchoError { + constructor(message = 'Permission denied') { + super(message, 403, { code: 'permission_denied' }) + this.name = 'PermissionDeniedError' + } +} + +/** + * Error thrown on resource conflict (409). + */ +export class ConflictError extends HonchoError { + constructor(message = 'Resource conflict', body?: unknown) { + super(message, 409, { code: 'conflict', body }) + this.name = 'ConflictError' + } +} + +/** + * Error thrown when entity cannot be processed (422). + */ +export class UnprocessableEntityError extends HonchoError { + constructor(message = 'Unprocessable entity', body?: unknown) { + super(message, 422, { code: 'unprocessable_entity', body }) + this.name = 'UnprocessableEntityError' + } +} + +/** + * Error thrown when a resource is not found (404). + */ +export class NotFoundError extends HonchoError { + constructor(message = 'Resource not found') { + super(message, 404, { code: 'not_found' }) + this.name = 'NotFoundError' + } +} + +/** + * Error thrown when rate limited (429). + */ +export class RateLimitError extends HonchoError { + readonly retryAfter?: number + + constructor(message = 'Rate limit exceeded', retryAfter?: number) { + super(message, 429, { code: 'rate_limit_exceeded' }) + this.name = 'RateLimitError' + this.retryAfter = retryAfter + } +} + +/** + * Error thrown on server errors (5xx). + */ +export class ServerError extends HonchoError { + constructor(message = 'Server error', status = 500) { + super(message, status, { code: 'server_error' }) + this.name = 'ServerError' + } +} + +/** + * Error thrown when a request times out. + */ +export class TimeoutError extends HonchoError { + constructor(message = 'Request timed out') { + super(message, 0, { code: 'timeout' }) + this.name = 'TimeoutError' + } +} + +/** + * Error thrown when a connection fails. + */ +export class ConnectionError extends HonchoError { + constructor(message = 'Connection failed') { + super(message, 0, { code: 'connection_error' }) + this.name = 'ConnectionError' + } +} + +/** + * Create the appropriate error type based on HTTP status code. + */ +export function createErrorFromResponse( + status: number, + message: string, + body?: unknown, + retryAfter?: number +): HonchoError { + switch (status) { + case 400: + return new BadRequestError(message, body) + case 401: + return new AuthenticationError(message) + case 403: + return new PermissionDeniedError(message) + case 404: + return new NotFoundError(message) + case 409: + return new ConflictError(message, body) + case 422: + return new UnprocessableEntityError(message, body) + case 429: + return new RateLimitError(message, retryAfter) + default: + if (status >= 500) { + return new ServerError(message, status) + } + return new HonchoError(message, status, { body }) + } +} diff --git a/sdks/typescript/src/http/index.ts b/sdks/typescript/src/http/index.ts new file mode 100644 index 00000000..cee8adf7 --- /dev/null +++ b/sdks/typescript/src/http/index.ts @@ -0,0 +1,25 @@ +export { + HonchoHTTPClient, + type HonchoHTTPClientConfig, + type RequestOptions, +} from './client' +export { + AuthenticationError, + BadRequestError, + ConflictError, + ConnectionError, + createErrorFromResponse, + HonchoError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + ServerError, + TimeoutError, + UnprocessableEntityError, +} from './errors' +export { + createDialecticStream, + type DialecticStreamChunk, + DialecticStreamResponse, + parseSSE, +} from './streaming' diff --git a/sdks/typescript/src/http/streaming.ts b/sdks/typescript/src/http/streaming.ts new file mode 100644 index 00000000..92901c4a --- /dev/null +++ b/sdks/typescript/src/http/streaming.ts @@ -0,0 +1,147 @@ +/** + * Parse Server-Sent Events from a Response body. + * + * Yields parsed JSON data from each "data:" line in the SSE stream. + */ +export async function* parseSSE( + response: Response +): AsyncGenerator { + if (!response.body) { + throw new Error('Response body is null') + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.slice(6) // Remove "data: " prefix + if (jsonStr.trim() === '[DONE]') { + return + } + try { + const data = JSON.parse(jsonStr) as T + yield data + } catch { + // Skip invalid JSON lines + } + } + } + } + + // Process any remaining data in the buffer + if (buffer.startsWith('data: ')) { + const jsonStr = buffer.slice(6) + if (jsonStr.trim() !== '[DONE]') { + try { + const data = JSON.parse(jsonStr) as T + yield data + } catch { + // Skip invalid JSON + } + } + } + } finally { + reader.releaseLock() + } +} + +/** + * Chunk data from the dialectic streaming endpoint. + */ +export interface DialecticStreamChunk { + done: boolean + delta: { + content?: string + } +} + +/** + * Async iterable wrapper for dialectic streaming responses. + * + * Provides a convenient interface for iterating over streaming content + * and collecting the final response. + */ +export class DialecticStreamResponse implements AsyncIterable { + private generator: AsyncGenerator + private chunks: string[] = [] + private consumed = false + + constructor(generator: AsyncGenerator) { + this.generator = generator + } + + /** + * Iterate over content chunks as they arrive. + */ + async *[Symbol.asyncIterator](): AsyncGenerator { + if (this.consumed) { + // If already consumed, yield from cached chunks + for (const chunk of this.chunks) { + yield chunk + } + return + } + + for await (const chunk of this.generator) { + this.chunks.push(chunk) + yield chunk + } + this.consumed = true + } + + /** + * Get the complete response after streaming finishes. + */ + async getFinalResponse(): Promise { + if (!this.consumed) { + for await (const _ of this) { + // Consume all chunks + } + } + return this.chunks.join('') + } + + /** + * Collect all chunks into an array. + */ + async toArray(): Promise { + if (!this.consumed) { + for await (const _ of this) { + // Consume all chunks + } + } + return [...this.chunks] + } +} + +/** + * Create a DialecticStreamResponse from an SSE response. + */ +export function createDialecticStream( + response: Response +): DialecticStreamResponse { + async function* streamContent(): AsyncGenerator { + for await (const chunk of parseSSE(response)) { + if (chunk.done) { + return + } + const content = chunk.delta?.content + if (content) { + yield content + } + } + } + + return new DialecticStreamResponse(streamContent()) +} diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 54a0503a..f6b11d26 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -1,25 +1,61 @@ // Main entry point for the Honcho TypeScript SDK // Exports all main classes and types -export type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages' +// Domain classes export { Honcho } from './client' -export { Conclusion, ConclusionScope } from './conclusions' +export { + Conclusion, + type ConclusionCreateParams, + ConclusionScope, +} from './conclusions' +// HTTP infrastructure +export { + AuthenticationError, + BadRequestError, + ConflictError, + ConnectionError, + HonchoError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + ServerError, + TimeoutError, + UnprocessableEntityError, +} from './http/errors' +// Streaming types +export { + type DialecticStreamChunk, + DialecticStreamResponse, +} from './http/streaming' +export { Message, type MessageInput } from './message' export { Page } from './pagination' export { Peer, PeerContext } from './peer' -export { Session, SessionPeerConfig } from './session' +export { Session } from './session' export { SessionContext, SessionSummaries, Summary, type SummaryData, } from './session_context' -export { - type Conclusion as ConclusionData, - type ConclusionQueryParams, - type DialecticStreamChunk, - type DialecticStreamDelta, - DialecticStreamResponse, -} from './types' + +// API types (snake_case, for advanced usage) +export type { + ConclusionQueryParams, + ConclusionResponse, + MessageResponse, + PageResponse, + PeerContextResponse, + PeerResponse, + QueueStatus, + QueueStatusResponse, + RepresentationOptions, + SessionContextResponse, + SessionQueueStatus, + SessionResponse, + SessionSummariesResponse, + SummaryResponse, + WorkspaceResponse, +} from './types/api' // Export validation types for advanced usage export type { @@ -30,7 +66,6 @@ export type { GetRepresentationParams, HonchoConfig, MessageAddition, - MessageCreate, PeerAddition, PeerConfig, PeerGetRepresentationParams, @@ -39,6 +74,7 @@ export type { QueueStatusOptions, SessionConfig, SessionMetadata, + SessionPeerConfig, WorkspaceConfig, WorkspaceMetadata, } from './validation' diff --git a/sdks/typescript/src/message.ts b/sdks/typescript/src/message.ts new file mode 100644 index 00000000..956ad2bd --- /dev/null +++ b/sdks/typescript/src/message.ts @@ -0,0 +1,87 @@ +import type { MessageResponse } from './types/api' +import type { MessageConfiguration } from './validation' + +/** + * Input for creating a message. + * + * This is the type returned by `Peer.message()` and accepted by + * `Session.addMessages()`. + */ +export interface MessageInput { + /** The peer ID who authored this message */ + peerId: string + /** The message content */ + content: string + /** Optional metadata to associate with the message */ + metadata?: Record + /** Optional configuration for the message (reasoning settings) */ + configuration?: MessageConfiguration + /** Optional ISO 8601 timestamp for when the message was created */ + createdAt?: string +} + +/** + * A message in a Honcho session. + */ +export class Message { + /** Unique identifier for this message */ + readonly id: string + /** The message content */ + readonly content: string + /** The peer ID who authored this message */ + readonly peerId: string + /** The session ID this message belongs to */ + readonly sessionId: string + /** The workspace ID this message belongs to */ + readonly workspaceId: string + /** Metadata associated with this message */ + readonly metadata: Record + /** ISO 8601 timestamp for when the message was created */ + readonly createdAt: string + /** Number of tokens in this message */ + readonly tokenCount: number + + constructor( + id: string, + content: string, + peerId: string, + sessionId: string, + workspaceId: string, + metadata: Record, + createdAt: string, + tokenCount: number + ) { + this.id = id + this.content = content + this.peerId = peerId + this.sessionId = sessionId + this.workspaceId = workspaceId + this.metadata = metadata + this.createdAt = createdAt + this.tokenCount = tokenCount + } + + /** + * Create a Message from an API response. + */ + static fromApiResponse(data: MessageResponse): Message { + return new Message( + data.id, + data.content, + data.peer_id, + data.session_id, + data.workspace_id, + data.metadata, + data.created_at, + data.token_count + ) + } + + toString(): string { + const truncatedContent = + this.content.length > 50 + ? `${this.content.slice(0, 50)}...` + : this.content + return `Message(id='${this.id}', peerId='${this.peerId}', content='${truncatedContent}')` + } +} diff --git a/sdks/typescript/src/pagination.ts b/sdks/typescript/src/pagination.ts index 03acb971..8c5b6857 100644 --- a/sdks/typescript/src/pagination.ts +++ b/sdks/typescript/src/pagination.ts @@ -1,46 +1,95 @@ -import type { Page as CorePage } from '@honcho-ai/core/pagination' +import type { PageResponse } from './types/api' + +/** + * Function type for fetching the next page of results. + */ +export type NextPageFetcher = ( + page: number, + size: number +) => Promise> /** * Generic paginated result wrapper for Honcho SDK. - * Provides async iteration and transformation capabilities while preserving - * pagination functionality from the underlying core Page. + * Provides async iteration and transformation capabilities. */ -// biome-ignore lint/suspicious/noExplicitAny: Generic type parameter with reasonable default for internal transform -export class Page implements AsyncIterable { - private _originalPage: CorePage +export class Page implements AsyncIterable { + private _data: PageResponse private _transformFunc?: (item: TOriginal) => T + private _fetchNextPage?: NextPageFetcher /** * Initialize a new Page. * - * @param originalPage - The original Page to wrap + * @param data - The page response data * @param transformFunc - Optional function to transform objects from the original type to type T. * If not provided, objects are passed through unchanged. + * @param fetchNextPage - Optional function to fetch the next page of results. */ constructor( - originalPage: CorePage, - transformFunc?: (item: TOriginal) => T + data: PageResponse, + transformFunc?: (item: TOriginal) => T, + fetchNextPage?: NextPageFetcher ) { - this._originalPage = originalPage + this._data = data this._transformFunc = transformFunc + this._fetchNextPage = fetchNextPage + } + + /** + * Create a Page from raw response data. + */ + static from( + data: PageResponse, + fetchNextPage?: NextPageFetcher + ): Page { + return new Page(data, undefined, fetchNextPage) + } + + /** + * Create a Page with a transformation function. + */ + static fromWithTransform( + data: PageResponse, + transformFunc: (item: TOriginal) => T, + fetchNextPage?: NextPageFetcher + ): Page { + return new Page(data, transformFunc, fetchNextPage) } /** * Async iterator for all transformed items across all pages. + * + * **Warning:** This iterator automatically fetches ALL subsequent pages as you iterate. + * For large datasets, this may result in many API calls. If you only need + * the current page, use the `items` property instead. */ async *[Symbol.asyncIterator](): AsyncIterator { - for await (const item of this._originalPage) { + // Yield items from current page + for (const item of this._data.items) { yield this._transformFunc ? this._transformFunc(item) : (item as unknown as T) } + + // Fetch and yield items from subsequent pages + let currentPage: Page | null = this + while (currentPage.hasNextPage) { + const nextPage = await currentPage.getNextPage() + if (!nextPage) break + currentPage = nextPage + for (const item of nextPage._data.items) { + yield nextPage._transformFunc + ? nextPage._transformFunc(item) + : (item as unknown as T) + } + } } /** * Get a transformed item by index on the current page. */ get(index: number): T { - const items = this._originalPage.items || [] + const items = this._data.items || [] if (index < 0 || index >= items.length) { throw new RangeError( `Index ${index} is out of bounds for page with ${items.length} items` @@ -56,15 +105,14 @@ export class Page implements AsyncIterable { * Get the number of items on the current page. */ get length(): number { - const items = this._originalPage.items || [] - return items.length + return this._data.items?.length ?? 0 } /** * Get all transformed items on the current page. */ get items(): T[] { - const items = this._originalPage.items || [] + const items = this._data.items || [] return this._transformFunc ? items.map(this._transformFunc) : (items as unknown as T[]) @@ -73,55 +121,63 @@ export class Page implements AsyncIterable { /** * Get the total number of items across all pages. */ - get total(): number | undefined { - return this._originalPage?.total + get total(): number { + return this._data.total } /** - * Get the current page number. + * Get the current page number (1-indexed). */ - get page(): number | undefined { - return this._originalPage?.page + get page(): number { + return this._data.page } /** * Get the page size. */ - get size(): number | undefined { - return this._originalPage?.size + get size(): number { + return this._data.size } /** * Get the total number of pages. */ - get pages(): number | undefined { - return this._originalPage?.pages + get pages(): number { + return this._data.pages } /** * Check if there's a next page. */ get hasNextPage(): boolean { - if (typeof this._originalPage.hasNextPage === 'function') { - return this._originalPage.hasNextPage() - } - return false + return this._data.page < this._data.pages } /** * Fetch the next page of results. - * Returns null if there are no more pages. + * Returns null if there are no more pages or if no fetch function is provided. */ async getNextPage(): Promise | null> { - if (typeof this._originalPage.getNextPage !== 'function') { + if (!this.hasNextPage || !this._fetchNextPage) { return null } - const nextOriginalPage = await this._originalPage.getNextPage() - if (!nextOriginalPage) { - return null - } + const nextPageData = await this._fetchNextPage( + this._data.page + 1, + this._data.size + ) - return new Page(nextOriginalPage, this._transformFunc) + return new Page(nextPageData, this._transformFunc, this._fetchNextPage) + } + + /** + * Collect all items from all pages into an array. + */ + async toArray(): Promise { + const allItems: T[] = [] + for await (const item of this) { + allItems.push(item) + } + return allItems } } diff --git a/sdks/typescript/src/peer.ts b/sdks/typescript/src/peer.ts index c8968b62..2ce07468 100644 --- a/sdks/typescript/src/peer.ts +++ b/sdks/typescript/src/peer.ts @@ -1,26 +1,95 @@ -import type HonchoCore from '@honcho-ai/core' -import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages' +import { API_VERSION } from './api-version' import { ConclusionScope } from './conclusions' +import type { HonchoHTTPClient } from './http/client' +import { + createDialecticStream, + type DialecticStreamResponse, +} from './http/streaming' +import { Message, type MessageInput } from './message' import { Page } from './pagination' -import { - Representation, - type RepresentationData, - type RepresentationOptions, -} from './representation' import { Session } from './session' -import { type DialecticStreamChunk, DialecticStreamResponse } from './types' +import type { + MessageResponse, + PageResponse, + PeerCardResponse, + PeerChatResponse, + PeerContextResponse, + PeerResponse, + RepresentationResponse, + SessionResponse, +} from './types/api' import { + CardTargetSchema, ChatQuerySchema, FilterSchema, type Filters, LimitSchema, + type MessageConfiguration, + MessageConfigurationSchema, MessageContentSchema, MessageMetadataSchema, + type PeerConfig, + PeerConfigSchema, PeerGetRepresentationParamsSchema, + PeerMetadataSchema, + peerConfigFromApi, + peerConfigToApi, SearchQuerySchema, - type MessageCreate as ValidatedMessageCreate, } from './validation' +/** + * Represents context for a peer, including representation and peer card. + */ +export class PeerContext { + /** + * The peer ID this context belongs to. + */ + readonly peerId: string + + /** + * The target peer ID if this is a local context. + */ + readonly targetId: string + + /** + * The peer's representation, if available. + */ + readonly representation: string | null + + /** + * The peer card, if available. + */ + readonly peerCard: string[] | null + + constructor( + peerId: string, + targetId: string, + representation: string | null, + peerCard: string[] | null + ) { + this.peerId = peerId + this.targetId = targetId + this.representation = representation + this.peerCard = peerCard + } + + /** + * Create a PeerContext from an API response. + */ + static fromApiResponse(response: PeerContextResponse): PeerContext { + return new PeerContext( + response.peer_id, + response.target_id, + response.representation, + response.peer_card + ) + } + + toString(): string { + return `PeerContext(peerId='${this.peerId}', targetId='${this.targetId}')` + } +} + /** * Represents a peer in the Honcho system. * @@ -38,9 +107,10 @@ export class Peer { */ readonly workspaceId: string /** - * Reference to the parent Honcho client instance. + * Reference to the HTTP client instance. */ - private _client: HonchoCore + private _http: HonchoHTTPClient + private _ensureWorkspace: () => Promise /** * Private cached metadata for this peer. */ @@ -48,7 +118,7 @@ export class Peer { /** * Private cached configuration for this peer. */ - private _configuration?: Record + private _configuration?: PeerConfig /** * Cached metadata for this peer. May be stale if the peer @@ -65,10 +135,10 @@ export class Peer { * Cached configuration for this peer. May be stale if the peer * was not recently fetched from the API. * - * Call getConfig() to get the latest configuration from the server, + * Call getConfiguration() to get the latest configuration from the server, * which will also update this cached value. */ - get configuration(): Record | undefined { + get configuration(): PeerConfig | undefined { return this._configuration } @@ -77,24 +147,158 @@ export class Peer { * * @param id - Unique identifier for this peer within the workspace * @param workspaceId - Workspace ID for scoping operations - * @param client - Reference to the parent Honcho client instance + * @param http - Reference to the HTTP client instance * @param metadata - Optional metadata to initialize the cached value - * @param configuration - Optional configuration to initialize the cached value + * @param configuration - Optional configuration to initialize the cached value (camelCase) */ constructor( id: string, workspaceId: string, - client: HonchoCore, + http: HonchoHTTPClient, metadata?: Record, - configuration?: Record + configuration?: PeerConfig, + ensureWorkspace: () => Promise = async () => undefined ) { this.id = id this.workspaceId = workspaceId - this._client = client + this._http = http this._metadata = metadata this._configuration = configuration + this._ensureWorkspace = ensureWorkspace } + // =========================================================================== + // Private API Methods + // =========================================================================== + + private async _getOrCreate(params: { + id: string + metadata?: Record + configuration?: Record + }): Promise { + await this._ensureWorkspace() + return this._http.post( + `/${API_VERSION}/workspaces/${this.workspaceId}/peers`, + { body: params } + ) + } + + private async _update(params: { + metadata?: Record + configuration?: Record + }): Promise { + await this._ensureWorkspace() + return this._http.put( + `/${API_VERSION}/workspaces/${this.workspaceId}/peers/${this.id}`, + { body: params } + ) + } + + private async _listSessions(params?: { + filters?: Record + page?: number + size?: number + }): 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 }, + } + ) + } + + private async _chat(params: { + query: string + stream?: boolean + target?: string + session_id?: string + reasoning_level?: string + }): Promise { + await this._ensureWorkspace() + return this._http.post( + `/${API_VERSION}/workspaces/${this.workspaceId}/peers/${this.id}/chat`, + { body: params } + ) + } + + private async _chatStream(params: { + query: string + target?: string + session_id?: string + reasoning_level?: string + }): Promise { + await this._ensureWorkspace() + return this._http.stream( + 'POST', + `/${API_VERSION}/workspaces/${this.workspaceId}/peers/${this.id}/chat`, + { + body: { + ...params, + stream: true, + }, + } + ) + } + + private async _search(params: { + query: string + filters?: Record + limit?: number + }): Promise { + await this._ensureWorkspace() + return this._http.post( + `/${API_VERSION}/workspaces/${this.workspaceId}/peers/${this.id}/search`, + { body: params } + ) + } + + private async _getRepresentation(params: { + session_id?: string + target?: string + search_query?: string + search_top_k?: number + search_max_distance?: number + include_most_frequent?: boolean + max_conclusions?: number + }): Promise { + await this._ensureWorkspace() + return this._http.post( + `/${API_VERSION}/workspaces/${this.workspaceId}/peers/${this.id}/representation`, + { body: params } + ) + } + + private async _getContext(params: { + target?: string + search_query?: string + search_top_k?: number + search_max_distance?: number + include_most_frequent?: boolean + max_conclusions?: number + }): Promise { + await this._ensureWorkspace() + return this._http.get( + `/${API_VERSION}/workspaces/${this.workspaceId}/peers/${this.id}/context`, + { query: params } + ) + } + + private async _getCard(params: { + target?: string + }): Promise { + await this._ensureWorkspace() + return this._http.get( + `/${API_VERSION}/workspaces/${this.workspaceId}/peers/${this.id}/card`, + { query: params } + ) + } + + // =========================================================================== + // Public Methods + // =========================================================================== + /** * Query the peer's representation with a natural language question. * @@ -103,29 +307,37 @@ export class Peer { * representation of another peer (what this peer knows about the target peer). * * @param query - The natural language question to ask - * @param stream - Whether to stream the response - * @param target - Optional target peer for local representation query. If provided, - * queries what this peer knows about the target peer rather than - * querying the peer's global representation. Can be a peer ID string - * or a Peer object. - * @param session - Optional session to scope the query to. If provided, only - * information from that session is considered. Can be a session - * ID string or a Session object. - * @param reasoningLevel - Optional reasoning level for the query: "minimal", "low", "medium", - * "high", or "max". Defaults to "low" if not provided. - * @returns Promise resolving to: - * - For non-streaming: response string or null if no relevant information - * - For streaming: DialecticStreamResponse that can be iterated over + * @param options.target - Optional target peer for local representation query. If provided, + * queries what this peer knows about the target peer rather than + * querying the peer's global representation. Can be a peer ID string + * or a Peer object. + * @param options.session - Optional session to scope the query to. If provided, only + * information from that session is considered. Can be a session + * ID string or a Session object. + * @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low", "medium", + * "high", or "max". Defaults to "low" if not provided. + * @returns Promise resolving to the response string, or null if no relevant information + * + * @example + * ```typescript + * // Simple query + * const response = await peer.chat('What do you know about this user?') + * + * // Query with options + * const response = await peer.chat('What does this peer think about coding?', { + * target: otherPeer, + * reasoningLevel: 'high' + * }) + * ``` */ async chat( query: string, options?: { - stream?: boolean target?: string | Peer session?: string | Session reasoningLevel?: string } - ): Promise { + ): Promise { const targetId = options?.target ? typeof options.target === 'string' ? options.target @@ -139,165 +351,185 @@ export class Peer { const chatParams = ChatQuerySchema.parse({ query, - stream: options?.stream, target: targetId, session: resolvedSessionId, reasoningLevel: options?.reasoningLevel, }) - if (chatParams.stream) { - const body = { - query: chatParams.query, - stream: true, - target: chatParams.target, - session_id: chatParams.session, - reasoning_level: chatParams.reasoningLevel, - } - - const url = `${this._client.baseURL}/v2/workspaces/${this.workspaceId}/peers/${this.id}/chat` - const apiKey = this._client.apiKey - - async function* streamResponse(): AsyncGenerator< - string, - void, - undefined - > { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'text/event-stream', - // Include auth headers if present - ...(apiKey && { - Authorization: `Bearer ${apiKey}`, - }), - }, - body: JSON.stringify(body), - }) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - if (!response.body) { - throw new Error('Response body is null') - } - - const reader = response.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - - for (const line of lines) { - if (line.startsWith('data: ')) { - const jsonStr = line.slice(6) // Remove "data: " prefix - try { - const chunkData: DialecticStreamChunk = JSON.parse(jsonStr) - if (chunkData.done) { - return - } - const content = chunkData.delta.content - if (content) { - yield content - } - } catch {} - } - } - } - } finally { - reader.releaseLock() - } - } - - return new DialecticStreamResponse(streamResponse()) - } - - const response = await this._client.workspaces.peers.chat( - this.workspaceId, - this.id, - { - query: chatParams.query, - stream: false, - target: chatParams.target, - session_id: chatParams.session, - reasoning_level: chatParams.reasoningLevel, - } - ) - if (!response.content || response.content === 'None') { + const response = await this._chat({ + query: chatParams.query, + stream: false, + target: chatParams.target, + session_id: chatParams.session, + reasoning_level: chatParams.reasoningLevel, + }) + if (!response.content) { return null } return response.content } + /** + * Query the peer's representation with a natural language question and stream the response. + * + * Makes an API call to the Honcho dialectic endpoint to query either the peer's + * global representation (all content associated with this peer) or their local + * representation of another peer (what this peer knows about the target peer). + * The response is streamed back as it is generated. + * + * @param query - The natural language question to ask + * @param options.target - Optional target peer for local representation query. If provided, + * queries what this peer knows about the target peer rather than + * querying the peer's global representation. Can be a peer ID string + * or a Peer object. + * @param options.session - Optional session to scope the query to. If provided, only + * information from that session is considered. Can be a session + * ID string or a Session object. + * @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low", "medium", + * "high", or "max". Defaults to "low" if not provided. + * @returns Promise resolving to a DialecticStreamResponse that can be iterated over + * + * @example + * ```typescript + * // Stream a response + * const stream = await peer.chatStream('What do you know about this user?') + * for await (const chunk of stream) { + * process.stdout.write(chunk) + * } + * + * // Stream with options + * const stream = await peer.chatStream('What does this peer think about coding?', { + * target: otherPeer, + * reasoningLevel: 'high' + * }) + * ``` + */ + async chatStream( + query: string, + options?: { + target?: string | Peer + session?: string | Session + reasoningLevel?: string + } + ): Promise { + const targetId = options?.target + ? typeof options.target === 'string' + ? options.target + : options.target.id + : undefined + const resolvedSessionId = options?.session + ? typeof options.session === 'string' + ? options.session + : options.session.id + : undefined + + const chatParams = ChatQuerySchema.parse({ + query, + target: targetId, + session: resolvedSessionId, + reasoningLevel: options?.reasoningLevel, + }) + + const response = await this._chatStream({ + query: chatParams.query, + target: chatParams.target, + session_id: chatParams.session, + reasoning_level: chatParams.reasoningLevel, + }) + + return createDialecticStream(response) + } + /** * 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. * - * @param filters - Optional filter criteria for sessions. See [search filters documentation](https://docs.honcho.dev/v2/guides/using-filters). + * @param filters - Optional filter criteria for sessions. 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 getSessions(filters?: Filters | null): Promise> { + async sessions(filters?: Filters): Promise> { const validatedFilter = filters ? FilterSchema.parse(filters) : undefined - const sessionsPage = await this._client.workspaces.peers.sessions.list( - this.workspaceId, - this.id, - { - filters: validatedFilter, - } - ) + const sessionsPage = await this._listSessions({ filters: validatedFilter }) + + const fetchNextPage = async ( + page: number, + size: number + ): Promise> => { + return this._listSessions({ filters: validatedFilter, page, size }) + } + return new Page( sessionsPage, - (session) => new Session(session.id, this.workspaceId, this._client) + (session) => + new Session( + session.id, + this.workspaceId, + this._http, + session.metadata ?? undefined, + session.configuration ?? undefined + ), + fetchNextPage ) } /** - * Create a message object attributed to this peer. + * Build a message object attributed to this peer (synchronous, no API call). * - * This is a convenience method for creating message objects with this peer's ID. - * The created message object can then be added to sessions or used in other operations. + * This is a convenience method for creating message objects with this peer's ID + * already set. The returned object can then be passed to `session.addMessages()`. + * + * **Note:** This method is synchronous and does NOT send the message to Honcho. + * To actually create the message on the server, pass the returned object to + * `session.addMessages()`. * * @param content - The text content for the message * @param options.metadata - Optional metadata to associate with the message * @param options.configuration - Optional message-level configuration (e.g., reasoning settings) - * @param options.created_at - Optional ISO 8601 timestamp for the message - * @returns A new message object with this peer's ID and the provided content + * @param options.createdAt - Optional ISO 8601 timestamp for the message + * @returns A message object ready to be passed to `session.addMessages()` + * + * @example + * ```typescript + * const msg = peer.message('Hello!') + * await session.addMessages(msg) + * + * // Or batch multiple messages: + * await session.addMessages([ + * alice.message('Hi Bob'), + * bob.message('Hey Alice!') + * ]) + * ``` */ message( content: string, options?: { metadata?: Record - configuration?: Record - created_at?: string | Date + configuration?: MessageConfiguration + createdAt?: string | Date } - ): ValidatedMessageCreate { + ): MessageInput { const validatedContent = MessageContentSchema.parse(content) const validatedMetadata = options?.metadata ? MessageMetadataSchema.parse(options.metadata) : undefined + const validatedConfiguration = options?.configuration + ? MessageConfigurationSchema.parse(options.configuration) + : undefined const createdAt = - options?.created_at instanceof Date - ? options.created_at.toISOString() - : options?.created_at + options?.createdAt instanceof Date + ? options.createdAt.toISOString() + : options?.createdAt return { - peer_id: this.id, + peerId: this.id, content: validatedContent, metadata: validatedMetadata, - configuration: options?.configuration, - created_at: createdAt, + configuration: validatedConfiguration, + createdAt, } } @@ -312,10 +544,7 @@ export class Peer { * Returns an empty dictionary if no metadata is set */ async getMetadata(): Promise> { - const peer = await this._client.workspaces.peers.getOrCreate( - this.workspaceId, - { id: this.id } - ) + const peer = await this._getOrCreate({ id: this.id }) this._metadata = peer.metadata || {} return this._metadata } @@ -331,67 +560,42 @@ export class Peer { * Keys must be strings, values can be any JSON-serializable type */ async setMetadata(metadata: Record): Promise { - await this._client.workspaces.peers.update(this.workspaceId, this.id, { - metadata, - }) - this._metadata = metadata + const validatedMetadata = PeerMetadataSchema.parse(metadata) + await this._update({ metadata: validatedMetadata }) + this._metadata = validatedMetadata } /** * Get the current workspace-level configuration for this peer. * * Makes an API call to retrieve configuration associated with this peer. - * Configuration currently includes one optional flag, `observe_me`. + * Configuration currently includes one optional flag, `observeMe`. * This method also updates the cached configuration property. * - * @returns Promise resolving to a dictionary containing the peer's configuration + * @returns Promise resolving to the peer's configuration */ - async getConfig(): Promise> { - const peer = await this._client.workspaces.peers.getOrCreate( - this.workspaceId, - { id: this.id } - ) - this._configuration = peer.configuration || {} + async getConfiguration(): Promise { + const peer = await this._getOrCreate({ id: this.id }) + this._configuration = peerConfigFromApi(peer.configuration) || {} return this._configuration } /** - * Set the configuration for this peer. Currently the only supported config - * value is the `observe_me` flag, which controls whether derivation tasks - * should be created for this peer's global representation. Default is True. + * Set the configuration for this peer. Currently the only supported configuration + * value is the `observeMe` flag, which controls whether derivation tasks + * should be created for this peer's global representation. Default is true. * * Makes an API call to update the configuration associated with this peer. * This will overwrite any existing configuration with the provided values. * This method also updates the cached configuration property. * - * @param config - A dictionary of configuration to associate with this peer. - * Keys must be strings, values can be any JSON-serializable type + * @param configuration - Configuration to associate with this peer. + * Supports `observeMe` (boolean) to control observation. */ - async setConfig(config: Record): Promise { - await this._client.workspaces.peers.update(this.workspaceId, this.id, { - configuration: config, - }) - this._configuration = config - } - - /** - * Get the current workspace-level configuration for this peer. - * - * @deprecated Use getConfig() instead - * @returns Promise resolving to a dictionary containing the peer's configuration - */ - async getPeerConfig(): Promise> { - return this.getConfig() - } - - /** - * Set the configuration for this peer. - * - * @deprecated Use setConfig() instead - * @param config - A dictionary of configuration to associate with this peer - */ - async setPeerConfig(config: Record): Promise { - return this.setConfig(config) + async setConfiguration(configuration: PeerConfig): Promise { + const validatedConfig = PeerConfigSchema.parse(configuration) + await this._update({ configuration: peerConfigToApi(validatedConfig) }) + this._configuration = validatedConfig } /** @@ -401,12 +605,9 @@ export class Peer { * associated with this peer and updates the cached properties. */ async refresh(): Promise { - const peer = await this._client.workspaces.peers.getOrCreate( - this.workspaceId, - { id: this.id } - ) + const peer = await this._getOrCreate({ id: this.id }) this._metadata = peer.metadata || {} - this._configuration = peer.configuration || {} + this._configuration = peerConfigFromApi(peer.configuration) || {} } /** @@ -415,7 +616,7 @@ export class Peer { * Makes an API call to search endpoint. * * @param query The search query to use - * @param filters - Optional filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v2/guides/using-filters). + * @param filters - Optional filters to scope the search. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). * @param limit - Optional limit on the number of results to return. * @returns Promise resolving to an array of Message objects representing the search results. * Returns an empty array if no messages are found. @@ -431,15 +632,12 @@ export class Peer { const validatedLimit = options?.limit ? LimitSchema.parse(options.limit) : undefined - return await this._client.workspaces.peers.search( - this.workspaceId, - this.id, - { - query: validatedQuery, - filters: validatedFilters, - limit: validatedLimit, - } - ) + const response = await this._search({ + query: validatedQuery, + filters: validatedFilters, + limit: validatedLimit, + }) + return response.map(Message.fromApiResponse) } /** @@ -451,39 +649,17 @@ export class Peer { * * @param target - Optional target peer for local card. If provided, returns this * peer's card of the target peer. Can be a Peer object or peer ID string. - * @returns Promise resolving to a string containing the peer card + * @returns Promise resolving to an array of strings containing the peer card items, + * or null if no peer card exists */ - async card(target?: string | Peer): Promise { - // Validate target parameter - if ( - target !== undefined && - typeof target !== 'string' && - !(target instanceof Peer) - ) { - throw new TypeError( - `target must be string, Peer, or undefined, got ${typeof target}` - ) - } + async card(target?: string | Peer): Promise { + const validatedTarget = CardTargetSchema.parse(target) - if (typeof target === 'string' && target.trim().length === 0) { - throw new Error('target string cannot be empty') - } + const response = await this._getCard({ + target: validatedTarget, + }) - const response = await this._client.workspaces.peers.card( - this.workspaceId, - this.id, - { - target: target instanceof Peer ? target.id : target, - } - ) - - if (!response.peer_card) { - return '' - } - - const items: string[] = response.peer_card - - return items.join('\n') + return response.peer_card } /** @@ -491,38 +667,51 @@ export class Peer { * * Makes an API call to retrieve the representation for this peer. * - * @param session - Optional session to scope the representation to. - * @param target - Optional target peer to get the representation of. If provided, - * returns the representation of the target from the perspective of this peer. - * @param options - Optional representation options to filter and configure the results - * @returns Promise resolving to a Representation object containing explicit and deductive conclusions + * @param options.session - Optional session to scope the representation to. + * @param options.target - Optional target peer to get the representation of. If provided, + * returns the representation of the target from the perspective of this peer. + * @param options.searchQuery - Optional semantic search query to filter relevant conclusions. + * @param options.searchTopK - Number of semantically relevant conclusions to return. + * @param options.searchMaxDistance - Maximum semantic distance for search results (0.0-1.0). + * @param options.includeMostFrequent - Whether to include the most frequent conclusions. + * @param options.maxConclusions - Maximum number of conclusions to include. + * @returns Promise resolving to a string representation containing conclusions * * @example * ```typescript * // Get global representation - * const globalRep = await peer.getRepresentation() - * console.log(globalRep.toString()) + * const globalRep = await peer.representation() * * // Get representation scoped to a session - * const sessionRep = await peer.getRepresentation('session-123') + * const sessionRep = await peer.representation({ session: 'session-123' }) * * // Get representation with semantic search - * const searchedRep = await peer.getRepresentation(undefined, undefined, { + * const searchedRep = await peer.representation({ * searchQuery: 'preferences', * searchTopK: 10, * maxConclusions: 50 * }) * ``` */ - async getRepresentation( - session?: string | Session, - target?: string | Peer, - options?: RepresentationOptions - ): Promise { + async representation(options?: { + session?: string | Session + target?: string | Peer + searchQuery?: string + searchTopK?: number + searchMaxDistance?: number + includeMostFrequent?: boolean + maxConclusions?: number + }): Promise { const getRepresentationParams = PeerGetRepresentationParamsSchema.parse({ - session, - target, - options, + session: options?.session, + target: options?.target, + options: { + searchQuery: options?.searchQuery, + searchTopK: options?.searchTopK, + searchMaxDistance: options?.searchMaxDistance, + includeMostFrequent: options?.includeMostFrequent, + maxConclusions: options?.maxConclusions, + }, }) const sessionId = getRepresentationParams.session ? typeof getRepresentationParams.session === 'string' @@ -535,20 +724,16 @@ export class Peer { : getRepresentationParams.target.id : undefined - const response = await this._client.workspaces.peers.representation( - this.workspaceId, - this.id, - { - session_id: sessionId, - target: targetId, - search_query: getRepresentationParams.options?.searchQuery, - search_top_k: getRepresentationParams.options?.searchTopK, - search_max_distance: getRepresentationParams.options?.searchMaxDistance, - include_most_frequent: - getRepresentationParams.options?.includeMostFrequent, - max_conclusions: getRepresentationParams.options?.maxConclusions, - } - ) + const response = await this._getRepresentation({ + session_id: sessionId, + target: targetId, + search_query: getRepresentationParams.options?.searchQuery, + search_top_k: getRepresentationParams.options?.searchTopK, + search_max_distance: getRepresentationParams.options?.searchMaxDistance, + include_most_frequent: + getRepresentationParams.options?.includeMostFrequent, + max_conclusions: getRepresentationParams.options?.maxConclusions, + }) return response.representation } @@ -558,54 +743,56 @@ export class Peer { * This is a convenience method that retrieves both the working representation * and peer card in a single API call. * - * @param target - Optional target peer to get context for. If provided, returns - * the context for the target from this peer's perspective. - * @param options - Optional representation options to filter and configure the results + * @param options.target - Optional target peer to get context for. If provided, returns + * the context for the target from this peer's perspective. + * @param options.searchQuery - Optional semantic search query to filter relevant conclusions. + * @param options.searchTopK - Number of semantically relevant conclusions to return. + * @param options.searchMaxDistance - Maximum semantic distance for search results (0.0-1.0). + * @param options.includeMostFrequent - Whether to include the most frequent conclusions. + * @param options.maxConclusions - Maximum number of conclusions to include. * @returns Promise resolving to a PeerContext object containing representation and peer card * * @example * ```typescript * // Get own context - * const context = await peer.getContext() + * const context = await peer.context() * console.log(context.representation?.toString()) * console.log(context.peerCard) * * // Get context for another peer - * const context = await peer.getContext('other-peer-id') + * const context = await peer.context({ target: 'other-peer-id' }) * * // Get context with semantic search - * const context = await peer.getContext(undefined, { + * const context = await peer.context({ * searchQuery: 'preferences', * searchTopK: 10 * }) * ``` */ - async getContext( - target?: string | Peer, - options?: RepresentationOptions - ): Promise { - const targetId = target - ? typeof target === 'string' - ? target - : target.id + async context(options?: { + target?: string | Peer + searchQuery?: string + searchTopK?: number + searchMaxDistance?: number + includeMostFrequent?: boolean + maxConclusions?: number + }): Promise { + const targetId = options?.target + ? typeof options.target === 'string' + ? options.target + : options.target.id : undefined - const response = await this._client.workspaces.peers.context( - this.workspaceId, - this.id, - { - 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, - } - ) + 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, + }) - return PeerContext.fromApiResponse( - response as unknown as Record - ) + return PeerContext.fromApiResponse(response) } /** @@ -629,7 +816,13 @@ export class Peer { * ``` */ get conclusions(): ConclusionScope { - return new ConclusionScope(this._client, this.workspaceId, this.id, this.id) + return new ConclusionScope( + this._http, + this.workspaceId, + this.id, + this.id, + () => this._ensureWorkspace() + ) } /** @@ -653,16 +846,17 @@ export class Peer { * const results = await bobConclusions.query('work history') * * // Get the representation from these conclusions - * const rep = await bobConclusions.getRepresentation() + * const rep = await bobConclusions.representation() * ``` */ conclusionsOf(target: string | Peer): ConclusionScope { const targetId = typeof target === 'string' ? target : target.id return new ConclusionScope( - this._client, + this._http, this.workspaceId, this.id, - targetId + targetId, + () => this._ensureWorkspace() ) } @@ -675,76 +869,3 @@ export class Peer { return `Peer(id='${this.id}')` } } - -/** - * Context for a peer, including representation and peer card. - * - * This class holds both the working representation and peer card for a peer, - * typically returned from the getContext API call. - */ -export class PeerContext { - /** - * The ID of the observer peer. - */ - readonly peerId: string - - /** - * The ID of the target peer being observed. - */ - readonly targetId: string - - /** - * The working representation (may be null if no conclusions exist). - */ - readonly representation: Representation | null - - /** - * List of peer card strings (may be null if no card exists). - */ - readonly peerCard: string[] | null - - constructor( - peerId: string, - targetId: string, - representation: Representation | null, - peerCard: string[] | null - ) { - this.peerId = peerId - this.targetId = targetId - this.representation = representation - this.peerCard = peerCard - } - - /** - * Create a PeerContext from an API response. - * - * @param response - API response object with peer_id, target_id, representation, and peer_card - * @returns A new PeerContext instance - */ - static fromApiResponse(response: Record): PeerContext { - const peerId = (response.peer_id as string | undefined) ?? '' - const targetId = (response.target_id as string | undefined) ?? '' - - let representation: Representation | null = null - if (response.representation) { - representation = Representation.fromData( - response.representation as RepresentationData - ) - } - - const peerCard = (response.peer_card as string[] | undefined) ?? null - - return new PeerContext(peerId, targetId, representation, peerCard) - } - - /** - * Return a string representation of the PeerContext. - * - * @returns A string representation suitable for debugging - */ - toString(): string { - const hasRep = this.representation !== null - const hasCard = this.peerCard !== null && this.peerCard.length > 0 - return `PeerContext(peerId='${this.peerId}', targetId='${this.targetId}', hasRepresentation=${hasRep}, hasPeerCard=${hasCard})` - } -} diff --git a/sdks/typescript/src/representation.ts b/sdks/typescript/src/representation.ts deleted file mode 100644 index f80b3f0f..00000000 --- a/sdks/typescript/src/representation.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * Options for representation retrieval. - */ -export interface RepresentationOptions { - /** - * Semantic search query to filter relevant conclusions. - */ - searchQuery?: string - - /** - * Number of semantically relevant conclusions to return. - */ - searchTopK?: number - - /** - * Maximum semantic distance for search results (0.0-1.0). - */ - searchMaxDistance?: number - - /** - * Whether to include the most frequent conclusions. - */ - includeMostFrequent?: boolean - - /** - * Maximum number of conclusions to include. - */ - maxConclusions?: number -} - -/** - * Metadata associated with a conclusion. - */ -export interface ConclusionMetadata { - created_at: string - message_ids: Array<[number, number]> - session_name: string -} - -/** - * An explicit conclusion with full metadata. - * Represents facts LITERALLY stated - direct quotes or clear paraphrases only. - */ -export interface ExplicitConclusionBase { - content: string -} - -/** - * Base interface for deductive conclusions - logical conclusions. - */ -export interface DeductiveConclusionBase { - premises: string[] - conclusion: string -} - -export interface ExplicitConclusion - extends ExplicitConclusionBase, - ConclusionMetadata {} - -/** - * A deductive conclusion with full metadata. - * Represents conclusions that MUST be true given explicit facts and premises. - */ -export interface DeductiveConclusion - extends DeductiveConclusionBase, - ConclusionMetadata {} - -/** - * Raw representation data structure returned from the API. - */ -export interface RepresentationData { - explicit: ExplicitConclusion[] - deductive: DeductiveConclusion[] -} - -/** - * A Representation is a traversable and diffable map of conclusions. - * - * At the base, we have a list of explicit conclusions, derived from a peer's messages. - * From there, deductive conclusions can be made by establishing logical relationships - * between explicit conclusions. - * - * All of a peer's conclusions are stored as documents in a collection. These documents - * can be queried in various ways to produce this Representation object. - * - * A "working representation" is a version of this data structure representing the most - * recent conclusions within a single session. - */ -export class Representation { - /** - * Facts LITERALLY stated - direct quotes or clear paraphrases only, no interpretation or inference. - */ - explicit: ExplicitConclusion[] - - /** - * Conclusions that MUST be true given explicit facts and premises - strict logical necessities. - */ - deductive: DeductiveConclusion[] - - /** - * Create a new Representation from conclusion lists. - * - * @param explicit - List of explicit conclusions - * @param deductive - List of deductive conclusions - */ - constructor( - explicit: ExplicitConclusion[] = [], - deductive: DeductiveConclusion[] = [] - ) { - this.explicit = explicit - this.deductive = deductive - } - - /** - * Check if the representation is empty. - * - * @returns True if both explicit and deductive conclusion lists are empty - */ - isEmpty(): boolean { - return this.explicit.length === 0 && this.deductive.length === 0 - } - - /** - * Given this and another representation, return a new representation with only - * conclusions that are unique to the other. - * - * Note: This only removes literal duplicates based on stringified comparison, - * not semantically equivalent ones. - * - * @param other - The representation to compare against - * @returns A new Representation containing only conclusions unique to other - */ - diff(other: Representation): Representation { - const thisExplicitSet = new Set( - this.explicit.map((obs) => this._hashExplicit(obs)) - ) - const thisDeductiveSet = new Set( - this.deductive.map((obs) => this._hashDeductive(obs)) - ) - - const uniqueExplicit = other.explicit.filter( - (obs) => !thisExplicitSet.has(this._hashExplicit(obs)) - ) - const uniqueDeductive = other.deductive.filter( - (obs) => !thisDeductiveSet.has(this._hashDeductive(obs)) - ) - - return new Representation(uniqueExplicit, uniqueDeductive) - } - - /** - * Merge another representation into this one. - * - * This automatically deduplicates explicit and deductive conclusions. - * Preserves order of conclusions to retain FIFO order. - * - * Note: Conclusions with the same timestamp may not have order preserved, - * but that's acceptable since they're from the same timestamp. - * - * @param other - The representation to merge into this one - * @param maxConclusions - Optional maximum number of conclusions to keep per type - */ - merge(other: Representation, maxConclusions?: number): void { - // Deduplicate by converting to Set using hash, then back to array - const explicitMap = new Map() - const deductiveMap = new Map() - - // Add existing conclusions - for (const obs of this.explicit) { - explicitMap.set(this._hashExplicit(obs), obs) - } - for (const obs of this.deductive) { - deductiveMap.set(this._hashDeductive(obs), obs) - } - - // Add new conclusions (overwrites duplicates) - for (const obs of other.explicit) { - explicitMap.set(this._hashExplicit(obs), obs) - } - for (const obs of other.deductive) { - deductiveMap.set(this._hashDeductive(obs), obs) - } - - // Convert back to arrays and sort by created_at - this.explicit = Array.from(explicitMap.values()).sort( - (a, b) => - this._parseTimestampForSort(a.created_at) - - this._parseTimestampForSort(b.created_at) - ) - this.deductive = Array.from(deductiveMap.values()).sort( - (a, b) => - this._parseTimestampForSort(a.created_at) - - this._parseTimestampForSort(b.created_at) - ) - - // Apply max conclusions limit if specified - if (maxConclusions !== undefined) { - this.explicit = this.explicit.slice(-maxConclusions) - this.deductive = this.deductive.slice(-maxConclusions) - } - } - - /** - * Format representation into a clean, readable string for LLM prompts. - * - * Timestamps are stripped of subsecond precision for cleaner display. - * - * @returns Formatted string with clear sections and numbered items including timestamps - * - * @example - * ``` - * EXPLICIT: - * 1. [2025-01-01T12:00:00Z] The user has a dog named Rover - * 2. [2025-01-01T12:01:00Z] The user's dog is 5 years old - * - * DEDUCTIVE: - * 1. [2025-01-01T12:01:00Z] Rover is 5 years old - * - The user has a dog named Rover - * - The user's dog is 5 years old - * ``` - */ - toString(): string { - const parts: string[] = [] - - parts.push('EXPLICIT:\n') - for (let i = 0; i < this.explicit.length; i++) { - const obs = this.explicit[i] - const timestamp = this._stripMicroseconds(obs.created_at) - parts.push(`${i + 1}. [${timestamp}] ${obs.content}`) - } - parts.push('') - - parts.push('DEDUCTIVE:\n') - for (let i = 0; i < this.deductive.length; i++) { - const obs = this.deductive[i] - const timestamp = this._stripMicroseconds(obs.created_at) - parts.push(`${i + 1}. [${timestamp}] ${obs.conclusion}`) - for (const premise of obs.premises) { - parts.push(` - ${premise}`) - } - } - parts.push('') - - return parts.join('\n') - } - - /** - * Format representation into a clean, readable string without timestamps. - * - * @returns Formatted string with clear sections and numbered items without temporal metadata - * - * @example - * ``` - * EXPLICIT: - * 1. The user has a dog named Rover - * 2. The user's dog is 5 years old - * - * DEDUCTIVE: - * 1. Rover is 5 years old - * - The user has a dog named Rover - * - The user's dog is 5 years old - * ``` - */ - toStringNoTimestamps(): string { - const parts: string[] = [] - - parts.push('EXPLICIT:\n') - for (let i = 0; i < this.explicit.length; i++) { - parts.push(`${i + 1}. ${this.explicit[i].content}`) - } - parts.push('') - - parts.push('DEDUCTIVE:\n') - for (let i = 0; i < this.deductive.length; i++) { - const obs = this.deductive[i] - parts.push(`${i + 1}. ${obs.conclusion}`) - for (const premise of obs.premises) { - parts.push(` - ${premise}`) - } - } - parts.push('') - - return parts.join('\n') - } - - /** - * Format a Representation object as markdown. - * - * Timestamps are stripped of subsecond precision for cleaner display. - * - * @returns Formatted markdown string with headers and lists - */ - toMarkdown(): string { - const parts: string[] = [] - - parts.push('## Explicit Conclusions\n') - for (let i = 0; i < this.explicit.length; i++) { - const obs = this.explicit[i] - const timestamp = this._stripMicroseconds(obs.created_at) - parts.push(`${i + 1}. [${timestamp}] ${obs.content}`) - } - parts.push('') - - parts.push('## Deductive Conclusions\n') - for (let i = 0; i < this.deductive.length; i++) { - const obs = this.deductive[i] - const timestamp = this._stripMicroseconds(obs.created_at) - parts.push(`${i + 1}. **Conclusion**: ${obs.conclusion}`) - parts.push(` **Created**: ${timestamp}`) - if (obs.premises.length > 0) { - parts.push(' **Premises**:') - for (const premise of obs.premises) { - parts.push(` - ${premise}`) - } - } - parts.push('') - } - - return parts.join('\n') - } - - /** - * Create a Representation from raw API response data. - * - * @param data - Raw representation data from the API - * @returns A new Representation instance - */ - static fromData(data: RepresentationData): Representation { - return new Representation(data.explicit, data.deductive) - } - - /** - * Create a hash string for an explicit conclusion for deduplication. - * Based on content, created_at, and session_name. - */ - private _hashExplicit(obs: ExplicitConclusion): string { - return JSON.stringify({ - content: obs.content, - created_at: obs.created_at, - session_name: obs.session_name, - }) - } - - /** - * Create a hash string for a deductive conclusion for deduplication. - * Based on conclusion, created_at, and session_name (premises not included). - */ - private _hashDeductive(obs: DeductiveConclusion): string { - return JSON.stringify({ - conclusion: obs.conclusion, - created_at: obs.created_at, - session_name: obs.session_name, - }) - } - - /** - * Strip microseconds from ISO timestamp for cleaner display. - */ - private _stripMicroseconds(timestamp: string): string { - try { - const date = new Date(timestamp) - return date.toISOString().replace(/\.\d{3}Z$/, 'Z') - } catch { - return timestamp - } - } - - /** - * Safely parse a timestamp and return milliseconds since epoch for sorting. - * Handles microsecond precision by truncating to milliseconds before parsing. - * - * @param timestamp - ISO 8601 timestamp string (may include microseconds) - * @returns Milliseconds since epoch, or 0 if parsing fails - */ - private _parseTimestampForSort(timestamp: string): number { - try { - // Normalize fractional seconds to 3 digits (milliseconds) - // Match pattern: YYYY-MM-DDTHH:mm:ss.SSSSSS(Z or timezone) - const normalized = timestamp.replace( - /(\.\d{3})\d+(Z|[+-]\d{2}:\d{2})$/, - '$1$2' - ) - const time = new Date(normalized).getTime() - // Return 0 if parsing failed (NaN) - return Number.isNaN(time) ? 0 : time - } catch { - return 0 - } - } -} diff --git a/sdks/typescript/src/session.ts b/sdks/typescript/src/session.ts index 4105ee41..e8f12f05 100644 --- a/sdks/typescript/src/session.ts +++ b/sdks/typescript/src/session.ts @@ -1,14 +1,23 @@ -import type HonchoCore from '@honcho-ai/core' -import type { - QueueStatusParams, - QueueStatusResponse, -} from '@honcho-ai/core/resources/workspaces/queue' -import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages' -import type { Uploadable } from '@honcho-ai/core/uploads' +import { API_VERSION } from './api-version' +import type { HonchoHTTPClient } from './http/client' +import { Message } from './message' import { Page } from './pagination' import { Peer } from './peer' -import type { RepresentationOptions } from './representation' -import { SessionContext, SessionSummaries, Summary } from './session_context' +import { SessionContext, SessionSummaries } from './session_context' +import type { + MessageResponse, + PageResponse, + PeerResponse, + QueueStatus, + QueueStatusParams, + QueueStatusResponse, + RepresentationOptions, + RepresentationResponse, + SessionContextResponse, + SessionResponse, + SessionSummariesResponse, +} from './types/api' +import { transformQueueStatus } from './utils' import { ContextParamsSchema, FileUploadSchema, @@ -17,86 +26,45 @@ import { GetRepresentationParamsSchema, LimitSchema, type MessageAddition, - MessageAdditionSchema, + MessageAdditionToApiSchema, + MessageMetadataSchema, type PeerAddition, - PeerAdditionSchema, + PeerAdditionToApiSchema, type PeerRemoval, PeerRemovalSchema, type QueueStatusOptions, SearchQuerySchema, + type SessionConfig, + SessionConfigSchema, + SessionMetadataSchema, + type SessionPeerConfig, SessionPeerConfigSchema, + sessionConfigFromApi, + sessionConfigToApi, } from './validation' -/** - * Configuration options for a peer within a specific session. - * - * Controls how peers interact and observe each other within the context - * of a particular session, allowing for fine-grained control over - * representation building and theory-of-mind behaviors. - */ -export class SessionPeerConfig { - /** - * Whether other peers in this session should try to form a session-level - * theory-of-mind representation of this peer. When false, prevents other - * peers from building local representations of this peer within this session. - */ - observe_me?: boolean | null - - /** - * Whether this peer should form session-level theory-of-mind representations - * of other peers in the session. When false, this peer will not build local - * representations of other peers within this session. - */ - observe_others?: boolean | null - - /** - * Initialize SessionPeerConfig with conclusion settings. - * - * @param observe_me - Whether other peers should observe this peer in the session - * @param observe_others - Whether this peer should observe others in the session - */ - constructor(observe_me?: boolean | null, observe_others?: boolean | null) { - const validatedConfig = SessionPeerConfigSchema.parse({ - observe_me, - observe_others, - }) - this.observe_me = validatedConfig.observe_me - this.observe_others = validatedConfig.observe_others - } -} - /** * Represents a session in the Honcho system. * - * Sessions are scoped to a set of peers and contain messages/content. They create - * bidirectional relationships between peers and provide a context for multi-party - * conversations and interactions. Sessions serve as containers for conversations, - * allowing peers to communicate while maintaining both global and local - * representations of each other. - * - * Key features: - * - Multi-peer conversations with configurable conclusion settings - * - Message storage and retrieval with filtering capabilities - * - Context optimization for token-limited scenarios - * - File upload support with automatic message creation - * - Session-scoped peer representations and theory-of-mind modeling - * - Search functionality across session messages + * Sessions are conversation contexts that can involve multiple peers. They track + * message history, manage peer participation with configurable observation settings, + * and provide context retrieval for LLM interactions. * * @example * ```typescript * const session = await honcho.session('conversation-123') * * // Add peers to the session - * await session.addPeers(['user1', 'assistant1']) + * await session.addPeers([user, assistant]) * - * // Send messages + * // Add messages * await session.addMessages([ - * { peer_id: 'user1', content: 'Hello!' }, - * { peer_id: 'assistant1', content: 'Hi there!' } + * user.message('Hello!'), + * assistant.message('Hi there!') * ]) * - * // Get optimized context - * const context = await session.getContext(true, 4000) + * // Get context for LLM + * const ctx = await session.context({ peerPerspective: assistant }) * ``` */ export class Session { @@ -108,18 +76,10 @@ export class Session { * Workspace ID for scoping operations. */ readonly workspaceId: string - /** - * Reference to the parent Honcho client instance. - */ - private _client: HonchoCore - /** - * Private cached metadata for this session. - */ + private _http: HonchoHTTPClient private _metadata?: Record - /** - * Private cached configuration for this session. - */ - private _configuration?: Record + private _configuration?: SessionConfig + private _ensureWorkspace: () => Promise /** * Cached metadata for this session. May be stale if the session @@ -136,10 +96,10 @@ export class Session { * Cached configuration for this session. May be stale if the session * was not recently fetched from the API. * - * Call getConfig() to get the latest configuration from the server, + * Call getConfiguration() to get the latest configuration from the server, * which will also update this cached value. */ - get configuration(): Record | undefined { + get configuration(): SessionConfig | undefined { return this._configuration } @@ -148,147 +108,320 @@ export class Session { * * @param id - Unique identifier for this session within the workspace * @param workspaceId - Workspace ID for scoping operations - * @param client - Reference to the parent Honcho client instance + * @param http - Reference to the HTTP client instance * @param metadata - Optional metadata to initialize the cached value * @param configuration - Optional configuration to initialize the cached value */ constructor( id: string, workspaceId: string, - client: HonchoCore, + http: HonchoHTTPClient, metadata?: Record, - configuration?: Record + configuration?: SessionConfig, + ensureWorkspace: () => Promise = async () => undefined ) { this.id = id this.workspaceId = workspaceId - this._client = client + this._http = http this._metadata = metadata this._configuration = configuration + this._ensureWorkspace = ensureWorkspace } + // =========================================================================== + // Private API Methods + // =========================================================================== + + private async _getOrCreate(params: { + id: string + metadata?: Record + configuration?: SessionConfig + }): Promise { + await this._ensureWorkspace() + return this._http.post( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions`, + { + body: { + id: params.id, + metadata: params.metadata, + configuration: sessionConfigToApi(params.configuration), + }, + } + ) + } + + private async _update(params: { + metadata?: Record + configuration?: SessionConfig + }): Promise { + await this._ensureWorkspace() + return this._http.put( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}`, + { + body: { + metadata: params.metadata, + configuration: sessionConfigToApi(params.configuration), + }, + } + ) + } + + private async _delete(): Promise { + await this._ensureWorkspace() + return this._http.delete( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}` + ) + } + + private async _clone(params?: { + message_id?: string + }): Promise { + await this._ensureWorkspace() + return this._http.post( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/clone`, + { query: params } + ) + } + + private async _getContext(params: { + tokens?: number + summary?: boolean + last_message?: string + peer_target?: string + peer_perspective?: string + limit_to_session?: boolean + search_top_k?: number + search_max_distance?: number + include_most_frequent?: boolean + max_conclusions?: number + }): Promise { + await this._ensureWorkspace() + return this._http.get( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/context`, + { query: params } + ) + } + + private async _getSummaries(): Promise { + await this._ensureWorkspace() + return this._http.get( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/summaries` + ) + } + + private async _search(params: { + query: string + filters?: Record + limit?: number + }): Promise { + await this._ensureWorkspace() + return this._http.post( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/search`, + { body: params } + ) + } + + private async _addPeers( + peers: Record< + string, + { observe_me?: boolean | null; observe_others?: boolean | null } + > + ): Promise { + await this._ensureWorkspace() + await this._http.post( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/peers`, + { body: peers } + ) + } + + private async _setPeers( + peers: Record< + string, + { observe_me?: boolean | null; observe_others?: boolean | null } + > + ): Promise { + await this._ensureWorkspace() + await this._http.put( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/peers`, + { body: peers } + ) + } + + private async _removePeers(peerIds: string[]): Promise { + await this._ensureWorkspace() + await this._http.delete( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/peers`, + { body: peerIds } + ) + } + + private async _listPeers(): Promise> { + await this._ensureWorkspace() + return this._http.get>( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/peers` + ) + } + + private async _getPeerConfiguration( + peerId: string + ): Promise<{ observe_me?: boolean | null; observe_others?: boolean | null }> { + await this._ensureWorkspace() + return this._http.get<{ + observe_me?: boolean | null + observe_others?: boolean | null + }>( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/peers/${peerId}/config` + ) + } + + private async _setPeerConfiguration( + peerId: string, + config: { observe_me?: boolean | null; observe_others?: boolean | null } + ): Promise { + await this._ensureWorkspace() + await this._http.put( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/peers/${peerId}/config`, + { body: config } + ) + } + + private async _createMessages(params: { + messages: Array<{ + peer_id: string + content: string + metadata?: Record + configuration?: Record + created_at?: string + }> + }): Promise { + await this._ensureWorkspace() + return this._http.post( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/messages`, + { body: params } + ) + } + + private async _listMessages(params?: { + filters?: Record + page?: number + size?: number + }): 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 }, + } + ) + } + + private async _uploadFile(formData: FormData): Promise { + await this._ensureWorkspace() + return this._http.upload( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/messages/upload`, + formData + ) + } + + private async _getQueueStatus( + params?: QueueStatusParams + ): Promise { + await this._ensureWorkspace() + const query: Record = {} + if (params?.observer_id) query.observer_id = params.observer_id + if (params?.sender_id) query.sender_id = params.sender_id + if (params?.session_id) query.session_id = params.session_id + + return this._http.get( + `/${API_VERSION}/workspaces/${this.workspaceId}/queue/status`, + { query } + ) + } + + private async _getRepresentation( + peerId: string, + params: { + session_id?: string + target?: string + search_query?: string + search_top_k?: number + search_max_distance?: number + include_most_frequent?: boolean + max_conclusions?: number + } + ): Promise { + await this._ensureWorkspace() + return this._http.post( + `/${API_VERSION}/workspaces/${this.workspaceId}/peers/${peerId}/representation`, + { body: params } + ) + } + + private async _updateMessage( + messageId: string, + params: { metadata: Record } + ): Promise { + await this._ensureWorkspace() + return this._http.put( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/messages/${messageId}`, + { body: params } + ) + } + + // =========================================================================== + // Public Methods + // =========================================================================== + /** * Add peers to this session. * - * Makes an API call to add one or more peers to this session. Adding peers - * creates bidirectional relationships and allows them to participate in - * the session's conversations. Peers can be added with optional session-specific - * configuration to control conclusion behaviors. + * Makes an API call to add one or more peers to the session. Peers can be + * specified as IDs, Peer objects, or with observation configuration. * - * @param peers - Peers to add to the session. Can be: - * - string: Single peer ID - * - Peer: Single Peer object - * - Array: List of peer IDs and/or Peer objects - * - [string | Peer, SessionPeerConfig]: Single peer with session config - * - Array: Mixed list - * of peers and peer+config combinations + * @param peers - Peers to add. Can be a single peer ID, Peer object, array of either, + * or an object mapping peer IDs to their observation config * * @example * ```typescript - * // Add single peer - * await session.addPeers('user123') + * // Add by ID + * await session.addPeers('user-123') * * // Add multiple peers - * await session.addPeers(['user1', 'user2', peer3]) + * await session.addPeers([user, assistant]) * - * // Add peer with custom config - * await session.addPeers(['user1', new SessionPeerConfig(false, true)]) - * - * // Add mixed peers with and without configs - * await session.addPeers([ - * 'user1', - * ['user2', new SessionPeerConfig(true, false)], - * peer3 - * ]) + * // Add with observation config + * await session.addPeers({ + * 'user-123': { observeMe: true, observeOthers: true }, + * 'assistant': { observeMe: false } + * }) * ``` */ async addPeers(peers: PeerAddition): Promise { - const validatedPeers = PeerAdditionSchema.parse(peers) - const peerDict: Record = {} - const peersArray = Array.isArray(validatedPeers) - ? validatedPeers - : [validatedPeers] - - for (const peer of peersArray) { - if (typeof peer === 'string') { - // Handle string peer ID - peerDict[peer] = {} - } else if (Array.isArray(peer)) { - // Handle tuple [string | Peer, SessionPeerConfig] - const peerId = typeof peer[0] === 'string' ? peer[0] : peer[0].id - peerDict[peerId] = peer[1] - } else if (typeof peer === 'object' && 'id' in peer) { - // Handle Peer object - peerDict[peer.id] = {} - } else { - // This should never happen with proper typing, but handle gracefully - throw new Error(`Invalid peer type: ${typeof peer}`) - } - } - - await this._client.workspaces.sessions.peers.add( - this.workspaceId, - this.id, - peerDict - ) + const peerDict = PeerAdditionToApiSchema.parse(peers) + await this._addPeers(peerDict) } /** - * Set the complete peer list for this session. + * Set the peers for this session, replacing any existing peer list. * - * Makes an API call to replace the current peer list with the provided peers. - * This will remove any peers not in the new list and add any that are missing. - * Unlike addPeers(), this method overwrites the entire peer membership. + * Makes an API call to replace the session's peer list with the provided peers. + * Any peers not included will be removed from the session. * - * @param peers - Peers to set for the session. Can be: - * - string: Single peer ID - * - Peer: Single Peer object - * - Array: List of peer IDs and/or Peer objects - * - [string | Peer, SessionPeerConfig]: Single peer with session config - * - Array: Mixed list - * of peers and peer+config combinations + * @param peers - Peers to set. Can be a single peer ID, Peer object, array of either, + * or an object mapping peer IDs to their observation config */ async setPeers(peers: PeerAddition): Promise { - const validatedPeers = PeerAdditionSchema.parse(peers) - const peerDict: Record = {} - const peersArray = Array.isArray(validatedPeers) - ? validatedPeers - : [validatedPeers] - - for (const peer of peersArray) { - if (typeof peer === 'string') { - // Handle string peer ID - peerDict[peer] = {} - } else if (Array.isArray(peer)) { - // Handle tuple [string | Peer, SessionPeerConfig] - const peerId = typeof peer[0] === 'string' ? peer[0] : peer[0].id - peerDict[peerId] = peer[1] - } else if (typeof peer === 'object' && 'id' in peer) { - // Handle Peer object - peerDict[peer.id] = {} - } else { - // This should never happen with proper typing, but handle gracefully - throw new Error(`Invalid peer type: ${typeof peer}`) - } - } - - await this._client.workspaces.sessions.peers.set( - this.workspaceId, - this.id, - peerDict - ) + const peerDict = PeerAdditionToApiSchema.parse(peers) + await this._setPeers(peerDict) } /** * Remove peers from this session. * - * Makes an API call to remove one or more peers from this session. - * Removed peers will no longer be able to participate in the session - * unless added back. Their existing messages remain in the session. + * Makes an API call to remove one or more peers from the session. * - * @param peers - Peers to remove from the session. Can be: - * - string: Single peer ID - * - Peer: Single Peer object - * - Array: List of peer IDs and/or Peer objects + * @param peers - Peers to remove. Can be a single peer ID, Peer object, or array of either */ async removePeers(peers: PeerRemoval): Promise { const validatedPeers = PeerRemovalSchema.parse(peers) @@ -299,218 +432,191 @@ export class Session { ? validatedPeers : validatedPeers.id, ] - await this._client.workspaces.sessions.peers.remove( - this.workspaceId, - this.id, - peerIds - ) + await this._removePeers(peerIds) } /** * Get all peers in this session. * - * Makes an API call to retrieve the list of peers that are currently - * members of this session. Automatically converts the paginated response - * into a list for convenience -- the max number of peers in a session is usually 10. + * Makes an API call to retrieve all peers that are currently part of this session. * - * @returns Promise resolving to a list of Peer objects that are members of this session + * @returns Promise resolving to an array of Peer objects in this session */ - async getPeers(): Promise { - const peersPage = await this._client.workspaces.sessions.peers.list( - this.workspaceId, - this.id - ) + async peers(): Promise { + const peersPage = await this._listPeers() return peersPage.items.map( - (peer) => new Peer(peer.id, this.workspaceId, this._client) + (peer) => + new Peer( + peer.id, + this.workspaceId, + this._http, + undefined, + undefined, + () => this._ensureWorkspace() + ) ) } /** - * Get the configuration for a peer in this session. + * Get the session-specific configuration for a peer. * - * Makes an API call to retrieve the session-specific configuration for a peer. - * This includes conclusion settings that control how this peer interacts - * with other peers within this session context. + * Makes an API call to retrieve the observation settings for a specific peer + * within this session. * - * @param peer - The peer to get configuration for. Can be peer ID string or Peer object - * @returns Promise resolving to SessionPeerConfig object with the peer's session settings + * @param peer - The peer to get configuration for (ID string or Peer object) + * @returns Promise resolving to the peer's session configuration with observation settings */ - async getPeerConfig(peer: string | Peer): Promise { + async getPeerConfiguration(peer: string | Peer): Promise { const peerId = typeof peer === 'string' ? peer : peer.id - return await this._client.workspaces.sessions.peers.config( - this.workspaceId, - this.id, - peerId - ) + const response = await this._getPeerConfiguration(peerId) + return { + observeMe: response.observe_me, + observeOthers: response.observe_others, + } } /** - * Set the configuration for a peer in this session. + * Set the session-specific configuration for a peer. * - * Makes an API call to update the session-specific configuration for a peer. - * This controls conclusion behaviors and theory-of-mind formation within - * this session context. + * Makes an API call to update the observation settings for a specific peer + * within this session. * - * @param peer - The peer to configure. Can be peer ID string or Peer object - * @param config - SessionPeerConfig object specifying the conclusion settings + * @param peer - The peer to configure (ID string or Peer object) + * @param configuration - Configuration with observation settings + * @param configuration.observeMe - Whether this peer's messages generate observations about them + * @param configuration.observeOthers - Whether this peer observes other peers in the session */ - async setPeerConfig( + async setPeerConfiguration( peer: string | Peer, - config: SessionPeerConfig + configuration: SessionPeerConfig ): Promise { const peerId = typeof peer === 'string' ? peer : peer.id - const validatedConfig = SessionPeerConfigSchema.parse(config) - await this._client.workspaces.sessions.peers.setConfig( - this.workspaceId, - this.id, - peerId, - { - observe_others: validatedConfig.observe_others, - observe_me: validatedConfig.observe_me, - } - ) + const validatedConfig = SessionPeerConfigSchema.parse(configuration) + await this._setPeerConfiguration(peerId, { + observe_others: validatedConfig.observeOthers, + observe_me: validatedConfig.observeMe, + }) } /** - * Add one or more messages to this session. + * Add messages to this session. * - * Makes an API call to store messages in this session. Any message added - * to a session will automatically add the creating peer to the session - * if they are not already a member. Messages are the primary way content - * flows through the Honcho system. + * Makes an API call to create one or more messages in the session. Messages + * are processed asynchronously to update peer representations. * - * @param messages - Messages to add to the session. Can be: - * - MessageCreate: Single message object with peer_id and content - * - MessageCreate[]: Array of message objects + * @param messages - Messages to add. Can be a single MessageInput or array of them. + * Use `peer.message()` to create MessageInput objects. + * @returns Promise resolving to an array of created Message objects * * @example * ```typescript - * // Add single message - * await session.addMessages({ - * peer_id: 'user123', - * content: 'Hello world!' - * }) + * // Add a single message + * await session.addMessages(user.message('Hello!')) * * // Add multiple messages * await session.addMessages([ - * { peer_id: 'user1', content: 'Hello!' }, - * { peer_id: 'assistant', content: 'Hi there!' } + * user.message('Hello!'), + * assistant.message('Hi there!'), + * user.message('How are you?') * ]) - * // Add message with custom ISO 8601 timestamp - * await session.addMessages({ - * peer_id: 'user123', - * content: 'Hello world!', - * created_at: '2021-01-01T00:00:00.000Z' - * }) * ``` */ async addMessages(messages: MessageAddition): Promise { - const validatedMessages = MessageAdditionSchema.parse(messages) - const messagesList = Array.isArray(validatedMessages) - ? validatedMessages - : [validatedMessages] - return await this._client.workspaces.sessions.messages.create( - this.workspaceId, - this.id, - { - messages: messagesList, - } - ) + const transformedMessages = MessageAdditionToApiSchema.parse(messages) + const apiMessages = transformedMessages.map((msg) => ({ + peer_id: msg.peer_id, + content: msg.content, + metadata: msg.metadata, + configuration: msg.configuration ?? undefined, + created_at: msg.created_at ?? undefined, + })) + const response = await this._createMessages({ messages: apiMessages }) + return response.map(Message.fromApiResponse) } /** - * Get messages from this session with optional filtering. + * Get all messages in this session. * - * Makes an API call to retrieve messages from this session. Results can be - * filtered based on various criteria and are returned in a paginated format. - * Messages are ordered by creation time (most recent first by default). + * Makes an API call to retrieve messages in the session, with optional filtering. * - * @param filters - Optional filter criteria for messages. See [search filters documentation](https://docs.honcho.dev/v2/guides/using-filters). - * @returns Promise resolving to a Page of Message objects matching the specified criteria + * @param filters - Optional filter criteria for messages. 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 getMessages(filters?: Filters): Promise> { + async messages(filters?: Filters): Promise> { const validatedFilter = filters ? FilterSchema.parse(filters) : undefined - const messagesPage = await this._client.workspaces.sessions.messages.list( - this.workspaceId, - this.id, - validatedFilter - ) - return new Page(messagesPage) + const messagesPage = await this._listMessages({ filters: validatedFilter }) + + const fetchNextPage = async ( + page: number, + size: number + ): Promise> => { + return this._listMessages({ filters: validatedFilter, page, size }) + } + + return new Page(messagesPage, Message.fromApiResponse, fetchNextPage) } /** - * Get metadata for this session. + * Get the current metadata for this session. * - * Makes an API call to retrieve the current metadata associated with this session. - * Metadata can include custom attributes, settings, or any other key-value data - * that provides context about the session. This method also updates the cached - * metadata property. + * Makes an API call to retrieve metadata associated with this session. + * This method also updates the cached metadata property. * * @returns Promise resolving to a dictionary containing the session's metadata. * Returns an empty dictionary if no metadata is set */ async getMetadata(): Promise> { - const session = await this._client.workspaces.sessions.getOrCreate( - this.workspaceId, - { id: this.id } - ) + const session = await this._getOrCreate({ id: this.id }) this._metadata = session.metadata || {} return this._metadata } /** - * Set metadata for this session. + * Set the metadata for this session. * * Makes an API call to update the metadata associated with this session. * This will overwrite any existing metadata with the provided values. - * Metadata is useful for storing custom attributes, configuration, or - * contextual information about the session. This method also updates the - * cached metadata property. + * This method also updates the cached metadata property. * * @param metadata - A dictionary of metadata to associate with this session. * Keys must be strings, values can be any JSON-serializable type */ async setMetadata(metadata: Record): Promise { - await this._client.workspaces.sessions.update(this.workspaceId, this.id, { - metadata, - }) - this._metadata = metadata + const validatedMetadata = SessionMetadataSchema.parse(metadata) + await this._update({ metadata: validatedMetadata }) + this._metadata = validatedMetadata } /** - * Get configuration for this session. + * Get the current configuration for this session. * - * Makes an API call to retrieve the current configuration associated with this session. - * Configuration includes settings that control session behavior. This method also - * updates the cached configuration property. + * Makes an API call to retrieve configuration associated with this session. + * This method also updates the cached configuration property. * - * @returns Promise resolving to a dictionary containing the session's configuration. - * Returns an empty dictionary if no configuration is set + * @returns Promise resolving to the session's configuration. + * Returns an empty object if no configuration is set */ - async getConfig(): Promise> { - const session = await this._client.workspaces.sessions.getOrCreate( - this.workspaceId, - { id: this.id } - ) - this._configuration = session.configuration || {} + async getConfiguration(): Promise { + const session = await this._getOrCreate({ id: this.id }) + this._configuration = sessionConfigFromApi(session.configuration) || {} return this._configuration } /** - * Set configuration for this session. + * Set the configuration for this session. * * Makes an API call to update the configuration associated with this session. * This will overwrite any existing configuration with the provided values. * This method also updates the cached configuration property. * - * @param configuration - A dictionary of configuration to associate with this session. - * Keys must be strings, values can be any JSON-serializable type + * @param configuration - Configuration to associate with this session. + * Includes reasoning, peerCard, summary, and dream settings. */ - async setConfig(configuration: Record): Promise { - await this._client.workspaces.sessions.update(this.workspaceId, this.id, { - configuration, - }) - this._configuration = configuration + async setConfiguration(configuration: SessionConfig): Promise { + const validatedConfig = SessionConfigSchema.parse(configuration) + await this._update({ configuration: validatedConfig }) + this._configuration = validatedConfig } /** @@ -520,108 +626,76 @@ export class Session { * associated with this session and updates the cached properties. */ async refresh(): Promise { - const session = await this._client.workspaces.sessions.getOrCreate( - this.workspaceId, - { id: this.id } - ) + const session = await this._getOrCreate({ id: this.id }) this._metadata = session.metadata || {} - this._configuration = session.configuration || {} + this._configuration = sessionConfigFromApi(session.configuration) || {} } /** - * Delete this session and all associated data. - * - * Makes an API call to permanently delete this session and all related data including: - * - Messages - * - Message embeddings - * - Conclusions - * - Session-Peer associations - * - Background processing queue items + * Delete this session. * + * Makes an API call to permanently delete the session and all its messages. * This action cannot be undone. */ async delete(): Promise { - await this._client.workspaces.sessions.delete(this.workspaceId, this.id) + await this._delete() } /** - * Clone this session, optionally up to a specific message. + * Clone this session. * - * Makes an API call to create a copy of this session with a new ID. - * All messages and peers from the original session are copied to the new session. - * If a messageId is provided, only messages up to and including that message - * are copied. + * Makes an API call to create a copy of the session. If a message ID is provided, + * the clone will only include messages up to and including that message. * - * @param messageId - Optional message ID to cut off the clone at. If provided, - * the cloned session will only contain messages up to and - * including this message. - * @returns Promise resolving to a new Session object representing the cloned session - * - * @example - * ```typescript - * // Clone entire session - * const cloned = await session.clone() - * - * // Clone session up to a specific message - * const cloned = await session.clone('msg_abc123') - * ``` + * @param messageId - Optional message ID to clone up to. If not provided, + * clones the entire session + * @returns Promise resolving to the new cloned Session object */ async clone(messageId?: string): Promise { - const clonedSessionData = await this._client.workspaces.sessions.clone( - this.workspaceId, - this.id, - messageId ? { message_id: messageId } : {} + const clonedSessionData = await this._clone( + messageId ? { message_id: messageId } : undefined ) return new Session( clonedSessionData.id, this.workspaceId, - this._client, + this._http, clonedSessionData.metadata ?? undefined, - clonedSessionData.configuration ?? undefined + sessionConfigFromApi(clonedSessionData.configuration) ?? undefined, + () => this._ensureWorkspace() ) } /** - * Get optimized context for this session within a token limit. + * Get context for this session, suitable for LLM prompts. * - * Makes an API call to retrieve a curated list of messages that provides - * optimal context for the conversation while staying within the specified - * token limit. Uses tiktoken for token counting, so results should be - * compatible with OpenAI models. The context optimization balances - * recency and relevance to provide the best conversational context. + * Makes an API call to retrieve a curated context including messages, optional + * summary, and peer representation. The context can be converted to OpenAI or + * Anthropic message formats. * * @param options - Configuration options for context retrieval - * @param options.summary - Whether to include summary information in the context. - * When true, includes session summary if available. Defaults to true - * @param options.tokens - Maximum number of tokens to include in the context. If not provided, - * uses the server's default configuration - * @param options.peerTarget - The target of the perspective. If given without `peerPerspective`, - * will get the Honcho-level representation and peer card for this peer. - * If given with `peerPerspective`, will get the representation and card - * for this peer from the perspective of that peer. - * @param options.lastUserMessage - The most recent message, used to fetch semantically relevant - * conclusions and returned as part of the context object. - * Can be either a message ID string or a Message object. - * @param options.peerPerspective - A peer to get context for. If given, response will attempt to - * include representation and card from the perspective of that peer. - * Must be provided with `peerTarget`. - * @returns Promise resolving to a SessionContext object containing the optimized - * message history and summary (if available) that maximizes conversational - * context while respecting the token limit + * @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 + * @returns Promise resolving to a SessionContext with messages, summary, and representation * - * @note Token counting is performed using tiktoken. For models using different - * tokenizers, you may need to adjust the token limit accordingly. + * @example + * ```typescript + * const ctx = await session.context({ + * summary: true, + * peerPerspective: assistant, + * peerTarget: user + * }) + * + * // Convert to OpenAI format + * const messages = ctx.toOpenAI(assistant) + * ``` */ - async getContext( - summary?: boolean, - tokens?: number, - peerTarget?: string | Peer, - lastUserMessage?: string | Message, - peerPerspective?: string | Peer, - representationOptions?: RepresentationOptions - ): Promise - async getContext(options?: { + async context(options?: { summary?: boolean tokens?: number peerTarget?: string | Peer @@ -629,153 +703,82 @@ export class Session { peerPerspective?: string | Peer limitToSession?: boolean representationOptions?: RepresentationOptions - }): Promise - async getContext( - summaryOrOptions?: - | boolean - | { - summary?: boolean - tokens?: number - peerTarget?: string | Peer - lastUserMessage?: string | Message - peerPerspective?: string | Peer - limitToSession?: boolean - representationOptions?: RepresentationOptions - }, - tokens?: number, - peerTarget?: string | Peer, - lastUserMessage?: string | Message, - peerPerspective?: string | Peer, - representationOptions?: RepresentationOptions - ): Promise { - // Normalize positional arguments into options object - let options: { - summary?: boolean - tokens?: number - peerTarget?: string - lastUserMessage?: string - peerPerspective?: string - limitToSession?: boolean - representationOptions?: RepresentationOptions - } + }): Promise { + const opts = options || {} - if ( - typeof summaryOrOptions === 'boolean' || - // biome-ignore lint/complexity/noArguments: Need to detect which overload pattern is being used - (summaryOrOptions === undefined && arguments.length > 1) - ) { - // Positional arguments pattern - options = { - summary: summaryOrOptions as boolean | undefined, - tokens, - peerTarget: typeof peerTarget === 'object' ? peerTarget.id : peerTarget, - lastUserMessage: - typeof lastUserMessage === 'string' - ? lastUserMessage - : lastUserMessage?.id, - peerPerspective: - typeof peerPerspective === 'object' - ? peerPerspective.id - : peerPerspective, - representationOptions, - } - } else { - // Options object pattern - options = (summaryOrOptions as typeof options) || {} - } + // Resolve Peer objects to their IDs + const peerTargetId = + typeof opts.peerTarget === 'object' ? opts.peerTarget.id : opts.peerTarget + const peerPerspectiveId = + typeof opts.peerPerspective === 'object' + ? opts.peerPerspective.id + : opts.peerPerspective + const lastUserMessageText = + typeof opts.lastUserMessage === 'string' + ? opts.lastUserMessage + : opts.lastUserMessage?.content const contextParams = ContextParamsSchema.parse({ - summary: options.summary, - tokens: options.tokens, - peerTarget: options.peerTarget, - lastUserMessage: options.lastUserMessage, - peerPerspective: options.peerPerspective, - limitToSession: options.limitToSession, - representationOptions: options.representationOptions, + summary: opts.summary, + tokens: opts.tokens, + peerTarget: peerTargetId, + lastUserMessage: lastUserMessageText, + peerPerspective: peerPerspectiveId, + limitToSession: opts.limitToSession, + representationOptions: opts.representationOptions, }) - // Extract message ID if lastUserMessage is a Message object - const lastMessageId = + const lastMessageText = typeof contextParams.lastUserMessage === 'string' ? contextParams.lastUserMessage - : contextParams.lastUserMessage?.id + : contextParams.lastUserMessage?.content - const context = await this._client.workspaces.sessions.context( - this.workspaceId, - this.id, - { - tokens: contextParams.tokens, - summary: contextParams.summary, - last_message: lastMessageId, - peer_target: contextParams.peerTarget, - peer_perspective: contextParams.peerPerspective, - limit_to_session: contextParams.limitToSession, - search_top_k: contextParams.representationOptions?.searchTopK, - search_max_distance: - contextParams.representationOptions?.searchMaxDistance, - include_most_frequent: - contextParams.representationOptions?.includeMostFrequent, - max_conclusions: contextParams.representationOptions?.maxConclusions, - } - ) - // Convert the summary response to Summary object if present - const summary = context.summary ? new Summary(context.summary) : null - return new SessionContext( - this.id, - context.messages, - summary, - context.peer_representation - ? JSON.stringify(context.peer_representation) - : null, - context.peer_card ?? null - ) + const context = await this._getContext({ + tokens: contextParams.tokens, + summary: contextParams.summary, + last_message: lastMessageText, + peer_target: contextParams.peerTarget, + peer_perspective: contextParams.peerPerspective, + limit_to_session: contextParams.limitToSession, + search_top_k: contextParams.representationOptions?.searchTopK, + search_max_distance: + contextParams.representationOptions?.searchMaxDistance, + include_most_frequent: + contextParams.representationOptions?.includeMostFrequent, + max_conclusions: contextParams.representationOptions?.maxConclusions, + }) + + return SessionContext.fromApiResponse(this.id, context) } /** - * Get available summaries for this session. + * Get the summaries for this session. * - * Makes an API call to retrieve both short and long summaries for this session, - * if they are available. Summaries are created asynchronously by the backend - * as messages are added to the session. + * Makes an API call to retrieve both short and long summaries for the session. + * Summaries are generated automatically as messages accumulate. * - * @returns Promise resolving to a SessionSummaries object containing: - * - id: The session ID - * - shortSummary: The short summary if available, including metadata - * - longSummary: The long summary if available, including metadata - * - * @note Summaries may be null if: - * - Not enough messages have been added to trigger summary generation - * - The summary generation is still in progress - * - Summary generation is disabled for this session + * @returns Promise resolving to a SessionSummaries object with short and long summaries */ - async getSummaries(): Promise { - // Use the core SDK's summaries method - const data = await this._client.workspaces.sessions.summaries( - this.workspaceId, - this.id - ) - - // Return a SessionSummaries instance - return new SessionSummaries(data) + async summaries(): Promise { + const data = await this._getSummaries() + return SessionSummaries.fromApiResponse(data) } /** * Search for messages in this session. * - * Makes an API call to search for messages in this session. + * Makes an API call to perform semantic search over messages in this session. * - * @param query The search query to use - * @param filters - Optional filters to scope the search: see [search filters documentation](https://docs.honcho.dev/v2/guides/using-filters). - * @param limit Number of results to return (1-100, default: 10). - * @returns A list of Message objects representing the search results. - * Returns an empty list if no messages are found. + * @param query - The search query to use + * @param options - Search options + * @param options.filters - Optional filters to scope the search. See + * [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). + * @param options.limit - Number of results to return (1-100, default: 10) + * @returns Promise resolving to an array of Message objects matching the query */ async search( query: string, - options?: { - filters?: Filters - limit?: number - } + options?: { filters?: Filters; limit?: number } ): Promise { const validatedQuery = SearchQuerySchema.parse(query) const validatedFilters = options?.filters @@ -784,30 +787,27 @@ export class Session { const validatedLimit = options?.limit ? LimitSchema.parse(options.limit) : undefined - return await this._client.workspaces.sessions.search( - this.workspaceId, - this.id, - { - query: validatedQuery, - filters: validatedFilters, - limit: validatedLimit, - } - ) + const response = await this._search({ + query: validatedQuery, + filters: validatedFilters, + limit: validatedLimit, + }) + return response.map(Message.fromApiResponse) } /** - * Get the queue processing status for this session, optionally scoped to an observer or sender. + * Get the queue processing status for this session. * - * Makes an API call to retrieve the current status of the queue processing queue. - * The queue is responsible for processing messages and updating peer representations. - * This method automatically scopes the status to this session. + * Makes an API call to retrieve the current status of background processing + * for messages in this session. The queue processes messages to update + * peer representations. * * @param options - Configuration options for the status request - * @param options.observer - Optional observer (ID string or Peer object) to scope the status to - * @param options.sender - Optional sender (ID string or Peer object) to scope the status to - * @returns Promise resolving to the queue status information including work unit counts + * @param options.observer - Optional observer peer to scope the status to + * @param options.sender - Optional sender peer to scope the status to + * @returns Promise resolving to queue status information including work unit counts */ - async getQueueStatus( + async queueStatus( options?: Omit< QueueStatusOptions, 'sessionId' | 'observerId' | 'senderId' @@ -815,13 +815,7 @@ export class Session { observer?: string | Peer sender?: string | Peer } - ): Promise<{ - totalWorkUnits: number - completedWorkUnits: number - inProgressWorkUnits: number - pendingWorkUnits: number - sessions?: Record - }> { + ): Promise { const resolvedObserverId = options?.observer ? typeof options.observer === 'string' ? options.observer @@ -833,138 +827,62 @@ export class Session { : options.sender.id : undefined - const queryParams: QueueStatusParams = { - session_id: this.id, // Always use this session's ID - } + const queryParams: QueueStatusParams = { session_id: this.id } if (resolvedObserverId) queryParams.observer_id = resolvedObserverId if (resolvedSenderId) queryParams.sender_id = resolvedSenderId - const status = await this._client.workspaces.queue.status( - this.workspaceId, - queryParams - ) - - return { - totalWorkUnits: status.total_work_units, - completedWorkUnits: status.completed_work_units, - inProgressWorkUnits: status.in_progress_work_units, - pendingWorkUnits: status.pending_work_units, - sessions: status.sessions || undefined, - } + const status = await this._getQueueStatus(queryParams) + return transformQueueStatus(status) } /** - * Poll getQueueStatus until pending_work_units and in_progress_work_units are both 0. - * This allows you to guarantee that all messages have been processed by the queue for - * use with the dialectic endpoint. + * Upload a file to this session as a message. * - * The polling estimates sleep time by assuming each work unit takes 1 second. + * Makes an API call to upload a file, which is processed and stored as one or + * more messages in the session. * - * @param options - Configuration options for the status request - * @param options.observer - Optional observer (ID string or Peer object) to scope the status to - * @param options.sender - Optional sender (ID string or Peer object) to scope the status to - * @param options.timeoutMs - Optional timeout in milliseconds (default: 300000 - 5 minutes) - * @returns Promise resolving to the final queue status when processing is complete - * @throws Error if timeout is exceeded before processing completes - */ - async pollQueueStatus( - options?: Omit< - QueueStatusOptions, - 'sessionId' | 'observerId' | 'senderId' - > & { - observer?: string | Peer - sender?: string | Peer - } - ): Promise<{ - totalWorkUnits: number - completedWorkUnits: number - inProgressWorkUnits: number - pendingWorkUnits: number - sessions?: Record - }> { - const timeoutMs = options?.timeoutMs ?? 300000 // Default to 5 minutes - const startTime = Date.now() - - while (true) { - const status = await this.getQueueStatus(options) - if (status.pendingWorkUnits === 0 && status.inProgressWorkUnits === 0) { - return status - } - - // Check if timeout has been exceeded - const elapsedTime = Date.now() - startTime - if (elapsedTime >= timeoutMs) { - throw new Error( - `Polling timeout exceeded after ${timeoutMs}ms. ` + - `Current status: ${status.pendingWorkUnits} pending, ${status.inProgressWorkUnits} in progress work units.` - ) - } - - // Sleep for the expected time to complete all current work units - // Assuming each pending and in-progress work unit takes 1 second - const totalWorkUnits = - status.pendingWorkUnits + status.inProgressWorkUnits - const sleepMs = Math.max(1000, totalWorkUnits * 1000) // Sleep at least 1 second - - // Ensure we don't sleep past the timeout - const remainingTime = timeoutMs - elapsedTime - const actualSleepMs = Math.min(sleepMs, remainingTime) - - if (actualSleepMs > 0) { - await new Promise((resolve) => setTimeout(resolve, actualSleepMs)) - } - } - } - - /** - * Upload a file to create messages in this session. - * - * Makes an API call to upload a file and convert it into messages. The file is - * processed to extract text content, split into appropriately sized chunks, - * and created as messages attributed to the specified peer. The peer will be - * automatically added to the session if not already a member. - * - * @param file - File to upload. Can be: - * - File objects (browser File API) - * - Buffer or Uint8Array with filename and content_type - * - { filename: string, content: Buffer | Uint8Array, content_type: string } - * @param peer - The peer (ID string or Peer object) to attribute the created messages to - * @param options - Optional parameters for the uploaded messages - * @param options.metadata - Optional metadata dictionary to associate with the messages - * @param options.configuration - Optional configuration dictionary to associate with the messages - * @param options.created_at - Optional created-at timestamp for the messages. Should be an ISO 8601 formatted string. - * @returns Promise resolving to a list of Message objects representing the created messages - * - * @note Supported file types include PDFs, text files, and JSON documents. - * Large files will be automatically split into multiple messages to fit - * within message size limits. + * @param file - The file to upload. Can be a File, Blob, or an object with + * filename, content (Buffer/Uint8Array), and content_type + * @param peer - The peer who is uploading the file (ID string or Peer object) + * @param options - Upload options + * @param options.metadata - Optional metadata to associate with the message(s) + * @param options.configuration - Optional configuration for processing + * @param options.createdAt - Optional timestamp for the message (string or Date) + * @returns Promise resolving to an array of Message objects created from the file * * @example * ```typescript - * // Upload a file - * const messages = await session.uploadFile(fileInput.files[0], 'user123') - * console.log(`Created ${messages.length} messages from file`) + * // Upload a File object (browser) + * const messages = await session.uploadFile(fileInput.files[0], user) * - * // Upload a file with metadata and timestamp - * const messages = await session.uploadFile(fileInput.files[0], 'user123', { - * metadata: { source: 'upload' }, - * created_at: '2021-01-01T00:00:00.000Z' - * }) + * // Upload from Node.js buffer + * const messages = await session.uploadFile({ + * filename: 'document.pdf', + * content: fs.readFileSync('document.pdf'), + * content_type: 'application/pdf' + * }, user) * ``` */ async uploadFile( - file: Uploadable, + file: + | File + | Blob + | { + filename: string + content: Buffer | Uint8Array + content_type: string + }, peer: string | Peer, options?: { metadata?: Record configuration?: Record - created_at?: string | Date + createdAt?: string | Date } ): Promise { const createdAt = - options?.created_at instanceof Date - ? options.created_at.toISOString() - : options?.created_at + options?.createdAt instanceof Date + ? options.createdAt.toISOString() + : options?.createdAt const resolvedPeerId = typeof peer === 'string' ? peer : peer.id @@ -973,68 +891,64 @@ export class Session { peer: resolvedPeerId, metadata: options?.metadata, configuration: options?.configuration, - created_at: createdAt, + createdAt: createdAt, }) - // Build body with file and peer_id, plus optional fields as JSON strings - const body = { - file: uploadParams.file, - peer_id: resolvedPeerId, - ...(uploadParams.metadata !== undefined && uploadParams.metadata !== null - ? { metadata: JSON.stringify(uploadParams.metadata) } - : {}), - ...(uploadParams.configuration !== undefined && - uploadParams.configuration !== null - ? { configuration: JSON.stringify(uploadParams.configuration) } - : {}), - ...(uploadParams.created_at !== undefined && - uploadParams.created_at !== null - ? { created_at: uploadParams.created_at } - : {}), + const formData = new FormData() + + if (file instanceof File || file instanceof Blob) { + formData.append('file', file) + } 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 response = await this._client.workspaces.sessions.messages.upload( - this.workspaceId, - this.id, - body - ) + formData.append('peer_id', resolvedPeerId) + if (uploadParams.metadata !== undefined && uploadParams.metadata !== null) { + formData.append('metadata', JSON.stringify(uploadParams.metadata)) + } + if ( + uploadParams.configuration !== undefined && + uploadParams.configuration !== null + ) { + formData.append( + 'configuration', + JSON.stringify(uploadParams.configuration) + ) + } + if ( + uploadParams.createdAt !== undefined && + uploadParams.createdAt !== null + ) { + formData.append('created_at', uploadParams.createdAt) + } - return response + const response = await this._uploadFile(formData) + return response.map(Message.fromApiResponse) } /** - * Get a subset of Honcho's Representation of a peer in this session. + * Get a peer's representation scoped to this session. * - * Makes an API call to retrieve the session-scoped representation that has been - * built for a peer. This can be either the peer's global representation or - * their local representation of another peer (theory-of-mind). + * Makes an API call to retrieve the representation for a peer, limited to + * conclusions derived from this session's messages. * - * @param peer - The peer to get the working representation of. Can be peer ID string or Peer object - * @param target - Optional target peer. If provided, returns what `peer` knows about - * `target` within this session context rather than `peer`'s global representation - * @param options - Optional representation options to filter and configure the results - * @returns Promise resolving to a Representation string - * - * @example - * ```typescript - * // Get peer's global representation in this session - * const globalRep = await session.getRepresentation('user123') - * console.log(globalRep) - * - * // Get what user123 knows about assistant in this session - * const localRep = await session.getRepresentation('user123', 'assistant') - * - * // Get representation with semantic search - * const searchedRep = await session.getRepresentation('user123', undefined, { - * searchQuery: 'preferences', - * searchTopK: 10 - * }) - * ``` + * @param peer - The peer to get representation for (ID string or Peer object) + * @param options - Representation options + * @param options.target - Optional target peer for local representation + * @param options.searchQuery - Optional semantic search query to filter conclusions + * @param options.searchTopK - Number of semantically relevant conclusions to return + * @param options.searchMaxDistance - Maximum semantic distance for search results (0.0-1.0) + * @param options.includeMostFrequent - Whether to include the most frequent conclusions + * @param options.maxConclusions - Maximum number of conclusions to include + * @returns Promise resolving to a string representation containing conclusions */ - async getRepresentation( + async representation( peer: string | Peer, - target?: string | Peer, options?: { + target?: string | Peer searchQuery?: string searchTopK?: number searchMaxDistance?: number @@ -1044,8 +958,14 @@ export class Session { ): Promise { const getRepresentationParams = GetRepresentationParamsSchema.parse({ peer, - target, - options, + target: options?.target, + options: { + searchQuery: options?.searchQuery, + searchTopK: options?.searchTopK, + searchMaxDistance: options?.searchMaxDistance, + includeMostFrequent: options?.includeMostFrequent, + maxConclusions: options?.maxConclusions, + }, }) const peerId = typeof getRepresentationParams.peer === 'string' @@ -1057,23 +977,41 @@ export class Session { : getRepresentationParams.target.id : undefined - const response = await this._client.workspaces.peers.representation( - this.workspaceId, - peerId, - { - session_id: this.id, - target: targetId, - search_query: getRepresentationParams.options?.searchQuery, - search_top_k: getRepresentationParams.options?.searchTopK, - search_max_distance: getRepresentationParams.options?.searchMaxDistance, - include_most_frequent: - getRepresentationParams.options?.includeMostFrequent, - max_conclusions: getRepresentationParams.options?.maxConclusions, - } - ) + const response = await this._getRepresentation(peerId, { + session_id: this.id, + target: targetId, + search_query: getRepresentationParams.options?.searchQuery, + search_top_k: getRepresentationParams.options?.searchTopK, + search_max_distance: getRepresentationParams.options?.searchMaxDistance, + include_most_frequent: + getRepresentationParams.options?.includeMostFrequent, + max_conclusions: getRepresentationParams.options?.maxConclusions, + }) return response.representation } + /** + * Update the metadata of a message in this session. + * + * Makes an API call to update the metadata of a specific message. + * + * @param message - Either a Message object or a message ID string + * @param metadata - The metadata to update for the message + * @returns Promise resolving to the updated Message object + */ + async updateMessage( + message: Message | string, + metadata: Record + ): Promise { + const validatedMetadata = MessageMetadataSchema.parse(metadata) + const messageId = typeof message === 'string' ? message : message.id + + const response = await this._updateMessage(messageId, { + metadata: validatedMetadata ?? {}, + }) + return Message.fromApiResponse(response) + } + /** * Return a string representation of the Session. * diff --git a/sdks/typescript/src/session_context.ts b/sdks/typescript/src/session_context.ts index 6eca21e0..9c513e49 100644 --- a/sdks/typescript/src/session_context.ts +++ b/sdks/typescript/src/session_context.ts @@ -1,12 +1,13 @@ -import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages' +import { Message } from './message' import type { Peer } from './peer' +import type { SessionContextResponse, SummaryResponse } from './types/api' export interface SummaryData { content: string - message_id: string - summary_type: string - created_at: string - token_count: number + messageId: string + summaryType: string + createdAt: string + tokenCount: number } /** @@ -40,10 +41,20 @@ export class Summary { constructor(data: SummaryData) { this.content = data.content - this.messageId = data.message_id - this.summaryType = data.summary_type - this.createdAt = data.created_at - this.tokenCount = data.token_count + this.messageId = data.messageId + this.summaryType = data.summaryType + this.createdAt = data.createdAt + this.tokenCount = data.tokenCount + } + + static fromApiResponse(data: SummaryResponse): Summary { + return new Summary({ + content: data.content, + messageId: data.message_id, + summaryType: data.summary_type, + createdAt: data.created_at, + tokenCount: data.token_count, + }) } } @@ -56,6 +67,13 @@ export class SessionSummaries { */ readonly id: string + /** + * Alias for id - the session ID. + */ + get sessionId(): string { + return this.id + } + /** * The short summary if available. */ @@ -66,16 +84,26 @@ export class SessionSummaries { */ readonly longSummary: Summary | null - constructor(data: { + constructor( + id: string, + shortSummary: Summary | null, + longSummary: Summary | null + ) { + this.id = id + this.shortSummary = shortSummary + this.longSummary = longSummary + } + + static fromApiResponse(data: { id: string - short_summary?: SummaryData | null - long_summary?: SummaryData | null - }) { - this.id = data.id - this.shortSummary = data.short_summary - ? new Summary(data.short_summary) - : null - this.longSummary = data.long_summary ? new Summary(data.long_summary) : null + short_summary?: SummaryResponse | null + long_summary?: SummaryResponse | null + }): SessionSummaries { + return new SessionSummaries( + data.id, + data.short_summary ? Summary.fromApiResponse(data.short_summary) : null, + data.long_summary ? Summary.fromApiResponse(data.long_summary) : null + ) } } @@ -153,8 +181,8 @@ export class SessionContext { ): Array<{ role: string; content: string; name?: string }> { const assistantId = typeof assistant === 'string' ? assistant : assistant.id const messages = this.messages.map((message) => ({ - role: message.peer_id === assistantId ? 'assistant' : 'user', - name: message.peer_id, + role: message.peerId === assistantId ? 'assistant' : 'user', + name: message.peerId, content: message.content, })) @@ -205,14 +233,14 @@ export class SessionContext { ): Array<{ role: string; content: string }> { const assistantId = typeof assistant === 'string' ? assistant : assistant.id const messages = this.messages.map((message) => - message.peer_id === assistantId + message.peerId === assistantId ? { role: 'assistant', content: message.content, } : { role: 'user', - content: `${message.peer_id}: ${message.content}`, + content: `${message.peerId}: ${message.content}`, } ) @@ -249,6 +277,22 @@ export class SessionContext { return this.messages.length + (this.summary ? 1 : 0) } + /** + * Create a SessionContext from an API response. + */ + static fromApiResponse( + sessionId: string, + data: SessionContextResponse + ): SessionContext { + return new SessionContext( + sessionId, + data.messages.map(Message.fromApiResponse), + data.summary ? Summary.fromApiResponse(data.summary) : null, + data.peer_representation ?? null, + data.peer_card ?? null + ) + } + /** * Return a string representation of the SessionContext. */ diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts deleted file mode 100644 index bbb2bcf3..00000000 --- a/sdks/typescript/src/types.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { Session } from './session' - -/** - * Shared types for the Honcho TypeScript SDK. - */ - -/** - * Conclusion - external view of a document (theory-of-mind data). - */ -export interface Conclusion { - id: string - content: string - observer_id: string - observed_id: string - session_id: string - created_at: string -} - -/** - * Parameters for creating a conclusion. - */ -export interface ConclusionCreateParam { - /** The conclusion content/text */ - content: string - /** The session this conclusion relates to (ID string or Session object) */ - sessionId: string | Session -} - -/** - * Parameters for semantic search of conclusions. - */ -export interface ConclusionQueryParams { - query: string - top_k?: number - distance?: number - filters?: Record -} - -/** - * Delta object for streaming dialectic responses. - */ -export interface DialecticStreamDelta { - content?: string -} - -/** - * Chunk in a streaming dialectic response. - */ -export interface DialecticStreamChunk { - delta: DialecticStreamDelta - done: boolean -} - -/** - * Iterator for streaming dialectic responses with utilities for accessing the final response. - * - * Similar to OpenAI and Anthropic streaming patterns, this allows you to: - * - Iterate over chunks as they arrive - * - Access the final accumulated response after streaming completes - * - * @example - * ```typescript - * const stream = await peer.chat("Hello", { stream: true }) - * - * // Stream chunks - * for await (const chunk of stream) { - * process.stdout.write(chunk) - * } - * - * // Get final response object - * const final = stream.getFinalResponse() - * console.log(`\nFull content: ${final.content}`) - * ``` - */ -export class DialecticStreamResponse implements AsyncIterable { - private iterator: AsyncIterator - private accumulatedContent: string[] = [] - private _isComplete = false - - constructor(iterator: AsyncIterator) { - this.iterator = iterator - } - - [Symbol.asyncIterator](): AsyncIterator { - return { - next: async () => { - const result = await this.iterator.next() - if (result.done) { - this._isComplete = true - return { done: true, value: undefined } - } - this.accumulatedContent.push(result.value) - return { done: false, value: result.value } - }, - } - } - - /** - * Get the final accumulated response after streaming completes. - * - * @returns An object with the full content - * - * @note This should be called after the stream has been fully consumed. - * If called before completion, it returns the content accumulated so far. - */ - getFinalResponse(): { content: string } { - return { content: this.accumulatedContent.join('') } - } - - /** - * Check if the stream has finished. - */ - get isComplete(): boolean { - return this._isComplete - } -} diff --git a/sdks/typescript/src/types/api.ts b/sdks/typescript/src/types/api.ts new file mode 100644 index 00000000..6e1a53b3 --- /dev/null +++ b/sdks/typescript/src/types/api.ts @@ -0,0 +1,370 @@ +/** + * API response types for the Honcho SDK. + * These types mirror the Pydantic schemas from the backend. + */ + +import type { + MessageConfigApi, + SessionConfigApi, + WorkspaceConfigApi, +} from '../validation' + +// ============================================================================= +// Workspace Types +// ============================================================================= + +export interface WorkspaceResponse { + id: string + metadata: Record + configuration: WorkspaceConfigApi + created_at: string +} + +export interface WorkspaceCreateParams { + id: string + metadata?: Record + configuration?: WorkspaceConfigApi +} + +export interface WorkspaceUpdateParams { + metadata?: Record + configuration?: WorkspaceConfigApi +} + +export interface WorkspaceListParams { + filters?: Record + page?: number + size?: number +} + +// ============================================================================= +// Peer Types +// ============================================================================= + +export interface PeerResponse { + id: string + workspace_id: string + metadata: Record + configuration: Record + created_at: string +} + +export interface PeerCreateParams { + id: string + metadata?: Record + configuration?: Record +} + +export interface PeerUpdateParams { + metadata?: Record + configuration?: Record +} + +export interface PeerListParams { + filters?: Record + page?: number + size?: number +} + +export interface PeerChatParams { + query: string + stream?: boolean + session_id?: string + target?: string + reasoning_level?: 'minimal' | 'low' | 'medium' | 'high' | 'max' +} + +export interface PeerChatResponse { + content: string | null +} + +export interface PeerRepresentationParams { + session_id?: string + target?: string + search_query?: string + search_top_k?: number + search_max_distance?: number + include_most_frequent?: boolean + max_conclusions?: number +} + +export interface PeerCardParams { + target?: string +} + +export interface PeerCardResponse { + peer_card: string[] | null +} + +export interface PeerContextParams { + target?: string + search_query?: string + search_top_k?: number + search_max_distance?: number + include_most_frequent?: boolean + max_conclusions?: number +} + +export interface PeerContextResponse { + peer_id: string + target_id: string + representation: string | null + peer_card: string[] | null +} + +// ============================================================================= +// Session Types +// ============================================================================= + +export interface SessionResponse { + id: string + workspace_id: string + is_active: boolean + metadata: Record + configuration: SessionConfigApi + created_at: string +} + +export interface SessionCreateParams { + id: string + metadata?: Record + configuration?: SessionConfigApi + peers?: Record +} + +export interface SessionUpdateParams { + metadata?: Record + configuration?: SessionConfigApi +} + +export interface SessionListParams { + filters?: Record + page?: number + size?: number +} + +export interface SessionCloneParams { + message_id?: string +} + +export interface SessionPeerConfigParams { + observe_me?: boolean | null + observe_others?: boolean | null +} + +export interface SessionContextParams { + tokens?: number + summary?: boolean + last_message?: string + peer_target?: string + peer_perspective?: string + limit_to_session?: boolean + search_top_k?: number + search_max_distance?: number + include_most_frequent?: boolean + max_conclusions?: number +} + +export interface SummaryResponse { + content: string + message_id: string + summary_type: string + created_at: string + token_count: number +} + +export interface SessionContextResponse { + id: string + messages: MessageResponse[] + summary: SummaryResponse | null + peer_representation: string | null + peer_card: string[] | null +} + +export interface SessionSummariesResponse { + id: string + short_summary: SummaryResponse | null + long_summary: SummaryResponse | null +} + +// ============================================================================= +// Message Types +// ============================================================================= + +/** + * Raw API response for a message (snake_case). + * Use the Message class for SDK consumers. + */ +export interface MessageResponse { + id: string + content: string + peer_id: string + session_id: string + workspace_id: string + metadata: Record + created_at: string + token_count: number +} + +export interface MessageCreateParams { + peer_id: string + content: string + metadata?: Record + configuration?: MessageConfigApi + created_at?: string +} + +export interface MessageBatchCreateParams { + messages: MessageCreateParams[] +} + +export interface MessageUpdateParams { + metadata?: Record +} + +export interface MessageListParams { + filters?: Record + page?: number + size?: number +} + +export interface MessageSearchParams { + query: string + filters?: Record + limit?: number +} + +// ============================================================================= +// Conclusion Types +// ============================================================================= + +export interface ConclusionResponse { + id: string + content: string + observer_id: string + observed_id: string + session_id: string + created_at: string +} + +export interface ConclusionCreateParams { + content: string + observer_id: string + observed_id: string + session_id: string +} + +export interface ConclusionBatchCreateParams { + conclusions: ConclusionCreateParams[] +} + +export interface ConclusionListParams { + filters?: Record + page?: number + size?: number +} + +export interface ConclusionQueryParams { + query: string + top_k?: number + distance?: number + filters?: Record +} + +// ============================================================================= +// Representation Types +// ============================================================================= + +export interface RepresentationResponse { + representation: string +} + +/** + * Options for representation retrieval. + */ +export interface RepresentationOptions { + /** + * Semantic search query to filter relevant conclusions. + */ + searchQuery?: string + + /** + * Number of semantically relevant conclusions to return. + */ + searchTopK?: number + + /** + * Maximum semantic distance for search results (0.0-1.0). + */ + searchMaxDistance?: number + + /** + * Whether to include the most frequent conclusions. + */ + includeMostFrequent?: boolean + + /** + * Maximum number of conclusions to include. + */ + maxConclusions?: number +} + +// ============================================================================= +// Queue Types +// ============================================================================= + +export interface SessionQueueStatusResponse { + session_id: string | null + total_work_units: number + completed_work_units: number + in_progress_work_units: number + pending_work_units: number +} + +export interface QueueStatusResponse { + total_work_units: number + completed_work_units: number + in_progress_work_units: number + pending_work_units: number + sessions?: Record +} + +export interface QueueStatusParams { + observer_id?: string + sender_id?: string + session_id?: string +} + +/** + * Queue status scoped to a single session. + */ +export interface SessionQueueStatus { + sessionId: string | null + totalWorkUnits: number + completedWorkUnits: number + inProgressWorkUnits: number + pendingWorkUnits: number +} + +/** + * Queue status scoped to a workspace. + */ +export interface QueueStatus { + totalWorkUnits: number + completedWorkUnits: number + inProgressWorkUnits: number + pendingWorkUnits: number + sessions?: Record +} + +// ============================================================================= +// Pagination Types +// ============================================================================= + +export interface PageResponse { + items: T[] + page: number + size: number + total: number + pages: number +} diff --git a/sdks/typescript/src/utils.ts b/sdks/typescript/src/utils.ts new file mode 100644 index 00000000..0092c25b --- /dev/null +++ b/sdks/typescript/src/utils.ts @@ -0,0 +1,58 @@ +import type { + QueueStatus, + QueueStatusResponse, + SessionQueueStatus, + SessionQueueStatusResponse, +} from './types/api' + +/** + * Resolve an ID from either a string or an object with an `id` property. + */ +export function resolveId(obj: string | { id: string }): string { + return typeof obj === 'string' ? obj : obj.id +} + +/** + * Interface for queue status objects that can be polled + */ +export interface PollableQueueStatus { + pendingWorkUnits: number + inProgressWorkUnits: number +} + +/** + * Transform a SessionQueueStatusResponse to SessionQueueStatus (snake_case to camelCase). + */ +function transformSessionQueueStatus( + status: SessionQueueStatusResponse +): SessionQueueStatus { + return { + sessionId: status.session_id, + totalWorkUnits: status.total_work_units, + completedWorkUnits: status.completed_work_units, + inProgressWorkUnits: status.in_progress_work_units, + pendingWorkUnits: status.pending_work_units, + } +} + +/** + * Transform a QueueStatusResponse to QueueStatus (snake_case to camelCase). + */ +export function transformQueueStatus(status: QueueStatusResponse): QueueStatus { + const sessions = status.sessions + ? Object.fromEntries( + Object.entries(status.sessions).map(([key, value]) => [ + key, + transformSessionQueueStatus(value), + ]) + ) + : undefined + + return { + totalWorkUnits: status.total_work_units, + completedWorkUnits: status.completed_work_units, + inProgressWorkUnits: status.in_progress_work_units, + pendingWorkUnits: status.pending_work_units, + sessions, + } +} diff --git a/sdks/typescript/src/validation.ts b/sdks/typescript/src/validation.ts index 530b9b8a..073f8f5c 100644 --- a/sdks/typescript/src/validation.ts +++ b/sdks/typescript/src/validation.ts @@ -1,5 +1,5 @@ -import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages' import { z } from 'zod' +import type { MessageResponse } from './types/api' /** * Validation schemas for the Honcho TypeScript SDK. @@ -8,6 +8,18 @@ import { z } from 'zod' * to the SDK, providing clear error messages when validation fails. */ +/** + * Schema for workspace ID validation. + */ +export const WorkspaceIdSchema = z + .string() + .min(1, 'Workspace ID must be a non-empty string') + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Workspace ID may only contain letters, numbers, underscores, and hyphens' + ) + .max(100, 'Workspace ID can be at most 100 characters') + /** * Schema for Honcho client configuration options. */ @@ -15,18 +27,18 @@ 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: z - .string() - .min(1, 'Workspace ID must be a non-empty string') - .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.unknown()).optional(), + defaultQuery: z + .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) + .optional(), }) /** @@ -37,7 +49,9 @@ export const PeerMetadataSchema = z.record(z.string(), z.unknown()) /** * Schema for peer configuration. */ -export const PeerConfigSchema = z.record(z.string(), z.unknown()) +export const PeerConfigSchema = z.object({ + observeMe: z.boolean().nullable().optional(), +}) /** * Schema for peer ID validation. @@ -45,16 +59,67 @@ export const PeerConfigSchema = z.record(z.string(), z.unknown()) export const PeerIdSchema = z .string() .min(1, 'Peer ID must be a non-empty string') + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Peer ID may only contain letters, numbers, underscores, and hyphens' + ) + .max(100, 'Peer ID can be at most 100 characters') /** * Schema for session metadata. */ export const SessionMetadataSchema = z.record(z.string(), z.unknown()) +// ============================================================================= +// Configuration Schemas (typed) +// ============================================================================= + +/** + * 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(), +}) + +/** + * 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(), +}) + +/** + * 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(), +}) + +/** + * Schema for dream configuration. + * Used in workspace and session configuration. + */ +export const DreamConfigSchema = z.object({ + enabled: z.boolean().nullable().optional(), +}) + /** * Schema for session configuration. + * Includes reasoning, peer card, summary, and dream settings. */ -export const SessionConfigSchema = z.record(z.string(), z.unknown()) +export const SessionConfigSchema = z.object({ + reasoning: ReasoningConfigSchema.nullable().optional(), + peerCard: PeerCardConfigSchema.nullable().optional(), + summary: SummaryConfigSchema.nullable().optional(), + dream: DreamConfigSchema.nullable().optional(), +}) /** * Schema for session ID validation. @@ -62,13 +127,18 @@ export const SessionConfigSchema = z.record(z.string(), z.unknown()) export const SessionIdSchema = z .string() .min(1, 'Session ID must be a non-empty string') + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Session ID may only contain letters, numbers, underscores, and hyphens' + ) + .max(100, 'Session ID can be at most 100 characters') /** * Schema for session peer configuration. */ export const SessionPeerConfigSchema = z.object({ - observe_me: z.boolean().nullable().optional(), - observe_others: z.boolean().nullable().optional(), + observeMe: z.boolean().nullable().optional(), + observeOthers: z.boolean().nullable().optional(), }) /** @@ -90,22 +160,24 @@ export const MessageMetadataSchema = z /** * Schema for message configuration. - * Configuration can include deriver and peer_card settings. + * Only includes reasoning settings. */ export const MessageConfigurationSchema = z - .record(z.string(), z.unknown()) + .object({ + reasoning: ReasoningConfigSchema.nullable().optional(), + }) .nullable() .optional() /** - * Schema for message creation. + * Schema for message input. */ -export const MessageCreateSchema = z.object({ - peer_id: PeerIdSchema, +export const MessageInputSchema = z.object({ + peerId: PeerIdSchema, content: MessageContentSchema, metadata: MessageMetadataSchema, configuration: MessageConfigurationSchema, - created_at: z.string().nullable().optional(), + createdAt: z.string().nullable().optional(), }) /** @@ -129,15 +201,14 @@ export const FilterSchema = z.record(z.string(), z.unknown()).optional() */ export const ChatQuerySchema = z.object({ query: SearchQuerySchema, - stream: z.boolean().optional().default(false), target: z - .union([z.string(), z.object({ id: z.string() })]) + .union([PeerIdSchema, z.object({ id: PeerIdSchema })]) .optional() .transform((val) => val ? (typeof val === 'string' ? val : val.id) : undefined ), session: z - .union([z.string(), z.object({ id: z.string() })]) + .union([SessionIdSchema, z.object({ id: SessionIdSchema })]) .optional() .transform((val) => val ? (typeof val === 'string' ? val : val.id) : undefined @@ -148,23 +219,31 @@ export const ChatQuerySchema = z.object({ }) /** - * Schema for validating Message objects from the core SDK. + * Schema for validating Message API responses (snake_case). */ -const MessageSchema: z.ZodType = z.object({ +const MessageResponseSchema: z.ZodType = z.object({ id: z.string(), content: z.string(), created_at: z.string(), - peer_id: z.string(), - session_id: z.string(), + peer_id: PeerIdSchema, + session_id: SessionIdSchema, token_count: z.number(), - workspace_id: z.string(), - metadata: z.record(z.string(), z.unknown()).optional(), -}) as z.ZodType + workspace_id: WorkspaceIdSchema, + metadata: z.record(z.string(), z.unknown()), +}) as z.ZodType /** * 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() @@ -191,14 +270,11 @@ export const RepresentationOptionsSchema = z.object({ export const ContextParamsSchema = z .object({ summary: z.boolean().optional(), - tokens: z - .number() - .positive('Token limit must be a positive number') - .optional(), + tokens: z.int('Token limit must be an integer').optional(), lastUserMessage: z .union([ z.string().min(1, 'Last user message must be a non-empty string'), - MessageSchema, + MessageResponseSchema, ]) .optional(), peerTarget: PeerIdSchema.optional(), @@ -228,13 +304,12 @@ export const ContextParamsSchema = z * Schema for deriver status options. */ export const QueueStatusOptionsSchema = z.object({ - observer: z.union([z.string(), z.object({ id: z.string() })]).optional(), - sender: z.union([z.string(), z.object({ id: z.string() })]).optional(), - session: z.union([z.string(), z.object({ id: z.string() })]).optional(), - timeoutMs: z - .number() - .positive('Timeout must be a positive number') + 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(), }) /** @@ -265,73 +340,487 @@ export const FileUploadSchema = z.object({ 'File must not be null or undefined' ), ]), - peer: z.union([PeerIdSchema, z.object({ id: z.string() })]), + peer: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]), metadata: MessageMetadataSchema, configuration: z.record(z.string(), z.unknown()).optional(), - created_at: z.string().nullable().optional(), + createdAt: z.string().nullable().optional(), }) /** * Schema for get representation parameters. */ export const GetRepresentationParamsSchema = z.object({ - peer: z.union([z.string(), z.object({ id: z.string() })]), - target: z.union([z.string(), z.object({ id: z.string() })]).optional(), - options: RepresentationOptionsSchema.extend({ - searchQuery: SearchQuerySchema.optional(), - }).optional(), + peer: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]), + target: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]).optional(), + options: RepresentationOptionsSchema.optional(), }) /** * Schema for peer get representation parameters. */ export const PeerGetRepresentationParamsSchema = z.object({ - session: z.union([z.string(), z.object({ id: z.string() })]).optional(), - target: z.union([z.string(), z.object({ id: z.string() })]).optional(), - options: RepresentationOptionsSchema.extend({ - searchQuery: SearchQuerySchema.optional(), - }).optional(), + session: z + .union([SessionIdSchema, z.object({ id: SessionIdSchema })]) + .optional(), + target: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]).optional(), + options: RepresentationOptionsSchema.optional(), }) +/** + * Schema for peer card target parameter. + */ +export const CardTargetSchema = z + .union([PeerIdSchema, z.object({ id: PeerIdSchema })]) + .optional() + .transform((val) => + val ? (typeof val === 'string' ? val : val.id) : undefined + ) + /** * Schema for peer addition to session. */ export const PeerAdditionSchema = z.union([ - z.string(), - z.object({ id: z.string() }), + PeerIdSchema, + z.object({ id: PeerIdSchema }), z.array( z.union([ - z.string(), - z.object({ id: z.string() }), + PeerIdSchema, + z.object({ id: PeerIdSchema }), z.tuple([ - z.union([z.string(), z.object({ id: z.string() })]), + z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]), SessionPeerConfigSchema, ]), ]) ), z.tuple([ - z.union([z.string(), z.object({ id: z.string() })]), + z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]), SessionPeerConfigSchema, ]), ]) +/** + * API format for session peer config. + */ +export type SessionPeerConfigApi = { + observe_me?: boolean | null + observe_others?: boolean | null +} + +/** + * API format for peer config. + */ +export type PeerConfigApi = { + observe_me?: boolean | null +} + +/** + * Transform peer config to API format. + */ +export function peerConfigToApi( + config: { observeMe?: boolean | null } | undefined +): PeerConfigApi | undefined { + if (!config) return undefined + return { + observe_me: config.observeMe, + } +} + +/** + * Transform peer config from snake_case (API) to camelCase (SDK). + */ +export function peerConfigFromApi( + config: PeerConfigApi | Record | undefined +): { observeMe?: boolean | null } | undefined { + if (!config) return undefined + const apiConfig = config as PeerConfigApi + return { + observeMe: apiConfig.observe_me, + } +} + +// ============================================================================= +// Configuration API Types +// ============================================================================= + +/** + * API format for reasoning config (snake_case). + */ +export type ReasoningConfigApi = { + enabled?: boolean | null + custom_instructions?: string | null +} + +/** + * API format for peer card config (snake_case). + */ +export type PeerCardConfigApi = { + use?: boolean | null + create?: boolean | null +} + +/** + * API format for summary config (snake_case). + */ +export type SummaryConfigApi = { + enabled?: boolean | null + messages_per_short_summary?: number | null + messages_per_long_summary?: number | null +} + +/** + * API format for dream config (snake_case). + */ +export type DreamConfigApi = { + enabled?: boolean | null +} + +/** + * API format for workspace configuration (snake_case). + */ +export type WorkspaceConfigApi = { + reasoning?: ReasoningConfigApi | null + peer_card?: PeerCardConfigApi | null + summary?: SummaryConfigApi | null + dream?: DreamConfigApi | null +} + +/** + * API format for session configuration (same as workspace). + */ +export type SessionConfigApi = WorkspaceConfigApi + +/** + * API format for message configuration (snake_case). + */ +export type MessageConfigApi = { + reasoning?: ReasoningConfigApi | null +} + +// ============================================================================= +// Configuration Conversion Functions +// ============================================================================= + +/** + * Transform reasoning config to API format. + */ +function reasoningConfigToApi( + config: + | { enabled?: boolean | null; customInstructions?: string | null } + | null + | undefined +): ReasoningConfigApi | null | undefined { + if (config === null) return null + if (config === undefined) return undefined + return { + enabled: config.enabled, + custom_instructions: config.customInstructions, + } +} + +/** + * Transform reasoning config from API format. + */ +function reasoningConfigFromApi( + config: ReasoningConfigApi | null | undefined +): + | { enabled?: boolean | null; customInstructions?: string | null } + | null + | undefined { + if (config === null) return null + if (config === undefined) return undefined + return { + enabled: config.enabled, + customInstructions: config.custom_instructions, + } +} + +/** + * Transform peer card config to API format. + */ +function peerCardConfigToApi( + config: { use?: boolean | null; create?: boolean | null } | null | undefined +): PeerCardConfigApi | null | undefined { + if (config === null) return null + if (config === undefined) return undefined + return { + use: config.use, + create: config.create, + } +} + +/** + * Transform peer card config from API format. + */ +function peerCardConfigFromApi( + config: PeerCardConfigApi | null | undefined +): { use?: boolean | null; create?: boolean | null } | null | undefined { + if (config === null) return null + if (config === undefined) return undefined + return { + use: config.use, + create: config.create, + } +} + +/** + * Transform summary config to API format. + */ +function summaryConfigToApi( + config: + | { + enabled?: boolean | null + messagesPerShortSummary?: number | null + messagesPerLongSummary?: number | null + } + | null + | undefined +): SummaryConfigApi | null | undefined { + if (config === null) return null + if (config === undefined) return undefined + return { + enabled: config.enabled, + messages_per_short_summary: config.messagesPerShortSummary, + messages_per_long_summary: config.messagesPerLongSummary, + } +} + +/** + * Transform summary config from API format. + */ +function summaryConfigFromApi(config: SummaryConfigApi | null | undefined): + | { + enabled?: boolean | null + messagesPerShortSummary?: number | null + messagesPerLongSummary?: number | null + } + | null + | undefined { + if (config === null) return null + if (config === undefined) return undefined + return { + enabled: config.enabled, + messagesPerShortSummary: config.messages_per_short_summary, + messagesPerLongSummary: config.messages_per_long_summary, + } +} + +/** + * Transform dream config to API format. + */ +function dreamConfigToApi( + config: { enabled?: boolean | null } | null | undefined +): DreamConfigApi | null | undefined { + if (config === null) return null + if (config === undefined) return undefined + return { + enabled: config.enabled, + } +} + +/** + * Transform dream config from API format. + */ +function dreamConfigFromApi( + config: DreamConfigApi | null | undefined +): { enabled?: boolean | null } | null | undefined { + if (config === null) return null + if (config === undefined) return undefined + return { + enabled: config.enabled, + } +} + +/** + * Transform workspace config to API format (camelCase to snake_case). + */ +export function workspaceConfigToApi( + config: WorkspaceConfig | undefined +): WorkspaceConfigApi | undefined { + if (!config) return undefined + return { + reasoning: reasoningConfigToApi(config.reasoning), + peer_card: peerCardConfigToApi(config.peerCard), + summary: summaryConfigToApi(config.summary), + dream: dreamConfigToApi(config.dream), + } +} + +/** + * Transform workspace config from API format (snake_case to camelCase). + */ +export function workspaceConfigFromApi( + config: WorkspaceConfigApi | Record | undefined +): WorkspaceConfig | undefined { + if (!config) return undefined + const apiConfig = config as WorkspaceConfigApi + return { + reasoning: reasoningConfigFromApi(apiConfig.reasoning), + peerCard: peerCardConfigFromApi(apiConfig.peer_card), + summary: summaryConfigFromApi(apiConfig.summary), + dream: dreamConfigFromApi(apiConfig.dream), + } +} + +/** + * Transform session config to API format (camelCase to snake_case). + */ +export function sessionConfigToApi( + config: SessionConfig | undefined +): SessionConfigApi | undefined { + if (!config) return undefined + return { + reasoning: reasoningConfigToApi(config.reasoning), + peer_card: peerCardConfigToApi(config.peerCard), + summary: summaryConfigToApi(config.summary), + dream: dreamConfigToApi(config.dream), + } +} + +/** + * Transform session config from API format (snake_case to camelCase). + */ +export function sessionConfigFromApi( + config: SessionConfigApi | Record | undefined +): SessionConfig | undefined { + if (!config) return undefined + const apiConfig = config as SessionConfigApi + return { + reasoning: reasoningConfigFromApi(apiConfig.reasoning), + peerCard: peerCardConfigFromApi(apiConfig.peer_card), + summary: summaryConfigFromApi(apiConfig.summary), + dream: dreamConfigFromApi(apiConfig.dream), + } +} + +/** + * Transform message config to API format (camelCase to snake_case). + */ +export function messageConfigToApi( + config: MessageConfiguration | undefined +): MessageConfigApi | undefined { + if (!config) return undefined + return { + reasoning: reasoningConfigToApi(config.reasoning), + } +} + +/** + * Transform message config from API format (snake_case to camelCase). + */ +export function messageConfigFromApi( + config: MessageConfigApi | Record | undefined +): MessageConfiguration | undefined { + if (!config) return undefined + const apiConfig = config as MessageConfigApi + return { + reasoning: reasoningConfigFromApi(apiConfig.reasoning), + } +} + +/** + * Check if a value is a config object (has observeMe or observeOthers). + */ +function isSessionPeerConfig( + val: unknown +): val is { observeMe?: boolean | null; observeOthers?: boolean | null } { + return ( + typeof val === 'object' && + val !== null && + !('id' in val) && + ('observeMe' in val || 'observeOthers' in val) + ) +} + +/** + * Check if input is a tuple [peer, config]. + */ +function isTuple( + input: unknown +): input is + | [string, { observeMe?: boolean | null; observeOthers?: boolean | null }] + | [ + { id: string }, + { observeMe?: boolean | null; observeOthers?: boolean | null }, + ] { + return ( + Array.isArray(input) && input.length === 2 && isSessionPeerConfig(input[1]) + ) +} + +/** + * Schema that validates and transforms peer addition input to API format. + * Handles all input variations and outputs a dictionary ready for the API. + */ +export const PeerAdditionToApiSchema = PeerAdditionSchema.transform( + (input): Record => { + const result: Record = {} + + // Helper to process a single peer entry + const processEntry = (entry: unknown): void => { + if (typeof entry === 'string') { + result[entry] = {} + } else if (isTuple(entry)) { + const [peer, config] = entry + const id = typeof peer === 'string' ? peer : peer.id + result[id] = { + observe_me: config.observeMe, + observe_others: config.observeOthers, + } + } else if (typeof entry === 'object' && entry !== null && 'id' in entry) { + result[(entry as { id: string }).id] = {} + } + } + + // Handle single tuple specially (it's an array but represents one entry) + if (isTuple(input)) { + processEntry(input) + } else if (Array.isArray(input)) { + // Array of entries + for (const item of input) { + processEntry(item) + } + } else { + // Single string or object + processEntry(input) + } + + return result + } +) + /** * Schema for peer removal from session. */ export const PeerRemovalSchema = z.union([ - z.string(), - z.object({ id: z.string() }), - z.array(z.union([z.string(), z.object({ id: z.string() })])), + PeerIdSchema, + z.object({ id: PeerIdSchema }), + z.array(z.union([PeerIdSchema, z.object({ id: PeerIdSchema })])), ]) /** * Schema for message addition to session. */ export const MessageAdditionSchema = z.union([ - MessageCreateSchema, - z.array(MessageCreateSchema), + MessageInputSchema, + z.array(MessageInputSchema), ]) +/** + * Schema that validates and transforms message addition to API format. + */ +export const MessageAdditionToApiSchema = MessageAdditionSchema.transform( + (input) => { + const messages = Array.isArray(input) ? input : [input] + return messages.map((msg) => ({ + peer_id: msg.peerId, + content: msg.content, + metadata: msg.metadata, + configuration: messageConfigToApi(msg.configuration ?? undefined), + created_at: msg.createdAt, + })) + } +) + /** * Schema for workspace metadata. */ @@ -339,8 +828,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.record(z.string(), z.unknown()) +export const WorkspaceConfigSchema = z.object({ + reasoning: ReasoningConfigSchema.nullable().optional(), + peerCard: PeerCardConfigSchema.nullable().optional(), + summary: SummaryConfigSchema.nullable().optional(), + dream: DreamConfigSchema.nullable().optional(), +}) /** * Schema for limit. @@ -379,7 +874,7 @@ export type PeerConfig = z.infer export type SessionMetadata = z.infer export type SessionConfig = z.infer export type SessionPeerConfig = z.infer -export type MessageCreate = z.infer +export type MessageInput = z.infer export type Filters = z.infer export type ChatQuery = z.infer export type ContextParams = z.infer @@ -392,9 +887,15 @@ export type PeerGetRepresentationParams = z.infer< typeof PeerGetRepresentationParamsSchema > export type PeerAddition = z.infer +export type PeerAdditionApi = z.infer export type PeerRemoval = z.infer export type MessageAddition = z.infer export type WorkspaceMetadata = z.infer export type WorkspaceConfig = z.infer +export type ReasoningConfig = z.infer +export type PeerCardConfig = z.infer +export type SummaryConfig = z.infer +export type DreamConfig = z.infer +export type MessageConfiguration = z.infer export type Limit = z.infer export type ConclusionQueryParams = z.infer diff --git a/src/cache/client.py b/src/cache/client.py index d4005fb1..0381dd4a 100644 --- a/src/cache/client.py +++ b/src/cache/client.py @@ -116,8 +116,31 @@ async def close_cache() -> None: await cache.close() +# Deriver flush mode - bypasses batch token threshold when enabled +# Uses direct Redis access to avoid cashews serialization/namespace issues +DERIVER_FLUSH_KEY = "honcho:deriver:flush_mode" + + +async def is_deriver_flush_enabled() -> bool: + """Check if deriver flush mode is enabled (bypasses batch threshold).""" + if not is_cache_enabled(): + return False + try: + import redis.asyncio as aioredis + + redis_client = aioredis.from_url(settings.CACHE.URL) # pyright: ignore[reportUnknownMemberType] + try: + result = await redis_client.get(DERIVER_FLUSH_KEY) + return result == b"1" + finally: + await redis_client.aclose() + except Exception: + return False + + __all__ = [ "init_cache", "close_cache", "cache", + "is_deriver_flush_enabled", ] diff --git a/src/config.py b/src/config.py index 68cb2bf3..5e365c8b 100644 --- a/src/config.py +++ b/src/config.py @@ -219,10 +219,10 @@ class LLMSettings(HonchoSettings): DEFAULT_MAX_TOKENS: Annotated[int, Field(default=1000, gt=0, le=100_000)] = 2500 # Maximum characters for tool output to prevent token explosion. - # Set to 30,000 chars (~7,500 tokens at 4 chars/token) to stay well under + # Set to 10,000 chars (~2,500 tokens at 4 chars/token) to stay well under # typical context limits while providing substantial tool output. - MAX_TOOL_OUTPUT_CHARS: Annotated[int, Field(default=30000, gt=0, le=100_000)] = ( - 30000 + MAX_TOOL_OUTPUT_CHARS: Annotated[int, Field(default=10000, gt=0, le=100_000)] = ( + 10000 ) # Maximum characters for individual message content in tool results. @@ -316,6 +316,9 @@ class DialecticLevelSettings(BaseModel): MAX_TOOL_ITERATIONS: Annotated[ int, Field(ge=0, le=50, validation_alias="max_tool_iterations") ] + MAX_OUTPUT_TOKENS: Annotated[ + int | None, Field(ge=1, le=100_000, validation_alias="max_output_tokens") + ] = None # None means use global DIALECTIC.MAX_OUTPUT_TOKENS TOOL_CHOICE: Annotated[str | None, Field(validation_alias="tool_choice")] = ( None # None/auto lets model decide, "any"/"required" forces tool use ) @@ -356,12 +359,13 @@ class DialecticSettings(HonchoSettings): PROVIDER="google", MODEL="gemini-2.5-flash-lite", THINKING_BUDGET_TOKENS=0, - MAX_TOOL_ITERATIONS=5, + MAX_TOOL_ITERATIONS=1, + MAX_OUTPUT_TOKENS=250, TOOL_CHOICE="any", ), "low": DialecticLevelSettings( PROVIDER="google", - MODEL="gemini-3-flash-preview", + MODEL="gemini-2.5-flash-lite", THINKING_BUDGET_TOKENS=0, MAX_TOOL_ITERATIONS=5, TOOL_CHOICE="any", @@ -370,17 +374,17 @@ class DialecticSettings(HonchoSettings): PROVIDER="anthropic", MODEL="claude-haiku-4-5", THINKING_BUDGET_TOKENS=1024, - MAX_TOOL_ITERATIONS=4, + MAX_TOOL_ITERATIONS=2, ), "high": DialecticLevelSettings( PROVIDER="anthropic", - MODEL="claude-opus-4-5", - THINKING_BUDGET_TOKENS=0, + MODEL="claude-haiku-4-5", + THINKING_BUDGET_TOKENS=1024, MAX_TOOL_ITERATIONS=4, ), "max": DialecticLevelSettings( PROVIDER="anthropic", - MODEL="claude-opus-4-5", + MODEL="claude-haiku-4-5", THINKING_BUDGET_TOKENS=2048, MAX_TOOL_ITERATIONS=10, ), @@ -396,8 +400,8 @@ class DialecticSettings(HonchoSettings): # Session history injection: max tokens of recent messages to include when session_id is specified. # Set to 0 to disable automatic session history injection. SESSION_HISTORY_MAX_TOKENS: Annotated[ - int, Field(default=16_384, ge=0, le=100_000) - ] = 16_384 + int, Field(default=4_096, ge=0, le=16_384) + ] = 4_096 @model_validator(mode="after") def _validate_token_budgets(self) -> "DialecticSettings": diff --git a/src/crud/workspace.py b/src/crud/workspace.py index 1dcd3635..7c73c6f7 100644 --- a/src/crud/workspace.py +++ b/src/crud/workspace.py @@ -325,6 +325,17 @@ async def delete_workspace( ) ) + # Also delete any queue items that reference messages in this workspace + # (handles race condition where deriver creates new queue items) + message_ids_subquery = select(models.Message.id).where( + models.Message.workspace_name == workspace_name + ) + await db.execute( + delete(models.QueueItem).where( + models.QueueItem.message_id.in_(message_ids_subquery) + ) + ) + # Get all collections for this workspace to delete their vector namespaces collections_result = await db.execute( select(models.Collection).where( diff --git a/src/deriver/consumer.py b/src/deriver/consumer.py index 806fff9a..28eca6d8 100644 --- a/src/deriver/consumer.py +++ b/src/deriver/consumer.py @@ -156,7 +156,7 @@ async def process_representation_batch( messages: list[Message], message_level_configuration: ResolvedConfiguration | None, *, - observer: str | None, + observers: list[str] | None, observed: str | None, queue_items_count: int, ) -> None: @@ -166,20 +166,20 @@ async def process_representation_batch( Args: messages: List of messages to process message_level_configuration: Resolved configuration for this batch - observer: The observer of the messages + observers: List of observers for the messages observed: The observed of the messages """ if not messages or not messages[0]: logger.debug("process_representation_batch received no messages") return - if observed is None or observer is None: - raise ValueError("observed and observer are required for representation tasks") + if observed is None or observers is None or len(observers) == 0: + raise ValueError("observed and observers are required for representation tasks") await process_representation_tasks_batch( messages, message_level_configuration, - observer=observer, + observers=observers, observed=observed, queue_items_count=queue_items_count, ) diff --git a/src/deriver/deriver.py b/src/deriver/deriver.py index 77a3bcb4..965b84f8 100644 --- a/src/deriver/deriver.py +++ b/src/deriver/deriver.py @@ -28,17 +28,17 @@ async def process_representation_tasks_batch( messages: list[Message], message_level_configuration: ResolvedConfiguration | None, *, - observer: str, + observers: list[str], observed: str, queue_items_count: int, ) -> None: """ - Process messages with minimal overhead - single LLM call, no peer card. + Process messages with minimal overhead - single LLM call, save to multiple collections. Args: messages: List of messages to process (includes interleaving context). message_level_configuration: Optional configuration override. - observer: The observer peer ID. + observers: List of observer peer IDs (collections to save to). observed: The observed peer ID. queue_items_count: Number of QueueItem records being processed in this batch. """ @@ -71,13 +71,13 @@ async def process_representation_tasks_batch( return accumulate_metric( - f"minimal_deriver_{latest_message.id}_{observer}", + f"minimal_deriver_{latest_message.id}_{observed}", "starting_message_id", earliest_message.id, "id", ) accumulate_metric( - f"minimal_deriver_{latest_message.id}_{observer}", + f"minimal_deriver_{latest_message.id}_{observed}", "ending_message_id", latest_message.id, "id", @@ -105,7 +105,7 @@ async def process_representation_tasks_batch( context_prep_duration = (time.perf_counter() - overall_start) * 1000 accumulate_metric( - f"minimal_deriver_{latest_message.id}_{observer}", + f"minimal_deriver_{latest_message.id}_{observed}", "context_preparation", context_prep_duration, "ms", @@ -135,7 +135,7 @@ async def process_representation_tasks_batch( llm_duration = (time.perf_counter() - llm_start) * 1000 accumulate_metric( - f"minimal_deriver_{latest_message.id}_{observer}", + f"minimal_deriver_{latest_message.id}_{observed}", "llm_call_duration", llm_duration, "ms", @@ -169,24 +169,31 @@ async def process_representation_tasks_batch( latest_message.session_name, ) else: - representation_manager = RepresentationManager( - workspace_name=latest_message.workspace_name, - observer=observer, - observed=observed, - ) + # Save to all observer collections + for observer in observers: + representation_manager = RepresentationManager( + workspace_name=latest_message.workspace_name, + observer=observer, + observed=observed, + ) - await representation_manager.save_representation( - observations, - message_ids, - latest_message.session_name, - latest_message.created_at, - message_level_configuration, - ) + try: + await representation_manager.save_representation( + observations, + message_ids, + latest_message.session_name, + latest_message.created_at, + message_level_configuration, + ) + except Exception as e: + logger.error( + "Failed to save representation for observer %s: %s", observer, e + ) # Log metrics overall_duration = (time.perf_counter() - overall_start) * 1000 accumulate_metric( - f"minimal_deriver_{latest_message.id}_{observer}", + f"minimal_deriver_{latest_message.id}_{observed}", "total_processing_time", overall_duration, "ms", @@ -194,7 +201,7 @@ async def process_representation_tasks_batch( total_observations = len(observations.explicit) + len(observations.deductive) accumulate_metric( - f"minimal_deriver_{latest_message.id}_{observer}", + f"minimal_deriver_{latest_message.id}_{observed}", "observation_count", total_observations, "count", @@ -203,20 +210,20 @@ async def process_representation_tasks_batch( if settings.DERIVER.LOG_OBSERVATIONS: # Log messages fed into deriver accumulate_metric( - f"minimal_deriver_{latest_message.id}_{observer}", + f"minimal_deriver_{latest_message.id}_{observed}", "messages", formatted_messages, "blob", ) # Log actual observations created as blob metrics accumulate_metric( - f"minimal_deriver_{latest_message.id}_{observer}", + f"minimal_deriver_{latest_message.id}_{observed}", "explicit_observations", "\n".join(f" • {obs}" for obs in observations.explicit), "blob", ) - log_performance_metrics("minimal_deriver", f"{latest_message.id}_{observer}") + log_performance_metrics("minimal_deriver", f"{latest_message.id}_{observed}") # Emit telemetry event emit( diff --git a/src/deriver/enqueue.py b/src/deriver/enqueue.py index b2476ea2..9641bdfe 100644 --- a/src/deriver/enqueue.py +++ b/src/deriver/enqueue.py @@ -171,7 +171,7 @@ def create_representation_record( conf: ResolvedConfiguration, session_id: str | None = None, *, - observer: str, + observers: list[str], observed: str, ) -> dict[str, Any]: """ @@ -181,8 +181,8 @@ def create_representation_record( message: The message payload conf: Resolved configuration for this particular message session_id: Optional session ID + observers: List of observer peer names observed: Name of the sender - observer: Name of the target Returns: Queue record dictionary with workspace_name and message_id as separate fields @@ -199,7 +199,7 @@ def create_representation_record( message=message, configuration=conf, task_type="representation", - observer=observer, + observers=observers, observed=observed, ) return { @@ -345,23 +345,19 @@ async def generate_queue_records( if not conf.reasoning.enabled: return records - if should_observe: - # global representation task - records.append( - create_representation_record( - message, - conf, - observed=observed, - observer=observed, - session_id=session_id, - ) - ) + # Collect all observers into a single list + observers: list[str] = [] + if should_observe: + # Self-observation: the sender observes themselves + observers.append(observed) + + # Other peers who want to observe for peer_name, peer_conf in peers_with_configuration.items(): if peer_name == observed: continue - # If the observer peer has left the session, we don't need to enqueue a representation task for them. + # If the observer peer has left the session, skip them if not peer_conf[2]: continue @@ -372,22 +368,26 @@ async def generate_queue_records( if session_peer_config is None or not session_peer_config.observe_others: continue - records.append( - # peer representation task - create_representation_record( - message, - conf, - observed=observed, - observer=peer_name, - session_id=session_id, - ) + observers.append(peer_name) + + # Create a single record with all observers (if any) + if observers: + records.append( + create_representation_record( + message, + conf, + observed=observed, + observers=observers, + session_id=session_id, ) + ) logger.debug( - "message %s from %s created %s queue items", + "message %s from %s created %s queue items with %s observers", message_id, observed, len(records), + len(observers), ) return records diff --git a/src/deriver/queue_manager.py b/src/deriver/queue_manager.py index 8da310e7..f41abde4 100644 --- a/src/deriver/queue_manager.py +++ b/src/deriver/queue_manager.py @@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql import func from src import models -from src.cache.client import close_cache, init_cache +from src.cache.client import close_cache, init_cache, is_deriver_flush_enabled from src.config import settings from src.dependencies import tracked_db from src.deriver.consumer import ( @@ -216,7 +216,7 @@ class QueueManager: """ Get available work units that aren't being processed. For representation tasks, only returns work units with accumulated tokens - >= REPRESENTATION_BATCH_MAX_TOKENS (forced batching). + >= REPRESENTATION_BATCH_MAX_TOKENS (forced batching), unless flush mode is enabled. Returns a dict mapping work_unit_key to aqs_id. """ limit: int = max(0, self.workers - self.get_total_owned_work_units()) @@ -224,6 +224,7 @@ class QueueManager: return {} batch_max_tokens = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS + flush_enabled = await is_deriver_flush_enabled() async with tracked_db("get_available_work_units") as db: representation_prefix = "representation:" @@ -264,7 +265,11 @@ class QueueManager: ) .exists() ) - .where( + ) + + # Apply batch threshold filter unless flush mode is enabled + if not flush_enabled and batch_max_tokens > 0: + query = query.where( or_( ~work_units_subq.c.work_unit_key.startswith( representation_prefix @@ -273,7 +278,6 @@ class QueueManager: >= batch_max_tokens, ) ) - ) result = await db.execute(query) available_units = result.scalars().all() @@ -435,10 +439,21 @@ class QueueManager: break try: + # Extract observers from the payload (handle both old and new format) + payload = items_to_process[0].payload + observers = payload.get("observers") + if observers is None: + # Legacy format: single observer string + legacy_observer = payload.get("observer") + if legacy_observer: + observers = [legacy_observer] + else: + observers = [] + await process_representation_batch( messages_context, message_level_configuration, - observer=work_unit.observer, + observers=observers, observed=work_unit.observed, queue_items_count=len(items_to_process), ) diff --git a/src/dialectic/core.py b/src/dialectic/core.py index b6b26e41..ae547273 100644 --- a/src/dialectic/core.py +++ b/src/dialectic/core.py @@ -24,7 +24,12 @@ from src.telemetry.logging import ( log_token_usage_metrics, ) from src.telemetry.otel.metrics import DialecticComponents, TokenTypes -from src.utils.agent_tools import DIALECTIC_TOOLS, create_tool_executor, search_memory +from src.utils.agent_tools import ( + DIALECTIC_TOOLS, + DIALECTIC_TOOLS_MINIMAL, + create_tool_executor, + search_memory, +) from src.utils.clients import ( HonchoLLMCallResponse, StreamingResponseWithMetadata, @@ -146,8 +151,12 @@ class DialecticAgent: tool calls, improving response quality and speed. Performs two separate searches to prevent retrieval dilution: - - 25 explicit observations (produced by deriver) - - 25 higher-level observations (produced in dreaming/background/chat) + - Explicit observations (produced by deriver) + - Higher-level observations (produced in dreaming/background/chat) + + The number of observations fetched depends on reasoning level: + - minimal: 10 of each type (reduced context for cost savings) + - all others: 25 of each type Args: query: The user's query @@ -155,6 +164,9 @@ class DialecticAgent: Returns: Formatted observations string or None if no observations found """ + # Use reduced prefetch for minimal reasoning to save tokens + prefetch_limit = 10 if self.reasoning_level == "minimal" else 25 + try: # Search explicit observations separately explicit_repr = await search_memory( @@ -163,7 +175,7 @@ class DialecticAgent: observer=self.observer, observed=self.observed, query=query, - limit=25, + limit=prefetch_limit, levels=["explicit"], ) @@ -174,7 +186,7 @@ class DialecticAgent: observer=self.observer, observed=self.observed, query=query, - limit=25, + limit=prefetch_limit, levels=["deductive", "inductive", "contradiction"], ) @@ -379,11 +391,24 @@ class DialecticAgent: # Get level-specific settings level_settings = settings.DIALECTIC.LEVELS[self.reasoning_level] + # Use minimal tools for minimal reasoning to reduce cost + tools = ( + DIALECTIC_TOOLS_MINIMAL + if self.reasoning_level == "minimal" + else DIALECTIC_TOOLS + ) + # Use level-specific max_output_tokens if set, otherwise global default + max_tokens = ( + level_settings.MAX_OUTPUT_TOKENS + if level_settings.MAX_OUTPUT_TOKENS is not None + else settings.DIALECTIC.MAX_OUTPUT_TOKENS + ) + response: HonchoLLMCallResponse[str] = await honcho_llm_call( llm_settings=level_settings, prompt="", # Ignored since we pass messages - max_tokens=settings.DIALECTIC.MAX_OUTPUT_TOKENS, - tools=DIALECTIC_TOOLS, + max_tokens=max_tokens, + tools=tools, tool_choice=level_settings.TOOL_CHOICE, tool_executor=tool_executor, max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS, @@ -430,15 +455,28 @@ class DialecticAgent: # Get level-specific settings level_settings = settings.DIALECTIC.LEVELS[self.reasoning_level] + # Use minimal tools for minimal reasoning to reduce cost + tools = ( + DIALECTIC_TOOLS_MINIMAL + if self.reasoning_level == "minimal" + else DIALECTIC_TOOLS + ) + # Use level-specific max_output_tokens if set, otherwise global default + max_tokens = ( + level_settings.MAX_OUTPUT_TOKENS + if level_settings.MAX_OUTPUT_TOKENS is not None + else settings.DIALECTIC.MAX_OUTPUT_TOKENS + ) + response = cast( StreamingResponseWithMetadata, await honcho_llm_call( llm_settings=level_settings, prompt="", # Ignored since we pass messages - max_tokens=settings.DIALECTIC.MAX_OUTPUT_TOKENS, + max_tokens=max_tokens, stream=True, stream_final_only=True, - tools=DIALECTIC_TOOLS, + tools=tools, tool_choice=level_settings.TOOL_CHOICE, tool_executor=tool_executor, max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS, diff --git a/src/dialectic/prompts.py b/src/dialectic/prompts.py index 51ae0ccf..948bff70 100644 --- a/src/dialectic/prompts.py +++ b/src/dialectic/prompts.py @@ -234,4 +234,4 @@ If after thorough searching you find NOTHING relevant: After gathering context, reason through the information you found *before* stating your final answer. For comparison questions, explicitly compare the values. Only after you've verified your reasoning should you state your conclusion. Do NOT be pedantic, rather, be helpful and try to give the answer that the asker would expect -- they're the one who knows the most about themselves. Try to 'read their mind' -- understand the information they're really after and share it with them! Be **as specific as possible** given the information you have. Do not explain your tool usage - just provide the synthesized answer. -""" # nosec B608 +""" diff --git a/src/main.py b/src/main.py index 92a793a0..73eedcc5 100644 --- a/src/main.py +++ b/src/main.py @@ -153,7 +153,7 @@ app = FastAPI( title="Honcho API", summary="The Identity Layer for the Agentic World", description="""Honcho is a platform for giving agents user-centric memory and social cognition.""", - version="2.6.0", + version="3.0.0", contact={ "name": "Plastic Labs", "url": "https://honcho.dev", @@ -183,13 +183,13 @@ app.add_middleware( add_pagination(app) -app.include_router(workspaces.router, prefix="/v2") -app.include_router(peers.router, prefix="/v2") -app.include_router(sessions.router, prefix="/v2") -app.include_router(messages.router, prefix="/v2") -app.include_router(conclusions.router, prefix="/v2") -app.include_router(keys.router, prefix="/v2") -app.include_router(webhooks.router, prefix="/v2") +app.include_router(workspaces.router, prefix="/v3") +app.include_router(peers.router, prefix="/v3") +app.include_router(sessions.router, prefix="/v3") +app.include_router(messages.router, prefix="/v3") +app.include_router(conclusions.router, prefix="/v3") +app.include_router(keys.router, prefix="/v3") +app.include_router(webhooks.router, prefix="/v3") # Global exception handlers diff --git a/src/routers/peers.py b/src/routers/peers.py index 5c759ff2..c9ceba75 100644 --- a/src/routers/peers.py +++ b/src/routers/peers.py @@ -223,7 +223,7 @@ async def chat( reasoning_level=options.reasoning_level, ) - return schemas.DialecticResponse(content=str(response)) + return schemas.DialecticResponse(content=response if response else None) @router.post( diff --git a/src/schemas.py b/src/schemas.py index e1567d95..2f9fceb6 100644 --- a/src/schemas.py +++ b/src/schemas.py @@ -665,7 +665,7 @@ class DialecticOptions(BaseModel): class DialecticResponse(BaseModel): - content: str + content: str | None class DialecticStreamDelta(BaseModel): diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index a392532c..5c8df29b 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -502,6 +502,13 @@ DIALECTIC_TOOLS: list[dict[str, Any]] = [ TOOLS["get_reasoning_chain"], # Traverse reasoning trees ] +# Minimal tools for dialectic agent at "minimal" reasoning level +# Reduces cost by limiting tool definitions in context +DIALECTIC_TOOLS_MINIMAL: list[dict[str, Any]] = [ + TOOLS["search_memory"], + TOOLS["search_messages"], +] + # Tools for the dreamer agent (consolidation + peer card + deduplication) DREAMER_TOOLS: list[dict[str, Any]] = [ # Preference extraction (should be called first) diff --git a/src/utils/queue_payload.py b/src/utils/queue_payload.py index 002a5f8f..9adeae92 100644 --- a/src/utils/queue_payload.py +++ b/src/utils/queue_payload.py @@ -18,7 +18,7 @@ class RepresentationPayload(BasePayload): task_type: Literal["representation"] = "representation" session_name: str content: str - observer: str + observers: list[str] observed: str created_at: datetime configuration: ResolvedConfiguration @@ -117,7 +117,7 @@ def create_payload( task_type: Literal["representation", "summary"], message_seq_in_session: int | None = None, *, - observer: str | None = None, + observers: list[str] | None = None, observed: str | None = None, ) -> dict[str, Any]: """ @@ -131,7 +131,7 @@ def create_payload( message: The original message dictionary task_type: Type of task ('representation' or 'summary') message_seq_in_session: Required for summary tasks, must be None for representation - observer: Name of the observer peer (required for representation tasks) + observers: List of observer peer names (required for representation tasks) observed: Name of the observed peer (*always* the peer who sent the message) (required for representation tasks) @@ -166,8 +166,8 @@ def create_payload( if not isinstance(created_at, datetime): raise TypeError("created_at must be a datetime object") - if observer is None: - raise ValueError("observer is required for representation tasks") + if observers is None or len(observers) == 0: + raise ValueError("observers is required for representation tasks") if observed is None: raise ValueError("observed is required for representation tasks") @@ -176,7 +176,7 @@ def create_payload( content=content, session_name=session_name, created_at=created_at, - observer=observer, + observers=observers, observed=observed, configuration=configuration, ) diff --git a/src/utils/work_unit.py b/src/utils/work_unit.py index 952ff12c..6e0e25d4 100644 --- a/src/utils/work_unit.py +++ b/src/utils/work_unit.py @@ -50,6 +50,10 @@ def construct_work_unit_key( if not dream_type: raise ValueError("dream_type is required for dream tasks") return f"{task_type}:{dream_type}:{workspace_name}:{observer}:{observed}" + if task_type == "representation": + # Representation tasks don't include observer in the key since + # we process once and save to multiple collections + return f"{task_type}:{workspace_name}:{session_name}:{observed}" return f"{task_type}:{workspace_name}:{session_name}:{observer}:{observed}" if task_type == "webhook": @@ -89,7 +93,31 @@ def parse_work_unit_key(work_unit_key: str) -> ParsedWorkUnit: parts = work_unit_key.split(":") task_type = parts[0] - if task_type in ["representation", "summary"]: + if task_type == "representation": + if len(parts) == 4: + # New format: representation:{workspace}:{session}:{observed} + return ParsedWorkUnit( + task_type=task_type, + workspace_name=parts[1], + session_name=parts[2], + observer=None, + observed=parts[3], + ) + elif len(parts) == 5: + # Legacy format: representation:{workspace}:{session}:{observer}:{observed} + return ParsedWorkUnit( + task_type=task_type, + workspace_name=parts[1], + session_name=parts[2], + observer=parts[3], + observed=parts[4], + ) + else: + raise ValueError( + f"Invalid work_unit_key format for task_type {task_type}: {work_unit_key}" + ) + + if task_type == "summary": if len(parts) != 5: raise ValueError( f"Invalid work_unit_key format for task_type {task_type}: {work_unit_key}" diff --git a/tests/bench/beam.py b/tests/bench/beam.py index 6d8cc2b5..352106f5 100644 --- a/tests/bench/beam.py +++ b/tests/bench/beam.py @@ -65,7 +65,6 @@ Optional arguments: import argparse import asyncio -import logging import os import time from datetime import datetime @@ -73,15 +72,11 @@ from pathlib import Path from typing import Any, cast from dotenv import load_dotenv -from honcho import AsyncHoncho -from honcho.async_client.session import SessionPeerConfig -from honcho_core.types.workspaces.sessions.message_create_param import ( - MessageCreateParam, -) +from honcho.api_types import MessageCreateParams +from honcho.session import SessionPeerConfig from openai import AsyncOpenAI from src.config import settings -from src.telemetry.metrics_collector import MetricsCollector from .beam_common import ( ConversationResult, @@ -95,13 +90,21 @@ from .beam_common import ( load_conversation, print_summary, ) +from .runner_common import ( + ReasoningLevel, + RunnerMixin, + add_common_arguments, + create_openai_client, + export_metrics, + validate_common_arguments, +) # Load .env from bench directory bench_dir = Path(__file__).parent load_dotenv(bench_dir / ".env") -class BEAMRunner: +class BEAMRunner(RunnerMixin): """ Executes BEAM benchmark tests against a Honcho instance. """ @@ -114,6 +117,8 @@ class BEAMRunner: timeout_seconds: int | None = None, cleanup_workspace: bool = True, use_get_context: bool = False, + redis_url: str = "redis://localhost:6379/0", + reasoning_level: ReasoningLevel | None = None, ): """ Initialize the BEAM test runner. @@ -125,6 +130,8 @@ class BEAMRunner: timeout_seconds: Timeout for deriver queue in seconds cleanup_workspace: If True, delete workspace after executing conversation use_get_context: If True, use get_context + judge LLM instead of dialectic .chat endpoint + redis_url: Redis URL for flush mode signaling (default: redis://localhost:6379/0) + reasoning_level: Reasoning level for dialectic chat (default: None) """ self.data_dir: Path = data_dir self.base_api_port: int = base_api_port @@ -134,37 +141,19 @@ class BEAMRunner: ) self.cleanup_workspace: bool = cleanup_workspace self.use_get_context: bool = use_get_context + self.redis_url: str = redis_url + self.reasoning_level: ReasoningLevel | None = reasoning_level - # Initialize metrics collector - self.metrics_collector: MetricsCollector = MetricsCollector() - self.metrics_collector.start_collection( - f"beam_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - ) - - # Configure logging - logging.basicConfig( - level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s" - ) - self.logger: logging.Logger = logging.getLogger(__name__) - - # Suppress HTTP request logs from the Honcho SDK - logging.getLogger("httpx").setLevel(logging.ERROR) - logging.getLogger("httpcore").setLevel(logging.ERROR) + # Initialize common components (metrics, logging) + self._init_common("beam") # Initialize OpenRouter client for judging - openrouter_api_key = os.getenv("LLM_OPENAI_COMPATIBLE_API_KEY") openrouter_base_url = os.getenv( "LLM_OPENAI_COMPATIBLE_BASE_URL", "https://openrouter.ai/api/v1" ) - - if not openrouter_api_key: - raise ValueError( - "LLM_OPENAI_COMPATIBLE_API_KEY is not set in tests/bench/.env" - ) - - self.openrouter_client: AsyncOpenAI = AsyncOpenAI( - api_key=openrouter_api_key, + self.openrouter_client: AsyncOpenAI = create_openai_client( base_url=openrouter_base_url, + env_key_name="LLM_OPENAI_COMPATIBLE_API_KEY", ) # Model to use for judging (OpenRouter format) @@ -172,126 +161,6 @@ class BEAMRunner: "BEAM_JUDGE_MODEL", "anthropic/claude-sonnet-4.5" ) - def get_honcho_url_for_index(self, conversation_index: int) -> str: - """ - Get the Honcho URL for a given conversation index using round-robin distribution. - - Args: - conversation_index: Index of the conversation - - Returns: - URL of the Honcho instance to use for this conversation - """ - instance_id = conversation_index % self.pool_size - port = self.base_api_port + instance_id - return f"http://localhost:{port}" - - async def create_honcho_client( - self, workspace_id: str, honcho_url: str - ) -> AsyncHoncho: - """ - Create a Honcho client for a specific workspace. - - Args: - workspace_id: Workspace ID - honcho_url: URL of the Honcho instance - - Returns: - AsyncHoncho client instance - """ - return AsyncHoncho( - environment="local", - workspace_id=workspace_id, - base_url=honcho_url, - ) - - async def wait_for_deriver_queue_empty( - self, honcho_client: AsyncHoncho, session_id: str | None = None - ) -> bool: - """Wait for the deriver queue to be empty.""" - start_time = time.time() - while True: - try: - status = await honcho_client.get_queue_status(session=session_id) - except Exception: - await asyncio.sleep(1) - elapsed_time = time.time() - start_time - if elapsed_time >= self.timeout_seconds: - return False - continue - - if status.pending_work_units == 0 and status.in_progress_work_units == 0: - return True - - elapsed_time = time.time() - start_time - if elapsed_time >= self.timeout_seconds: - return False - await asyncio.sleep(1) - - async def trigger_dream_and_wait( - self, - honcho_client: AsyncHoncho, - workspace_id: str, - observer: str, - observed: str | None = None, - session_id: str | None = None, - ) -> bool: - """ - Trigger a dream task and wait for it to complete. - - Args: - honcho_client: Honcho client instance - workspace_id: Workspace identifier - observer: Observer peer name - observed: Observed peer name (defaults to observer) - session_id: Session ID to scope the dream to - - Returns: - True if dream completed successfully, False on timeout - """ - import httpx - - observed = observed or observer - honcho_url = self.get_honcho_url_for_index(0) - - url = f"{honcho_url}/v2/workspaces/{workspace_id}/schedule_dream" - payload: dict[str, Any] = { - "observer": observer, - "observed": observed, - "dream_type": "omni", - "session_id": session_id or f"{workspace_id}_session", - } - - # Trigger the dream via API - try: - async with httpx.AsyncClient() as client: - response = await client.post( - url, - json=payload, - timeout=30.0, - ) - if response.status_code != 204: - print( - f"[{workspace_id}] ERROR: Dream trigger failed with status {response.status_code}" - ) - print(f"[{workspace_id}] Response body: {response.text}") - return False - except Exception as e: - print(f"[{workspace_id}] ERROR: Dream trigger exception: {e}") - return False - - print(f"[{workspace_id}] Dream triggered for {observer}/{observed}") - - # Wait for dream queue to empty - print(f"[{workspace_id}] Waiting for dream to complete...") - await asyncio.sleep(2) # Give time for dream to be enqueued - success = await self.wait_for_deriver_queue_empty(honcho_client) - if success: - print(f"[{workspace_id}] Dream queue empty") - else: - print(f"[{workspace_id}] Dream queue timeout") - return success - async def _process_single_question( self, session: Any, @@ -317,7 +186,7 @@ class BEAMRunner: # For instruction_following, always use get_context + OpenRouter API # so the LLM can follow user-specified instructions from Honcho context if self.use_get_context or ability == "instruction_following": - context = await session.get_context( + context = await session.aio.context( summary=True, peer_target="user", last_user_message=question, @@ -357,7 +226,10 @@ Review the context carefully for any such instructions before responding.""" else: actual_response = response.choices[0].message.content or "" else: - actual_response = await user_peer.chat(question) + actual_response = await user_peer.aio.chat( + question, + reasoning_level=self.reasoning_level, + ) actual_response = ( actual_response if isinstance(actual_response, str) else "" ) @@ -435,7 +307,7 @@ Review the context carefully for any such instructions before responding.""" # Create workspace for this conversation workspace_id = f"beam_{context_length}_{conversation_id}" - honcho_client = await self.create_honcho_client(workspace_id, honcho_url) + honcho_client = self.create_honcho_client(workspace_id, honcho_url) result: ConversationResult = { "conversation_id": conversation_id, @@ -461,15 +333,15 @@ Review the context carefully for any such instructions before responding.""" questions_data = conv_data["questions"] # Create peers - user_peer = await honcho_client.peer(id="user") - assistant_peer = await honcho_client.peer(id="assistant") + user_peer = await honcho_client.aio.peer(id="user") + assistant_peer = await honcho_client.aio.peer(id="assistant") # Create session for this conversation session_id = f"{workspace_id}_session" - session = await honcho_client.session(id=session_id) + session = await honcho_client.aio.session(id=session_id) # Configure peer observation - observe the user peer - await session.add_peers( + await session.aio.add_peers( [ ( user_peer, @@ -484,7 +356,7 @@ Review the context carefully for any such instructions before responding.""" # Ingest conversation turns print(f"[{workspace_id}] Ingesting conversation turns...") - messages: list[MessageCreateParam] = [] + messages: list[MessageCreateParams] = [] # Handle different data structures for 10M vs other sizes for batch in chat_data: @@ -553,7 +425,7 @@ Review the context carefully for any such instructions before responding.""" # Add messages in batches of 100 for i in range(0, len(messages), 100): batch = messages[i : i + 100] - await session.add_messages(batch) + await session.aio.add_messages(batch) print( f"[{workspace_id}] Ingested {result['total_messages']} messages. Waiting for deriver queue..." @@ -561,6 +433,7 @@ Review the context carefully for any such instructions before responding.""" # Wait for deriver queue to empty await asyncio.sleep(1) + await self.flush_deriver_queue() queue_empty = await self.wait_for_deriver_queue_empty(honcho_client) if not queue_empty: result["error"] = "Deriver queue timeout" @@ -625,7 +498,7 @@ Review the context carefully for any such instructions before responding.""" # Cleanup workspace if requested if self.cleanup_workspace: try: - await honcho_client.delete_workspace(workspace_id) + await honcho_client.aio.delete_workspace(workspace_id) print(f"[{workspace_id}] Cleaned up workspace") except Exception as e: print(f"Failed to delete workspace: {e}") @@ -711,6 +584,13 @@ async def main() -> int: parser = argparse.ArgumentParser( description="Run BEAM benchmark tests against a Honcho instance", formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s --context-length 100K + %(prog)s --context-length 500K --pool-size 4 + %(prog)s --context-length 100K --conversation-ids conv1,conv2 + %(prog)s --context-length 100K --reasoning-level high + """, ) parser.add_argument( @@ -727,54 +607,17 @@ async def main() -> int: help="Comma-separated list of conversation IDs to test (default: all)", ) - parser.add_argument( - "--base-api-port", - type=int, - default=8000, - help="Base port for Honcho API instances (default: 8000)", - ) - - parser.add_argument( - "--pool-size", - type=int, - default=1, - help="Number of Honcho instances in the pool (default: 1)", - ) - - parser.add_argument( - "--timeout", - type=int, - default=None, - help="Timeout for deriver queue to empty in seconds (default: 10 minutes (600s))", - ) - - parser.add_argument( - "--batch-size", - type=int, - default=10, - help="Number of conversations to run concurrently in each batch (default: 1)", - ) - - parser.add_argument( - "--json-output", - type=Path, - help="Path to write JSON summary results for analytics (optional)", - ) - - parser.add_argument( - "--cleanup-workspace", - action="store_true", - help="Delete workspace after executing each conversation (default: True)", - ) - - parser.add_argument( - "--use-get-context", - action="store_true", - help="Use get_context + judge LLM instead of dialectic .chat endpoint (default: False)", - ) + # Add common arguments shared across all runners + add_common_arguments(parser) args = parser.parse_args() + # Validate common arguments + error = validate_common_arguments(args) + if error: + print(error) + return 1 + # Setup data directory data_dir = Path(__file__).parent / "beam_data" if not data_dir.exists(): @@ -789,6 +632,8 @@ async def main() -> int: timeout_seconds=args.timeout, cleanup_workspace=args.cleanup_workspace, use_get_context=args.use_get_context, + redis_url=args.redis_url, + reasoning_level=args.reasoning_level, ) try: @@ -822,6 +667,7 @@ async def main() -> int: "base_api_port": runner.base_api_port, "pool_size": runner.pool_size, "timeout_seconds": runner.timeout_seconds, + "reasoning_level": runner.reasoning_level, "deriver_settings": settings.DERIVER.model_dump(), "dialectic_settings": settings.DIALECTIC.model_dump(), "dream_settings": settings.DREAM.model_dump(), @@ -829,11 +675,7 @@ async def main() -> int: ) # Export metrics - metrics_output = Path( - f"tests/bench/perf_metrics/beam_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" - ) - runner.metrics_collector.export_to_json(metrics_output) - runner.metrics_collector.cleanup_collection() + export_metrics(runner.metrics_collector, "beam") return 0 diff --git a/tests/bench/locomo.py b/tests/bench/locomo.py index 866aa4be..cfa2c969 100644 --- a/tests/bench/locomo.py +++ b/tests/bench/locomo.py @@ -59,26 +59,19 @@ Optional arguments: import argparse import asyncio -import logging -import os import time from datetime import datetime from pathlib import Path from typing import Any, cast -import httpx from anthropic import AsyncAnthropic from anthropic.types import MessageParam from dotenv import load_dotenv -from honcho import AsyncHoncho -from honcho.async_client.session import SessionPeerConfig -from honcho_core.types.workspaces.sessions.message_create_param import ( - MessageCreateParam, -) +from honcho.api_types import MessageCreateParams +from honcho.session import SessionPeerConfig from openai import AsyncOpenAI from src.config import settings -from src.telemetry.metrics_collector import MetricsCollector from .locomo_common import ( CATEGORY_NAMES, @@ -96,6 +89,15 @@ from .locomo_common import ( parse_locomo_date, print_summary, ) +from .runner_common import ( + ReasoningLevel, + RunnerMixin, + add_common_arguments, + create_anthropic_client, + create_openai_client, + export_metrics, + validate_common_arguments, +) # Load .env from bench directory bench_dir = Path(__file__).parent @@ -167,7 +169,7 @@ def determine_question_target(question: str, speaker_a: str, speaker_b: str) -> return speaker_a -class LoCoMoRunner: +class LoCoMoRunner(RunnerMixin): """ Executes LoCoMo benchmark tests against a Honcho instance. """ @@ -180,6 +182,8 @@ class LoCoMoRunner: timeout_seconds: int | None = None, cleanup_workspace: bool = False, use_get_context: bool = False, + redis_url: str = "redis://localhost:6379/0", + reasoning_level: ReasoningLevel | None = None, ): """ Initialize the LoCoMo test runner. @@ -191,151 +195,27 @@ class LoCoMoRunner: timeout_seconds: Timeout for deriver queue in seconds cleanup_workspace: If True, delete workspace after executing conversation use_get_context: If True, use get_context + judge LLM instead of dialectic .chat endpoint + redis_url: Redis URL for flush mode signaling (default: redis://localhost:6379/0) + reasoning_level: Reasoning level for dialectic chat (default: None) """ self.base_api_port: int = base_api_port self.pool_size: int = pool_size - self.anthropic_api_key: str | None = anthropic_api_key self.timeout_seconds: int = ( timeout_seconds if timeout_seconds is not None else 600 ) self.cleanup_workspace: bool = cleanup_workspace self.use_get_context: bool = use_get_context + self.redis_url: str = redis_url + self.reasoning_level: ReasoningLevel | None = reasoning_level - # Initialize metrics collector - self.metrics_collector: MetricsCollector = MetricsCollector() - self.metrics_collector.start_collection( - f"locomo_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + # Initialize common components (metrics, logging) + self._init_common("locomo") + + # Initialize LLM clients + self.anthropic_client: AsyncAnthropic = create_anthropic_client( + anthropic_api_key ) - - # Configure logging - logging.basicConfig( - level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s" - ) - self.logger: logging.Logger = logging.getLogger(__name__) - - # Suppress HTTP request logs from the Honcho SDK - logging.getLogger("httpx").setLevel(logging.ERROR) - logging.getLogger("httpcore").setLevel(logging.ERROR) - - if self.anthropic_api_key: - self.anthropic_client: AsyncAnthropic = AsyncAnthropic( - api_key=self.anthropic_api_key - ) - else: - api_key = os.getenv("LLM_ANTHROPIC_API_KEY") - if not api_key: - raise ValueError("LLM_ANTHROPIC_API_KEY is not set") - self.anthropic_client = AsyncAnthropic(api_key=api_key) - - # Initialize OpenAI client for judging responses - openai_api_key = os.getenv("OPENAI_API_KEY") - if not openai_api_key: - raise ValueError("OPENAI_API_KEY is not set") - self.openai_client: AsyncOpenAI = AsyncOpenAI(api_key=openai_api_key) - - def get_honcho_url_for_index(self, index: int) -> str: - """Get the Honcho URL for a given index using round-robin distribution.""" - instance_id = index % self.pool_size - port = self.base_api_port + instance_id - return f"http://localhost:{port}" - - async def create_honcho_client( - self, workspace_id: str, honcho_url: str - ) -> AsyncHoncho: - """Create a Honcho client for a specific workspace.""" - return AsyncHoncho( - environment="local", - workspace_id=workspace_id, - base_url=honcho_url, - ) - - async def wait_for_deriver_queue_empty( - self, honcho_client: AsyncHoncho, session_id: str | None = None - ) -> bool: - """Wait for the deriver queue to be empty.""" - start_time = time.time() - while True: - try: - status = await honcho_client.get_queue_status(session=session_id) - except Exception: - await asyncio.sleep(1) - elapsed_time = time.time() - start_time - if elapsed_time >= self.timeout_seconds: - return False - continue - - if status.pending_work_units == 0 and status.in_progress_work_units == 0: - return True - - elapsed_time = time.time() - start_time - if elapsed_time >= self.timeout_seconds: - return False - await asyncio.sleep(1) - - async def trigger_dream_and_wait( - self, - honcho_client: AsyncHoncho, - workspace_id: str, - observer: str, - observed: str | None = None, - session_id: str | None = None, - ) -> bool: - """ - Trigger a dream task and wait for it to complete. - - Args: - honcho_client: Honcho client instance - workspace_id: Workspace identifier - observer: Observer peer name - observed: Observed peer name (defaults to observer) - session_id: Session ID to scope the dream to - - Returns: - True if dream completed successfully, False on timeout - """ - observed = observed or observer - honcho_url = self.get_honcho_url_for_index(0) - - url = f"{honcho_url}/v2/workspaces/{workspace_id}/schedule_dream" - payload = { - "observer": observer, - "observed": observed, - "dream_type": "omni", - "session_id": session_id or f"{workspace_id}_session", - } - - print(f"[{workspace_id}] Triggering dream at {url}") - - try: - async with httpx.AsyncClient() as client: - response = await client.post( - url, - json=payload, - timeout=30.0, - ) - if response.status_code != 204: - print( - f"[{workspace_id}] ERROR: Dream trigger failed with status {response.status_code}" - ) - print(f"[{workspace_id}] Response body: {response.text}") - return False - except Exception as e: - print(f"[{workspace_id}] ERROR: Dream trigger exception: {e}") - return False - - print( - f"[{workspace_id}] Dream triggered successfully for {observer}/{observed}" - ) - - # Wait for dream queue to empty - print(f"[{workspace_id}] Waiting for dream to complete...") - await asyncio.sleep(2) - success = await self.wait_for_deriver_queue_empty(honcho_client) - if success: - print(f"[{workspace_id}] Dream queue empty") - else: - print(f"[{workspace_id}] Dream queue timeout") - return success + self.openai_client: AsyncOpenAI = create_openai_client() async def execute_conversation( self, @@ -370,7 +250,7 @@ class LoCoMoRunner: # Create workspace for this conversation workspace_id = f"locomo_{sample_id}" - honcho_client = await self.create_honcho_client(workspace_id, honcho_url) + honcho_client = self.create_honcho_client(workspace_id, honcho_url) result: ConversationResult = { "sample_id": sample_id, @@ -390,15 +270,15 @@ class LoCoMoRunner: try: # Create peers using their actual names as IDs - peer_a = await honcho_client.peer(id=speaker_a) - peer_b = await honcho_client.peer(id=speaker_b) + peer_a = await honcho_client.aio.peer(id=speaker_a) + peer_b = await honcho_client.aio.peer(id=speaker_b) # Create session for this conversation session_id = f"{workspace_id}_session" - session = await honcho_client.session(id=session_id) + session = await honcho_client.aio.session(id=session_id) # Configure peer observation - observe BOTH peers since questions ask about both speakers - await session.add_peers( + await session.aio.add_peers( [ ( peer_a, @@ -417,7 +297,7 @@ class LoCoMoRunner: print(f"[{workspace_id}] Ingesting {len(sessions)} sessions...") - messages: list[MessageCreateParam] = [] + messages: list[MessageCreateParams] = [] total_tokens = 0 for date_str, session_messages in sessions: @@ -448,7 +328,7 @@ class LoCoMoRunner: # Add messages in batches of 100 for i in range(0, len(messages), 100): batch = messages[i : i + 100] - await session.add_messages(batch) + await session.aio.add_messages(batch) print( f"[{workspace_id}] Ingested {len(messages)} messages (~{total_tokens:,} tokens). Waiting for deriver queue..." @@ -456,6 +336,7 @@ class LoCoMoRunner: # Wait for deriver queue to empty await asyncio.sleep(1) + await self.flush_deriver_queue() queue_empty = await self.wait_for_deriver_queue_empty(honcho_client) if not queue_empty: result["error"] = "Deriver queue timeout" @@ -531,7 +412,7 @@ class LoCoMoRunner: try: if self.use_get_context: # Use get_context + LLM - target the appropriate peer - context = await session.get_context( + context = await session.aio.context( summary=True, peer_target=target_speaker, last_user_message=question, @@ -552,8 +433,10 @@ class LoCoMoRunner: actual_response = getattr(content_block, "text", "") else: # Use dialectic .chat endpoint on the appropriate peer - actual_response = await target_peer.chat( - question, session=session_id + actual_response = await target_peer.aio.chat( + question, + session=session_id, + reasoning_level=self.reasoning_level, ) actual_response = ( actual_response if isinstance(actual_response, str) else "" @@ -623,7 +506,7 @@ class LoCoMoRunner: # Cleanup workspace if requested if self.cleanup_workspace: try: - await honcho_client.delete_workspace(workspace_id) + await honcho_client.aio.delete_workspace(workspace_id) print(f"[{workspace_id}] Cleaned up workspace") except Exception as e: print(f"Failed to delete workspace: {e}") @@ -736,6 +619,7 @@ Examples: %(prog)s --data-file locomo10.json --pool-size 4 %(prog)s --data-file locomo10.json --sample-id "sample_0" %(prog)s --data-file locomo10.json --test-count 5 --question-count 20 + %(prog)s --data-file locomo10.json --reasoning-level high """, ) @@ -746,58 +630,16 @@ Examples: help="Path to LoCoMo JSON file (required)", ) - parser.add_argument( - "--base-api-port", - type=int, - default=8000, - help="Base port for Honcho API instances (default: 8000)", - ) - - parser.add_argument( - "--pool-size", - type=int, - default=1, - help="Number of Honcho instances in the pool (default: 1)", - ) + # Add common arguments shared across all runners + add_common_arguments(parser) + # LoCoMo-specific arguments parser.add_argument( "--anthropic-api-key", type=str, help="Anthropic API key for response judging (optional)", ) - parser.add_argument( - "--timeout", - type=int, - default=None, - help="Timeout for deriver queue to empty in seconds (default: 10 minutes)", - ) - - parser.add_argument( - "--batch-size", - type=int, - default=1, - help="Number of conversations to run concurrently in each batch (default: 1)", - ) - - parser.add_argument( - "--json-output", - type=Path, - help="Path to write JSON summary results for analytics (optional)", - ) - - parser.add_argument( - "--cleanup-workspace", - action="store_true", - help="Delete workspace after executing each conversation (default: False)", - ) - - parser.add_argument( - "--use-get-context", - action="store_true", - help="Use get_context + judge LLM instead of dialectic .chat endpoint (default: False)", - ) - parser.add_argument( "--sample-id", type=str, @@ -818,19 +660,17 @@ Examples: args = parser.parse_args() - # Validate arguments + # Validate common arguments + error = validate_common_arguments(args) + if error: + print(error) + return 1 + + # Validate locomo-specific arguments if not args.data_file.exists(): print(f"Error: Data file {args.data_file} does not exist") return 1 - if args.batch_size <= 0: - print(f"Error: Batch size must be positive, got {args.batch_size}") - return 1 - - if args.pool_size <= 0: - print(f"Error: Pool size must be positive, got {args.pool_size}") - return 1 - # Create test runner runner = LoCoMoRunner( base_api_port=args.base_api_port, @@ -839,6 +679,8 @@ Examples: timeout_seconds=args.timeout, cleanup_workspace=args.cleanup_workspace, use_get_context=args.use_get_context, + redis_url=args.redis_url, + reasoning_level=args.reasoning_level, ) try: @@ -873,6 +715,7 @@ Examples: "base_api_port": runner.base_api_port, "pool_size": runner.pool_size, "timeout_seconds": runner.timeout_seconds, + "reasoning_level": runner.reasoning_level, "deriver_settings": settings.DERIVER.model_dump(), "dialectic_settings": settings.DIALECTIC.model_dump(), "dream_settings": settings.DREAM.model_dump(), @@ -881,11 +724,7 @@ Examples: ) # Export metrics to JSON file - metrics_output = Path( - f"tests/bench/perf_metrics/locomo_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" - ) - runner.metrics_collector.export_to_json(metrics_output) - runner.metrics_collector.cleanup_collection() + export_metrics(runner.metrics_collector, "locomo") # Return exit code based on results avg_score = ( diff --git a/tests/bench/longmem.py b/tests/bench/longmem.py index aff6568b..8072d90f 100644 --- a/tests/bench/longmem.py +++ b/tests/bench/longmem.py @@ -57,27 +57,20 @@ Optional arguments: import argparse import asyncio import json -import logging -import os import time from datetime import datetime from pathlib import Path from typing import Any, cast -import httpx from anthropic import AsyncAnthropic from anthropic.types import MessageParam from dotenv import load_dotenv -from honcho import AsyncHoncho -from honcho.async_client.session import SessionPeerConfig -from honcho_core.types.workspaces.sessions.message_create_param import ( - MessageCreateParam, -) +from honcho.api_types import MessageCreateParams +from honcho.session import SessionPeerConfig from openai import AsyncOpenAI from typing_extensions import TypedDict from src.config import settings -from src.telemetry.metrics_collector import MetricsCollector from .longmem_common import ( calculate_timing_statistics, @@ -90,6 +83,15 @@ from .longmem_common import ( parse_longmemeval_date, write_json_summary, ) +from .runner_common import ( + ReasoningLevel, + RunnerMixin, + add_common_arguments, + create_anthropic_client, + create_openai_client, + export_metrics, + validate_common_arguments, +) load_dotenv() @@ -127,7 +129,7 @@ class TestResult(TypedDict): output_lines: list[str] -class LongMemEvalRunner: +class LongMemEvalRunner(RunnerMixin): """ Executes longmemeval JSON tests against a Honcho instance. """ @@ -141,6 +143,8 @@ class LongMemEvalRunner: merge_sessions: bool = False, cleanup_workspace: bool = False, use_get_context: bool = False, + redis_url: str = "redis://localhost:6379/0", + reasoning_level: ReasoningLevel | None = None, ): """ Initialize the test runner. @@ -153,62 +157,28 @@ class LongMemEvalRunner: merge_sessions: If True, merge all sessions within a question into one session cleanup_workspace: If True, delete workspace after executing question (default: False) use_get_context: If True, use get_context + judge LLM instead of dialectic .chat endpoint + redis_url: Redis URL for flush mode signaling (default: redis://localhost:6379/0) + reasoning_level: Reasoning level for dialectic chat (default: None) """ self.base_api_port: int = base_api_port self.pool_size: int = pool_size - self.anthropic_api_key: str | None = anthropic_api_key self.timeout_seconds: int = ( timeout_seconds if timeout_seconds is not None else 10000 ) self.merge_sessions: bool = merge_sessions self.cleanup_workspace: bool = cleanup_workspace self.use_get_context: bool = use_get_context + self.redis_url: str = redis_url + self.reasoning_level: ReasoningLevel | None = reasoning_level - # Initialize metrics collector - self.metrics_collector: MetricsCollector = MetricsCollector() - self.metrics_collector.start_collection( - f"longmem_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + # Initialize common components (metrics, logging) + self._init_common("longmem") + + # Initialize LLM clients + self.anthropic_client: AsyncAnthropic = create_anthropic_client( + anthropic_api_key ) - - # Configure logging - logging.basicConfig( - level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s" - ) - self.logger: logging.Logger = logging.getLogger(__name__) - - # Suppress HTTP request logs from the Honcho SDK - logging.getLogger("httpx").setLevel(logging.ERROR) - logging.getLogger("httpcore").setLevel(logging.ERROR) - - if self.anthropic_api_key: - self.anthropic_client: AsyncAnthropic = AsyncAnthropic( - api_key=self.anthropic_api_key - ) - else: - api_key = os.getenv("LLM_ANTHROPIC_API_KEY") - if not api_key: - raise ValueError("LLM_ANTHROPIC_API_KEY is not set") - self.anthropic_client = AsyncAnthropic(api_key=api_key) - - # OpenAI client for GPT-4o judge (per LongMemEval paper) - openai_api_key = os.getenv("OPENAI_API_KEY") - if not openai_api_key: - raise ValueError("OPENAI_API_KEY is not set (required for GPT-4o judge)") - self.openai_client: AsyncOpenAI = AsyncOpenAI(api_key=openai_api_key) - - def get_honcho_url_for_index(self, question_index: int) -> str: - """ - Get the Honcho URL for a given question index using round-robin distribution. - - Args: - question_index: Index of the question in the test file - - Returns: - URL of the Honcho instance to use for this question - """ - instance_id = question_index % self.pool_size - port = self.base_api_port + instance_id - return f"http://localhost:{port}" + self.openai_client: AsyncOpenAI = create_openai_client() def _get_latest_input_tokens_used(self) -> int | None: """Get the uncached input tokens from the most recent dialectic_chat metric. @@ -245,111 +215,6 @@ class LongMemEvalRunner: return None - async def create_honcho_client( - self, workspace_id: str, honcho_url: str - ) -> AsyncHoncho: - """ - Create a Honcho client for a specific workspace. - - Args: - workspace_id: Workspace ID for the test - honcho_url: URL of the Honcho instance - - Returns: - AsyncHoncho client instance - """ - return AsyncHoncho( - environment="local", - workspace_id=workspace_id, - base_url=honcho_url, - ) - - async def wait_for_deriver_queue_empty( - self, honcho_client: AsyncHoncho, session_id: str | None = None - ) -> bool: - start_time = time.time() - while True: - try: - status = await honcho_client.get_queue_status(session=session_id) - except Exception as _e: - await asyncio.sleep(1) - elapsed_time = time.time() - start_time - if elapsed_time >= self.timeout_seconds: - return False - continue - - if status.pending_work_units == 0 and status.in_progress_work_units == 0: - return True - - elapsed_time = time.time() - start_time - if elapsed_time >= self.timeout_seconds: - return False - await asyncio.sleep(1) - - async def trigger_dream_and_wait( - self, - honcho_client: AsyncHoncho, - workspace_id: str, - observer: str, - observed: str | None = None, - session_id: str | None = None, - ) -> bool: - """ - Trigger a dream task and wait for it to complete. - - Args: - honcho_client: Honcho client instance - workspace_id: Workspace identifier - observer: Observer peer name - observed: Observed peer name (defaults to observer) - session_id: Session ID to scope the dream to - - Returns: - True if dream completed successfully, False on timeout - """ - observed = observed or observer - honcho_url = self.get_honcho_url_for_index(0) - - url = f"{honcho_url}/v2/workspaces/{workspace_id}/schedule_dream" - payload: dict[str, Any] = { - "observer": observer, - "observed": observed, - "dream_type": "omni", - "session_id": session_id or f"{workspace_id}_session", - } - - # Trigger the dream via API - try: - async with httpx.AsyncClient() as client: - response = await client.post( - url, - json=payload, - timeout=30.0, - ) - if response.status_code != 204: - print( - f"[{workspace_id}] ERROR: Dream trigger failed with status {response.status_code}" - ) - print(f"[{workspace_id}] Response body: {response.text}") - return False - except Exception as e: - print(f"[{workspace_id}] ERROR: Dream trigger exception: {e}") - return False - - print( - f"[{workspace_id}] Dream triggered successfully for {observer}/{observed}" - ) - - # Wait for dream queue to empty - print(f"[{workspace_id}] Waiting for dream to complete...") - await asyncio.sleep(2) # Give time for dream to be enqueued - success = await self.wait_for_deriver_queue_empty(honcho_client) - if success: - print(f"[{workspace_id}] Dream queue empty") - else: - print(f"[{workspace_id}] Dream queue timeout") - return success - async def execute_question( self, question_data: dict[str, Any], honcho_url: str ) -> TestResult: @@ -383,7 +248,7 @@ class LongMemEvalRunner: # Create workspace for this question workspace_id = f"{question_id}_{question_type}" - honcho_client = await self.create_honcho_client(workspace_id, honcho_url) + honcho_client = self.create_honcho_client(workspace_id, honcho_url) results: TestResult = { "question_id": question_id, @@ -400,8 +265,8 @@ class LongMemEvalRunner: } try: - user_peer = await honcho_client.peer(id="user") - assistant_peer = await honcho_client.peer(id="assistant") + user_peer = await honcho_client.aio.peer(id="user") + assistant_peer = await honcho_client.aio.peer(id="assistant") # Process haystack sessions haystack_dates = question_data.get("haystack_dates", []) @@ -444,11 +309,11 @@ class LongMemEvalRunner: if self.merge_sessions: # Create a single merged session for all messages merged_session_id = f"{workspace_id}_merged" - session = await honcho_client.session(id=merged_session_id) + session = await honcho_client.aio.session(id=merged_session_id) # Configure peer observation based on question type if is_assistant_type: - await session.add_peers( + await session.aio.add_peers( [ ( user_peer, @@ -465,7 +330,7 @@ class LongMemEvalRunner: ] ) else: - await session.add_peers( + await session.aio.add_peers( [ ( user_peer, @@ -483,7 +348,7 @@ class LongMemEvalRunner: ) # Collect all messages from all sessions in chronological order - all_messages: list[MessageCreateParam] = [] + all_messages: list[MessageCreateParams] = [] for session_date, session_messages in zip( parsed_dates, haystack_sessions, strict=True ): @@ -526,7 +391,7 @@ class LongMemEvalRunner: if all_messages: for i in range(0, len(all_messages), 100): batch = all_messages[i : i + 100] - await session.add_messages(batch) + await session.aio.add_messages(batch) results["sessions_created"].append( SessionResult( @@ -539,12 +404,12 @@ class LongMemEvalRunner: for session_date, session_id, session_messages in zip( parsed_dates, haystack_session_ids, haystack_sessions, strict=True ): - session = await honcho_client.session(id=session_id) + session = await honcho_client.aio.session(id=session_id) # Configure peer observation based on question type if is_assistant_type: # For assistant questions, observe the assistant peer - await session.add_peers( + await session.aio.add_peers( [ ( user_peer, @@ -562,7 +427,7 @@ class LongMemEvalRunner: ) else: # For user questions, observe the user peer (default behavior) - await session.add_peers( + await session.aio.add_peers( [ ( user_peer, @@ -579,7 +444,7 @@ class LongMemEvalRunner: ] ) - honcho_messages: list[MessageCreateParam] = [] + honcho_messages: list[MessageCreateParams] = [] for msg in session_messages: role = msg["role"] content = msg["content"] @@ -620,7 +485,7 @@ class LongMemEvalRunner: if honcho_messages: for i in range(0, len(honcho_messages), 100): batch = honcho_messages[i : i + 100] - await session.add_messages(batch) + await session.aio.add_messages(batch) results["sessions_created"].append( SessionResult( @@ -635,6 +500,9 @@ class LongMemEvalRunner: 1 ) # Give time for at least some tasks to be queued, so deriver queue size check doesn't immediately return 0 + # Enable flush mode to bypass batch token threshold + await self.flush_deriver_queue() + queue_empty = await self.wait_for_deriver_queue_empty(honcho_client) if not queue_empty: output_lines.append("Deriver queue never emptied!!!") @@ -682,11 +550,11 @@ class LongMemEvalRunner: raise ValueError( "Merged session ID is required when using get_context. Set --merge-sessions to True." ) - session = await honcho_client.session(id=merged_session_id) + session = await honcho_client.aio.session(id=merged_session_id) # Get context for the appropriate peer peer_id = "assistant" if is_assistant_type else "user" - context = await session.get_context( + context = await session.aio.context( summary=True, peer_target=peer_id, last_user_message=question, @@ -716,15 +584,21 @@ class LongMemEvalRunner: # Use the appropriate peer based on question type if is_assistant_type: # For assistant questions, use the assistant peer - actual_response = await assistant_peer.chat(question_with_date) + actual_response = await assistant_peer.aio.chat( + question_with_date, + reasoning_level=self.reasoning_level, + ) else: # For user questions, use the user peer (default behavior) - actual_response = await user_peer.chat(question_with_date) + actual_response = await user_peer.aio.chat( + question_with_date, + reasoning_level=self.reasoning_level, + ) # Clean up workspace if requested if self.cleanup_workspace: try: - await honcho_client.delete_workspace(workspace_id) + await honcho_client.aio.delete_workspace(workspace_id) print(f"[{workspace_id}] cleaned up workspace") except Exception as e: print(f"Failed to delete workspace: {e}") @@ -1014,6 +888,7 @@ class LongMemEvalRunner: "base_api_port": self.base_api_port, "pool_size": self.pool_size, "timeout_seconds": self.timeout_seconds, + "reasoning_level": self.reasoning_level, "deriver_settings": settings.DERIVER.model_dump(), "dialectic_settings": settings.DIALECTIC.model_dump(), "dream_settings": settings.DREAM.model_dump(), @@ -1064,6 +939,7 @@ Examples: %(prog)s --test-file test.json --base-api-port 8000 --pool-size 4 # Custom base port with pool %(prog)s --test-file test.json --test-count 50 # Run only first 50 tests %(prog)s --test-file test.json --question-id "q123" # Run only question with ID "q123" + %(prog)s --test-file test.json --reasoning-level high # Use high reasoning level """, ) @@ -1074,64 +950,22 @@ Examples: help="Path to longmemeval JSON file (required)", ) - parser.add_argument( - "--base-api-port", - type=int, - default=8000, - help="Base port for Honcho API instances (default: 8000)", - ) - - parser.add_argument( - "--pool-size", - type=int, - default=1, - help="Number of Honcho instances in the pool (default: 1)", - ) + # Add common arguments shared across all runners + add_common_arguments(parser) + # LongMemEval-specific arguments parser.add_argument( "--anthropic-api-key", type=str, help="Anthropic API key for response judging (optional)", ) - parser.add_argument( - "--timeout", - type=int, - default=None, - help="Timeout for deriver queue to empty in seconds (default: 10 minutes)", - ) - - parser.add_argument( - "--batch-size", - type=int, - default=10, - help="Number of questions to run concurrently in each batch (default: 10)", - ) - - parser.add_argument( - "--json-output", - type=Path, - help="Path to write JSON summary results for analytics (optional)", - ) - parser.add_argument( "--merge-sessions", action="store_true", help="Merge all sessions within a question into a single session (default: False)", ) - parser.add_argument( - "--cleanup-workspace", - action="store_true", - help="Delete workspace after executing each question (default: False)", - ) - - parser.add_argument( - "--use-get-context", - action="store_true", - help="Use get_context + judge LLM instead of dialectic .chat endpoint (default: False)", - ) - parser.add_argument( "--test-count", type=int, @@ -1146,19 +980,17 @@ Examples: args = parser.parse_args() - # Validate arguments + # Validate common arguments + error = validate_common_arguments(args) + if error: + print(error) + return 1 + + # Validate longmem-specific arguments if not args.test_file.exists(): print(f"Error: Test file {args.test_file} does not exist") return 1 - if args.batch_size <= 0: - print(f"Error: Batch size must be positive, got {args.batch_size}") - return 1 - - if args.pool_size <= 0: - print(f"Error: Pool size must be positive, got {args.pool_size}") - return 1 - if args.test_count is not None and args.test_count <= 0: print(f"Error: Test count must be positive, got {args.test_count}") return 1 @@ -1172,6 +1004,8 @@ Examples: merge_sessions=args.merge_sessions, cleanup_workspace=args.cleanup_workspace, use_get_context=args.use_get_context, + redis_url=args.redis_url, + reasoning_level=args.reasoning_level, ) try: @@ -1199,11 +1033,7 @@ Examples: ) # Export metrics to JSON file - metrics_output = Path( - f"tests/bench/perf_metrics/{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" - ) - runner.metrics_collector.export_to_json(metrics_output) - runner.metrics_collector.cleanup_collection() + export_metrics(runner.metrics_collector, "longmem") # Return exit code based on results all_passed = all(r.get("passed", False) for r in results) diff --git a/tests/bench/run_tests.py b/tests/bench/run_tests.py index c0efa234..13ed113b 100644 --- a/tests/bench/run_tests.py +++ b/tests/bench/run_tests.py @@ -6,7 +6,7 @@ This script: 1. Loads test definitions from JSON files 2. Creates a workspace for each test 3. Adds all messages to sessions -4. Waits for the deriver queue to be empty +4. Waits for the deriver queue to be empty (TODO implement this differently!) 5. Executes queries and judges responses using an LLM """ @@ -15,7 +15,6 @@ import asyncio import json import logging import os -import sys import time from pathlib import Path from typing import Any @@ -23,8 +22,8 @@ from typing import Any import tiktoken from anthropic import AsyncAnthropic from dotenv import load_dotenv -from honcho import AsyncHoncho -from honcho.async_client.session import SessionPeerConfig +from honcho import Honcho +from honcho.session import SessionPeerConfig from typing_extensions import TypedDict load_dotenv() @@ -141,7 +140,7 @@ class TestRunner: with open(test_file) as f: return json.load(f) - async def create_honcho_client(self, workspace_id: str) -> AsyncHoncho: + def create_honcho_client(self, workspace_id: str) -> Honcho: """ Create a Honcho client for a specific workspace. @@ -149,39 +148,14 @@ class TestRunner: workspace_id: Workspace ID for the test Returns: - AsyncHoncho client instance + Honcho client instance """ - return AsyncHoncho( + return Honcho( environment="local", workspace_id=workspace_id, base_url=self.honcho_url, ) - async def wait_for_deriver_queue_empty( - self, honcho_client: AsyncHoncho, session_id: str | None = None - ) -> bool: - """ - Wait for the deriver queue to be empty. - - Args: - honcho_client: Honcho client instance - timeout: Maximum time to wait in seconds - - Returns: - True if queue is empty, False if timeout exceeded - """ - try: - await honcho_client.poll_queue_status( - session=session_id, - timeout=float(self.timeout_seconds) - if self.timeout_seconds - else 10000.0, - ) - return True - except Exception as e: - self.logger.warning(f"Error polling deriver status: {e}") - return False - async def judge_response( self, query: str, expected_response: str, actual_response: str ) -> dict[str, Any]: @@ -289,7 +263,7 @@ Evaluate whether the actual response contains the core correct information from # Create workspace for this test workspace_id = f"test_{test_name}_{int(time.time())}" - honcho_client = await self.create_honcho_client(workspace_id) + honcho_client = self.create_honcho_client(workspace_id) results: TestResult = { "test_name": test_name, @@ -342,11 +316,11 @@ Evaluate whether the actual response contains the core correct information from # Create all peers first peers: dict[str, Any] = {} for peer_name in all_peers: - peers[peer_name] = await honcho_client.peer(id=peer_name) + peers[peer_name] = await honcho_client.aio.peer(id=peer_name) for session_name, session_data in sessions.items(): # Create session - session = await honcho_client.session(id=str(session_name)) + session = await honcho_client.aio.session(id=str(session_name)) output_lines.append(f"\n session: {session_name}") @@ -384,7 +358,7 @@ Evaluate whether the actual response contains the core correct information from peer_configs.append((peers[peer_name], config)) output_lines.append(f" peer config: {peer_name} -> {config}") - await session.add_peers(peer_configs) + await session.aio.add_peers(peer_configs) # Add messages to session messages = session_data.get("messages", []) @@ -398,7 +372,7 @@ Evaluate whether the actual response contains the core correct information from output_lines.append(f" {peer_name}: {truncated_content}") # Add messages to session - await session.add_messages( + await session.aio.add_messages( [peers[msg["peer"]].message(msg["content"]) for msg in messages] ) @@ -420,12 +394,13 @@ Evaluate whether the actual response contains the core correct information from observed: str | None = query_data.get("observed") # Wait for deriver queue to be empty for this session - queue_empty = await self.wait_for_deriver_queue_empty( - honcho_client, session_id=session_name - ) - if not queue_empty: - print(f"Deriver queue never emptied for session {session_name}!!!") - sys.exit(1) + # TODO implement this differently! + # queue_empty = await self.wait_for_deriver_queue_empty( + # honcho_client, session_id=session_name + # ) + # if not queue_empty: + # print(f"Deriver queue never emptied for session {session_name}!!!") + # sys.exit(1) output_lines.append(f"\n query {i + 1}: {query}") context_parts: list[str] = [] @@ -450,21 +425,21 @@ Evaluate whether the actual response contains the core correct information from # Execute chat query if session_name and observed: - response_text = await query_peer.chat( + response_text = await query_peer.aio.chat( query, session=session_name, target=peers[observed], ) elif session_name: - response_text = await query_peer.chat( + response_text = await query_peer.aio.chat( query, session=session_name ) elif observed: - response_text = await query_peer.chat( + response_text = await query_peer.aio.chat( query, target=peers[observed] ) else: - response_text = await query_peer.chat(query) + response_text = await query_peer.aio.chat(query) actual_response: str = ( response_text if response_text is not None else "" @@ -532,19 +507,20 @@ Evaluate whether the actual response contains the core correct information from session_name = str(get_context_call["session"]) summary = get_context_call["summary"] max_tokens: int | None = get_context_call.get("max_tokens") - session = await honcho_client.session(id=session_name) + session = await honcho_client.aio.session(id=session_name) # Wait for deriver queue to be empty for this session - queue_empty = await self.wait_for_deriver_queue_empty( - honcho_client, session_id=session_name - ) - if not queue_empty: - output_lines.append( - f"Deriver queue never emptied for session {session_name}!!!" - ) - sys.exit(1) + # TODO implement this differently! + # queue_empty = await self.wait_for_deriver_queue_empty( + # honcho_client, session_id=session_name + # ) + # if not queue_empty: + # output_lines.append( + # f"Deriver queue never emptied for session {session_name}!!!" + # ) + # sys.exit(1) - session_context = await session.get_context( + session_context = await session.aio.context( summary=summary, tokens=max_tokens ) diff --git a/tests/bench/runner_common.py b/tests/bench/runner_common.py new file mode 100644 index 00000000..b1072025 --- /dev/null +++ b/tests/bench/runner_common.py @@ -0,0 +1,370 @@ +""" +Shared utilities for Honcho benchmark test runners. + +Contains common functionality for queue management, dream triggering, +Honcho client creation, and CLI argument parsing used across longmem, +beam, and locomo runners. +""" + +import argparse +import asyncio +import logging +import os +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Literal + +import httpx +import redis.asyncio as aioredis +from anthropic import AsyncAnthropic +from honcho import Honcho +from openai import AsyncOpenAI +from redis.asyncio.client import Redis + +from src.telemetry.metrics_collector import MetricsCollector + +# Valid reasoning levels for dialectic chat +ReasoningLevel = Literal["minimal", "low", "medium", "high", "max"] +REASONING_LEVELS: list[str] = ["minimal", "low", "medium", "high", "max"] + + +def add_common_arguments(parser: argparse.ArgumentParser) -> None: + """ + Add common command line arguments shared across all benchmark runners. + + Args: + parser: ArgumentParser to add arguments to + """ + parser.add_argument( + "--base-api-port", + type=int, + default=8000, + help="Base port for Honcho API instances (default: 8000)", + ) + + parser.add_argument( + "--pool-size", + type=int, + default=1, + help="Number of Honcho instances in the pool (default: 1)", + ) + + parser.add_argument( + "--timeout", + type=int, + default=None, + help="Timeout for deriver queue to empty in seconds (default: 10 minutes)", + ) + + parser.add_argument( + "--batch-size", + type=int, + default=10, + help="Number of items to run concurrently in each batch (default: 10)", + ) + + parser.add_argument( + "--json-output", + type=Path, + help="Path to write JSON summary results for analytics (optional)", + ) + + parser.add_argument( + "--cleanup-workspace", + action="store_true", + help="Delete workspace after executing each test (default: False)", + ) + + parser.add_argument( + "--use-get-context", + action="store_true", + help="Use get_context + judge LLM instead of dialectic .chat endpoint (default: False)", + ) + + parser.add_argument( + "--redis-url", + type=str, + default="redis://localhost:6379/0", + help="Redis URL for flush mode signaling (default: redis://localhost:6379/0)", + ) + + parser.add_argument( + "--reasoning-level", + type=str, + choices=REASONING_LEVELS, + default=None, + help="Reasoning level for dialectic chat: minimal, low, medium, high, max (default: None)", + ) + + +def validate_common_arguments(args: argparse.Namespace) -> str | None: + """ + Validate common command line arguments. + + Args: + args: Parsed arguments + + Returns: + Error message if validation fails, None otherwise + """ + if args.batch_size <= 0: + return f"Error: Batch size must be positive, got {args.batch_size}" + + if args.pool_size <= 0: + return f"Error: Pool size must be positive, got {args.pool_size}" + + return None + + +def configure_logging() -> logging.Logger: + """ + Configure logging for benchmark runners. + + Sets up logging with WARNING level and suppresses HTTP request logs. + + Returns: + Logger instance for the calling module + """ + logging.basicConfig( + level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s" + ) + # Suppress HTTP request logs from the Honcho SDK + logging.getLogger("httpx").setLevel(logging.ERROR) + logging.getLogger("httpcore").setLevel(logging.ERROR) + return logging.getLogger(__name__) + + +def create_anthropic_client(api_key: str | None = None) -> AsyncAnthropic: + """ + Create an AsyncAnthropic client. + + Args: + api_key: Optional API key. If not provided, uses LLM_ANTHROPIC_API_KEY env var. + + Returns: + AsyncAnthropic client instance + + Raises: + ValueError: If no API key is available + """ + if api_key: + return AsyncAnthropic(api_key=api_key) + + env_key = os.getenv("LLM_ANTHROPIC_API_KEY") + if not env_key: + raise ValueError("LLM_ANTHROPIC_API_KEY is not set") + return AsyncAnthropic(api_key=env_key) + + +def create_openai_client( + api_key: str | None = None, + base_url: str | None = None, + env_key_name: str = "OPENAI_API_KEY", +) -> AsyncOpenAI: + """ + Create an AsyncOpenAI client. + + Args: + api_key: Optional API key. If not provided, uses env_key_name env var. + base_url: Optional base URL for OpenAI-compatible APIs (e.g., OpenRouter). + env_key_name: Name of the environment variable for the API key. + + Returns: + AsyncOpenAI client instance + + Raises: + ValueError: If no API key is available + """ + key = api_key or os.getenv(env_key_name) + if not key: + raise ValueError(f"{env_key_name} is not set") + + if base_url: + return AsyncOpenAI(api_key=key, base_url=base_url) + return AsyncOpenAI(api_key=key) + + +def create_metrics_collector(prefix: str) -> MetricsCollector: + """ + Create and start a MetricsCollector. + + Args: + prefix: Prefix for the collection name (e.g., "longmem", "beam", "locomo") + + Returns: + Started MetricsCollector instance + """ + collector = MetricsCollector() + collector.start_collection(f"{prefix}_{datetime.now().strftime('%Y%m%d_%H%M%S')}") + return collector + + +def export_metrics( + collector: MetricsCollector, + prefix: str, + output_dir: str = "tests/bench/perf_metrics", +) -> Path: + """ + Export metrics to a JSON file and cleanup the collector. + + Args: + collector: MetricsCollector instance + prefix: Prefix for the output filename + output_dir: Directory for output files + + Returns: + Path to the exported metrics file + """ + metrics_output = Path( + f"{output_dir}/{prefix}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + ) + collector.export_to_json(metrics_output) + collector.cleanup_collection() + return metrics_output + + +class RunnerMixin: + """ + Mixin class providing common functionality for benchmark runners. + + Requires the following attributes on the class: + - redis_url: str + - timeout_seconds: int + - base_api_port: int + - pool_size: int + - reasoning_level: ReasoningLevel | None (optional) + """ + + # These are expected to be set by the inheriting class's __init__ + redis_url: str = "" + timeout_seconds: int = 0 + base_api_port: int = 0 + pool_size: int = 0 + reasoning_level: ReasoningLevel | None = None + # These are initialized by _init_common() - use Any to satisfy type checker + # since the actual type is set at runtime + metrics_collector: Any = None + logger: Any = None + + def _init_common(self, metrics_prefix: str) -> None: + """ + Initialize common runner components. + + Call this at the end of your __init__ after setting instance attributes. + + Args: + metrics_prefix: Prefix for metrics collection (e.g., "longmem", "beam") + """ + self.metrics_collector = create_metrics_collector(metrics_prefix) + self.logger = configure_logging() + + def get_honcho_url_for_index(self, index: int) -> str: + """Get the Honcho URL for a given index using round-robin distribution.""" + instance_id = index % self.pool_size + port = self.base_api_port + instance_id + return f"http://localhost:{port}" + + def create_honcho_client(self, workspace_id: str, honcho_url: str) -> Honcho: + """Create a Honcho client for a specific workspace.""" + return Honcho( + environment="local", + workspace_id=workspace_id, + base_url=honcho_url, + ) + + async def flush_deriver_queue(self) -> None: + """Enable deriver flush mode to bypass batch token threshold.""" + redis_client: Redis = aioredis.from_url(self.redis_url) # pyright: ignore[reportUnknownMemberType] + try: + await redis_client.set("honcho:deriver:flush_mode", "1", ex=60) + print("Enabled deriver flush mode") + finally: + await redis_client.aclose() + + async def wait_for_deriver_queue_empty( + self, honcho_client: Honcho, session_id: str | None = None + ) -> bool: + """Wait for the deriver queue to be empty.""" + start_time = time.time() + while True: + try: + status = await honcho_client.aio.queue_status(session=session_id) + except Exception: + await asyncio.sleep(1) + elapsed_time = time.time() - start_time + if elapsed_time >= self.timeout_seconds: + return False + continue + + if status.pending_work_units == 0 and status.in_progress_work_units == 0: + return True + + elapsed_time = time.time() - start_time + if elapsed_time >= self.timeout_seconds: + return False + await asyncio.sleep(1) + + async def trigger_dream_and_wait( + self, + honcho_client: Honcho, + workspace_id: str, + observer: str, + observed: str | None = None, + session_id: str | None = None, + ) -> bool: + """ + Trigger a dream task and wait for it to complete. + + Args: + honcho_client: Honcho client instance + workspace_id: Workspace identifier + observer: Observer peer name + observed: Observed peer name (defaults to observer) + session_id: Session ID to scope the dream to + + Returns: + True if dream completed successfully, False on timeout + """ + observed = observed or observer + honcho_url = self.get_honcho_url_for_index(0) + + url = f"{honcho_url}/v3/workspaces/{workspace_id}/schedule_dream" + payload: dict[str, Any] = { + "observer": observer, + "observed": observed, + "dream_type": "omni", + "session_id": session_id or f"{workspace_id}_session", + } + + try: + async with httpx.AsyncClient() as client: + response = await client.post( + url, + json=payload, + timeout=30.0, + ) + if response.status_code != 204: + print( + f"[{workspace_id}] ERROR: Dream trigger failed with status {response.status_code}" + ) + print(f"[{workspace_id}] Response body: {response.text}") + return False + except Exception as e: + print(f"[{workspace_id}] ERROR: Dream trigger exception: {e}") + return False + + print( + f"[{workspace_id}] Dream triggered successfully for {observer}/{observed}" + ) + + # Wait for dream queue to empty + print(f"[{workspace_id}] Waiting for dream to complete...") + await asyncio.sleep(2) # Give time for dream to be enqueued + await self.flush_deriver_queue() + success = await self.wait_for_deriver_queue_empty(honcho_client) + if success: + print(f"[{workspace_id}] Dream queue empty") + else: + print(f"[{workspace_id}] Dream queue timeout") + return success diff --git a/tests/conftest.py b/tests/conftest.py index 513cd48e..38f9f216 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -104,7 +104,7 @@ async def setup_test_database(db_url: URL): Returns: engine: SQLAlchemy engine """ - engine = create_async_engine(str(db_url), echo=True) + engine = create_async_engine(str(db_url), echo=False) async with engine.connect() as conn: try: logger.info("Attempting to create pgvector extension...") @@ -503,6 +503,13 @@ def mock_vector_store(): def mock_llm_call_functions(): """Mock LLM functions to avoid needing API keys during tests""" + # Create an async generator for streaming responses + async def mock_stream(*args, **kwargs): # pyright: ignore[reportUnusedParameter, reportMissingParameterType, reportUnknownParameterType] + """Mock streaming response that yields chunks""" + chunks = ["Test ", "streaming ", "response"] + for chunk in chunks: + yield chunk + # Create mock responses for different function types # Note: critical_analysis_call was removed as the deriver now uses agentic approach # Note: dialectic_call/dialectic_stream were replaced with agentic_chat @@ -516,6 +523,9 @@ def mock_llm_call_functions(): patch( "src.routers.peers.agentic_chat", new_callable=AsyncMock ) as mock_agentic_chat, + patch( + "src.routers.peers.agentic_chat_stream", side_effect=mock_stream + ) as mock_agentic_chat_stream, ): # Mock return values for different function types mock_short_summary.return_value = "Test short summary content" @@ -528,6 +538,7 @@ def mock_llm_call_functions(): "short_summary": mock_short_summary, "long_summary": mock_long_summary, "agentic_chat": mock_agentic_chat, + "agentic_chat_stream": mock_agentic_chat_stream, } diff --git a/tests/deriver/conftest.py b/tests/deriver/conftest.py index 8f7b1897..91ec680b 100644 --- a/tests/deriver/conftest.py +++ b/tests/deriver/conftest.py @@ -158,7 +158,7 @@ def create_queue_payload() -> Callable[..., Any]: configuration=configuration, task_type=task_type, message_seq_in_session=message_seq_in_session, - observer=observer, + observers=[observer] if observer else None, observed=observed, ) diff --git a/tests/deriver/test_deriver_processing.py b/tests/deriver/test_deriver_processing.py index 2e8198b8..0cde8a68 100644 --- a/tests/deriver/test_deriver_processing.py +++ b/tests/deriver/test_deriver_processing.py @@ -5,7 +5,7 @@ import pytest from src import models from src.utils.representation import Representation -from src.utils.work_unit import construct_work_unit_key +from src.utils.work_unit import construct_work_unit_key, parse_work_unit_key @pytest.mark.asyncio @@ -19,12 +19,12 @@ class TestDeriverProcessing: """Test that work unit keys are generated correctly""" session, peers = sample_session_with_peers - peer1, peer2, _ = peers + peer1 = peers[0] # Create a payload for representation task + # Note: observer is no longer part of the work_unit_key for representation tasks representation_payload = { "session_name": session.name, - "observer": peer2.name, "observed": peer1.name, "task_type": "representation", } @@ -33,7 +33,10 @@ class TestDeriverProcessing: work_unit_key = construct_work_unit_key( session.workspace_name, representation_payload ) - expected_key = f"representation:{session.workspace_name}:{session.name}:{peer2.name}:{peer1.name}" + # Representation keys no longer include observer (deduplication change) + expected_key = ( + f"representation:{session.workspace_name}:{session.name}:{peer1.name}" + ) assert work_unit_key == expected_key # Create a payload for summary task @@ -90,6 +93,96 @@ class TestDeriverProcessing: # Verify the methods were called assert mock_representation_manager.save_representation.called # type: ignore[attr-defined] + +class TestBackwardsCompatibility: + """Test backwards compatibility for queue items created before the deduplication change.""" + + def test_parse_legacy_representation_work_unit_key(self): + """Test that legacy 5-part representation work unit keys are parsed correctly. + + Before the deduplication change, representation keys had the format: + representation:{workspace}:{session}:{observer}:{observed} + + After the change, the format is: + representation:{workspace}:{session}:{observed} + + We need to support both for backwards compatibility with existing queue items. + """ + legacy_key = ( + "representation:workspace_123:session_456:observer_peer:observed_peer" + ) + parsed = parse_work_unit_key(legacy_key) + + assert parsed.task_type == "representation" + assert parsed.workspace_name == "workspace_123" + assert parsed.session_name == "session_456" + assert parsed.observer == "observer_peer" + assert parsed.observed == "observed_peer" + + def test_parse_new_representation_work_unit_key(self): + """Test that new 4-part representation work unit keys are parsed correctly.""" + new_key = "representation:workspace_123:session_456:observed_peer" + parsed = parse_work_unit_key(new_key) + + assert parsed.task_type == "representation" + assert parsed.workspace_name == "workspace_123" + assert parsed.session_name == "session_456" + assert parsed.observer is None + assert parsed.observed == "observed_peer" + + def test_parse_invalid_representation_work_unit_key_raises(self): + """Test that invalid representation keys raise ValueError.""" + with pytest.raises(ValueError): + parse_work_unit_key("representation:workspace:session") + + with pytest.raises(ValueError): + parse_work_unit_key("representation:a:b:c:d:e") + + def test_legacy_payload_observer_converted_to_observers_list(self): + """Test that legacy payloads with singular 'observer' are handled correctly.""" + legacy_payload: dict[str, Any] = { + "observer": "peer_observer", + "observed": "peer_observed", + "task_type": "representation", + } + + # This mirrors the logic in queue_manager.py process_work_unit + observers = legacy_payload.get("observers") + if observers is None: + legacy_observer = legacy_payload.get("observer") + observers = [legacy_observer] if legacy_observer else [] + + assert observers == ["peer_observer"] + + def test_new_payload_observers_list_used_directly(self): + """Test that new payloads with 'observers' list are used directly.""" + new_payload: dict[str, Any] = { + "observers": ["peer1", "peer2"], + "observed": "peer3", + "task_type": "representation", + } + + observers = new_payload.get("observers") + if observers is None: + legacy_observer = new_payload.get("observer") + observers = [legacy_observer] if legacy_observer else [] + + assert observers == ["peer1", "peer2"] + + def test_empty_payload_results_in_empty_observers_list(self): + """Test that payloads with neither observer nor observers return empty list.""" + empty_payload: dict[str, Any] = { + "observed": "peer_observed", + "task_type": "representation", + } + + observers = empty_payload.get("observers") + if observers is None: + legacy_observer = empty_payload.get("observer") + observers = [legacy_observer] if legacy_observer else [] + + assert observers == [] + # async def test_representation_batch_uses_earliest_cutoff( # self, # db_session: AsyncSession, diff --git a/tests/deriver/test_queue_processing.py b/tests/deriver/test_queue_processing.py index b36839aa..9ca78f97 100644 --- a/tests/deriver/test_queue_processing.py +++ b/tests/deriver/test_queue_processing.py @@ -346,7 +346,7 @@ class TestQueueProcessing: _message_level_configuration: Any, *, observed: str | None = None, # pyright: ignore[reportUnusedParameter] - observer: str | None = None, # pyright: ignore[reportUnusedParameter] + observers: list[str] | None = None, # pyright: ignore[reportUnusedParameter] queue_items_count: int | None = None, # pyright: ignore[reportUnusedParameter] ) -> None: processed_batches.append( @@ -909,7 +909,7 @@ class TestQueueProcessing: _message_level_configuration: Any, *, observed: str | None = None, # pyright: ignore[reportUnusedParameter] - observer: str | None = None, # pyright: ignore[reportUnusedParameter] + observers: list[str] | None = None, # pyright: ignore[reportUnusedParameter] queue_items_count: int | None = None, # pyright: ignore[reportUnusedParameter] ) -> None: processed_batches.append( @@ -1028,7 +1028,7 @@ class TestQueueProcessing: _message_level_configuration: Any, *, observed: str | None = None, # pyright: ignore[reportUnusedParameter] - observer: str | None = None, # pyright: ignore[reportUnusedParameter] + observers: list[str] | None = None, # pyright: ignore[reportUnusedParameter] queue_items_count: int | None = None, # pyright: ignore[reportUnusedParameter] ) -> None: processed_batches.append( diff --git a/tests/integration/test_enqueue.py b/tests/integration/test_enqueue.py index bdfd3f5e..09bc9eda 100644 --- a/tests/integration/test_enqueue.py +++ b/tests/integration/test_enqueue.py @@ -220,30 +220,12 @@ class TestEnqueueFunction: ) queue_items = result.scalars().all() - # Explicitly match up payloads by sender/target/task_type - # For each message, we expect a representation - expected_payloads: list[dict[str, Any]] = [] - for _ in range(NUM_MESSAGES): - expected_payloads.append( - { - "observed": test_peer1.name, - "observer": test_peer1.name, - "task_type": "representation", - } - ) - actual_payloads = [ - { - "observed": item.payload.get("observed"), - "observer": item.payload.get("observer"), - "task_type": item.payload.get("task_type"), - } - for item in queue_items - ] - assert len(actual_payloads) == len(expected_payloads) - - # Assert that all expected payloads are present in actual_payloads - for expected in expected_payloads: - assert expected in actual_payloads + # With deduplication, each message creates 1 queue item with observers list + # For each message, we expect a representation with observers=[sender] + for item in queue_items: + assert item.payload.get("task_type") == "representation" + assert item.payload.get("observed") == test_peer1.name + assert item.payload.get("observers") == [test_peer1.name] @pytest.mark.asyncio async def test_session_with_multiple_peers_all_observe_others( @@ -288,46 +270,22 @@ class TestEnqueueFunction: await enqueue(payload) final_count = await self.count_queue_items(db_session) - # Should create NUM_MESSAGES * 2 queue items: - # 1 representation for sender, 1 local representation for observer - assert final_count - initial_count == NUM_MESSAGES * 2 + # With deduplication: 1 queue item per message (with all observers in a list) + assert final_count - initial_count == NUM_MESSAGES result = await db_session.execute( select(QueueItem).where(QueueItem.session_id == test_session.id) ) queue_items = result.scalars().all() - # Explicitly match up payloads by sender/target/task_type - # For each message, we expect a representation and a local representation for observer - expected_payloads: list[dict[str, Any]] = [] - for _ in range(NUM_MESSAGES): - expected_payloads.append( - { - "observed": test_peer1.name, - "observer": test_peer1.name, - "task_type": "representation", - } - ) - expected_payloads.append( - { - "observed": test_peer1.name, - "observer": test_peer2.name, - "task_type": "representation", - } - ) - actual_payloads = [ - { - "observed": item.payload.get("observed"), - "observer": item.payload.get("observer"), - "task_type": item.payload.get("task_type"), - } - for item in queue_items - ] - assert len(actual_payloads) == len(expected_payloads) - - # Assert that all expected payloads are present in actual_payloads - for expected in expected_payloads: - assert expected in actual_payloads + # Each queue item should have both observers in the list + for item in queue_items: + assert item.payload.get("task_type") == "representation" + assert item.payload.get("observed") == test_peer1.name + observers = item.payload.get("observers") + assert observers is not None + assert test_peer1.name in observers # self-observation + assert test_peer2.name in observers # peer2 observes others @pytest.mark.asyncio async def test_session_with_multiple_peers_some_observe_others( @@ -382,50 +340,25 @@ class TestEnqueueFunction: await enqueue(payload) final_count = await self.count_queue_items(db_session) - # Should create NUM_MESSAGES * 2 queue items: - # 1 representation for sender, 1 local representation for 1 peer observer - assert final_count - initial_count == NUM_MESSAGES * 2 + # With deduplication: 1 queue item per message (with all observers in a list) + assert final_count - initial_count == NUM_MESSAGES result = await db_session.execute( select(QueueItem).where(QueueItem.session_id == test_session.id) ) queue_items = result.scalars().all() - # Explicitly match up payloads by sender/target/task_type - # For each message, we expect a representation and a local representation for observer - expected_payloads: list[dict[str, Any]] = [] - for _ in range(NUM_MESSAGES): - expected_payloads.append( - { - "observed": test_peer1.name, - "observer": test_peer1.name, - "task_type": "representation", - } - ) - expected_payloads.append( - { - "observed": test_peer1.name, - "observer": observing_peer.name, - "task_type": "representation", - } - ) - actual_payloads = [ - { - "observed": item.payload.get("observed"), - "observer": item.payload.get("observer"), - "task_type": item.payload.get("task_type"), - } - for item in queue_items - ] - assert len(actual_payloads) == len(expected_payloads) - - # Assert that all expected payloads are present in actual_payloads - for expected in expected_payloads: - assert expected in actual_payloads - - assert unobserving_peer.name not in [ - item.payload.get("observer") for item in queue_items - ] + # Each queue item should have sender and observing_peer as observers + for item in queue_items: + assert item.payload.get("task_type") == "representation" + assert item.payload.get("observed") == test_peer1.name + observers = item.payload.get("observers") + assert observers is not None + assert test_peer1.name in observers # self-observation + assert observing_peer.name in observers # observing_peer observes others + assert ( + unobserving_peer.name not in observers + ) # unobserving_peer does not observe @pytest.mark.asyncio async def test_session_peer_config_overrides_peer_config( @@ -525,53 +458,31 @@ class TestEnqueueFunction: await enqueue(payload) final_count = await self.count_queue_items(db_session) - # Should create 4 queue items: - # 1 representation for test_peer1 (sender) and observer_peer (target) - # 1 representation for additional_sender_peer (sender) and observer_peer (target) - assert final_count - initial_count == 4 + # With deduplication: 1 queue item per message (2 messages total) + assert final_count - initial_count == 2 # Verify the correct representations were created result = await db_session.execute( select(QueueItem).where(QueueItem.session_id == test_session.id) ) - queue_items = result.all() + queue_items = result.scalars().all() - # Build expected payloads for this scenario - expected_payloads: list[dict[str, Any]] = [] - # 2 messages: one from test_peer1, one from additional_sender_peer - for sender in [test_peer1.name, additional_sender_peer.name]: - # representation for sender (self) - expected_payloads.append( - { - "task_type": "representation", - "observed": sender, - "observer": sender, - } - ) - # representation for observer_peer (observe_others=True) - expected_payloads.append( - { - "task_type": "representation", - "observed": sender, - "observer": observer_peer.name, - } - ) + # Each message should have a queue item with observers list containing + # the sender and observer_peer + senders_found: set[str] = set() + for item in queue_items: + assert item.payload.get("task_type") == "representation" + observed = item.payload.get("observed") + observers = item.payload.get("observers") + assert observed is not None + assert observers is not None + senders_found.add(observed) + assert observed in observers # self-observation + assert observer_peer.name in observers # observer_peer observes others - # Extract actual payloads (task_type, observed, observer) from queue_items - actual_payloads = [ - { - "task_type": item[0].payload.get("task_type"), - "observed": item[0].payload.get("observed"), - "observer": item[0].payload.get("observer"), - } - for item in queue_items - ] - - assert len(actual_payloads) == len(expected_payloads) - - # For each expected payload, assert it is present in actual_payloads - for expected in expected_payloads: - assert expected in actual_payloads + # Both senders should have queue items + assert test_peer1.name in senders_found + assert additional_sender_peer.name in senders_found # RACE CONDITION TESTS - Testing the new logic for peers that have left @pytest.mark.asyncio @@ -632,40 +543,22 @@ class TestEnqueueFunction: await enqueue(payload) final_count = await self.count_queue_items(db_session) - # Should create 2 queue items: - # 1 representation for sender (using default config since they left) - # 1 representation for observer (still in session and observing others) - assert final_count - initial_count == 2 + # 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() - expected_payloads = [ - { - "observed": sender_peer.name, - "observer": sender_peer.name, - "task_type": "representation", - }, - { - "observed": sender_peer.name, - "observer": observer_peer.name, - "task_type": "representation", - }, - ] - actual_payloads = [ - { - "observed": item.payload.get("observed"), - "observer": item.payload.get("observer"), - "task_type": item.payload.get("task_type"), - } - for item in queue_items - ] - - assert len(actual_payloads) == len(expected_payloads) - for expected in expected_payloads: - assert expected in actual_payloads + assert len(queue_items) == 1 + item = queue_items[0] + assert item.payload.get("task_type") == "representation" + assert item.payload.get("observed") == sender_peer.name + observers = item.payload.get("observers") + assert observers is not None + assert sender_peer.name in observers # self-observation (default config) + assert observer_peer.name in observers # observer still in session @pytest.mark.asyncio async def test_observer_left_session_no_queue_items_generated( @@ -731,18 +624,19 @@ class TestEnqueueFunction: await enqueue(payload) final_count = await self.count_queue_items(db_session) - # Should create 2 queue items: - # 1 representation for sender - # 1 representation for observer_who_stayed (observer_who_left should be skipped) - assert final_count - initial_count == 2 + # 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() - # Verify observer_who_left is NOT in the target names - observers = [item.payload.get("observer") for item in queue_items] + assert len(queue_items) == 1 + item = queue_items[0] + observers = item.payload.get("observers") + assert observers is not None + # Verify observer_who_left is NOT in the observers list assert observer_who_left.name not in observers assert observer_who_stayed.name in observers assert sender_peer.name in observers @@ -792,40 +686,22 @@ class TestEnqueueFunction: await enqueue(payload) final_count = await self.count_queue_items(db_session) - # Should create 2 queue items: - # 1 representation for unknown sender (using default observe_me=True) - # 1 representation for observer (observing others) - assert final_count - initial_count == 2 + # 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() - expected_payloads = [ - { - "observed": existing_peer.name, - "observer": existing_peer.name, - "task_type": "representation", - }, - { - "observed": existing_peer.name, - "observer": observer_peer.name, - "task_type": "representation", - }, - ] - actual_payloads = [ - { - "observed": item.payload.get("observed"), - "observer": item.payload.get("observer"), - "task_type": item.payload.get("task_type"), - } - for item in queue_items - ] - - assert len(actual_payloads) == len(expected_payloads) - for expected in expected_payloads: - assert expected in actual_payloads + 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( @@ -912,46 +788,24 @@ class TestEnqueueFunction: await enqueue(payload) final_count = await self.count_queue_items(db_session) - # Should create 2 queue items: - # 1 representation for sender (observe_me=True) - # 1 representation for active_observer (observe_others=True and still active) - # inactive_observer should be skipped (left session) - # active_non_observer should be skipped (observe_others=False) - # inactive_non_observer should be skipped (left session) - assert final_count - initial_count == 2 + # 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() - expected_payloads = [ - { - "observed": sender_peer.name, - "observer": sender_peer.name, - "task_type": "representation", - }, - { - "observed": sender_peer.name, - "observer": active_observer.name, - "task_type": "representation", - }, - ] - actual_payloads = [ - { - "observed": item.payload.get("observed"), - "observer": item.payload.get("observer"), - "task_type": item.payload.get("task_type"), - } - for item in queue_items - ] + assert len(queue_items) == 1 + item = queue_items[0] + assert item.payload.get("task_type") == "representation" + assert item.payload.get("observed") == sender_peer.name + observers = item.payload.get("observers") + assert observers is not None + assert sender_peer.name in observers # self-observation + assert active_observer.name in observers # active and observing - assert len(actual_payloads) == len(expected_payloads) - for expected in expected_payloads: - assert expected in actual_payloads - - # Verify inactive peers are not in target names - observers = [item.payload.get("observer") for item in queue_items] + # Verify inactive and non-observing peers are not in observers list assert inactive_observer.name not in observers assert inactive_non_observer.name not in observers assert active_non_observer.name not in observers @@ -1196,7 +1050,7 @@ class TestAdvancedEnqueueEdgeCases: assert len(queue_items) == 1 assert queue_items[0].payload["observed"] == sender_peer.name - assert queue_items[0].payload["observer"] == sender_peer.name + assert queue_items[0].payload["observers"] == [sender_peer.name] assert queue_items[0].payload["task_type"] == "representation" @pytest.mark.asyncio @@ -1282,7 +1136,7 @@ class TestAdvancedEnqueueEdgeCases: assert len(queue_items) == 1 assert queue_items[0].payload["observed"] == sender_peer.name - assert queue_items[0].payload["observer"] == sender_peer.name + assert queue_items[0].payload["observers"] == [sender_peer.name] assert queue_items[0].payload["task_type"] == "representation" @pytest.mark.asyncio @@ -1328,40 +1182,22 @@ class TestAdvancedEnqueueEdgeCases: await enqueue(payload) final_count = await self.count_queue_items(db_session) - # Should create 2 queue items: - # 1 for never_joined_peer (using default config) - # 1 for observer (observe_others=True) - assert final_count - initial_count == 2 + # 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() - expected_payloads = [ - { - "observed": existing_peer.name, - "observer": existing_peer.name, - "task_type": "representation", - }, - { - "observed": existing_peer.name, - "observer": observer_peer.name, - "task_type": "representation", - }, - ] - actual_payloads = [ - { - "observed": item.payload.get("observed"), - "observer": item.payload.get("observer"), - "task_type": item.payload.get("task_type"), - } - for item in queue_items - ] - - assert len(actual_payloads) == len(expected_payloads) - for expected in expected_payloads: - assert expected in actual_payloads + 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 diff --git a/tests/integration/test_token_metrics.py b/tests/integration/test_token_metrics.py index d0c38834..aef7c094 100644 --- a/tests/integration/test_token_metrics.py +++ b/tests/integration/test_token_metrics.py @@ -325,7 +325,7 @@ class TestDeriverIngestionMetrics: await process_representation_tasks_batch( messages=messages, message_level_configuration=create_test_configuration(), - observer=peer.name, + observers=[peer.name], observed=peer.name, queue_items_count=len(messages), ) @@ -382,7 +382,7 @@ class TestDeriverIngestionMetrics: await process_representation_tasks_batch( messages=messages, message_level_configuration=create_test_configuration(), - observer=peer.name, + observers=[peer.name], observed=peer.name, queue_items_count=len(messages), ) @@ -439,7 +439,7 @@ class TestDeriverIngestionMetrics: await process_representation_tasks_batch( messages=messages, message_level_configuration=create_test_configuration(), - observer=peer.name, + observers=[peer.name], observed=peer.name, queue_items_count=len(messages), ) diff --git a/tests/routes/test_conclusions.py b/tests/routes/test_conclusions.py index 245a657b..58076250 100644 --- a/tests/routes/test_conclusions.py +++ b/tests/routes/test_conclusions.py @@ -76,7 +76,7 @@ class TestConclusionRoutes: # List conclusions response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions/list", + f"/v3/workspaces/{test_workspace.name}/conclusions/list", json={"filters": {"session_id": test_session.name}}, ) @@ -118,7 +118,7 @@ class TestConclusionRoutes: # List conclusions response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions/list", + f"/v3/workspaces/{test_workspace.name}/conclusions/list", json={"filters": {"session_id": test_session.name}}, ) @@ -182,7 +182,7 @@ class TestConclusionRoutes: # List conclusions filtered by observer response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions/list", + f"/v3/workspaces/{test_workspace.name}/conclusions/list", json={ "filters": {"observer": test_peer.name, "session_id": test_session.name} }, @@ -248,7 +248,7 @@ class TestConclusionRoutes: # List conclusions in reverse (oldest first) response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions/list?reverse=true", + f"/v3/workspaces/{test_workspace.name}/conclusions/list?reverse=true", json={"filters": {"session_id": test_session.name}}, ) @@ -302,7 +302,7 @@ class TestConclusionRoutes: # Get first page (default size) response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions/list?page=1&size=10", + f"/v3/workspaces/{test_workspace.name}/conclusions/list?page=1&size=10", json={"filters": {"session_id": test_session.name}}, ) @@ -313,7 +313,7 @@ class TestConclusionRoutes: # Get second page response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions/list?page=2&size=10", + f"/v3/workspaces/{test_workspace.name}/conclusions/list?page=2&size=10", json={"filters": {"session_id": test_session.name}}, ) @@ -346,9 +346,9 @@ class TestConclusionRoutes: db_session.add(test_session) await db_session.commit() - # Create test conclusions via API (this populates the vector store) - _create_response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions", + # Create test conclusions via API (ensures proper vector store integration) + create_response = client.post( + f"/v3/workspaces/{test_workspace.name}/conclusions", json={ "conclusions": [ { @@ -366,30 +366,11 @@ class TestConclusionRoutes: ] }, ) - - # Create test conclusions - doc1 = models.Document( - workspace_name=test_workspace.name, - observer=test_peer.name, - observed=test_peer2.name, - content="User loves pizza and pasta", - embedding=[0.9] * 1536, - session_name=test_session.name, - ) - doc2 = models.Document( - workspace_name=test_workspace.name, - observer=test_peer.name, - observed=test_peer2.name, - content="User dislikes vegetables", - embedding=[0.5] * 1536, - session_name=test_session.name, - ) - db_session.add_all([doc1, doc2]) - await db_session.commit() + assert create_response.status_code == 201 # Query conclusions response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions/query", + f"/v3/workspaces/{test_workspace.name}/conclusions/query", json={ "query": "food preferences", "filters": { @@ -436,27 +417,25 @@ class TestConclusionRoutes: db_session.add(test_session) await db_session.commit() - # Create collection - await self._create_collection( - db_session, test_workspace.name, test_peer.name, test_peer2.name + # Create multiple conclusions via API (ensures proper vector store integration) + conclusions = [ + { + "content": f"Conclusion about topic {i}", + "observer_id": test_peer.name, + "observed_id": test_peer2.name, + "session_id": test_session.name, + } + for i in range(5) + ] + create_response = client.post( + f"/v3/workspaces/{test_workspace.name}/conclusions", + json={"conclusions": conclusions}, ) - - # Create multiple conclusions - for i in range(5): - doc = models.Document( - workspace_name=test_workspace.name, - observer=test_peer.name, - observed=test_peer2.name, - content=f"Conclusion about topic {i}", - embedding=[0.1 * i] * 1536, - session_name=test_session.name, - ) - db_session.add(doc) - await db_session.commit() + assert create_response.status_code == 201 # Query with top_k=2 response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions/query", + f"/v3/workspaces/{test_workspace.name}/conclusions/query", json={ "query": "relevant topic", "top_k": 2, @@ -497,26 +476,25 @@ class TestConclusionRoutes: db_session.add(test_session) await db_session.commit() - # Create collection - await self._create_collection( - db_session, test_workspace.name, test_peer.name, test_peer2.name + # Create test conclusion via API (ensures proper vector store integration) + create_response = client.post( + f"/v3/workspaces/{test_workspace.name}/conclusions", + json={ + "conclusions": [ + { + "content": "Test conclusion", + "observer_id": test_peer.name, + "observed_id": test_peer2.name, + "session_id": test_session.name, + } + ] + }, ) - - # Create test conclusion - doc = models.Document( - workspace_name=test_workspace.name, - observer=test_peer.name, - observed=test_peer2.name, - content="Test conclusion", - embedding=[0.5] * 1536, - session_name=test_session.name, - ) - db_session.add(doc) - await db_session.commit() + assert create_response.status_code == 201 # Query with distance threshold response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions/query", + f"/v3/workspaces/{test_workspace.name}/conclusions/query", json={ "query": "test", "distance": 0.8, @@ -551,7 +529,7 @@ class TestConclusionRoutes: # Query without observer/observed filters should fail response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions/query", + f"/v3/workspaces/{test_workspace.name}/conclusions/query", json={"query": "test"}, ) @@ -583,7 +561,7 @@ class TestConclusionRoutes: # Query with invalid top_k (too high) response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions/query", + f"/v3/workspaces/{test_workspace.name}/conclusions/query", json={ "query": "test", "top_k": 101, # Max is 100 @@ -642,7 +620,7 @@ class TestConclusionRoutes: # Delete conclusion response = client.delete( - f"/v2/workspaces/{test_workspace.name}/conclusions/{conclusion_id}" + f"/v3/workspaces/{test_workspace.name}/conclusions/{conclusion_id}" ) assert response.status_code == 204 @@ -675,7 +653,7 @@ class TestConclusionRoutes: # Try to delete non-existent conclusion response = client.delete( - f"/v2/workspaces/{test_workspace.name}/conclusions/nonexistent_id" + f"/v3/workspaces/{test_workspace.name}/conclusions/nonexistent_id" ) assert response.status_code == 404 @@ -693,7 +671,7 @@ class TestConclusionRoutes: # Try to list conclusions for non-existent session response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions/list", + f"/v3/workspaces/{test_workspace.name}/conclusions/list", json={"filters": {"session_id": "nonexistent_session"}}, ) @@ -745,7 +723,7 @@ class TestConclusionRoutes: # List conclusions response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions/list", + f"/v3/workspaces/{test_workspace.name}/conclusions/list", json={"filters": {"session_id": test_session.name}}, ) @@ -792,7 +770,7 @@ class TestConclusionRoutes: # Create conclusion via API response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions", + f"/v3/workspaces/{test_workspace.name}/conclusions", json={ "conclusions": [ { @@ -843,7 +821,7 @@ class TestConclusionRoutes: # Create multiple conclusions via API response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions", + f"/v3/workspaces/{test_workspace.name}/conclusions", json={ "conclusions": [ { @@ -896,7 +874,7 @@ class TestConclusionRoutes: # Try to create conclusion with non-existent session response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions", + f"/v3/workspaces/{test_workspace.name}/conclusions", json={ "conclusions": [ { @@ -930,7 +908,7 @@ class TestConclusionRoutes: # Try to create conclusion with non-existent observer response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions", + f"/v3/workspaces/{test_workspace.name}/conclusions", json={ "conclusions": [ { @@ -971,7 +949,7 @@ class TestConclusionRoutes: # Try to create conclusion with empty content response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions", + f"/v3/workspaces/{test_workspace.name}/conclusions", json={ "conclusions": [ { @@ -997,7 +975,7 @@ class TestConclusionRoutes: # Try to create with empty conclusions list response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions", + f"/v3/workspaces/{test_workspace.name}/conclusions", json={"conclusions": []}, ) @@ -1029,7 +1007,7 @@ class TestConclusionRoutes: # Create conclusion via API (this should auto-create collection) response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions", + f"/v3/workspaces/{test_workspace.name}/conclusions", json={ "conclusions": [ { @@ -1081,7 +1059,7 @@ class TestConclusionRoutes: # Create conclusions with different observer/observed pairs response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions", + f"/v3/workspaces/{test_workspace.name}/conclusions", json={ "conclusions": [ { @@ -1139,7 +1117,7 @@ class TestConclusionRoutes: # Create conclusion via API create_response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions", + f"/v3/workspaces/{test_workspace.name}/conclusions", json={ "conclusions": [ { @@ -1157,7 +1135,7 @@ class TestConclusionRoutes: # List conclusions and verify the created one is there list_response = client.post( - f"/v2/workspaces/{test_workspace.name}/conclusions/list", + f"/v3/workspaces/{test_workspace.name}/conclusions/list", json={ "filters": { "observer": test_peer.name, diff --git a/tests/routes/test_files.py b/tests/routes/test_files.py index 78d919d3..e31e2367 100644 --- a/tests/routes/test_files.py +++ b/tests/routes/test_files.py @@ -28,7 +28,7 @@ async def _create_test_session( def _get_upload_url(workspace_name: str, session_name: str) -> str: """Helper function to get the session upload URL""" - return f"/v2/workspaces/{workspace_name}/sessions/{session_name}/messages/upload" + return f"/v3/workspaces/{workspace_name}/sessions/{session_name}/messages/upload" @pytest.mark.asyncio diff --git a/tests/routes/test_keys.py b/tests/routes/test_keys.py index ad8fa1c8..71e8d475 100644 --- a/tests/routes/test_keys.py +++ b/tests/routes/test_keys.py @@ -4,7 +4,7 @@ from tests.conftest import AuthClient def test_create_key_no_params(auth_client: AuthClient): """Test creating a key with no parameters""" - response = auth_client.post("/v2/keys") + response = auth_client.post("/v3/keys") # Only admin JWT should be allowed if auth_client.auth_type == "admin": @@ -25,14 +25,14 @@ def test_create_key_with_params( # Test with app_id response = auth_client.post( - "/v2/keys", params={"workspace_id": test_workspace.name} + "/v3/keys", params={"workspace_id": test_workspace.name} ) assert response.status_code == 200 assert "key" in response.json() # Test with app_id and user_id response = auth_client.post( - "/v2/keys", + "/v3/keys", params={"workspace_id": test_workspace.name, "peer_id": test_peer.name}, ) assert response.status_code == 200 @@ -40,7 +40,7 @@ def test_create_key_with_params( # Test with session_id and collection_id response = auth_client.post( - "/v2/keys", + "/v3/keys", params={ "workspace_id": test_workspace.name, "peer_id": test_peer.name, @@ -56,7 +56,7 @@ def test_create_key_with_expires_at( auth_client: AuthClient, sample_data: tuple[Workspace, Peer] ): """Test creating a key with an expiration date""" - response = auth_client.post("/v2/keys", params={"expires_at": "2025-01-01"}) + response = auth_client.post("/v3/keys", params={"expires_at": "2025-01-01"}) # Only admin JWT should be allowed if auth_client.auth_type == "admin": @@ -70,6 +70,6 @@ def test_create_key_with_expires_at( # assert that the key is expired response = auth_client.post( - "/v2/keys", params={"workspace_id": test_workspace.name} + "/v3/keys", params={"workspace_id": test_workspace.name} ) assert response.status_code == 401 diff --git a/tests/routes/test_messages.py b/tests/routes/test_messages.py index 1ee9d734..07040e20 100644 --- a/tests/routes/test_messages.py +++ b/tests/routes/test_messages.py @@ -24,7 +24,7 @@ async def test_create_message( await db_session.commit() response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={ "messages": [ { @@ -61,7 +61,7 @@ async def test_create_batch_messages_with_metadata( await db_session.commit() response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={ "messages": [ { @@ -105,7 +105,7 @@ async def test_create_batch_messages_without_metadata( await db_session.commit() response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={ "messages": [ { @@ -137,7 +137,7 @@ async def test_create_batch_messages_with_null_metadata( await db_session.commit() response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={ "messages": [ { @@ -179,7 +179,7 @@ async def test_get_messages( await db_session.commit() response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", json={}, ) assert response.status_code == 200 @@ -227,7 +227,7 @@ async def test_get_messages_with_reverse( # Test normal order response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", json={}, ) assert response.status_code == 200 @@ -235,7 +235,7 @@ async def test_get_messages_with_reverse( # Test reversed order response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list?reverse=true", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list?reverse=true", json={}, ) assert response.status_code == 200 @@ -275,7 +275,7 @@ async def test_get_messages_with_empty_filter( await db_session.commit() response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", json={"filters": {}}, ) assert response.status_code == 200 @@ -309,7 +309,7 @@ async def test_get_messages_with_null_filter( await db_session.commit() response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", json={"filters": None}, ) assert response.status_code == 200 @@ -343,7 +343,7 @@ async def test_get_messages_no_body( await db_session.commit() response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list" + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list" ) assert response.status_code == 200 data = response.json() @@ -385,7 +385,7 @@ async def test_get_filtered_messages( await db_session.commit() response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", json={"filters": {"metadata": {"key": "value2"}}}, ) assert response.status_code == 200 @@ -443,7 +443,7 @@ async def test_get_filtered_messages_with_complex_filter( # Test old-style filter (backward compatibility) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", json={"filters": {"metadata": {"priority": "high", "category": "technical"}}}, ) assert response.status_code == 200 @@ -454,7 +454,7 @@ async def test_get_filtered_messages_with_complex_filter( # Test new-style filter with AND operator response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", json={ "filters": { "AND": [ @@ -471,7 +471,7 @@ async def test_get_filtered_messages_with_complex_filter( # Test OR filter to get high priority OR question type response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", json={ "filters": { "OR": [ @@ -511,7 +511,7 @@ async def test_update_message( await db_session.commit() response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}", json={"metadata": {"new_key": "new_value"}}, ) assert response.status_code == 200 @@ -551,7 +551,7 @@ async def test_update_message_with_complex_metadata( } response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}", json={"metadata": complex_metadata}, ) assert response.status_code == 200 @@ -587,14 +587,14 @@ async def test_update_message_empty_metadata( await db_session.commit() response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}", json={"metadata": None}, ) assert response.status_code == 200 # now ensure that the metadata is not changed response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}" + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}" ) assert response.status_code == 200 data = response.json() @@ -627,7 +627,7 @@ async def test_update_message_with_empty_dict_metadata( await db_session.commit() response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}", json={"metadata": {}}, ) assert response.status_code == 200 @@ -659,7 +659,7 @@ async def test_get_single_message( await db_session.commit() response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}" + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}" ) assert response.status_code == 200 data = response.json() @@ -686,7 +686,7 @@ async def test_get_nonexistent_message( nonexistent_message_id = str(generate_nanoid()) response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{nonexistent_message_id}" + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{nonexistent_message_id}" ) assert response.status_code == 404 @@ -707,7 +707,7 @@ async def test_update_nonexistent_message( nonexistent_message_id = str(generate_nanoid()) response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{nonexistent_message_id}", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{nonexistent_message_id}", json={"metadata": {"key": "value"}}, ) assert response.status_code == 404 @@ -722,7 +722,7 @@ async def test_create_messages_for_nonexistent_session( nonexistent_session_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{nonexistent_session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{nonexistent_session_id}/messages", json={ "messages": [ { @@ -751,7 +751,7 @@ async def test_get_messages_for_nonexistent_session( nonexistent_session_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{nonexistent_session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{nonexistent_session_id}/messages/list", json={}, ) # Should return 200 with empty results (session doesn't exist = no messages) @@ -776,7 +776,7 @@ async def test_create_empty_batch_messages( await db_session.commit() response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={"messages": []}, ) # Should return 422 for validation error (empty list not allowed) @@ -804,7 +804,7 @@ async def test_create_batch_messages_max_limit( ] response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={"messages": messages}, ) assert response.status_code == 201 @@ -833,7 +833,7 @@ async def test_get_messages_handles_crud_value_error( mock_get.side_effect = ValueError("Test CRUD error") response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", json={}, ) @@ -860,7 +860,7 @@ async def test_get_message_handles_not_found( mock_get.return_value = None response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/nonexistent" + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/nonexistent" ) # Should raise ResourceNotFoundException which gets converted to 404 @@ -896,7 +896,7 @@ async def test_update_message_handles_crud_value_error( mock_update.side_effect = ValueError("Test CRUD error") response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}", json={"metadata": {"key": "value"}}, ) @@ -932,7 +932,7 @@ async def test_create_messages_with_file_too_large( form_data = {"peer_id": test_peer.name} response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/upload", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/upload", files=files, data=form_data, ) @@ -961,7 +961,7 @@ async def test_create_message_with_timestamp( ) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={ "messages": [ { @@ -1005,7 +1005,7 @@ async def test_create_message_without_timestamp_uses_default( before_request = datetime.datetime.now(datetime.timezone.utc) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={ "messages": [ { @@ -1057,7 +1057,7 @@ async def test_create_batch_messages_with_mixed_timestamps( before_request = datetime.datetime.now(datetime.timezone.utc) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={ "messages": [ { @@ -1128,7 +1128,7 @@ async def test_create_message_with_null_timestamp( before_request = datetime.datetime.now(datetime.timezone.utc) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={ "messages": [ { diff --git a/tests/routes/test_peers.py b/tests/routes/test_peers.py index 87ac4546..84f23887 100644 --- a/tests/routes/test_peers.py +++ b/tests/routes/test_peers.py @@ -13,7 +13,7 @@ def test_get_or_create_peer(client: TestClient, sample_data: tuple[Workspace, Pe test_workspace, _ = sample_data name = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": name, "metadata": {"peer_key": "peer_value"}}, ) assert response.status_code in [200, 201] @@ -32,7 +32,7 @@ def test_get_or_create_peer_with_configuration( configuration = {"experimental": True, "beta": False} response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": name, "configuration": configuration}, ) assert response.status_code in [200, 201] @@ -51,7 +51,7 @@ def test_get_or_create_peer_with_all_optional_params( configuration = {"feature1": True, "feature2": False} response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": name, "metadata": metadata, "configuration": configuration}, ) assert response.status_code in [200, 201] @@ -69,7 +69,7 @@ def test_get_or_create_existing_peer( # Create the peer response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": name, "metadata": {"peer_key": "peer_value"}}, ) assert response.status_code in [200, 201] @@ -77,7 +77,7 @@ def test_get_or_create_existing_peer( # Try to create the same peer again - should return existing peer response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": name, "metadata": {"peer_key": "peer_value"}}, ) assert response.status_code in [200, 201] @@ -93,21 +93,21 @@ def test_get_peers(client: TestClient, sample_data: tuple[Workspace, Peer]): # Create a few peers with metadata response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": str(generate_nanoid()), "metadata": {"peer_key": "peer_value"}}, ) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": str(generate_nanoid()), "metadata": {"peer_key": "peer_value"}}, ) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": str(generate_nanoid()), "metadata": {"peer_key": "peer_value2"}}, ) # Get all peers response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={}, ) assert response.status_code == 200 @@ -117,7 +117,7 @@ def test_get_peers(client: TestClient, sample_data: tuple[Workspace, Peer]): # Get peers with simple filter (backward compatibility) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"metadata": {"peer_key": "peer_value"}}}, ) assert response.status_code == 200 @@ -128,7 +128,7 @@ def test_get_peers(client: TestClient, sample_data: tuple[Workspace, Peer]): # Test new filter with NOT operator response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"NOT": [{"metadata": {"peer_key": "peer_value2"}}]}}, ) assert response.status_code == 200 @@ -146,7 +146,7 @@ def test_get_peers_with_empty_filter( test_workspace, _ = sample_data response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", json={"filters": {}} + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {}} ) assert response.status_code == 200 data = response.json() @@ -161,7 +161,7 @@ def test_get_peers_with_null_filter( test_workspace, _ = sample_data response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", json={"filters": None} + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": None} ) assert response.status_code == 200 data = response.json() @@ -172,7 +172,7 @@ def test_get_peers_with_null_filter( def test_update_peer(client: TestClient, sample_data: tuple[Workspace, Peer]): test_workspace, test_peer = sample_data response = client.put( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}", json={"metadata": {"new_key": "new_value"}}, ) assert response.status_code == 200 @@ -188,7 +188,7 @@ def test_update_peer_with_configuration( configuration = {"new_feature": True, "legacy_feature": False} response = client.put( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}", json={"configuration": configuration}, ) assert response.status_code == 200 @@ -205,7 +205,7 @@ def test_update_peer_with_all_optional_params( configuration = {"experimental": True, "beta": True} response = client.put( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}", json={"metadata": metadata, "configuration": configuration}, ) assert response.status_code == 200 @@ -222,13 +222,13 @@ def test_update_peer_with_null_metadata( # First set some metadata client.put( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}", json={"metadata": {"temp": "value"}}, ) # Then clear it with null response = client.put( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}", json={"metadata": None}, ) assert response.status_code == 200 @@ -243,7 +243,7 @@ def test_update_peer_with_null_configuration( test_workspace, test_peer = sample_data response = client.put( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}", json={"configuration": None}, ) assert response.status_code == 200 @@ -258,7 +258,7 @@ def test_get_sessions_for_peer_no_sessions( # Get sessions for the peer response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions", json={}, ) assert response.status_code == 200 @@ -272,7 +272,7 @@ def test_get_sessions_for_peer(client: TestClient, sample_data: tuple[Workspace, # Create session for the peer session_name = str(generate_nanoid()) create_response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_name, "peer_names": {test_peer.name: {}}}, ) assert create_response.status_code in [200, 201] @@ -281,7 +281,7 @@ def test_get_sessions_for_peer(client: TestClient, sample_data: tuple[Workspace, # Now get sessions for the peer and validate the session is returned response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions", ) assert response.status_code == 200 data = response.json() @@ -299,7 +299,7 @@ def test_get_sessions_for_peer_with_empty_filter( test_workspace, test_peer = sample_data response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions", json={"filters": {}}, ) assert response.status_code == 200 @@ -317,7 +317,7 @@ def test_chat( # Test chat endpoint response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/chat", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/chat", json={ "query": "Hello, how are you?", "stream": False, @@ -340,13 +340,13 @@ def test_chat_with_optional_params( # Create a session first client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) # Test chat without optional parameters response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/chat", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/chat", json={ "query": "Hello, how are you?", "stream": False, @@ -367,13 +367,13 @@ def test_get_peer_representation_with_session( # Create a session first client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) # Test representation scoped to session response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", json={ "session_id": session_id, }, @@ -392,7 +392,7 @@ def test_get_peer_representation_global( # Test global representation (no session_id) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", json={}, ) assert response.status_code == 200 @@ -410,13 +410,13 @@ def test_get_peer_representation_with_target( # Create a second peer to be the target target_peer_name = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": target_peer_name, "metadata": {}}, ) # Test representation of target from observer's perspective response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", json={ "target": target_peer_name, }, @@ -435,7 +435,7 @@ def test_get_peer_representation_with_search_query( # Test representation with semantic search query response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", json={ "search_query": "What are my interests and hobbies?", }, @@ -454,7 +454,7 @@ def test_get_peer_representation_with_search_top_k( # Test with valid search_top_k values for top_k in [1, 10, 50, 100]: response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", json={ "search_query": "test query", "search_top_k": top_k, @@ -474,7 +474,7 @@ def test_get_peer_representation_with_search_max_distance( # Test with valid search_max_distance values (0.0 to 1.0) for max_distance in [0.0, 0.5, 0.8, 1.0]: response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", json={ "search_query": "test query", "search_max_distance": max_distance, @@ -493,7 +493,7 @@ def test_get_peer_representation_with_include_most_frequent( # Test with include_most_frequent=True response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", json={ "search_query": "test query", "include_most_frequent": True, @@ -505,7 +505,7 @@ def test_get_peer_representation_with_include_most_frequent( # Test with include_most_frequent=False response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", json={ "search_query": "test query", "include_most_frequent": False, @@ -525,7 +525,7 @@ def test_get_peer_representation_with_max_observations( # Test with various max_observations values for max_obs in [1, 25, 50, 100]: response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", json={ "search_query": "test query", "max_observations": max_obs, @@ -546,11 +546,11 @@ def test_get_peer_representation_with_all_parameters( # Create a session and target peer target_peer_name = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": target_peer_name, "metadata": {}}, ) client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, "peer_names": {test_peer.name: {}, target_peer_name: {}}, @@ -559,7 +559,7 @@ def test_get_peer_representation_with_all_parameters( # Test with all parameters response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", json={ "session_id": session_id, "target": target_peer_name, @@ -584,7 +584,7 @@ def test_get_peer_representation_structure( # Get representation and validate structure response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", json={}, ) assert response.status_code == 200 @@ -603,7 +603,7 @@ def test_get_peer_representation_boundary_values( # Test minimum values response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", json={ "search_query": "test", "search_top_k": 1, @@ -615,7 +615,7 @@ def test_get_peer_representation_boundary_values( # Test maximum values response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", json={ "search_query": "test", "search_top_k": 100, @@ -634,7 +634,7 @@ def test_get_peer_representation_default_max_observations( # Test without max_observations - should use default of 25 response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation", json={ "search_query": "test query", }, @@ -650,7 +650,7 @@ def test_search_peer(client: TestClient, sample_data: tuple[Workspace, Peer]): # Add some messages to search through client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions/test_session/messages", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions/test_session/messages", json={ "messages": [ {"content": "Search this content", "peer_id": test_peer.name}, @@ -661,7 +661,7 @@ def test_search_peer(client: TestClient, sample_data: tuple[Workspace, Peer]): # Search with a query response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/search", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/search", json={"query": "search query", "limit": 10}, ) assert response.status_code == 200 @@ -679,7 +679,7 @@ def test_search_peer_empty_query( # Search with empty query response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/search", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/search", json={"query": "", "limit": 10}, ) assert response.status_code == 200 @@ -697,7 +697,7 @@ def test_search_peer_nonexistent( nonexistent_peer_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{nonexistent_peer_id}/search", + f"/v3/workspaces/{test_workspace.name}/peers/{nonexistent_peer_id}/search", json={"query": "test query", "limit": 10}, ) assert response.status_code == 200 @@ -714,7 +714,7 @@ def test_search_peer_with_messages( # Add some messages to search through client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions/test_session/messages", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions/test_session/messages", json={ "messages": [ {"content": "Search this content", "peer_id": test_peer.name}, @@ -725,7 +725,7 @@ def test_search_peer_with_messages( # Search for content response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/search", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/search", json={"query": "search", "limit": 10}, ) assert response.status_code == 200 @@ -743,7 +743,7 @@ def test_search_peer_with_limit( # Add some messages to search through client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions/test_session/messages", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions/test_session/messages", json={ "messages": [ {"content": "Search this content", "peer_id": test_peer.name}, @@ -755,7 +755,7 @@ def test_search_peer_with_limit( # Search with custom limit response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/search", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/search", json={"query": "search", "limit": 2}, ) @@ -775,7 +775,7 @@ def test_get_peers_with_complex_filter( # Create peers with different metadata for i in range(3): client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={ "name": str(generate_nanoid()), "metadata": {"index": i, "type": "test"}, @@ -784,7 +784,7 @@ def test_get_peers_with_complex_filter( # Test complex filter combination response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={ "filters": { "AND": [ @@ -816,7 +816,7 @@ def test_update_peer_all_fields( configuration = {"features": {"new_feature": True}} response = client.put( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}", json={"metadata": metadata, "configuration": configuration}, ) assert response.status_code == 200 @@ -832,14 +832,14 @@ def test_get_peer_card(client: TestClient, sample_data: tuple[Workspace, Peer]): # Create a second peer (the target/observed peer) target_peer_name = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": target_peer_name}, ) assert response.status_code in [200, 201] # Test getting observer's own card (should return null initially) response = client.get( - f"/v2/workspaces/{test_workspace.name}/peers/{observer_peer.name}/card" + f"/v3/workspaces/{test_workspace.name}/peers/{observer_peer.name}/card" ) assert response.status_code == 200 data = response.json() @@ -847,7 +847,7 @@ def test_get_peer_card(client: TestClient, sample_data: tuple[Workspace, Peer]): # Test getting card for target peer (should return null initially) response = client.get( - f"/v2/workspaces/{test_workspace.name}/peers/{observer_peer.name}/card", + f"/v3/workspaces/{test_workspace.name}/peers/{observer_peer.name}/card", params={"target": target_peer_name}, ) assert response.status_code == 200 @@ -867,7 +867,7 @@ async def test_get_peer_card_with_data( # Create a second peer (the target/observed peer) target_peer_name = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": target_peer_name}, ) assert response.status_code in [200, 201] @@ -895,7 +895,7 @@ async def test_get_peer_card_with_data( # Test getting observer's own card response = client.get( - f"/v2/workspaces/{test_workspace.name}/peers/{observer_peer.name}/card" + f"/v3/workspaces/{test_workspace.name}/peers/{observer_peer.name}/card" ) assert response.status_code == 200 data = response.json() @@ -903,7 +903,7 @@ async def test_get_peer_card_with_data( # Test getting card for target peer response = client.get( - f"/v2/workspaces/{test_workspace.name}/peers/{observer_peer.name}/card", + f"/v3/workspaces/{test_workspace.name}/peers/{observer_peer.name}/card", params={"target": target_peer_name}, ) assert response.status_code == 200 diff --git a/tests/routes/test_queue_status.py b/tests/routes/test_queue_status.py index 4ae82197..782aa3c1 100644 --- a/tests/routes/test_queue_status.py +++ b/tests/routes/test_queue_status.py @@ -18,7 +18,7 @@ class TestDeriverStatusEndpoint: """Test getting deriver status filtered by peer only""" workspace, peer = sample_data response = client.get( - f"/v2/workspaces/{workspace.name}/queue/status", + f"/v3/workspaces/{workspace.name}/queue/status", params={"observer_id": peer.name}, ) assert response.status_code == 200 @@ -36,7 +36,7 @@ class TestDeriverStatusEndpoint: db_session.add(session) await db_session.commit() response = client.get( - f"/v2/workspaces/{workspace.name}/queue/status", + f"/v3/workspaces/{workspace.name}/queue/status", params={"session_id": session.name}, ) assert response.status_code == 200 @@ -54,7 +54,7 @@ class TestDeriverStatusEndpoint: db_session.add(session) await db_session.commit() response = client.get( - f"/v2/workspaces/{workspace.name}/queue/status", + f"/v3/workspaces/{workspace.name}/queue/status", params={"observer_id": peer.name, "session_id": session.name}, ) assert response.status_code == 200 @@ -68,7 +68,7 @@ class TestDeriverStatusEndpoint: """Test getting deriver status with include_sender=True""" workspace, peer = sample_data response = client.get( - f"/v2/workspaces/{workspace.name}/queue/status", + f"/v3/workspaces/{workspace.name}/queue/status", params={"observer_id": peer.name, "sender_id": peer.name}, ) assert response.status_code == 200 @@ -82,7 +82,7 @@ class TestDeriverStatusEndpoint: """Test getting deriver status with include_sender=False (default)""" workspace, peer = sample_data response = client.get( - f"/v2/workspaces/{workspace.name}/queue/status", + f"/v3/workspaces/{workspace.name}/queue/status", params={"observer_id": peer.name}, ) assert response.status_code == 200 @@ -93,7 +93,7 @@ class TestDeriverStatusEndpoint: ): """Test getting deriver status without required parameters returns 200""" workspace, _ = sample_data - response = client.get(f"/v2/workspaces/{workspace.name}/queue/status") + response = client.get(f"/v3/workspaces/{workspace.name}/queue/status") assert response.status_code == 200 async def test_get_deriver_status_nonexistent_peer( @@ -102,7 +102,7 @@ class TestDeriverStatusEndpoint: """Test getting deriver status for nonexistent peer returns empty result""" workspace, _ = sample_data response = client.get( - f"/v2/workspaces/{workspace.name}/queue/status", + f"/v3/workspaces/{workspace.name}/queue/status", params={"observer_id": "nonexistent"}, ) assert response.status_code == 200 @@ -117,7 +117,7 @@ class TestDeriverStatusEndpoint: """Test getting deriver status for nonexistent session returns empty result""" workspace, _ = sample_data response = client.get( - f"/v2/workspaces/{workspace.name}/queue/status", + f"/v3/workspaces/{workspace.name}/queue/status", params={"session_id": "nonexistent"}, ) assert response.status_code == 200 @@ -128,7 +128,7 @@ class TestDeriverStatusEndpoint: async def test_get_deriver_status_nonexistent_workspace(self, client: TestClient): """Test getting deriver status for nonexistent workspace returns empty result""" - response = client.get("/v2/workspaces/nonexistent/queue/status") + response = client.get("/v3/workspaces/nonexistent/queue/status") assert response.status_code == 200 assert response.json()["total_work_units"] == 0 @@ -166,13 +166,13 @@ class TestDeriverStatusEndpoint: db_session.add_all(queue_items) await db_session.commit() # Test without parameters - response = client.get(f"/v2/workspaces/{workspace.name}/queue/status") + response = client.get(f"/v3/workspaces/{workspace.name}/queue/status") assert response.status_code == 200 assert response.json()["total_work_units"] == 5 assert response.json()["pending_work_units"] == 5 # Test with observer_id response = client.get( - f"/v2/workspaces/{workspace.name}/queue/status", + f"/v3/workspaces/{workspace.name}/queue/status", params={"observer_id": peer.name}, ) assert response.status_code == 200 @@ -180,7 +180,7 @@ class TestDeriverStatusEndpoint: assert response.json()["pending_work_units"] == 5 # Test with sender_id (new capability) response = client.get( - f"/v2/workspaces/{workspace.name}/queue/status", + f"/v3/workspaces/{workspace.name}/queue/status", params={"sender_id": peer.name}, ) assert response.status_code == 200 @@ -188,7 +188,7 @@ class TestDeriverStatusEndpoint: assert response.json()["pending_work_units"] == 5 # Test with both (OR filter) response = client.get( - f"/v2/workspaces/{workspace.name}/queue/status", + f"/v3/workspaces/{workspace.name}/queue/status", params={"observer_id": peer.name, "sender_id": peer.name}, ) assert response.status_code == 200 @@ -196,7 +196,7 @@ class TestDeriverStatusEndpoint: assert response.json()["pending_work_units"] == 5 # Test with different observer and sender (should be ok) response = client.get( - f"/v2/workspaces/{workspace.name}/queue/status", + f"/v3/workspaces/{workspace.name}/queue/status", params={"observer_id": peer.name, "sender_id": "different"}, ) assert response.status_code == 200 @@ -241,7 +241,7 @@ class TestDeriverStatusEndpoint: db_session.add_all(queue_items) await db_session.commit() response = client.get( - f"/v2/workspaces/{workspace.name}/queue/status", + f"/v3/workspaces/{workspace.name}/queue/status", params={"observer_id": peer.name}, ) assert response.status_code == 200 @@ -262,7 +262,7 @@ class TestDeriverStatusEndpoint: """Test various edge cases with empty or invalid parameters""" workspace, _ = sample_data response = client.get( - f"/v2/workspaces/{workspace.name}/queue/status", + f"/v3/workspaces/{workspace.name}/queue/status", params={ "observer_id": "", "session_id": "", @@ -305,7 +305,7 @@ class TestDeriverStatusEndpoint: responses = [] for _ in range(3): response = client.get( - f"/v2/workspaces/{workspace.name}/queue/status", + f"/v3/workspaces/{workspace.name}/queue/status", params={"observer_id": peer.name}, ) assert response.status_code == 200 diff --git a/tests/routes/test_scoped_api.py b/tests/routes/test_scoped_api.py index d07c9d58..c2d7d2e6 100644 --- a/tests/routes/test_scoped_api.py +++ b/tests/routes/test_scoped_api.py @@ -9,7 +9,7 @@ def test_create_workspace_with_auth(auth_client: AuthClient): name = str(generate_nanoid()) response = auth_client.post( - "/v2/workspaces", json={"name": name, "metadata": {"key": "value"}} + "/v3/workspaces", json={"name": name, "metadata": {"key": "value"}} ) # Check expected behavior based on auth type @@ -28,7 +28,7 @@ def test_auth_response_time(auth_client: AuthClient): start_time = time.time() response = auth_client.post( - "/v2/workspaces", json={"name": name, "metadata": {"key": "value"}} + "/v3/workspaces", json={"name": name, "metadata": {"key": "value"}} ) end_time = time.time() @@ -49,7 +49,7 @@ def test_get_or_create_workspace_with_auth(auth_client: AuthClient): name = str(generate_nanoid()) response = auth_client.post( - "/v2/workspaces", json={"name": name, "metadata": {"key": "value"}} + "/v3/workspaces", json={"name": name, "metadata": {"key": "value"}} ) if auth_client.auth_type != "admin": @@ -70,7 +70,7 @@ def test_get_workspace_with_auth( f"Bearer {create_jwt(JWTParams(w=test_workspace.name))}" ) - response = auth_client.post("/v2/workspaces", json={"name": test_workspace.name}) + response = auth_client.post("/v3/workspaces", json={"name": test_workspace.name}) # Admin JWT or JWT with matching workspace should be allowed if auth_client.auth_type in ["admin", "empty"]: @@ -92,7 +92,7 @@ def test_update_workspace_with_auth( new_name = str(generate_nanoid()) response = auth_client.put( - f"/v2/workspaces/{test_workspace.name}", + f"/v3/workspaces/{test_workspace.name}", json={"name": new_name, "metadata": {"new_key": "new_value"}}, ) @@ -118,7 +118,7 @@ def test_update_workspace_with_wrong_auth( new_name = str(generate_nanoid()) response = auth_client.put( - f"/v2/workspaces/{test_workspace.name}", + f"/v3/workspaces/{test_workspace.name}", json={"name": new_name, "metadata": {"new_key": "new_value"}}, ) @@ -143,7 +143,7 @@ def test_create_peer_with_auth( name = str(generate_nanoid()) response = auth_client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": name, "metadata": {"peer_key": "peer_value"}}, ) @@ -167,7 +167,7 @@ def test_get_peer_by_name_with_auth( # Use POST /list endpoint to get peers response = auth_client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"id": test_peer.name}}, ) @@ -185,7 +185,7 @@ def test_get_peer_by_name_with_auth( # Get specific peer using get_or_create endpoint response = auth_client.post( - f"/v2/workspaces/{test_workspace.name}/peers", json={"name": test_peer.name} + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": test_peer.name} ) assert response.status_code in [200, 201] @@ -204,7 +204,7 @@ def test_update_peer_with_auth( new_name = str(generate_nanoid()) response = auth_client.put( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}", json={"name": new_name, "metadata": {"updated_key": "updated_value"}}, ) @@ -221,7 +221,7 @@ def test_update_peer_with_auth( ) response = auth_client.put( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}", json={ "name": str(generate_nanoid()), "metadata": {"peer_key": "peer_value"}, @@ -244,7 +244,7 @@ def test_create_session_with_auth( session_name = str(generate_nanoid()) response = auth_client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"name": session_name, "peer_names": {test_peer.name: {}}}, ) @@ -262,7 +262,7 @@ def test_create_session_with_auth( session_name2 = str(generate_nanoid()) response = auth_client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"name": session_name2, "peer_names": {test_peer.name: {}}}, ) @@ -282,7 +282,7 @@ def test_get_session_by_name_with_auth( session_name = str(generate_nanoid()) create_response = auth_client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"name": session_name, "peer_names": {test_peer.name: {}}}, ) @@ -294,7 +294,7 @@ def test_get_session_by_name_with_auth( # Test with workspace scoped JWT - get the same session response = auth_client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", json={"name": session_name} + f"/v3/workspaces/{test_workspace.name}/sessions", json={"name": session_name} ) assert response.status_code in [200, 201] @@ -305,7 +305,7 @@ def test_get_session_by_name_with_auth( ) response = auth_client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"name": session_name}, ) assert response.status_code in [200, 201] @@ -313,7 +313,7 @@ def test_get_session_by_name_with_auth( # Test with wrong session_name (should be 401 since we have a session-scoped JWT) assert ( auth_client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"name": generate_nanoid()}, ).status_code == 401 @@ -325,7 +325,7 @@ def test_get_session_by_name_with_auth( ) assert auth_client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"name": session_name}, ).status_code in [200, 201] @@ -335,7 +335,7 @@ def test_get_session_by_name_with_auth( ) assert auth_client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"name": session_name}, ).status_code in [200, 201] @@ -343,7 +343,7 @@ def test_get_session_by_name_with_auth( wrong_session_name = generate_nanoid() assert ( auth_client.delete( - f"/v2/workspaces/{test_workspace.name}/sessions/{wrong_session_name}" + f"/v3/workspaces/{test_workspace.name}/sessions/{wrong_session_name}" ).status_code == 404 ) diff --git a/tests/routes/test_sessions.py b/tests/routes/test_sessions.py index 9804c0f9..fe8379f8 100644 --- a/tests/routes/test_sessions.py +++ b/tests/routes/test_sessions.py @@ -12,7 +12,7 @@ def test_get_or_create_session(client: TestClient, sample_data: tuple[Workspace, # Test creating a new session with no parameters response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": str(generate_nanoid())}, ) assert response.status_code in [200, 201] @@ -24,7 +24,7 @@ def test_get_or_create_session(client: TestClient, sample_data: tuple[Workspace, # Test creating a session with a specific id and peer_names (should get or create) session_id = str(generate_nanoid()) response2 = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) assert response2.status_code in [200, 201] @@ -35,7 +35,7 @@ def test_get_or_create_session(client: TestClient, sample_data: tuple[Workspace, # Test getting the same session again (should return the same session) response3 = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) assert response3.status_code in [200, 201] @@ -51,7 +51,7 @@ def test_create_session_with_metadata( test_workspace, test_peer = sample_data session_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, "peer_names": {test_peer.name: {}}, @@ -75,7 +75,7 @@ def test_create_session_with_configuration( configuration = {"experimental_feature": True, "beta_mode": False} response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, "peer_names": {test_peer.name: {}}, @@ -99,7 +99,7 @@ def test_create_session_with_all_optional_params( configuration = {"feature1": True, "feature2": False} response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, "peer_names": {test_peer.name: {}}, @@ -126,7 +126,7 @@ def test_create_session_with_too_many_peers( for _ in range(10): peer_name = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer_name, "metadata": {}}, ) assert response.status_code in [200, 201] @@ -134,7 +134,7 @@ def test_create_session_with_too_many_peers( # Test 1: Create session with 11 non-observers should succeed response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": str(generate_nanoid()), "peer_names": {peer_name: {} for peer_name in peer_names}, @@ -144,7 +144,7 @@ def test_create_session_with_too_many_peers( # Test 2: Try to create session with 11 observers (exceeds limit) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": str(generate_nanoid()), "peer_names": { @@ -157,7 +157,7 @@ def test_create_session_with_too_many_peers( assert "Maximum allowed is 10 observers" in response.json()["detail"] session_response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/list", + f"/v3/workspaces/{test_workspace.name}/sessions/list", json={"filters": {"id": "test_session"}}, ) assert session_response.status_code == 200 @@ -167,7 +167,7 @@ def test_create_session_with_too_many_peers( peer_names.pop() # Attempt to create session with same name response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": "test_session", "peer_names": {peer_name: {} for peer_name in peer_names}, @@ -184,7 +184,7 @@ def test_get_sessions(client: TestClient, sample_data: tuple[Workspace, Peer]): # Create a test session session_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, "peer_names": {test_peer.name: {}}, @@ -197,7 +197,7 @@ def test_get_sessions(client: TestClient, sample_data: tuple[Workspace, Peer]): assert "id" in data assert data["workspace_id"] == test_workspace.name response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/list", + f"/v3/workspaces/{test_workspace.name}/sessions/list", json={"filters": {"metadata": {"test_key": "test_value"}}}, ) assert response.status_code == 200 @@ -215,7 +215,7 @@ def test_get_sessions_with_empty_filter( test_workspace, _ = sample_data response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/list", json={"filters": {}} + f"/v3/workspaces/{test_workspace.name}/sessions/list", json={"filters": {}} ) assert response.status_code == 200 data = response.json() @@ -230,7 +230,7 @@ def test_update_delete_metadata( # Create a test session session_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, "peer_names": {test_peer.name: {}}, @@ -240,7 +240,7 @@ def test_update_delete_metadata( assert response.status_code in [200, 201] response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}", json={"metadata": {}}, ) assert response.status_code == 200 @@ -253,7 +253,7 @@ def test_update_session(client: TestClient, sample_data: tuple[Workspace, Peer]) # Create a test session session_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, "peer_names": {test_peer.name: {}}, @@ -262,7 +262,7 @@ def test_update_session(client: TestClient, sample_data: tuple[Workspace, Peer]) assert response.status_code in [200, 201] response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}", json={"metadata": {"new_key": "new_value"}}, ) assert response.status_code == 200 @@ -276,7 +276,7 @@ def test_delete_session(client: TestClient, sample_data: tuple[Workspace, Peer]) # Create a test session session_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, "peer_names": {test_peer.name: {}}, @@ -287,7 +287,7 @@ def test_delete_session(client: TestClient, sample_data: tuple[Workspace, Peer]) # Delete the session response = client.delete( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}", ) assert response.status_code == 202 data = response.json() @@ -295,7 +295,7 @@ def test_delete_session(client: TestClient, sample_data: tuple[Workspace, Peer]) # Verify the session is deleted by trying to list it response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/list", + f"/v3/workspaces/{test_workspace.name}/sessions/list", json={"filters": {"id": session_id}}, ) assert response.status_code == 200 @@ -312,14 +312,14 @@ def test_update_session_with_configuration( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) # Update with configuration configuration = {"new_feature": True, "legacy_feature": False} response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}", json={"metadata": {}, "configuration": configuration}, ) assert response.status_code == 200 @@ -336,7 +336,7 @@ def test_update_session_with_all_optional_params( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) @@ -344,7 +344,7 @@ def test_update_session_with_all_optional_params( metadata = {"updated_key": "updated_value"} configuration = {"experimental": True, "beta": True} response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}", json={"metadata": metadata, "configuration": configuration}, ) assert response.status_code == 200 @@ -362,7 +362,7 @@ def test_update_session_with_null_configuration( # Create session with configuration client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, "peer_names": {test_peer.name: {}}, @@ -372,7 +372,7 @@ def test_update_session_with_null_configuration( # Update with null configuration response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}", json={"metadata": {}, "configuration": None}, ) assert response.status_code == 200 @@ -385,7 +385,7 @@ def test_clone_session(client: TestClient, sample_data: tuple[Workspace, Peer]): # Create a test session session_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, "peer_names": {test_peer.name: {}}, @@ -396,7 +396,7 @@ def test_clone_session(client: TestClient, sample_data: tuple[Workspace, Peer]): # Create some messages in the session response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ { @@ -415,7 +415,7 @@ def test_clone_session(client: TestClient, sample_data: tuple[Workspace, Peer]): assert response.status_code == 201 response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/clone", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/clone", ) assert response.status_code == 201 data = response.json() @@ -423,7 +423,7 @@ def test_clone_session(client: TestClient, sample_data: tuple[Workspace, Peer]): # Check messages were cloned response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{data['id']}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{data['id']}/messages/list", json={}, ) @@ -448,13 +448,13 @@ def test_clone_session_with_cutoff( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) # Create messages response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ {"content": "Message 1", "peer_id": test_peer.name}, @@ -469,7 +469,7 @@ def test_clone_session_with_cutoff( # Clone with cutoff at first message response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/clone?message_id={first_message_id}", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/clone?message_id={first_message_id}", ) assert response.status_code == 201 data = response.json() @@ -481,7 +481,7 @@ def test_add_peers_to_session(client: TestClient, sample_data: tuple[Workspace, # Create another peer peer2_name = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer2_name, "metadata": {}}, ) assert response.status_code in [200, 201] @@ -489,7 +489,7 @@ def test_add_peers_to_session(client: TestClient, sample_data: tuple[Workspace, # Create a test session session_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, "peer_names": {test_peer.name: {}}, @@ -499,7 +499,7 @@ def test_add_peers_to_session(client: TestClient, sample_data: tuple[Workspace, # Add another peer to the session response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/peers", json={peer2_name: {}}, ) assert response.status_code == 200 @@ -510,7 +510,7 @@ def test_get_session_peers(client: TestClient, sample_data: tuple[Workspace, Pee # Create another peer peer2_name = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer2_name, "metadata": {}}, ) assert response.status_code in [200, 201] @@ -518,7 +518,7 @@ def test_get_session_peers(client: TestClient, sample_data: tuple[Workspace, Pee # Create a test session with multiple peers session_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, "peer_names": {test_peer.name: {}, peer2_name: {}}, @@ -528,7 +528,7 @@ def test_get_session_peers(client: TestClient, sample_data: tuple[Workspace, Pee # Get peers from the session response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/peers", ) assert response.status_code == 200 data = response.json() @@ -544,7 +544,7 @@ def test_set_session_peers(client: TestClient, sample_data: tuple[Workspace, Pee # Create another peer peer2_name = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer2_name, "metadata": {}}, ) assert response.status_code in [200, 201] @@ -552,7 +552,7 @@ def test_set_session_peers(client: TestClient, sample_data: tuple[Workspace, Pee # Create a test session session_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, "peer_names": {test_peer.name: {}}, @@ -562,14 +562,14 @@ def test_set_session_peers(client: TestClient, sample_data: tuple[Workspace, Pee # Set peers for the session (should replace existing peers) response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/peers", json={peer2_name: {}}, ) assert response.status_code == 200 # Check that only the new peer is in the session response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/peers", ) assert response.status_code == 200 data = response.json() @@ -588,7 +588,7 @@ def test_set_session_peers_with_observer_limit( # Create a test session session_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, }, @@ -600,7 +600,7 @@ def test_set_session_peers_with_observer_limit( for _ in range(14): peer_name = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer_name, "metadata": {}}, ) assert response.status_code in [200, 201] @@ -611,7 +611,7 @@ def test_set_session_peers_with_observer_limit( peer_name: {"observe_others": False} for peer_name in peer_names } response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/peers", json=peers_dict_no_observers, ) assert response.status_code == 200 # Should succeed since no observers @@ -621,7 +621,7 @@ def test_set_session_peers_with_observer_limit( peer_name: {"observe_others": True} for peer_name in peer_names[:11] } response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/peers", json=peers_dict_all_observers, ) assert response.status_code == 400 # ObserverException @@ -633,7 +633,7 @@ def test_set_session_peers_with_observer_limit( peer_name: {"observe_others": True} for peer_name in peer_names[:10] } response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/peers", json=peers_dict_ten_observers, ) assert response.status_code == 200 # Should succeed with exactly 10 observers @@ -649,7 +649,7 @@ def test_update_peer_config_observer_limit( # Create a test session session_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, }, @@ -661,7 +661,7 @@ def test_update_peer_config_observer_limit( for i in range(10): peer_name = f"observer_{i}" response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer_name, "metadata": {}}, ) assert response.status_code in [200, 201] @@ -675,14 +675,14 @@ def test_update_peer_config_observer_limit( peers_dict_observers[test_peer.name] = {"observe_others": False} response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/peers", json=peers_dict_observers, ) assert response.status_code == 200 # Should succeed with exactly 10 observers # Now try to update test_peer to become an observer (would exceed limit) response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers/{test_peer.name}/config", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/peers/{test_peer.name}/config", json={"observe_others": True}, ) assert response.status_code == 400 # ObserverException @@ -691,21 +691,21 @@ def test_update_peer_config_observer_limit( # Verify that updating a peer that's already an observer still works response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers/{peer_names[0]}/config", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/peers/{peer_names[0]}/config", json={"observe_others": True, "observe_me": False}, # Still an observer ) assert response.status_code == 204 # Should succeed since count doesn't change # Change one observer to non-observer response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers/{peer_names[0]}/config", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/peers/{peer_names[0]}/config", json={"observe_others": False}, ) assert response.status_code == 204 # Now test_peer can become an observer (9 + 1 = 10) response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers/{test_peer.name}/config", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/peers/{test_peer.name}/config", json={"observe_others": True}, ) assert response.status_code == 204 # Should succeed now @@ -718,7 +718,7 @@ def test_remove_peers_from_session( # Create another peer peer2_name = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer2_name, "metadata": {}}, ) assert response.status_code in [200, 201] @@ -726,7 +726,7 @@ def test_remove_peers_from_session( # Create a test session with multiple peers session_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, "peer_names": {test_peer.name: {}, peer2_name: {}}, @@ -737,14 +737,14 @@ def test_remove_peers_from_session( # Remove one peer from the session response = client.request( "DELETE", - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/peers", json=[test_peer.name], ) assert response.status_code == 200 # Check that only the remaining peer is in the session response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/peers", ) assert response.status_code == 200 data = response.json() @@ -760,13 +760,13 @@ def test_get_session_context(client: TestClient, sample_data: tuple[Workspace, P # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}}}, ) # Add some messages to have context client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ {"content": "Test message 1", "peer_id": test_peer.name}, @@ -777,7 +777,7 @@ def test_get_session_context(client: TestClient, sample_data: tuple[Workspace, P # Get context response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context", ) assert response.status_code == 200 data = response.json() @@ -799,13 +799,13 @@ def test_get_session_context_with_summary( # Create session with messages client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}}}, ) # Get context with summary response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context?summary=true", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context?summary=true", ) assert response.status_code == 200 data = response.json() @@ -821,13 +821,13 @@ def test_get_session_context_with_tokens( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}}}, ) # Get context with token limit response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context?tokens=100", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context?tokens=100", ) assert response.status_code == 200 data = response.json() @@ -844,13 +844,13 @@ def test_get_session_context_with_all_params( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}}}, ) # Get context with all parameters response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context?tokens=100&summary=true", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context?tokens=100&summary=true", ) assert response.status_code == 200 data = response.json() @@ -867,13 +867,13 @@ def test_get_session_summaries( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}}}, ) # Get summaries response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/summaries", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/summaries", ) assert response.status_code == 200 data = response.json() @@ -898,7 +898,7 @@ def test_get_session_summaries_nonexistent_session( # Try to get summaries for non-existent session # Should still return 200 with null summaries response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{nonexistent_session_id}/summaries", + f"/v3/workspaces/{test_workspace.name}/sessions/{nonexistent_session_id}/summaries", ) assert response.status_code == 200 data = response.json() @@ -914,13 +914,13 @@ def test_search_session(client: TestClient, sample_data: tuple[Workspace, Peer]) # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}}}, ) # Add messages to search through client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ {"content": "Search this content", "peer_id": test_peer.name}, @@ -931,7 +931,7 @@ def test_search_session(client: TestClient, sample_data: tuple[Workspace, Peer]) # Search with a query response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/search", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/search", json={"query": "search query", "limit": 10}, ) assert response.status_code == 200 @@ -950,13 +950,13 @@ def test_search_session_empty_query( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) # Search with empty query response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/search", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/search", json={"query": "", "limit": 10}, ) assert response.status_code == 200 @@ -974,7 +974,7 @@ def test_search_session_nonexistent( nonexistent_session_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{nonexistent_session_id}/search", + f"/v3/workspaces/{test_workspace.name}/sessions/{nonexistent_session_id}/search", json={"query": "test query", "limit": 10}, ) assert response.status_code == 200 @@ -992,13 +992,13 @@ def test_search_session_with_messages( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}}}, ) # Add messages to search through client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ {"content": "Search this content", "peer_id": test_peer.name}, @@ -1009,7 +1009,7 @@ def test_search_session_with_messages( # Search for content response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/search", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/search", json={"query": "search", "limit": 10}, ) assert response.status_code == 200 @@ -1028,13 +1028,13 @@ def test_search_session_with_limit( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}}}, ) # Add messages to search through client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ {"content": "Search this content", "peer_id": test_peer.name}, @@ -1046,7 +1046,7 @@ def test_search_session_with_limit( # Search with custom limit response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/search", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/search", json={"query": "search", "limit": 2}, ) @@ -1066,13 +1066,13 @@ def test_get_session_context_with_peer_target( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}}}, ) # Add some messages client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ {"content": "Test message 1", "peer_id": test_peer.name}, @@ -1082,7 +1082,7 @@ def test_get_session_context_with_peer_target( # Get context with peer_target response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context?peer_target={test_peer.name}", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context?peer_target={test_peer.name}", ) assert response.status_code == 200 data = response.json() @@ -1105,20 +1105,20 @@ def test_get_session_context_with_peer_perspective( # Create another peer peer2_name = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer2_name, "metadata": {}}, ) assert response.status_code in [200, 201] # Create session with both peers client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}, peer2_name: {}}}, ) # Get context with peer_perspective response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context?peer_target={test_peer.name}&peer_perspective={peer2_name}", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context?peer_target={test_peer.name}&peer_perspective={peer2_name}", ) assert response.status_code == 200 data = response.json() @@ -1135,13 +1135,13 @@ def test_get_session_context_peer_perspective_without_target_fails( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}}}, ) # Try to get context with peer_perspective but no peer_target (should fail) response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context?peer_perspective={test_peer.name}", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context?peer_perspective={test_peer.name}", ) # FastAPI returns 422 for validation errors, or 400 if it's a custom ValidationException assert response.status_code in [400, 422] @@ -1158,13 +1158,13 @@ def test_get_session_context_with_last_message( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}}}, ) # Get context with last_message and peer_target response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context", params={ "peer_target": test_peer.name, "last_message": "What is my favorite color?", @@ -1184,13 +1184,13 @@ def test_get_session_context_with_limit_to_session( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}}}, ) # Get context with limit_to_session=true response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context", params={ "peer_target": test_peer.name, "last_message": "Test query", @@ -1211,13 +1211,13 @@ def test_get_session_context_with_search_parameters( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}}}, ) # Get context with search parameters response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context", params={ "peer_target": test_peer.name, "last_message": "Test query", @@ -1239,13 +1239,13 @@ def test_get_session_context_with_include_most_frequent( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}}}, ) # Get context with include_most_frequent response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context", params={ "peer_target": test_peer.name, "last_message": "Test query", @@ -1266,13 +1266,13 @@ def test_get_session_context_with_max_observations( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}}}, ) # Get context with max_observations response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context", params={ "peer_target": test_peer.name, "last_message": "Test query", @@ -1294,20 +1294,20 @@ def test_get_session_context_with_all_representation_params( # Create another peer peer2_name = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer2_name, "metadata": {}}, ) assert response.status_code in [200, 201] # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}, peer2_name: {}}}, ) # Get context with all representation parameters response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context", params={ "tokens": 500, "peer_target": test_peer.name, @@ -1343,13 +1343,13 @@ def test_get_session_context_response_structure( # Create session client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peers": {test_peer.name: {}}}, ) # Add messages response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ {"content": "Message 1", "peer_id": test_peer.name}, @@ -1361,7 +1361,7 @@ def test_get_session_context_response_structure( # Get context and validate response structure response = client.get( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context", ) assert response.status_code == 200 data = response.json() diff --git a/tests/routes/test_validation_api.py b/tests/routes/test_validation_api.py index 8de2bdee..38bc115f 100644 --- a/tests/routes/test_validation_api.py +++ b/tests/routes/test_validation_api.py @@ -10,7 +10,7 @@ from src.models import Peer, Workspace def test_workspace_validations_api(client: TestClient): # Test name too short - response = client.post("/v2/workspaces", json={"name": "", "metadata": {}}) + response = client.post("/v3/workspaces", json={"name": "", "metadata": {}}) assert response.status_code == 422 error = response.json()["detail"][0] assert error["loc"] == ["body", "name"] @@ -18,7 +18,7 @@ def test_workspace_validations_api(client: TestClient): assert error["type"] == "string_too_short" # Test name too long - response = client.post("/v2/workspaces", json={"name": "a" * 101, "metadata": {}}) + response = client.post("/v3/workspaces", json={"name": "a" * 101, "metadata": {}}) assert response.status_code == 422 error = response.json()["detail"][0] assert error["loc"] == ["body", "name"] @@ -27,7 +27,7 @@ def test_workspace_validations_api(client: TestClient): # Test invalid metadata type response = client.post( - "/v2/workspaces", json={"name": "test", "metadata": "not a dict"} + "/v3/workspaces", json={"name": "test", "metadata": "not a dict"} ) assert response.status_code == 422 error = response.json()["detail"][0] @@ -40,7 +40,7 @@ def test_peer_validations_api(client: TestClient, sample_data: tuple[Workspace, # Test name too short response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", json={"name": "", "metadata": {}} + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": "", "metadata": {}} ) assert response.status_code == 422 error = response.json()["detail"][0] @@ -50,7 +50,7 @@ def test_peer_validations_api(client: TestClient, sample_data: tuple[Workspace, # Test name too long response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": "a" * 101, "metadata": {}}, ) assert response.status_code == 422 @@ -67,14 +67,14 @@ def test_message_validations_api( # Create a test session first session_id = str(generate_nanoid()) session_response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) assert session_response.status_code == 201 # Test content too long response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ { @@ -102,7 +102,7 @@ def test_session_validations_api( # Create a test session first session_id = str(generate_nanoid()) session_response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, "peer_names": {test_peer.name: {}}, @@ -114,7 +114,7 @@ def test_session_validations_api( # Test invalid metadata type response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}", json={"metadata": "not a dict"}, ) assert response.status_code == 422 @@ -125,14 +125,14 @@ def test_session_validations_api( # Test empty update # This should work but not change the session's metadata or configuration response = client.put( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}", json={}, ) assert response.status_code == 200 # Test that the session's metadata and configuration are not changed response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, }, @@ -156,14 +156,14 @@ def test_agent_query_validations_api( # Create a session first since agent query are session-based session_id = str(generate_nanoid()) session_response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) assert session_response.status_code == 201 # Test valid string query (under 10000 chars) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/chat", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/chat", params={"session_id": session_id, "target": "test_target"}, json={"query": "a" * 9999, "stream": False}, ) @@ -171,7 +171,7 @@ def test_agent_query_validations_api( # Test string query too long (over 10000 chars) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/chat", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/chat", params={"session_id": session_id, "target": "test_target"}, json={"query": "a" * 10001, "stream": False}, ) @@ -183,7 +183,7 @@ def test_agent_query_validations_api( # Test that strings over 20 chars are allowed response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/chat", + f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/chat", params={"session_id": session_id, "target": "test_target"}, json={"query": "a" * 100, "stream": False}, # 100 chars should be fine ) @@ -196,14 +196,14 @@ def test_required_field_validations_api( test_workspace, test_peer = sample_data session_id = str(generate_nanoid()) session_response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) assert session_response.status_code == 201 # Test missing required content in message response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={"messages": [{"peer_id": test_peer.name, "metadata": {}}]}, ) assert response.status_code == 422 @@ -213,7 +213,7 @@ def test_required_field_validations_api( # Test missing required peer_id in message response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={"messages": [{"content": "test", "metadata": {}}]}, ) assert response.status_code == 422 @@ -229,14 +229,14 @@ def test_filter_validations_api( # Create a session first session_id = str(generate_nanoid()) session_response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) assert session_response.status_code == 201 # Test invalid filter type in message list (at session level) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": "not a dict"}, ) assert response.status_code == 422 diff --git a/tests/routes/test_webhooks.py b/tests/routes/test_webhooks.py index 49e4e1cd..ef5765a2 100644 --- a/tests/routes/test_webhooks.py +++ b/tests/routes/test_webhooks.py @@ -13,7 +13,7 @@ async def test_create_webhook_endpoint( ): test_workspace, _ = sample_data response = client.post( - f"/v2/workspaces/{test_workspace.name}/webhooks", + f"/v3/workspaces/{test_workspace.name}/webhooks", json={ "url": "http://example.com/webhook", }, @@ -32,7 +32,7 @@ async def test_create_webhook_endpoint_invalid_url( ): test_workspace, _ = sample_data response = client.post( - f"/v2/workspaces/{test_workspace.name}/webhooks", + f"/v3/workspaces/{test_workspace.name}/webhooks", json={ "url": "192.168.1.1/webhook", }, @@ -46,7 +46,7 @@ async def test_create_webhook_endpoint_invalid_url( @pytest.mark.asyncio async def test_create_webhook_endpoint_missing_workspace(client: TestClient): response = client.post( - "/v2/workspaces/nonexistent-workspace/webhooks", + "/v3/workspaces/nonexistent-workspace/webhooks", json={ "url": "http://example.com/webhook", }, @@ -61,12 +61,12 @@ async def test_list_webhook_endpoints_with_data( ): test_workspace, _ = sample_data - list_response = client.get(f"/v2/workspaces/{test_workspace.name}/webhooks") + list_response = client.get(f"/v3/workspaces/{test_workspace.name}/webhooks") initial_count = len(list_response.json()["items"]) # Create first endpoint response1 = client.post( - f"/v2/workspaces/{test_workspace.name}/webhooks", + f"/v3/workspaces/{test_workspace.name}/webhooks", json={ "url": "http://example1.com/webhook", }, @@ -75,7 +75,7 @@ async def test_list_webhook_endpoints_with_data( # Create second endpoint response2 = client.post( - f"/v2/workspaces/{test_workspace.name}/webhooks", + f"/v3/workspaces/{test_workspace.name}/webhooks", json={ "url": "http://example2.com/webhook", }, @@ -83,7 +83,7 @@ async def test_list_webhook_endpoints_with_data( assert response2.status_code in [200, 201] # List endpoints - list_response = client.get(f"/v2/workspaces/{test_workspace.name}/webhooks") + list_response = client.get(f"/v3/workspaces/{test_workspace.name}/webhooks") assert list_response.status_code == 200 response_data = list_response.json() endpoints = response_data["items"] @@ -103,7 +103,7 @@ async def test_delete_webhook_endpoint( # Create webhook endpoint create_response = client.post( - f"/v2/workspaces/{test_workspace.name}/webhooks", + f"/v3/workspaces/{test_workspace.name}/webhooks", json={ "url": "http://example.com/webhook", }, @@ -114,12 +114,12 @@ async def test_delete_webhook_endpoint( # Delete webhook endpoint delete_response = client.delete( - f"/v2/workspaces/{test_workspace.name}/webhooks/{endpoint_id}" + f"/v3/workspaces/{test_workspace.name}/webhooks/{endpoint_id}" ) assert delete_response.status_code == 204 # Verify endpoint is deleted - list_response = client.get(f"/v2/workspaces/{test_workspace.name}/webhooks") + list_response = client.get(f"/v3/workspaces/{test_workspace.name}/webhooks") assert list_response.status_code == 200 response_data = list_response.json() assert response_data["items"] == [] @@ -131,7 +131,7 @@ async def test_delete_webhook_endpoint_not_found( ): test_workspace, _ = sample_data response = client.delete( - f"/v2/workspaces/{test_workspace.name}/webhooks/nonexistent-id" + f"/v3/workspaces/{test_workspace.name}/webhooks/nonexistent-id" ) assert response.status_code == 404 assert "not found" in response.json()["detail"] @@ -151,20 +151,20 @@ async def test_multiple_endpoints_per_workspace( "http://app3.com/webhook", ] - initial_response = client.get(f"/v2/workspaces/{test_workspace.name}/webhooks") + initial_response = client.get(f"/v3/workspaces/{test_workspace.name}/webhooks") initial_count = len(initial_response.json()["items"]) created_endpoints: list[Any] = [] for url in urls: response = client.post( - f"/v2/workspaces/{test_workspace.name}/webhooks", + f"/v3/workspaces/{test_workspace.name}/webhooks", json={"url": url}, ) assert response.status_code in [200, 201] created_endpoints.append(response.json()) # List all endpoints - list_response = client.get(f"/v2/workspaces/{test_workspace.name}/webhooks") + list_response = client.get(f"/v3/workspaces/{test_workspace.name}/webhooks") assert list_response.status_code == 200 response_data = list_response.json() endpoints = response_data["items"] @@ -177,12 +177,12 @@ async def test_multiple_endpoints_per_workspace( # Delete one endpoint delete_response = client.delete( - f"/v2/workspaces/{test_workspace.name}/webhooks/{created_endpoints[0]['id']}" + f"/v3/workspaces/{test_workspace.name}/webhooks/{created_endpoints[0]['id']}" ) assert delete_response.status_code == 204 # Verify only 2 endpoints remain - list_response = client.get(f"/v2/workspaces/{test_workspace.name}/webhooks") + list_response = client.get(f"/v3/workspaces/{test_workspace.name}/webhooks") assert list_response.status_code == 200 response_data = list_response.json() endpoints = response_data["items"] @@ -198,21 +198,21 @@ async def test_create_duplicate_webhook_endpoint( # Create the endpoint first response1 = client.post( - f"/v2/workspaces/{test_workspace.name}/webhooks", + f"/v3/workspaces/{test_workspace.name}/webhooks", json={"url": url}, ) assert response1.status_code in [200, 201] # Try to create it again response2 = client.post( - f"/v2/workspaces/{test_workspace.name}/webhooks", + f"/v3/workspaces/{test_workspace.name}/webhooks", json={"url": url}, ) assert response2.status_code in [200, 201] assert response1.json() == response2.json() # Verify only one endpoint exists - list_response = client.get(f"/v2/workspaces/{test_workspace.name}/webhooks") + list_response = client.get(f"/v3/workspaces/{test_workspace.name}/webhooks") assert list_response.status_code == 200 response_data = list_response.json() endpoints = response_data["items"] @@ -230,7 +230,7 @@ async def test_max_webhook_endpoints_per_workspace( # Create endpoints up to the limit for i in range(limit): response = client.post( - f"/v2/workspaces/{test_workspace.name}/webhooks", + f"/v3/workspaces/{test_workspace.name}/webhooks", json={ "url": f"http://example{i}.com/webhook", }, @@ -239,7 +239,7 @@ async def test_max_webhook_endpoints_per_workspace( # Try to create one more response = client.post( - f"/v2/workspaces/{test_workspace.name}/webhooks", + f"/v3/workspaces/{test_workspace.name}/webhooks", json={ "url": "http://extra.com/webhook", }, @@ -252,7 +252,7 @@ async def test_same_endpoint_in_different_workspaces( client: TestClient, sample_data: tuple[Workspace, Peer] ): ws1, _ = sample_data - ws2_response = client.post("/v2/workspaces", json={"name": "workspace-2"}) + ws2_response = client.post("/v3/workspaces", json={"name": "workspace-2"}) assert ws2_response.status_code in [200, 201] ws2 = ws2_response.json() @@ -260,14 +260,14 @@ async def test_same_endpoint_in_different_workspaces( # Create endpoint in workspace 1 response1 = client.post( - f"/v2/workspaces/{ws1.name}/webhooks", + f"/v3/workspaces/{ws1.name}/webhooks", json={"url": url}, ) assert response1.status_code in [200, 201] # Create same endpoint in workspace 2 response2 = client.post( - f"/v2/workspaces/{ws2['id']}/webhooks", + f"/v3/workspaces/{ws2['id']}/webhooks", json={"url": url}, ) assert response2.status_code in [200, 201] diff --git a/tests/routes/test_workspaces.py b/tests/routes/test_workspaces.py index d1ac27df..a7fd98bd 100644 --- a/tests/routes/test_workspaces.py +++ b/tests/routes/test_workspaces.py @@ -10,8 +10,8 @@ from src.models import Peer, Workspace def test_get_or_create_workspace(client: TestClient): name = str(generate_nanoid()) - # This should create the workspace using POST /v2/workspaces - response = client.post("/v2/workspaces", json={"name": name}) + # This should create the workspace using POST /v3/workspaces + response = client.post("/v3/workspaces", json={"name": name}) assert response.status_code in [200, 201] data = response.json() assert data["id"] == name @@ -24,7 +24,7 @@ def test_get_or_create_workspace_with_configuration(client: TestClient): configuration = {"feature1": True, "feature2": False} response = client.post( - "/v2/workspaces", json={"name": name, "configuration": configuration} + "/v3/workspaces", json={"name": name, "configuration": configuration} ) assert response.status_code in [200, 201] data = response.json() @@ -39,7 +39,7 @@ def test_get_or_create_workspace_with_all_optional_params(client: TestClient): configuration = {"experimental": True, "beta": False} response = client.post( - "/v2/workspaces", + "/v3/workspaces", json={"name": name, "metadata": metadata, "configuration": configuration}, ) assert response.status_code in [200, 201] @@ -54,14 +54,14 @@ def test_get_or_create_existing_workspace(client: TestClient): # Create the workspace response = client.post( - "/v2/workspaces", json={"name": name, "metadata": {"key": "value"}} + "/v3/workspaces", json={"name": name, "metadata": {"key": "value"}} ) assert response.status_code in [200, 201] workspace1 = response.json() # Try to create the same workspace again - should return existing workspace response = client.post( - "/v2/workspaces", json={"name": name, "metadata": {"key": "value"}} + "/v3/workspaces", json={"name": name, "metadata": {"key": "value"}} ) assert response.status_code in [200, 201] workspace2 = response.json() @@ -75,7 +75,7 @@ def test_get_or_create_existing_workspace(client: TestClient): async def test_get_all_workspaces(client: TestClient): # create a test workspace with metadata response = client.post( - "/v2/workspaces", + "/v3/workspaces", json={ "name": "test_workspace", "metadata": {"test_key": "test_value"}, @@ -83,7 +83,7 @@ async def test_get_all_workspaces(client: TestClient): ) response = client.post( - "/v2/workspaces/list", + "/v3/workspaces/list", json={}, ) assert response.status_code == 200 @@ -92,7 +92,7 @@ async def test_get_all_workspaces(client: TestClient): assert len(data["items"]) > 0 response = client.post( - "/v2/workspaces/list", + "/v3/workspaces/list", json={"filters": {"metadata": {"test_key": "test_value"}}}, ) assert response.status_code == 200 @@ -105,7 +105,7 @@ async def test_get_all_workspaces(client: TestClient): @pytest.mark.asyncio async def test_get_all_workspaces_with_empty_filter(client: TestClient): """Test workspace listing with empty filter object""" - response = client.post("/v2/workspaces/list", json={"filters": {}}) + response = client.post("/v3/workspaces/list", json={"filters": {}}) assert response.status_code == 200 data = response.json() assert "items" in data @@ -115,7 +115,7 @@ async def test_get_all_workspaces_with_empty_filter(client: TestClient): @pytest.mark.asyncio async def test_get_all_workspaces_with_null_filter(client: TestClient): """Test workspace listing with null filter""" - response = client.post("/v2/workspaces/list", json={"filters": None}) + response = client.post("/v3/workspaces/list", json={"filters": None}) assert response.status_code == 200 data = response.json() assert "items" in data @@ -126,7 +126,7 @@ def test_update_workspace(client: TestClient, sample_data: tuple[Workspace, Peer test_workspace, _ = sample_data _new_name = str(generate_nanoid()) response = client.put( - f"/v2/workspaces/{test_workspace.name}", + f"/v3/workspaces/{test_workspace.name}", json={"metadata": {"new_key": "new_value"}}, ) assert response.status_code == 200 @@ -142,7 +142,7 @@ def test_update_workspace_with_configuration( configuration = {"new_feature": True, "legacy_feature": False} response = client.put( - f"/v2/workspaces/{test_workspace.name}", json={"configuration": configuration} + f"/v3/workspaces/{test_workspace.name}", json={"configuration": configuration} ) assert response.status_code == 200 data = response.json() @@ -158,7 +158,7 @@ def test_update_workspace_with_all_optional_params( configuration = {"experimental": True, "beta": True} response = client.put( - f"/v2/workspaces/{test_workspace.name}", + f"/v3/workspaces/{test_workspace.name}", json={"metadata": metadata, "configuration": configuration}, ) assert response.status_code == 200 @@ -175,12 +175,12 @@ def test_update_workspace_with_null_metadata( # First set some metadata client.put( - f"/v2/workspaces/{test_workspace.name}", json={"metadata": {"temp": "value"}} + f"/v3/workspaces/{test_workspace.name}", json={"metadata": {"temp": "value"}} ) # Then clear it with null response = client.put( - f"/v2/workspaces/{test_workspace.name}", json={"metadata": None} + f"/v3/workspaces/{test_workspace.name}", json={"metadata": None} ) assert response.status_code == 200 data = response.json() @@ -196,7 +196,7 @@ def test_update_workspace_with_null_configuration( test_workspace, _ = sample_data response = client.put( - f"/v2/workspaces/{test_workspace.name}", json={"configuration": None} + f"/v3/workspaces/{test_workspace.name}", json={"configuration": None} ) assert response.status_code == 200 data = response.json() @@ -206,11 +206,11 @@ def test_update_workspace_with_null_configuration( def test_create_duplicate_workspace_name(client: TestClient): # Create an workspace name = str(generate_nanoid()) - response = client.post("/v2/workspaces", json={"name": name}) + response = client.post("/v3/workspaces", json={"name": name}) assert response.status_code in [200, 201] # Try to create another workspace with the same name - should return existing workspace - response = client.post("/v2/workspaces", json={"name": name}) + response = client.post("/v3/workspaces", json={"name": name}) # Should return the existing workspace with 200 status (get_or_create behavior) assert response.status_code in [200, 201] @@ -224,7 +224,7 @@ def test_search_workspace(client: TestClient, sample_data: tuple[Workspace, Peer # Test search with a query response = client.post( - f"/v2/workspaces/{test_workspace.name}/search", + f"/v3/workspaces/{test_workspace.name}/search", json={"query": "test search query", "limit": 10}, ) assert response.status_code == 200 @@ -242,7 +242,7 @@ def test_search_workspace_empty_query( # Test search with empty query response = client.post( - f"/v2/workspaces/{test_workspace.name}/search", json={"query": "", "limit": 10} + f"/v3/workspaces/{test_workspace.name}/search", json={"query": "", "limit": 10} ) assert response.status_code == 200 data = response.json() @@ -256,7 +256,7 @@ def test_search_workspace_nonexistent(client: TestClient): nonexistent_workspace_id = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{nonexistent_workspace_id}/search", + f"/v3/workspaces/{nonexistent_workspace_id}/search", json={"query": "test query", "limit": 10}, ) assert response.status_code == 200 @@ -271,18 +271,18 @@ def test_delete_workspace(client: TestClient): name = str(generate_nanoid()) # Create a workspace - response = client.post("/v2/workspaces", json={"name": name}) + response = client.post("/v3/workspaces", json={"name": name}) assert response.status_code in [200, 201] workspace = response.json() assert workspace["id"] == name # Delete the workspace - response = client.delete(f"/v2/workspaces/{name}") + response = client.delete(f"/v3/workspaces/{name}") assert response.status_code == 204 # Verify the workspace no longer exists by trying to update it response = client.put( - f"/v2/workspaces/{name}", json={"metadata": {"test": "value"}} + f"/v3/workspaces/{name}", json={"metadata": {"test": "value"}} ) # Should create a new workspace since the old one was deleted assert response.status_code == 200 @@ -292,7 +292,7 @@ def test_delete_nonexistent_workspace(client: TestClient): """Test deleting a workspace that doesn't exist""" nonexistent_workspace_id = str(generate_nanoid()) - response = client.delete(f"/v2/workspaces/{nonexistent_workspace_id}") + response = client.delete(f"/v3/workspaces/{nonexistent_workspace_id}") assert response.status_code == 404 data = response.json() assert "detail" in data @@ -304,23 +304,23 @@ def test_delete_workspace_with_peers(client: TestClient): workspace_name = str(generate_nanoid()) # Create workspace - response = client.post("/v2/workspaces", json={"name": workspace_name}) + response = client.post("/v3/workspaces", json={"name": workspace_name}) assert response.status_code in [200, 201] # Create peers peer1_name = str(generate_nanoid()) peer2_name = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{workspace_name}/peers", json={"name": peer1_name} + f"/v3/workspaces/{workspace_name}/peers", json={"name": peer1_name} ) assert response.status_code in [200, 201] response = client.post( - f"/v2/workspaces/{workspace_name}/peers", json={"name": peer2_name} + f"/v3/workspaces/{workspace_name}/peers", json={"name": peer2_name} ) assert response.status_code in [200, 201] # Delete workspace - response = client.delete(f"/v2/workspaces/{workspace_name}") + response = client.delete(f"/v3/workspaces/{workspace_name}") assert response.status_code == 204 @@ -329,13 +329,13 @@ def test_delete_workspace_with_sessions(client: TestClient): workspace_name = str(generate_nanoid()) # Create workspace - response = client.post("/v2/workspaces", json={"name": workspace_name}) + response = client.post("/v3/workspaces", json={"name": workspace_name}) assert response.status_code in [200, 201] # Create peer peer_name = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{workspace_name}/peers", json={"name": peer_name} + f"/v3/workspaces/{workspace_name}/peers", json={"name": peer_name} ) assert response.status_code in [200, 201] @@ -343,16 +343,16 @@ def test_delete_workspace_with_sessions(client: TestClient): session1_name = str(generate_nanoid()) session2_name = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{workspace_name}/sessions", json={"name": session1_name} + f"/v3/workspaces/{workspace_name}/sessions", json={"name": session1_name} ) assert response.status_code in [200, 201] response = client.post( - f"/v2/workspaces/{workspace_name}/sessions", json={"name": session2_name} + f"/v3/workspaces/{workspace_name}/sessions", json={"name": session2_name} ) assert response.status_code in [200, 201] # Delete workspace - response = client.delete(f"/v2/workspaces/{workspace_name}") + response = client.delete(f"/v3/workspaces/{workspace_name}") assert response.status_code == 204 @@ -361,33 +361,33 @@ def test_delete_workspace_with_messages(client: TestClient): workspace_name = str(generate_nanoid()) # Create workspace - response = client.post("/v2/workspaces", json={"name": workspace_name}) + response = client.post("/v3/workspaces", json={"name": workspace_name}) assert response.status_code in [200, 201] # Create peer peer_name = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{workspace_name}/peers", json={"name": peer_name} + f"/v3/workspaces/{workspace_name}/peers", json={"name": peer_name} ) assert response.status_code in [200, 201] # Create session session_name = str(generate_nanoid()) response = client.post( - f"/v2/workspaces/{workspace_name}/sessions", json={"name": session_name} + f"/v3/workspaces/{workspace_name}/sessions", json={"name": session_name} ) assert response.status_code in [200, 201] # Add peer to session response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/{session_name}/peers", + f"/v3/workspaces/{workspace_name}/sessions/{session_name}/peers", json={peer_name: {}}, ) assert response.status_code == 200 # Create messages response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/{session_name}/messages", + f"/v3/workspaces/{workspace_name}/sessions/{session_name}/messages", json={ "messages": [ {"content": "Test message 1", "peer_id": peer_name}, @@ -398,7 +398,7 @@ def test_delete_workspace_with_messages(client: TestClient): assert response.status_code == 201 # Delete workspace - response = client.delete(f"/v2/workspaces/{workspace_name}") + response = client.delete(f"/v3/workspaces/{workspace_name}") assert response.status_code == 204 @@ -407,12 +407,12 @@ def test_delete_workspace_with_webhooks(client: TestClient): workspace_name = str(generate_nanoid()) # Create workspace - response = client.post("/v2/workspaces", json={"name": workspace_name}) + response = client.post("/v3/workspaces", json={"name": workspace_name}) assert response.status_code in [200, 201] # Create webhook response = client.post( - f"/v2/workspaces/{workspace_name}/webhooks", + f"/v3/workspaces/{workspace_name}/webhooks", json={ "url": "https://example.com/webhook", }, @@ -420,11 +420,11 @@ def test_delete_workspace_with_webhooks(client: TestClient): assert response.status_code in [200, 201] # Delete workspace - response = client.delete(f"/v2/workspaces/{workspace_name}") + response = client.delete(f"/v3/workspaces/{workspace_name}") assert response.status_code == 204 # Verify webhook is deleted by checking workspace doesn't exist - response = client.get(f"/v2/workspaces/{workspace_name}/webhooks") + response = client.get(f"/v3/workspaces/{workspace_name}/webhooks") # This should either return 404 or empty list depending on implementation assert response.status_code in [404, 200] @@ -435,7 +435,7 @@ def test_delete_workspace_cascade(client: TestClient): # Create workspace with complex structure response = client.post( - "/v2/workspaces", + "/v3/workspaces", json={"name": workspace_name, "metadata": {"test": "cascade"}}, ) assert response.status_code in [200, 201] @@ -444,7 +444,7 @@ def test_delete_workspace_cascade(client: TestClient): peer_names = [str(generate_nanoid()) for _ in range(3)] for peer_name in peer_names: response = client.post( - f"/v2/workspaces/{workspace_name}/peers", json={"name": peer_name} + f"/v3/workspaces/{workspace_name}/peers", json={"name": peer_name} ) assert response.status_code in [200, 201] @@ -452,7 +452,7 @@ def test_delete_workspace_cascade(client: TestClient): session_names = [str(generate_nanoid()) for _ in range(2)] for session_name in session_names: response = client.post( - f"/v2/workspaces/{workspace_name}/sessions", json={"name": session_name} + f"/v3/workspaces/{workspace_name}/sessions", json={"name": session_name} ) assert response.status_code in [200, 201] @@ -460,14 +460,14 @@ def test_delete_workspace_cascade(client: TestClient): for session_name in session_names: for peer_name in peer_names[:2]: # Add 2 peers to each session response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/{session_name}/peers", + f"/v3/workspaces/{workspace_name}/sessions/{session_name}/peers", json={peer_name: {}}, ) assert response.status_code == 200 # Create messages in session response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/{session_name}/messages", + f"/v3/workspaces/{workspace_name}/sessions/{session_name}/messages", json={ "messages": [ { @@ -480,7 +480,7 @@ def test_delete_workspace_cascade(client: TestClient): assert response.status_code == 201 # Delete the workspace - response = client.delete(f"/v2/workspaces/{workspace_name}") + response = client.delete(f"/v3/workspaces/{workspace_name}") assert response.status_code == 204 @@ -492,11 +492,11 @@ def test_delete_workspace_returns_no_content(client: TestClient): # Create workspace with metadata and configuration response = client.post( - "/v2/workspaces", + "/v3/workspaces", json={"name": name, "metadata": metadata, "configuration": configuration}, ) assert response.status_code in [200, 201] # Delete workspace - response = client.delete(f"/v2/workspaces/{name}") + response = client.delete(f"/v3/workspaces/{name}") assert response.status_code == 204 diff --git a/tests/sdk/conftest.py b/tests/sdk/conftest.py index 20ed7aba..6afbcac0 100644 --- a/tests/sdk/conftest.py +++ b/tests/sdk/conftest.py @@ -13,7 +13,6 @@ sys.path.insert(0, str(sdk_src_path)) # This is a bit of a hack to make the main conftest discoverable sys.path.insert(0, str(Path(__file__).parent.parent)) -from sdks.python.src.honcho.async_client.client import AsyncHoncho # noqa: E402 from sdks.python.src.honcho.client import Honcho # noqa: E402 @@ -21,6 +20,7 @@ from sdks.python.src.honcho.client import Honcho # noqa: E402 def honcho_sync_test_client(client: TestClient) -> Honcho: """ Returns a Honcho SDK client configured to talk to the test API. + Uses sync operations directly. """ http_client = httpx.Client( transport=client._transport, # pyright: ignore @@ -29,44 +29,64 @@ def honcho_sync_test_client(client: TestClient) -> Honcho: ) honcho_client = Honcho( - workspace_id="sdk-test-workspace-sync", http_client=http_client + workspace_id="sdk-test-workspace-sync", + base_url=str(client.base_url), + http_client=http_client, ) return honcho_client @pytest_asyncio.fixture -async def honcho_async_test_client( - client: TestClient, -) -> AsyncHoncho: +async def honcho_async_test_client(client: TestClient): """ - Returns an async Honcho SDK client configured to talk to the test API. + Returns a Honcho SDK client configured to talk to the test API. + Uses .aio accessor for async operations. """ - async_http_client = httpx.AsyncClient( - transport=httpx.ASGITransport(app=client.app), - base_url=str(client.base_url), - headers=client.headers, - ) - + # For sync workspace creation http_client = httpx.Client( transport=client._transport, # pyright: ignore base_url=str(client.base_url), headers=client.headers, ) - honcho_client = AsyncHoncho( + honcho_client = Honcho( workspace_id="sdk-test-workspace-async", - async_http_client=async_http_client, + base_url=str(client.base_url), http_client=http_client, ) - return honcho_client + # Warm the app via the TestClient transport before using ASGITransport. + # This avoids running the same ASGI app concurrently across two event loops, + # without mutating the Honcho client's local metadata/config caches. + res = client.post("/v3/workspaces", json={"id": honcho_client.workspace_id}) + assert res.status_code in (200, 201) + + # Set up async HTTP client manually for the ASGI transport + from sdks.python.src.honcho.http import AsyncHonchoHTTPClient + + async_httpx_client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=client.app), + base_url=str(client.base_url), + headers=client.headers, + ) + async_http = AsyncHonchoHTTPClient( + base_url=str(client.base_url), + api_key=None, + http_client=async_httpx_client, + ) + honcho_client._async_http = async_http # pyright: ignore + + try: + yield honcho_client + finally: + await async_httpx_client.aclose() @pytest.fixture(params=["sync", "async"]) def client_fixture( request: pytest.FixtureRequest, honcho_sync_test_client: Honcho, - honcho_async_test_client: AsyncHoncho, -) -> tuple[Honcho | AsyncHoncho, str]: + honcho_async_test_client: Honcho, +) -> tuple[Honcho, str]: if request.param == "sync": return honcho_sync_test_client, "sync" return honcho_async_test_client, "async" diff --git a/tests/sdk/sdk_integration_test.py b/tests/sdk/sdk_integration_test.py index 7a839ffe..f5a14fe8 100644 --- a/tests/sdk/sdk_integration_test.py +++ b/tests/sdk/sdk_integration_test.py @@ -34,7 +34,7 @@ def test_peer_operations(honcho_test_client: Honcho): """ Tests creation and metadata operations for peers. """ - peers_page = honcho_test_client.get_peers() + peers_page = honcho_test_client.peers() assert len(list(peers_page)) == 0 peer = honcho_test_client.peer(id="test-peer-1") @@ -44,7 +44,7 @@ def test_peer_operations(honcho_test_client: Honcho): metadata = peer.get_metadata() assert metadata == {} - peers_page = honcho_test_client.get_peers() + peers_page = honcho_test_client.peers() assert len(list(peers_page)) == 1 peer.set_metadata({"foo": "bar"}) @@ -56,7 +56,7 @@ def test_session_operations(honcho_test_client: Honcho): """ Tests creation, peer management, and metadata for sessions. """ - sessions_page = honcho_test_client.get_sessions() + sessions_page = honcho_test_client.sessions() assert len(list(sessions_page)) == 0 session = honcho_test_client.session(id="test-session-1") @@ -66,7 +66,7 @@ def test_session_operations(honcho_test_client: Honcho): metadata = session.get_metadata() assert metadata == {} - sessions_page = honcho_test_client.get_sessions() + sessions_page = honcho_test_client.sessions() assert len(list(sessions_page)) == 1 session.set_metadata({"bar": "baz"}) @@ -80,7 +80,7 @@ def test_session_operations(honcho_test_client: Honcho): [assistant, (user, SessionPeerConfig(observe_others=False, observe_me=False))] ) - session_peers = session.get_peers() + session_peers = session.peers() assert len(session_peers) == 2 @@ -99,7 +99,7 @@ def test_message_and_chat_operations(honcho_test_client: Honcho): ] ) - messages = session.get_messages() + messages = session.messages() assert len(list(messages)) == 2 # This is a mock response from the agent diff --git a/tests/sdk/test_client.py b/tests/sdk/test_client.py index 1b247d9f..897ef09d 100644 --- a/tests/sdk/test_client.py +++ b/tests/sdk/test_client.py @@ -1,42 +1,36 @@ -from unittest.mock import patch - import pytest from fastapi.testclient import TestClient -from honcho_core.types.workspaces import QueueStatusResponse -from honcho_core.types.workspaces.sessions.message import Message -from sdks.python.src.honcho.async_client.client import AsyncHoncho -from sdks.python.src.honcho.async_client.pagination import AsyncPage -from sdks.python.src.honcho.async_client.peer import AsyncPeer -from sdks.python.src.honcho.async_client.session import AsyncSession +from sdks.python.src.honcho.api_types import QueueStatusResponse from sdks.python.src.honcho.client import Honcho -from sdks.python.src.honcho.pagination import SyncPage +from sdks.python.src.honcho.message import Message +from sdks.python.src.honcho.pagination import AsyncPage, SyncPage from sdks.python.src.honcho.peer import Peer from sdks.python.src.honcho.session import Session @pytest.mark.asyncio -async def test_client_init( - client_fixture: tuple[Honcho | AsyncHoncho, str], client: TestClient -): +async def test_client_init(client_fixture: tuple[Honcho, str], client: TestClient): """ - Tests that the Honcho SDK clients can be initialized and that they create a workspace. + Tests that the Honcho SDK clients can be initialized and that a workspace + is created on first use. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) assert honcho_client.workspace_id == "sdk-test-workspace-async" + # Use the sync client to avoid mixing ASGI transports in this test. + honcho_client.get_metadata() else: - assert isinstance(honcho_client, Honcho) assert honcho_client.workspace_id == "sdk-test-workspace-sync" + honcho_client.get_metadata() # Check all pages to find the workspace found_workspace = False page = 1 while not found_workspace: - res = client.post("/v2/workspaces/list", json={}, params={"page": page}) + res = client.post("/v3/workspaces/list", json={}, params={"page": page}) assert res.status_code == 200 data = res.json() @@ -59,21 +53,19 @@ async def test_client_init( @pytest.mark.asyncio -async def test_workspace_metadata(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_workspace_metadata(client_fixture: tuple[Honcho, str]): """ Tests getting and setting metadata on a workspace. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - metadata = await honcho_client.get_metadata() + metadata = await honcho_client.aio.get_metadata() assert metadata == {} - await honcho_client.set_metadata({"foo": "bar"}) - metadata = await honcho_client.get_metadata() + await honcho_client.aio.set_metadata({"foo": "bar"}) + metadata = await honcho_client.aio.get_metadata() assert metadata == {"foo": "bar"} else: - assert isinstance(honcho_client, Honcho) metadata = honcho_client.get_metadata() assert metadata == {} honcho_client.set_metadata({"foo": "bar"}) @@ -82,26 +74,28 @@ async def test_workspace_metadata(client_fixture: tuple[Honcho | AsyncHoncho, st @pytest.mark.asyncio -async def test_get_workspaces(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_workspaces(client_fixture: tuple[Honcho, str]): """ Tests listing available workspaces. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - workspaces = await honcho_client.get_workspaces() + workspaces = await honcho_client.aio.workspaces() else: - assert isinstance(honcho_client, Honcho) - workspaces = honcho_client.get_workspaces() + workspaces = honcho_client.workspaces() - assert isinstance(workspaces, list) - assert honcho_client.workspace_id in workspaces + # workspaces returns a paginated Page of workspace ID strings + assert hasattr(workspaces, "items") + assert isinstance(workspaces.items, list) + # Each item should be a string (workspace ID) + for ws_id in workspaces.items: + assert isinstance(ws_id, str) @pytest.mark.asyncio async def test_client_list_peers_and_sessions( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests listing peers and sessions at the client level. @@ -109,35 +103,33 @@ async def test_client_list_peers_and_sessions( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peers_page = await honcho_client.get_peers() + peers_page = await honcho_client.aio.peers() assert isinstance(peers_page, AsyncPage) assert len(peers_page.items) == 0 - sessions_page = await honcho_client.get_sessions() + sessions_page = await honcho_client.aio.sessions() assert isinstance(sessions_page, AsyncPage) assert len(sessions_page.items) == 0 - peer = await honcho_client.peer(id="test-peer-client") - assert isinstance(peer, AsyncPeer) - await peer.get_metadata() # Creates the peer + peer = await honcho_client.aio.peer(id="test-peer-client") + assert isinstance(peer, Peer) + await peer.aio.get_metadata() # Creates the peer - peers_page = await honcho_client.get_peers() + peers_page = await honcho_client.aio.peers() assert len(peers_page.items) == 1 - session = await honcho_client.session(id="test-session-client") - assert isinstance(session, AsyncSession) - await session.get_metadata() # Creates the session + session = await honcho_client.aio.session(id="test-session-client") + assert isinstance(session, Session) + await session.aio.get_metadata() # Creates the session - sessions_page = await honcho_client.get_sessions() + sessions_page = await honcho_client.aio.sessions() assert len(sessions_page.items) == 1 else: - assert isinstance(honcho_client, Honcho) - peers_page = honcho_client.get_peers() + peers_page = honcho_client.peers() assert isinstance(peers_page, SyncPage) assert len(list(peers_page)) == 0 - sessions_page = honcho_client.get_sessions() + sessions_page = honcho_client.sessions() assert isinstance(sessions_page, SyncPage) assert len(list(sessions_page)) == 0 @@ -145,19 +137,19 @@ async def test_client_list_peers_and_sessions( assert isinstance(peer, Peer) peer.get_metadata() - peers_page = honcho_client.get_peers() + peers_page = honcho_client.peers() assert len(list(peers_page)) == 1 session = honcho_client.session(id="test-session-client") assert isinstance(session, Session) session.get_metadata() - sessions_page = honcho_client.get_sessions() + sessions_page = honcho_client.sessions() assert len(list(sessions_page)) == 1 @pytest.mark.asyncio -async def test_workspace_search(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_workspace_search(client_fixture: tuple[Honcho, str]): """ Tests searching for messages within a workspace. """ @@ -165,19 +157,17 @@ async def test_workspace_search(client_fixture: tuple[Honcho | AsyncHoncho, str] search_query = "a unique message for workspace search" if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="search-session-ws") - assert isinstance(session, AsyncSession) - user = await honcho_client.peer(id="search-user-ws") - assert isinstance(user, AsyncPeer) - await session.add_messages([user.message(search_query)]) + session = await honcho_client.aio.session(id="search-session-ws") + assert isinstance(session, Session) + user = await honcho_client.aio.peer(id="search-user-ws") + assert isinstance(user, Peer) + await session.aio.add_messages([user.message(search_query)]) - search_results = await honcho_client.search(search_query) + search_results = await honcho_client.aio.search(search_query) assert isinstance(search_results, list) assert len(search_results) >= 1 assert search_query in search_results[0].content else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="search-session-ws") assert isinstance(session, Session) user = honcho_client.peer(id="search-user-ws") @@ -191,16 +181,15 @@ async def test_workspace_search(client_fixture: tuple[Honcho | AsyncHoncho, str] @pytest.mark.asyncio -async def test_get_deriver_status(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_get_queue_status(client_fixture: tuple[Honcho, str]): """ - Tests getting deriver status with various parameter combinations. + Tests getting queue status with various parameter combinations. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) # Test with no parameters - this should work in the SDK even though API requires at least one - status = await honcho_client.get_queue_status() + status = await honcho_client.aio.queue_status() assert isinstance(status, QueueStatusResponse) assert hasattr(status, "total_work_units") assert hasattr(status, "completed_work_units") @@ -208,30 +197,29 @@ async def test_get_deriver_status(client_fixture: tuple[Honcho | AsyncHoncho, st assert hasattr(status, "pending_work_units") # Test with peer_id only - peer = await honcho_client.peer(id="test-peer-deriver-status") - await peer.get_metadata() # Create the peer - status = await honcho_client.get_queue_status(observer=peer.id) + peer = await honcho_client.aio.peer(id="test-peer-queue-status") + await peer.aio.get_metadata() # Create the peer + status = await honcho_client.aio.queue_status(observer=peer.id) assert isinstance(status, QueueStatusResponse) # Test with session_id only - session = await honcho_client.session(id="test-session-deriver-status") - await session.get_metadata() # Create the session - status = await honcho_client.get_queue_status(session=session.id) + session = await honcho_client.aio.session(id="test-session-queue-status") + await session.aio.get_metadata() # Create the session + status = await honcho_client.aio.queue_status(session=session.id) assert isinstance(status, QueueStatusResponse) # Test with both peer and session - status = await honcho_client.get_queue_status( + status = await honcho_client.aio.queue_status( observer=peer.id, session=session.id ) assert isinstance(status, QueueStatusResponse) # Test with sender - status = await honcho_client.get_queue_status(observer=peer.id, sender=peer.id) + status = await honcho_client.aio.queue_status(observer=peer.id, sender=peer.id) assert isinstance(status, QueueStatusResponse) else: - assert isinstance(honcho_client, Honcho) # Test with no parameters - status = honcho_client.get_queue_status() + status = honcho_client.queue_status() assert isinstance(status, QueueStatusResponse) assert hasattr(status, "total_work_units") assert hasattr(status, "completed_work_units") @@ -241,81 +229,27 @@ async def test_get_deriver_status(client_fixture: tuple[Honcho | AsyncHoncho, st # Test with peer_id only peer = honcho_client.peer(id="test-peer-queue-status") peer.get_metadata() # Create the peer - status = honcho_client.get_queue_status(observer=peer.id) + status = honcho_client.queue_status(observer=peer.id) assert isinstance(status, QueueStatusResponse) # Test with session_id only session = honcho_client.session(id="test-session-queue-status") session.get_metadata() # Create the session - status = honcho_client.get_queue_status(session=session.id) + status = honcho_client.queue_status(session=session.id) assert isinstance(status, QueueStatusResponse) # Test with both peer and session - status = honcho_client.get_queue_status(observer=peer.id, session=session.id) + status = honcho_client.queue_status(observer=peer.id, session=session.id) assert isinstance(status, QueueStatusResponse) # Test with sender - status = honcho_client.get_queue_status(observer=peer.id, sender=peer.id) + status = honcho_client.queue_status(observer=peer.id, sender=peer.id) assert isinstance(status, QueueStatusResponse) -@pytest.mark.asyncio -async def test_poll_queue_status(client_fixture: tuple[Honcho | AsyncHoncho, str]): - """ - Tests polling queue status until completion. - """ - honcho_client, client_type = client_fixture - - # Mock the get_queue_status method to return a "completed" status - # to avoid infinite polling in tests - completed_status = QueueStatusResponse( - total_work_units=0, - completed_work_units=0, - in_progress_work_units=0, - pending_work_units=0, - ) - - if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - with patch.object( - honcho_client, "get_queue_status", return_value=completed_status - ): - status = await honcho_client.poll_queue_status() - assert isinstance(status, QueueStatusResponse) - assert status.pending_work_units == 0 - assert status.in_progress_work_units == 0 - - # Test with parameters - peer = await honcho_client.peer(id="test-peer-poll-status") - with patch.object( - honcho_client, "get_queue_status", return_value=completed_status - ): - status = await honcho_client.poll_queue_status( - observer=peer.id, sender=peer.id - ) - assert isinstance(status, QueueStatusResponse) - else: - assert isinstance(honcho_client, Honcho) - with patch.object( - honcho_client, "get_queue_status", return_value=completed_status - ): - status = honcho_client.poll_queue_status() - assert isinstance(status, QueueStatusResponse) - assert status.pending_work_units == 0 - assert status.in_progress_work_units == 0 - - # Test with parameters - peer = honcho_client.peer(id="test-peer-poll-status") - with patch.object( - honcho_client, "get_queue_status", return_value=completed_status - ): - status = honcho_client.poll_queue_status(observer=peer.id, sender=peer.id) - assert isinstance(status, QueueStatusResponse) - - @pytest.mark.asyncio async def test_update_message_with_message_object( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests updating message metadata using a Message object. @@ -323,36 +257,34 @@ async def test_update_message_with_message_object( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="test-update-msg-session") - peer = await honcho_client.peer(id="test-update-msg-peer") + session = await honcho_client.aio.session(id="test-update-msg-session") + peer = await honcho_client.aio.peer(id="test-update-msg-peer") # Create a message - await session.add_messages([peer.message("test message")]) - messages = await session.get_messages() + await session.aio.add_messages([peer.message("test message")]) + messages = await session.aio.messages() assert len(messages) >= 1 message = messages[0] assert isinstance(message, Message) # Update using Message object - updated = await honcho_client.update_message(message, {"key": "value"}) + updated = await session.aio.update_message(message, {"key": "value"}) assert isinstance(updated, Message) assert updated.metadata == {"key": "value"} assert updated.id == message.id else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="test-update-msg-session") peer = honcho_client.peer(id="test-update-msg-peer") # Create a message session.add_messages([peer.message("test message")]) - messages = session.get_messages() + messages = session.messages() assert len(messages) >= 1 message = messages[0] assert isinstance(message, Message) # Update using Message object - updated = honcho_client.update_message(message, {"key": "value"}) + updated = session.update_message(message, {"key": "value"}) assert isinstance(updated, Message) assert updated.metadata == {"key": "value"} assert updated.id == message.id @@ -360,7 +292,7 @@ async def test_update_message_with_message_object( @pytest.mark.asyncio async def test_update_message_with_message_id( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests updating message metadata using message_id string. @@ -368,65 +300,36 @@ async def test_update_message_with_message_id( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="test-update-msg-id-session") - peer = await honcho_client.peer(id="test-update-msg-id-peer") + session = await honcho_client.aio.session(id="test-update-msg-id-session") + peer = await honcho_client.aio.peer(id="test-update-msg-id-peer") # Create a message - await session.add_messages([peer.message("test message")]) + await session.aio.add_messages([peer.message("test message")]) - messages = await session.get_messages() + messages = await session.aio.messages() assert len(messages) >= 1 message = messages[0] assert message.metadata == {} # Update using message_id string - updated = await honcho_client.update_message( - message.id, {"updated": True}, session=session.id - ) + updated = await session.aio.update_message(message.id, {"updated": True}) assert isinstance(updated, Message) assert updated.metadata == {"updated": True} assert updated.id == message.id else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="test-update-msg-id-session") peer = honcho_client.peer(id="test-update-msg-id-peer") # Create a message messages = session.add_messages([peer.message("test message")]) - messages = session.get_messages() + messages = session.messages() assert len(messages) >= 1 message = messages[0] assert message.metadata == {} # Update using message_id string - updated = honcho_client.update_message( - message.id, {"updated": True}, session=session.id - ) + updated = session.update_message(message.id, {"updated": True}) assert isinstance(updated, Message) assert updated.metadata == {"updated": True} assert updated.id == message.id - - -@pytest.mark.asyncio -async def test_update_message_validation( - client_fixture: tuple[Honcho | AsyncHoncho, str], -): - """ - Tests that update_message raises ValueError when message ID is provided without session. - """ - honcho_client, client_type = client_fixture - - if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - with pytest.raises( - ValueError, match="session is required when message is a string ID" - ): - await honcho_client.update_message("msg_123", {"key": "value"}) - else: - assert isinstance(honcho_client, Honcho) - with pytest.raises( - ValueError, match="session is required when message is a string ID" - ): - honcho_client.update_message("msg_123", {"key": "value"}) diff --git a/tests/sdk/test_conclusions.py b/tests/sdk/test_conclusions.py index e2476ef7..7e910404 100644 --- a/tests/sdk/test_conclusions.py +++ b/tests/sdk/test_conclusions.py @@ -1,12 +1,10 @@ """Tests for observation SDK methods.""" import pytest -from honcho_core.types.workspaces.conclusion import Conclusion -from sdks.python.src.honcho.async_client.client import AsyncHoncho from sdks.python.src.honcho.client import Honcho from sdks.python.src.honcho.conclusions import ( - AsyncConclusionScope, + Conclusion, ConclusionCreateParams, ConclusionScope, ) @@ -14,7 +12,7 @@ from sdks.python.src.honcho.conclusions import ( @pytest.mark.asyncio async def test_observation_create_single( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests creating a single observation via the SDK. @@ -22,13 +20,12 @@ async def test_observation_create_single( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - observer = await honcho_client.peer(id="test-obs-create-single-observer") - target = await honcho_client.peer(id="test-obs-create-single-target") - session = await honcho_client.session(id="test-obs-create-single-session") + observer = await honcho_client.aio.peer(id="test-obs-create-single-observer") + target = await honcho_client.aio.peer(id="test-obs-create-single-target") + session = await honcho_client.aio.session(id="test-obs-create-single-session") # Ensure session and both peers exist by adding messages from both - await session.add_messages( + await session.aio.add_messages( [ observer.message("Hello from observer"), target.message("Hello from target"), @@ -37,10 +34,10 @@ async def test_observation_create_single( # Get observation scope for observer -> target obs_scope = observer.conclusions_of(target) - assert isinstance(obs_scope, AsyncConclusionScope) + assert isinstance(obs_scope, ConclusionScope) # Create a single observation - created = await obs_scope.create( + created = await obs_scope.aio.create( [ ConclusionCreateParams( content="User prefers dark mode", @@ -57,7 +54,6 @@ async def test_observation_create_single( assert created[0].session_id == session.id assert created[0].id # Has an ID else: - assert isinstance(honcho_client, Honcho) observer = honcho_client.peer(id="test-obs-create-single-observer") target = honcho_client.peer(id="test-obs-create-single-target") session = honcho_client.session(id="test-obs-create-single-session") @@ -95,7 +91,7 @@ async def test_observation_create_single( @pytest.mark.asyncio async def test_observation_create_batch( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests creating multiple observations in a batch via the SDK. @@ -103,13 +99,12 @@ async def test_observation_create_batch( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - observer = await honcho_client.peer(id="test-obs-create-batch-observer") - target = await honcho_client.peer(id="test-obs-create-batch-target") - session = await honcho_client.session(id="test-obs-create-batch-session") + observer = await honcho_client.aio.peer(id="test-obs-create-batch-observer") + target = await honcho_client.aio.peer(id="test-obs-create-batch-target") + session = await honcho_client.aio.session(id="test-obs-create-batch-session") # Ensure session and both peers exist - await session.add_messages( + await session.aio.add_messages( [ observer.message("Hello from observer"), target.message("Hello from target"), @@ -120,7 +115,7 @@ async def test_observation_create_batch( obs_scope = observer.conclusions_of(target) # Create multiple observations - created = await obs_scope.create( + created = await obs_scope.aio.create( [ ConclusionCreateParams( content="User prefers dark mode", @@ -149,7 +144,6 @@ async def test_observation_create_batch( assert obs.observed_id == target.id assert obs.session_id == session.id else: - assert isinstance(honcho_client, Honcho) observer = honcho_client.peer(id="test-obs-create-batch-observer") target = honcho_client.peer(id="test-obs-create-batch-target") session = honcho_client.session(id="test-obs-create-batch-session") @@ -189,7 +183,7 @@ async def test_observation_create_batch( @pytest.mark.asyncio async def test_observation_create_then_list( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests that created observations can be listed. @@ -197,13 +191,12 @@ async def test_observation_create_then_list( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - observer = await honcho_client.peer(id="test-obs-create-list-observer") - target = await honcho_client.peer(id="test-obs-create-list-target") - session = await honcho_client.session(id="test-obs-create-list-session") + observer = await honcho_client.aio.peer(id="test-obs-create-list-observer") + target = await honcho_client.aio.peer(id="test-obs-create-list-target") + session = await honcho_client.aio.session(id="test-obs-create-list-session") # Ensure session and both peers exist - await session.add_messages( + await session.aio.add_messages( [ observer.message("Hello from observer"), target.message("Hello from target"), @@ -214,7 +207,7 @@ async def test_observation_create_then_list( obs_scope = observer.conclusions_of(target) # Create observations - created = await obs_scope.create( + created = await obs_scope.aio.create( [ { "content": "Unique observation for list test", @@ -224,17 +217,12 @@ async def test_observation_create_then_list( ) # List observations - listed = await obs_scope.list() - - listed_all: list[Conclusion] = [ - Conclusion.model_validate(item) for item in listed.items - ] + listed = await obs_scope.aio.list() # The created observation should be in the list - listed_ids = {obs.id for obs in listed_all} + listed_ids = {obs.id for obs in listed.items} assert created[0].id in listed_ids else: - assert isinstance(honcho_client, Honcho) observer = honcho_client.peer(id="test-obs-create-list-observer") target = honcho_client.peer(id="test-obs-create-list-target") session = honcho_client.session(id="test-obs-create-list-session") @@ -270,7 +258,7 @@ async def test_observation_create_then_list( @pytest.mark.asyncio async def test_observation_create_then_query( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests that created observations can be queried semantically. @@ -278,13 +266,12 @@ async def test_observation_create_then_query( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - observer = await honcho_client.peer(id="test-obs-create-query-observer") - target = await honcho_client.peer(id="test-obs-create-query-target") - session = await honcho_client.session(id="test-obs-create-query-session") + observer = await honcho_client.aio.peer(id="test-obs-create-query-observer") + target = await honcho_client.aio.peer(id="test-obs-create-query-target") + session = await honcho_client.aio.session(id="test-obs-create-query-session") # Ensure session and both peers exist - await session.add_messages( + await session.aio.add_messages( [ observer.message("Hello from observer"), target.message("Hello from target"), @@ -295,7 +282,7 @@ async def test_observation_create_then_query( obs_scope = observer.conclusions_of(target) # Create observation with specific content - await obs_scope.create( + await obs_scope.aio.create( [ { "content": "User loves Italian cuisine especially pasta and pizza", @@ -305,14 +292,13 @@ async def test_observation_create_then_query( ) # Query for food-related observations - results = await obs_scope.query("food preferences") + results = await obs_scope.aio.query("food preferences") assert len(results) >= 1 # At least one result should mention Italian food contents = " ".join(obs.content for obs in results) assert "Italian" in contents or "pasta" in contents or "pizza" in contents else: - assert isinstance(honcho_client, Honcho) observer = honcho_client.peer(id="test-obs-create-query-observer") target = honcho_client.peer(id="test-obs-create-query-target") session = honcho_client.session(id="test-obs-create-query-session") @@ -349,7 +335,7 @@ async def test_observation_create_then_query( @pytest.mark.asyncio async def test_observation_create_then_delete( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests that created observations can be deleted. @@ -357,13 +343,12 @@ async def test_observation_create_then_delete( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - observer = await honcho_client.peer(id="test-obs-create-delete-observer") - target = await honcho_client.peer(id="test-obs-create-delete-target") - session = await honcho_client.session(id="test-obs-create-delete-session") + observer = await honcho_client.aio.peer(id="test-obs-create-delete-observer") + target = await honcho_client.aio.peer(id="test-obs-create-delete-target") + session = await honcho_client.aio.session(id="test-obs-create-delete-session") # Ensure session and both peers exist - await session.add_messages( + await session.aio.add_messages( [ observer.message("Hello from observer"), target.message("Hello from target"), @@ -374,26 +359,20 @@ async def test_observation_create_then_delete( obs_scope = observer.conclusions_of(target) # Create observations - created = await obs_scope.create( - [ - {"content": "Observation to be deleted", "session_id": session.id}, - ] + created = await obs_scope.aio.create( + [{"content": "Observation to be deleted", "session_id": session.id}] ) observation_id = created[0].id # Delete the observation - await obs_scope.delete(observation_id) + await obs_scope.aio.delete(observation_id) # List observations - should not contain deleted one - listed = await obs_scope.list() - listed_all: list[Conclusion] = [ - Conclusion.model_validate(item) for item in listed.items - ] - listed_ids = {obs.id for obs in listed_all} + listed = await obs_scope.aio.list() + listed_ids = {obs.id for obs in listed.items} assert observation_id not in listed_ids else: - assert isinstance(honcho_client, Honcho) observer = honcho_client.peer(id="test-obs-create-delete-observer") target = honcho_client.peer(id="test-obs-create-delete-target") session = honcho_client.session(id="test-obs-create-delete-session") @@ -411,9 +390,7 @@ async def test_observation_create_then_delete( # Create observations created = obs_scope.create( - [ - {"content": "Observation to be deleted", "session_id": session.id}, - ] + [{"content": "Observation to be deleted", "session_id": session.id}] ) observation_id = created[0].id @@ -429,7 +406,7 @@ async def test_observation_create_then_delete( @pytest.mark.asyncio async def test_self_observation_create( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests creating self-observations (observer == observed). @@ -437,21 +414,20 @@ async def test_self_observation_create( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peer = await honcho_client.peer(id="test-self-obs-create-peer") - session = await honcho_client.session(id="test-self-obs-create-session") + peer = await honcho_client.aio.peer(id="test-self-obs-create-peer") + session = await honcho_client.aio.session(id="test-self-obs-create-session") # Ensure session exists - await session.add_messages([peer.message("Hello")]) + await session.aio.add_messages([peer.message("Hello")]) # Get self-observation scope obs_scope = peer.conclusions - assert isinstance(obs_scope, AsyncConclusionScope) + assert isinstance(obs_scope, ConclusionScope) assert obs_scope.observer == peer.id assert obs_scope.observed == peer.id # Create a self-observation - created = await obs_scope.create( + created = await obs_scope.aio.create( [{"content": "I prefer morning workouts", "session_id": session.id}] ) @@ -459,7 +435,6 @@ async def test_self_observation_create( assert created[0].observer_id == peer.id assert created[0].observed_id == peer.id else: - assert isinstance(honcho_client, Honcho) peer = honcho_client.peer(id="test-self-obs-create-peer") session = honcho_client.session(id="test-self-obs-create-session") @@ -484,7 +459,7 @@ async def test_self_observation_create( @pytest.mark.asyncio async def test_observation_create_with_session_filter( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests creating observations and filtering list by session. @@ -492,20 +467,19 @@ async def test_observation_create_with_session_filter( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - observer = await honcho_client.peer(id="test-obs-session-filter-observer") - target = await honcho_client.peer(id="test-obs-session-filter-target") - session1 = await honcho_client.session(id="test-obs-session-filter-s1") - session2 = await honcho_client.session(id="test-obs-session-filter-s2") + observer = await honcho_client.aio.peer(id="test-obs-session-filter-observer") + target = await honcho_client.aio.peer(id="test-obs-session-filter-target") + session1 = await honcho_client.aio.session(id="test-obs-session-filter-s1") + session2 = await honcho_client.aio.session(id="test-obs-session-filter-s2") # Ensure sessions and both peers exist - await session1.add_messages( + await session1.aio.add_messages( [ observer.message("Hello 1 from observer"), target.message("Hello 1 from target"), ] ) - await session2.add_messages( + await session2.aio.add_messages( [ observer.message("Hello 2 from observer"), target.message("Hello 2 from target"), @@ -516,36 +490,25 @@ async def test_observation_create_with_session_filter( obs_scope = observer.conclusions_of(target) # Create observations in different sessions - await obs_scope.create( - [ - {"content": "Session 1 observation", "session_id": session1.id}, - ] + await obs_scope.aio.create( + [{"content": "Session 1 observation", "session_id": session1.id}] ) - await obs_scope.create( - [ - {"content": "Session 2 observation", "session_id": session2.id}, - ] + await obs_scope.aio.create( + [{"content": "Session 2 observation", "session_id": session2.id}] ) # List filtered by session1 - s1_obs = await obs_scope.list(session=session1) - s1_obs_all: list[Conclusion] = [ - Conclusion.model_validate(item) for item in s1_obs.items - ] - s1_contents = [obs.content for obs in s1_obs_all] + s1_obs = await obs_scope.aio.list(session=session1) + s1_contents = [obs.content for obs in s1_obs.items] assert "Session 1 observation" in s1_contents assert "Session 2 observation" not in s1_contents # List filtered by session2 - s2_obs = await obs_scope.list(session=session2) - s2_obs_all: list[Conclusion] = [ - Conclusion.model_validate(item) for item in s2_obs.items - ] - s2_contents = [obs.content for obs in s2_obs_all] + s2_obs = await obs_scope.aio.list(session=session2) + s2_contents = [obs.content for obs in s2_obs.items] assert "Session 2 observation" in s2_contents assert "Session 1 observation" not in s2_contents else: - assert isinstance(honcho_client, Honcho) observer = honcho_client.peer(id="test-obs-session-filter-observer") target = honcho_client.peer(id="test-obs-session-filter-target") session1 = honcho_client.session(id="test-obs-session-filter-s1") @@ -570,14 +533,10 @@ async def test_observation_create_with_session_filter( # Create observations in different sessions obs_scope.create( - [ - {"content": "Session 1 observation", "session_id": session1.id}, - ] + [{"content": "Session 1 observation", "session_id": session1.id}] ) obs_scope.create( - [ - {"content": "Session 2 observation", "session_id": session2.id}, - ] + [{"content": "Session 2 observation", "session_id": session2.id}] ) # List filtered by session1 @@ -595,7 +554,7 @@ async def test_observation_create_with_session_filter( @pytest.mark.asyncio async def test_observation_scope_via_peer_string( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests creating observations via conclusions_of(string). @@ -603,13 +562,12 @@ async def test_observation_scope_via_peer_string( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - observer = await honcho_client.peer(id="test-obs-string-target-observer") - target = await honcho_client.peer(id="test-obs-string-target-target") - session = await honcho_client.session(id="test-obs-string-target-session") + observer = await honcho_client.aio.peer(id="test-obs-string-target-observer") + target = await honcho_client.aio.peer(id="test-obs-string-target-target") + session = await honcho_client.aio.session(id="test-obs-string-target-session") # Ensure session and both peers exist - await session.add_messages( + await session.aio.add_messages( [ observer.message("Hello from observer"), target.message("Hello from target"), @@ -621,14 +579,13 @@ async def test_observation_scope_via_peer_string( assert obs_scope.observed == target.id # Create observation - created = await obs_scope.create( + created = await obs_scope.aio.create( [{"content": "Created via string target", "session_id": session.id}] ) assert len(created) == 1 assert created[0].observed_id == target.id else: - assert isinstance(honcho_client, Honcho) observer = honcho_client.peer(id="test-obs-string-target-observer") target = honcho_client.peer(id="test-obs-string-target-target") session = honcho_client.session(id="test-obs-string-target-session") diff --git a/tests/sdk/test_file_uploads.py b/tests/sdk/test_file_uploads.py index 4c427c49..777abaef 100644 --- a/tests/sdk/test_file_uploads.py +++ b/tests/sdk/test_file_uploads.py @@ -2,18 +2,17 @@ import json import pytest -from sdks.python.src.honcho.async_client.client import AsyncHoncho from sdks.python.src.honcho.client import Honcho @pytest.mark.asyncio async def test_session_upload_file( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests uploading a single file to a session. """ - honcho_client, _client_type = client_fixture + honcho_client, client_type = client_fixture # Create test file text_content = ( @@ -27,19 +26,17 @@ async def test_session_upload_file( text_file.name = "test.txt" # Handle sync and async clients separately - if isinstance(honcho_client, Honcho): - # Sync client - session = honcho_client.session(id="test-session-upload") - user = honcho_client.peer(id="user-upload") - messages = session.upload_file( + if client_type == "async": + session = await honcho_client.aio.session(id="test-session-upload") + user = await honcho_client.aio.peer(id="user-upload") + messages = await session.aio.upload_file( file=text_file, peer=user.id, ) else: - # Async client - session = await honcho_client.session(id="test-session-upload") - user = await honcho_client.peer(id="user-upload") - messages = await session.upload_file( + session = honcho_client.session(id="test-session-upload") + user = honcho_client.peer(id="user-upload") + messages = session.upload_file( file=text_file, peer=user.id, ) @@ -55,12 +52,12 @@ async def test_session_upload_file( @pytest.mark.asyncio async def test_large_file_chunking( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests that large files get split into multiple messages automatically. """ - honcho_client, _client_type = client_fixture + honcho_client, client_type = client_fixture # Create a large text file that will require chunking large_content = "This is a test line.\n" * 3000 # Should exceed 49500 chars @@ -72,19 +69,17 @@ async def test_large_file_chunking( large_file.name = "large_test.txt" # Handle sync and async clients separately - if isinstance(honcho_client, Honcho): - # Sync client - session = honcho_client.session(id="test-session-chunking") - user = honcho_client.peer(id="user-chunking") - messages = session.upload_file( + if client_type == "async": + session = await honcho_client.aio.session(id="test-session-chunking") + user = await honcho_client.aio.peer(id="user-chunking") + messages = await session.aio.upload_file( file=large_file, peer=user.id, ) else: - # Async client - session = await honcho_client.session(id="test-session-chunking") - user = await honcho_client.peer(id="user-chunking") - messages = await session.upload_file( + session = honcho_client.session(id="test-session-chunking") + user = honcho_client.peer(id="user-chunking") + messages = session.upload_file( file=large_file, peer=user.id, ) @@ -100,12 +95,12 @@ async def test_large_file_chunking( @pytest.mark.asyncio async def test_multiple_files_upload( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests uploading multiple files one by one. """ - honcho_client, _client_type = client_fixture + honcho_client, client_type = client_fixture # Create multiple files file1_content = "Content of first file" @@ -125,20 +120,18 @@ async def test_multiple_files_upload( file3.name = "file3.txt" # Handle sync and async clients separately - if isinstance(honcho_client, Honcho): - # Sync client + if client_type == "async": + session = await honcho_client.aio.session(id="test-session-multiple") + user = await honcho_client.aio.peer(id="user-multiple") + messages1 = await session.aio.upload_file(file=file1, peer=user.id) + messages2 = await session.aio.upload_file(file=file2, peer=user.id) + messages3 = await session.aio.upload_file(file=file3, peer=user.id) + else: session = honcho_client.session(id="test-session-multiple") user = honcho_client.peer(id="user-multiple") messages1 = session.upload_file(file=file1, peer=user.id) messages2 = session.upload_file(file=file2, peer=user.id) messages3 = session.upload_file(file=file3, peer=user.id) - else: - # Async client - session = await honcho_client.session(id="test-session-multiple") - user = await honcho_client.peer(id="user-multiple") - messages1 = await session.upload_file(file=file1, peer=user.id) - messages2 = await session.upload_file(file=file2, peer=user.id) - messages3 = await session.upload_file(file=file3, peer=user.id) # Should be at least one message per file assert len(messages1) >= 1 @@ -158,11 +151,11 @@ async def test_multiple_files_upload( @pytest.mark.asyncio -async def test_json_file_upload(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_json_file_upload(client_fixture: tuple[Honcho, str]): """ Tests uploading JSON files specifically. """ - honcho_client, _client_type = client_fixture + honcho_client, client_type = client_fixture # Create JSON file json_data = { @@ -181,19 +174,17 @@ async def test_json_file_upload(client_fixture: tuple[Honcho | AsyncHoncho, str] json_file.name = "test.json" # Handle sync and async clients separately - if isinstance(honcho_client, Honcho): - # Sync client - session = honcho_client.session(id="test-session-json") - user = honcho_client.peer(id="user-json") - messages = session.upload_file( + if client_type == "async": + session = await honcho_client.aio.session(id="test-session-json") + user = await honcho_client.aio.peer(id="user-json") + messages = await session.aio.upload_file( file=json_file, peer=user.id, ) else: - # Async client - session = await honcho_client.session(id="test-session-json") - user = await honcho_client.peer(id="user-json") - messages = await session.upload_file( + session = honcho_client.session(id="test-session-json") + user = honcho_client.peer(id="user-json") + messages = session.upload_file( file=json_file, peer=user.id, ) @@ -217,12 +208,12 @@ async def test_json_file_upload(client_fixture: tuple[Honcho | AsyncHoncho, str] @pytest.mark.asyncio async def test_file_upload_with_tuple_input( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests uploading files using tuple input format. """ - honcho_client, _client_type = client_fixture + honcho_client, client_type = client_fixture # Create test content content = "This is test content for tuple input" @@ -230,19 +221,17 @@ async def test_file_upload_with_tuple_input( content_type = "text/plain" # Handle sync and async clients separately - if isinstance(honcho_client, Honcho): - # Sync client - session = honcho_client.session(id="test-session-tuple") - user = honcho_client.peer(id="user-tuple") - messages = session.upload_file( + if client_type == "async": + session = await honcho_client.aio.session(id="test-session-tuple") + user = await honcho_client.aio.peer(id="user-tuple") + messages = await session.aio.upload_file( file=(filename, content.encode("utf-8"), content_type), peer=user.id, ) else: - # Async client - session = await honcho_client.session(id="test-session-tuple") - user = await honcho_client.peer(id="user-tuple") - messages = await session.upload_file( + session = honcho_client.session(id="test-session-tuple") + user = honcho_client.peer(id="user-tuple") + messages = session.upload_file( file=(filename, content.encode("utf-8"), content_type), peer=user.id, ) @@ -256,12 +245,12 @@ async def test_file_upload_with_tuple_input( @pytest.mark.asyncio async def test_file_upload_with_metadata( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests uploading a file with metadata parameter. """ - honcho_client, _client_type = client_fixture + honcho_client, client_type = client_fixture text_content = "Test file with metadata" from io import BytesIO @@ -275,18 +264,18 @@ async def test_file_upload_with_metadata( "priority": 1, } - if isinstance(honcho_client, Honcho): - session = honcho_client.session(id="test-session-metadata") - user = honcho_client.peer(id="user-metadata") - messages = session.upload_file( + if client_type == "async": + session = await honcho_client.aio.session(id="test-session-metadata") + user = await honcho_client.aio.peer(id="user-metadata") + messages = await session.aio.upload_file( file=text_file, peer=user.id, metadata=metadata, ) else: - session = await honcho_client.session(id="test-session-metadata") - user = await honcho_client.peer(id="user-metadata") - messages = await session.upload_file( + session = honcho_client.session(id="test-session-metadata") + user = honcho_client.peer(id="user-metadata") + messages = session.upload_file( file=text_file, peer=user.id, metadata=metadata, @@ -302,12 +291,12 @@ async def test_file_upload_with_metadata( @pytest.mark.asyncio async def test_file_upload_with_configuration( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests uploading a file with configuration parameter. """ - honcho_client, _client_type = client_fixture + honcho_client, client_type = client_fixture text_content = "Test file with configuration" from io import BytesIO @@ -315,26 +304,20 @@ async def test_file_upload_with_configuration( text_file = BytesIO(text_content.encode("utf-8")) text_file.name = "test_config.txt" - from typing import cast + configuration = {"skip_deriver": True, "custom_flag": "test"} - from honcho_core.types.workspaces.sessions.message_create_param import Configuration - - configuration = cast( - Configuration, cast(object, {"skip_deriver": True, "custom_flag": "test"}) - ) - - if isinstance(honcho_client, Honcho): - session = honcho_client.session(id="test-session-config") - user = honcho_client.peer(id="user-config") - messages = session.upload_file( + if client_type == "async": + session = await honcho_client.aio.session(id="test-session-config") + user = await honcho_client.aio.peer(id="user-config") + messages = await session.aio.upload_file( file=text_file, peer=user.id, configuration=configuration, ) else: - session = await honcho_client.session(id="test-session-config") - user = await honcho_client.peer(id="user-config") - messages = await session.upload_file( + session = honcho_client.session(id="test-session-config") + user = honcho_client.peer(id="user-config") + messages = session.upload_file( file=text_file, peer=user.id, configuration=configuration, @@ -350,12 +333,12 @@ async def test_file_upload_with_configuration( @pytest.mark.asyncio async def test_file_upload_with_created_at( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests uploading a file with created_at parameter. """ - honcho_client, _client_type = client_fixture + honcho_client, client_type = client_fixture text_content = "Test file with created_at" from datetime import datetime, timezone @@ -367,7 +350,15 @@ async def test_file_upload_with_created_at( test_timestamp = datetime(2023, 1, 15, 10, 30, 45, tzinfo=timezone.utc) created_at_str = test_timestamp.isoformat() - if isinstance(honcho_client, Honcho): + if client_type == "async": + session = await honcho_client.aio.session(id="test-session-timestamp") + user = await honcho_client.aio.peer(id="user-timestamp") + messages = await session.aio.upload_file( + file=text_file, + peer=user.id, + created_at=created_at_str, + ) + else: session = honcho_client.session(id="test-session-timestamp") user = honcho_client.peer(id="user-timestamp") messages = session.upload_file( @@ -375,14 +366,6 @@ async def test_file_upload_with_created_at( peer=user.id, created_at=test_timestamp.isoformat(), ) - else: - session = await honcho_client.session(id="test-session-timestamp") - user = await honcho_client.peer(id="user-timestamp") - messages = await session.upload_file( - file=text_file, - peer=user.id, - created_at=created_at_str, - ) assert len(messages) >= 1 assert text_content in messages[0].content @@ -396,12 +379,12 @@ async def test_file_upload_with_created_at( @pytest.mark.asyncio async def test_file_upload_with_all_parameters( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests uploading a file with metadata, configuration, and created_at all together. """ - honcho_client, _client_type = client_fixture + honcho_client, client_type = client_fixture text_content = "Test file with all parameters" from datetime import datetime, timezone @@ -410,21 +393,15 @@ async def test_file_upload_with_all_parameters( text_file = BytesIO(text_content.encode("utf-8")) text_file.name = "test_all_params.txt" - from typing import cast - - from honcho_core.types.workspaces.sessions.message_create_param import Configuration - metadata: dict[str, object] = {"source": "comprehensive_test", "version": "1.0"} - configuration = cast( - Configuration, cast(object, {"skip_deriver": False, "test_mode": True}) - ) + configuration = {"skip_deriver": False, "test_mode": True} test_timestamp = datetime(2023, 6, 20, 14, 15, 30, tzinfo=timezone.utc) created_at_str = test_timestamp.isoformat() - if isinstance(honcho_client, Honcho): - session = honcho_client.session(id="test-session-all") - user = honcho_client.peer(id="user-all") - messages = session.upload_file( + if client_type == "async": + session = await honcho_client.aio.session(id="test-session-all") + user = await honcho_client.aio.peer(id="user-all") + messages = await session.aio.upload_file( file=text_file, peer=user.id, metadata=metadata, @@ -432,9 +409,9 @@ async def test_file_upload_with_all_parameters( created_at=created_at_str, ) else: - session = await honcho_client.session(id="test-session-all") - user = await honcho_client.peer(id="user-all") - messages = await session.upload_file( + session = honcho_client.session(id="test-session-all") + user = honcho_client.peer(id="user-all") + messages = session.upload_file( file=text_file, peer=user.id, metadata=metadata, @@ -456,12 +433,12 @@ async def test_file_upload_with_all_parameters( @pytest.mark.asyncio async def test_file_upload_with_datetime_object( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests uploading a file with created_at as a datetime object (Python only). """ - honcho_client, _client_type = client_fixture + honcho_client, client_type = client_fixture text_content = "Test file with datetime object" from datetime import datetime, timezone @@ -472,16 +449,16 @@ async def test_file_upload_with_datetime_object( test_timestamp = datetime(2023, 3, 10, 8, 45, 20, tzinfo=timezone.utc) - if isinstance(honcho_client, Honcho): - session = honcho_client.session(id="test-session-datetime") - user = honcho_client.peer(id="user-datetime") - messages = session.upload_file( + if client_type == "async": + session = await honcho_client.aio.session(id="test-session-datetime") + user = await honcho_client.aio.peer(id="user-datetime") + messages = await session.aio.upload_file( file=text_file, peer=user.id, created_at=test_timestamp ) else: - session = await honcho_client.session(id="test-session-datetime") - user = await honcho_client.peer(id="user-datetime") - messages = await session.upload_file( + session = honcho_client.session(id="test-session-datetime") + user = honcho_client.peer(id="user-datetime") + messages = session.upload_file( file=text_file, peer=user.id, created_at=test_timestamp ) diff --git a/tests/sdk/test_metadata_caching.py b/tests/sdk/test_metadata_caching.py index 158b6da3..066a5e31 100644 --- a/tests/sdk/test_metadata_caching.py +++ b/tests/sdk/test_metadata_caching.py @@ -1,20 +1,19 @@ """Tests for metadata and configuration caching in Honcho SDK.""" +from typing import cast + import pytest -from sdks.python.src.honcho.async_client.client import AsyncHoncho -from sdks.python.src.honcho.async_client.pagination import AsyncPage -from sdks.python.src.honcho.async_client.peer import AsyncPeer -from sdks.python.src.honcho.async_client.session import AsyncSession +from sdks.python.src.honcho.api_types import PeerConfig, SessionConfiguration from sdks.python.src.honcho.client import Honcho -from sdks.python.src.honcho.pagination import SyncPage +from sdks.python.src.honcho.pagination import AsyncPage, SyncPage from sdks.python.src.honcho.peer import Peer from sdks.python.src.honcho.session import Session @pytest.mark.asyncio async def test_workspace_metadata_caching( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ) -> None: """ Tests that workspace metadata is properly cached after get/set operations. @@ -22,27 +21,23 @@ async def test_workspace_metadata_caching( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - # Should initialize with None metadata assert honcho_client.metadata is None # Get metadata should cache it - metadata = await honcho_client.get_metadata() + metadata = await honcho_client.aio.get_metadata() assert isinstance(metadata, dict) assert honcho_client.metadata == metadata # Set metadata should update cache - await honcho_client.set_metadata({"theme": "dark", "version": "1.0"}) + await honcho_client.aio.set_metadata({"theme": "dark", "version": "1.0"}) assert honcho_client.metadata == {"theme": "dark", "version": "1.0"} # Get should return cached value - retrieved = await honcho_client.get_metadata() + retrieved = await honcho_client.aio.get_metadata() assert retrieved == {"theme": "dark", "version": "1.0"} assert honcho_client.metadata == {"theme": "dark", "version": "1.0"} else: - assert isinstance(honcho_client, Honcho) - # Should initialize with None metadata assert honcho_client.metadata is None @@ -63,7 +58,7 @@ async def test_workspace_metadata_caching( @pytest.mark.asyncio async def test_peer_metadata_caching( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ) -> None: """ Tests that peer metadata is properly cached after get/set operations. @@ -71,24 +66,22 @@ async def test_peer_metadata_caching( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peer = await honcho_client.peer(id="test-peer-meta-cache") - assert isinstance(peer, AsyncPeer) + peer = await honcho_client.aio.peer(id="test-peer-meta-cache") + assert isinstance(peer, Peer) # Get metadata should cache it - metadata = await peer.get_metadata() + metadata = await peer.aio.get_metadata() assert isinstance(metadata, dict) assert peer.metadata == metadata # Set metadata should update cache - await peer.set_metadata({"name": "Alice", "role": "user"}) + await peer.aio.set_metadata({"name": "Alice", "role": "user"}) assert peer.metadata == {"name": "Alice", "role": "user"} # Get should return cached value - retrieved = await peer.get_metadata() + retrieved = await peer.aio.get_metadata() assert retrieved == {"name": "Alice", "role": "user"} else: - assert isinstance(honcho_client, Honcho) peer = honcho_client.peer(id="test-peer-meta-cache") assert isinstance(peer, Peer) @@ -108,7 +101,7 @@ async def test_peer_metadata_caching( @pytest.mark.asyncio async def test_peer_config_caching( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ) -> None: """ Tests that peer configuration is properly cached after get/set operations. @@ -116,81 +109,44 @@ async def test_peer_config_caching( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peer = await honcho_client.peer(id="test-peer-config-cache") - assert isinstance(peer, AsyncPeer) + peer = await honcho_client.aio.peer(id="test-peer-config-cache") + assert isinstance(peer, Peer) # Get config should cache it - config = await peer.get_config() - assert isinstance(config, dict) + config = await peer.aio.get_configuration() + assert isinstance(config, PeerConfig) assert peer.configuration == config # Set config should update cache - await peer.set_config({"observe_me": True, "observe_others": False}) - assert peer.configuration == {"observe_me": True, "observe_others": False} + new_config = PeerConfig(observe_me=True) + await peer.aio.set_configuration(new_config) + assert peer.configuration == new_config # Get should return cached value - retrieved = await peer.get_config() - assert retrieved == {"observe_me": True, "observe_others": False} + retrieved = await peer.aio.get_configuration() + assert retrieved == new_config else: - assert isinstance(honcho_client, Honcho) peer = honcho_client.peer(id="test-peer-config-cache") assert isinstance(peer, Peer) # Get config should cache it - config = peer.get_config() - assert isinstance(config, dict) + config = peer.get_configuration() + assert isinstance(config, PeerConfig) assert peer.configuration == config # Set config should update cache - peer.set_config({"observe_me": True, "observe_others": False}) - assert peer.configuration == {"observe_me": True, "observe_others": False} + new_config = PeerConfig(observe_me=True) + peer.set_configuration(new_config) + assert peer.configuration == new_config # Get should return cached value - retrieved = peer.get_config() - assert retrieved == {"observe_me": True, "observe_others": False} - - -@pytest.mark.asyncio -async def test_peer_deprecated_config_methods( - client_fixture: tuple[Honcho | AsyncHoncho, str], -) -> None: - """ - Tests that deprecated getPeerConfig/setPeerConfig methods work and cache properly. - """ - honcho_client, client_type = client_fixture - - if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peer = await honcho_client.peer(id="test-peer-deprecated-config") - assert isinstance(peer, AsyncPeer) - - # Deprecated get method should work and cache - config = await peer.get_peer_config() - assert isinstance(config, dict) - assert peer.configuration == config - - # Deprecated set method should work and update cache - await peer.set_peer_config({"observe_me": False}) - assert peer.configuration == {"observe_me": False} - else: - assert isinstance(honcho_client, Honcho) - peer = honcho_client.peer(id="test-peer-deprecated-config") - assert isinstance(peer, Peer) - - # Deprecated get method should work and cache - config = peer.get_peer_config() - assert isinstance(config, dict) - assert peer.configuration == config - - # Deprecated set method should work and update cache - peer.set_peer_config({"observe_me": False}) - assert peer.configuration == {"observe_me": False} + retrieved = peer.get_configuration() + assert retrieved == new_config @pytest.mark.asyncio async def test_peer_metadata_and_config_independence( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ) -> None: """ Tests that peer metadata and config are cached independently. @@ -198,56 +154,58 @@ async def test_peer_metadata_and_config_independence( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peer = await honcho_client.peer(id="test-peer-independent-cache") - assert isinstance(peer, AsyncPeer) + peer = await honcho_client.aio.peer(id="test-peer-independent-cache") + assert isinstance(peer, Peer) # Set both metadata and config - await peer.set_metadata({"name": "Test"}) - await peer.set_config({"observe_me": True}) + await peer.aio.set_metadata({"name": "Test"}) + config1 = PeerConfig(observe_me=True) + await peer.aio.set_configuration(config1) assert peer.metadata == {"name": "Test"} - assert peer.configuration == {"observe_me": True} + assert peer.configuration == config1 # Update metadata only - await peer.set_metadata({"name": "Updated"}) + await peer.aio.set_metadata({"name": "Updated"}) assert peer.metadata == {"name": "Updated"} - assert peer.configuration == {"observe_me": True} # Should remain unchanged + assert peer.configuration == config1 # Should remain unchanged # Update config only - await peer.set_config({"observe_me": False}) + config2 = PeerConfig(observe_me=False) + await peer.aio.set_configuration(config2) assert peer.metadata == {"name": "Updated"} # Should remain unchanged - assert peer.configuration == {"observe_me": False} + assert peer.configuration == config2 else: - assert isinstance(honcho_client, Honcho) peer = honcho_client.peer(id="test-peer-independent-cache") assert isinstance(peer, Peer) # Set both metadata and config peer.set_metadata({"name": "Test"}) - peer.set_config({"observe_me": True}) + config1 = PeerConfig(observe_me=True) + peer.set_configuration(config1) assert peer.metadata == {"name": "Test"} - assert peer.configuration == {"observe_me": True} + assert peer.configuration == config1 # Update metadata only peer.set_metadata({"name": "Updated"}) assert peer.metadata == {"name": "Updated"} - assert peer.configuration == {"observe_me": True} # Should remain unchanged + assert peer.configuration == config1 # Should remain unchanged # Update config only - peer.set_config({"observe_me": False}) + config2 = PeerConfig(observe_me=False) + peer.set_configuration(config2) assert peer.metadata == {"name": "Updated"} # Should remain unchanged - assert peer.configuration == {"observe_me": False} + assert peer.configuration == config2 @pytest.mark.asyncio async def test_peer_list_with_metadata_and_config( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ) -> None: """ Tests that listed peers have metadata and config populated from API response. @@ -255,66 +213,66 @@ async def test_peer_list_with_metadata_and_config( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - # Create peers with metadata and config - peer1 = await honcho_client.peer(id="test-list-peer1") - await peer1.set_metadata({"name": "Alice"}) - await peer1.set_config({"observe_me": True}) + peer1 = await honcho_client.aio.peer(id="test-list-peer1") + await peer1.aio.set_metadata({"name": "Alice"}) + await peer1.aio.set_configuration(PeerConfig(observe_me=True)) - peer2 = await honcho_client.peer(id="test-list-peer2") - await peer2.set_metadata({"name": "Bob"}) - await peer2.set_config({"observe_me": False}) + peer2 = await honcho_client.aio.peer(id="test-list-peer2") + await peer2.aio.set_metadata({"name": "Bob"}) + await peer2.aio.set_configuration(PeerConfig(observe_me=False)) # List peers and check cached data - peers_page = await honcho_client.get_peers() + peers_page = await honcho_client.aio.peers() assert isinstance(peers_page, AsyncPage) - peers = peers_page.items + peers = cast(list[Peer], peers_page.items) peer_map = {p.id: p for p in peers} if "test-list-peer1" in peer_map: p1 = peer_map["test-list-peer1"] assert p1.metadata == {"name": "Alice"} - assert p1.configuration == {"observe_me": True} + assert p1.configuration is not None + assert p1.configuration.observe_me is True if "test-list-peer2" in peer_map: p2 = peer_map["test-list-peer2"] assert p2.metadata == {"name": "Bob"} - assert p2.configuration == {"observe_me": False} + assert p2.configuration is not None + assert p2.configuration.observe_me is False else: - assert isinstance(honcho_client, Honcho) - # Create peers with metadata and config peer1 = honcho_client.peer(id="test-list-peer1") peer1.set_metadata({"name": "Alice"}) - peer1.set_config({"observe_me": True}) + peer1.set_configuration(PeerConfig(observe_me=True)) peer2 = honcho_client.peer(id="test-list-peer2") peer2.set_metadata({"name": "Bob"}) - peer2.set_config({"observe_me": False}) + peer2.set_configuration(PeerConfig(observe_me=False)) # List peers and check cached data - peers_page = honcho_client.get_peers() + peers_page = honcho_client.peers() assert isinstance(peers_page, SyncPage) - peers = list(peers_page) + peers = cast(list[Peer], list(peers_page)) peer_map = {p.id: p for p in peers} if "test-list-peer1" in peer_map: p1 = peer_map["test-list-peer1"] assert p1.metadata == {"name": "Alice"} - assert p1.configuration == {"observe_me": True} + assert p1.configuration is not None + assert p1.configuration.observe_me is True if "test-list-peer2" in peer_map: p2 = peer_map["test-list-peer2"] assert p2.metadata == {"name": "Bob"} - assert p2.configuration == {"observe_me": False} + assert p2.configuration is not None + assert p2.configuration.observe_me is False @pytest.mark.asyncio async def test_session_metadata_caching( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ) -> None: """ Tests that session metadata is properly cached after get/set operations. @@ -322,24 +280,22 @@ async def test_session_metadata_caching( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="test-session-meta-cache") - assert isinstance(session, AsyncSession) + session = await honcho_client.aio.session(id="test-session-meta-cache") + assert isinstance(session, Session) # Get metadata should cache it - metadata = await session.get_metadata() + metadata = await session.aio.get_metadata() assert isinstance(metadata, dict) assert session.metadata == metadata # Set metadata should update cache - await session.set_metadata({"title": "Chat Session", "active": True}) + await session.aio.set_metadata({"title": "Chat Session", "active": True}) assert session.metadata == {"title": "Chat Session", "active": True} # Get should return cached value - retrieved = await session.get_metadata() + retrieved = await session.aio.get_metadata() assert retrieved == {"title": "Chat Session", "active": True} else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="test-session-meta-cache") assert isinstance(session, Session) @@ -359,7 +315,7 @@ async def test_session_metadata_caching( @pytest.mark.asyncio async def test_session_config_caching( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ) -> None: """ Tests that session configuration is properly cached after get/set operations. @@ -367,44 +323,44 @@ async def test_session_config_caching( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="test-session-config-cache") - assert isinstance(session, AsyncSession) + session = await honcho_client.aio.session(id="test-session-config-cache") + assert isinstance(session, Session) # Get config should cache it - config = await session.get_config() - assert isinstance(config, dict) + config = await session.aio.get_configuration() + assert isinstance(config, SessionConfiguration) assert session.configuration == config # Set config should update cache - await session.set_config({"anonymous": True, "summarize": False}) - assert session.configuration == {"anonymous": True, "summarize": False} + new_config = SessionConfiguration() + await session.aio.set_configuration(new_config) + assert session.configuration == new_config # Get should return cached value - retrieved = await session.get_config() - assert retrieved == {"anonymous": True, "summarize": False} + retrieved = await session.aio.get_configuration() + assert retrieved == new_config else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="test-session-config-cache") assert isinstance(session, Session) # Get config should cache it - config = session.get_config() - assert isinstance(config, dict) + config = session.get_configuration() + assert isinstance(config, SessionConfiguration) assert session.configuration == config # Set config should update cache - session.set_config({"anonymous": True, "summarize": False}) - assert session.configuration == {"anonymous": True, "summarize": False} + new_config = SessionConfiguration() + session.set_configuration(new_config) + assert session.configuration == new_config # Get should return cached value - retrieved = session.get_config() - assert retrieved == {"anonymous": True, "summarize": False} + retrieved = session.get_configuration() + assert retrieved == new_config @pytest.mark.asyncio async def test_session_metadata_and_config_independence( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ) -> None: """ Tests that session metadata and config are cached independently. @@ -412,56 +368,58 @@ async def test_session_metadata_and_config_independence( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="test-session-independent-cache") - assert isinstance(session, AsyncSession) + session = await honcho_client.aio.session(id="test-session-independent-cache") + assert isinstance(session, Session) # Set both metadata and config - await session.set_metadata({"title": "Test"}) - await session.set_config({"anonymous": True}) + await session.aio.set_metadata({"title": "Test"}) + config1 = SessionConfiguration() + await session.aio.set_configuration(config1) assert session.metadata == {"title": "Test"} - assert session.configuration == {"anonymous": True} + assert session.configuration == config1 # Update metadata only - await session.set_metadata({"title": "Updated"}) + await session.aio.set_metadata({"title": "Updated"}) assert session.metadata == {"title": "Updated"} - assert session.configuration == {"anonymous": True} # Should remain unchanged + assert session.configuration == config1 # Should remain unchanged # Update config only - await session.set_config({"anonymous": False}) + config2 = SessionConfiguration() + await session.aio.set_configuration(config2) assert session.metadata == {"title": "Updated"} # Should remain unchanged - assert session.configuration == {"anonymous": False} + assert session.configuration == config2 else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="test-session-independent-cache") assert isinstance(session, Session) # Set both metadata and config session.set_metadata({"title": "Test"}) - session.set_config({"anonymous": True}) + config1 = SessionConfiguration() + session.set_configuration(config1) assert session.metadata == {"title": "Test"} - assert session.configuration == {"anonymous": True} + assert session.configuration == config1 # Update metadata only session.set_metadata({"title": "Updated"}) assert session.metadata == {"title": "Updated"} - assert session.configuration == {"anonymous": True} # Should remain unchanged + assert session.configuration == config1 # Should remain unchanged # Update config only - session.set_config({"anonymous": False}) + config2 = SessionConfiguration() + session.set_configuration(config2) assert session.metadata == {"title": "Updated"} # Should remain unchanged - assert session.configuration == {"anonymous": False} + assert session.configuration == config2 @pytest.mark.asyncio async def test_session_list_with_metadata_and_config( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ) -> None: """ Tests that listed sessions have metadata and config populated from API response. @@ -469,66 +427,62 @@ async def test_session_list_with_metadata_and_config( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - # Create sessions with metadata and config - session1 = await honcho_client.session(id="test-list-session1") - await session1.set_metadata({"title": "Session 1"}) - await session1.set_config({"anonymous": True}) + session1 = await honcho_client.aio.session(id="test-list-session1") + await session1.aio.set_metadata({"title": "Session 1"}) + await session1.aio.set_configuration(SessionConfiguration()) - session2 = await honcho_client.session(id="test-list-session2") - await session2.set_metadata({"title": "Session 2"}) - await session2.set_config({"anonymous": False}) + session2 = await honcho_client.aio.session(id="test-list-session2") + await session2.aio.set_metadata({"title": "Session 2"}) + await session2.aio.set_configuration(SessionConfiguration()) # List sessions and check cached data - sessions_page = await honcho_client.get_sessions() + sessions_page = await honcho_client.aio.sessions() assert isinstance(sessions_page, AsyncPage) - sessions = sessions_page.items + sessions = cast(list[Session], sessions_page.items) session_map = {s.id: s for s in sessions} if "test-list-session1" in session_map: s1 = session_map["test-list-session1"] assert s1.metadata == {"title": "Session 1"} - assert s1.configuration == {"anonymous": True} + assert s1.configuration is not None if "test-list-session2" in session_map: s2 = session_map["test-list-session2"] assert s2.metadata == {"title": "Session 2"} - assert s2.configuration == {"anonymous": False} + assert s2.configuration is not None else: - assert isinstance(honcho_client, Honcho) - # Create sessions with metadata and config session1 = honcho_client.session(id="test-list-session1") session1.set_metadata({"title": "Session 1"}) - session1.set_config({"anonymous": True}) + session1.set_configuration(SessionConfiguration()) session2 = honcho_client.session(id="test-list-session2") session2.set_metadata({"title": "Session 2"}) - session2.set_config({"anonymous": False}) + session2.set_configuration(SessionConfiguration()) # List sessions and check cached data - sessions_page = honcho_client.get_sessions() + sessions_page = honcho_client.sessions() assert isinstance(sessions_page, SyncPage) - sessions = list(sessions_page) + sessions = cast(list[Session], list(sessions_page)) session_map = {s.id: s for s in sessions} if "test-list-session1" in session_map: s1 = session_map["test-list-session1"] assert s1.metadata == {"title": "Session 1"} - assert s1.configuration == {"anonymous": True} + assert s1.configuration is not None if "test-list-session2" in session_map: s2 = session_map["test-list-session2"] assert s2.metadata == {"title": "Session 2"} - assert s2.configuration == {"anonymous": False} + assert s2.configuration is not None @pytest.mark.asyncio async def test_peer_initialization_with_metadata_and_config( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ) -> None: """ Tests that peers can be initialized with metadata and config. @@ -536,32 +490,30 @@ async def test_peer_initialization_with_metadata_and_config( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - - peer = await honcho_client.peer( + peer = await honcho_client.aio.peer( id="test-init-peer", metadata={"name": "Test Peer", "role": "assistant"}, - config={"observe_me": False}, - ) - assert isinstance(peer, AsyncPeer) - assert peer.metadata == {"name": "Test Peer", "role": "assistant"} - assert peer.configuration == {"observe_me": False} - else: - assert isinstance(honcho_client, Honcho) - - peer = honcho_client.peer( - id="test-init-peer", - metadata={"name": "Test Peer", "role": "assistant"}, - config={"observe_me": False}, + configuration=PeerConfig(observe_me=False), ) assert isinstance(peer, Peer) assert peer.metadata == {"name": "Test Peer", "role": "assistant"} - assert peer.configuration == {"observe_me": False} + assert peer.configuration is not None + assert peer.configuration.observe_me is False + else: + peer = honcho_client.peer( + id="test-init-peer", + metadata={"name": "Test Peer", "role": "assistant"}, + configuration=PeerConfig(observe_me=False), + ) + assert isinstance(peer, Peer) + assert peer.metadata == {"name": "Test Peer", "role": "assistant"} + assert peer.configuration is not None + assert peer.configuration.observe_me is False @pytest.mark.asyncio async def test_session_initialization_with_metadata_and_config( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ) -> None: """ Tests that sessions can be initialized with metadata and config. @@ -569,24 +521,20 @@ async def test_session_initialization_with_metadata_and_config( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - - session = await honcho_client.session( + session = await honcho_client.aio.session( id="test-init-session", metadata={"title": "Test Session", "tags": ["important"]}, - config={"anonymous": False}, - ) - assert isinstance(session, AsyncSession) - assert session.metadata == {"title": "Test Session", "tags": ["important"]} - assert session.configuration == {"anonymous": False} - else: - assert isinstance(honcho_client, Honcho) - - session = honcho_client.session( - id="test-init-session", - metadata={"title": "Test Session", "tags": ["important"]}, - config={"anonymous": False}, + configuration=SessionConfiguration(), ) assert isinstance(session, Session) assert session.metadata == {"title": "Test Session", "tags": ["important"]} - assert session.configuration == {"anonymous": False} + assert session.configuration is not None + else: + session = honcho_client.session( + id="test-init-session", + metadata={"title": "Test Session", "tags": ["important"]}, + configuration=SessionConfiguration(), + ) + assert isinstance(session, Session) + assert session.metadata == {"title": "Test Session", "tags": ["important"]} + assert session.configuration is not None diff --git a/tests/sdk/test_pagination.py b/tests/sdk/test_pagination.py index 021582cd..f5da1cf5 100644 --- a/tests/sdk/test_pagination.py +++ b/tests/sdk/test_pagination.py @@ -1,16 +1,13 @@ import pytest -from sdks.python.src.honcho.async_client.client import AsyncHoncho -from sdks.python.src.honcho.async_client.pagination import AsyncPage -from sdks.python.src.honcho.async_client.peer import AsyncPeer from sdks.python.src.honcho.client import Honcho -from sdks.python.src.honcho.pagination import SyncPage +from sdks.python.src.honcho.pagination import AsyncPage, SyncPage from sdks.python.src.honcho.peer import Peer @pytest.mark.asyncio async def test_page_get_next_page( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests that Page.get_next_page() works correctly for both sync and async clients. @@ -18,15 +15,13 @@ async def test_page_get_next_page( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - # Create multiple peers to test pagination for i in range(15): - peer = await honcho_client.peer(id=f"pagination-test-peer-async-{i}") - await peer.get_metadata() # Create the peer + peer = await honcho_client.aio.peer(id=f"pagination-test-peer-async-{i}") + await peer.aio.get_metadata() # Create the peer # Get first page - first_page = await honcho_client.get_peers() + first_page = await honcho_client.aio.peers() assert isinstance(first_page, AsyncPage) assert len(first_page.items) > 0 @@ -37,15 +32,13 @@ async def test_page_get_next_page( assert isinstance(second_page, AsyncPage) assert second_page.page == 2 else: - assert isinstance(honcho_client, Honcho) - # Create multiple peers to test pagination for i in range(15): peer = honcho_client.peer(id=f"pagination-test-peer-{i}") peer.get_metadata() # Create the peer # Get first page - first_page = honcho_client.get_peers() + first_page = honcho_client.peers() assert isinstance(first_page, SyncPage) assert len(first_page.items) > 0 @@ -59,7 +52,7 @@ async def test_page_get_next_page( @pytest.mark.asyncio async def test_page_transform_preserved_across_pages( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests that transformation function is preserved when getting next page. @@ -67,37 +60,33 @@ async def test_page_transform_preserved_across_pages( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - # Create multiple peers to ensure pagination for i in range(25): - peer = await honcho_client.peer(id=f"transform-test-peer-async-{i}") - await peer.get_metadata() + peer = await honcho_client.aio.peer(id=f"transform-test-peer-async-{i}") + await peer.aio.get_metadata() # Get first page - first_page = await honcho_client.get_peers() + first_page = await honcho_client.aio.peers() assert isinstance(first_page, AsyncPage) - # Verify items are AsyncPeer instances (transformed) + # Verify items are Peer instances (transformed) for item in first_page.items: - assert isinstance(item, AsyncPeer) + assert isinstance(item, Peer) # Get next page and verify transformation is preserved if first_page.has_next_page(): second_page = await first_page.get_next_page() assert second_page is not None for item in second_page.items: - assert isinstance(item, AsyncPeer) + assert isinstance(item, Peer) else: - assert isinstance(honcho_client, Honcho) - # Create multiple peers to ensure pagination for i in range(25): peer = honcho_client.peer(id=f"transform-test-peer-{i}") peer.get_metadata() # Get first page - first_page = honcho_client.get_peers() + first_page = honcho_client.peers() assert isinstance(first_page, SyncPage) # Verify items are Peer instances (transformed) @@ -114,7 +103,7 @@ async def test_page_transform_preserved_across_pages( @pytest.mark.asyncio async def test_page_get_next_page_throws_exception_on_last_page( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests that get_next_page() throws RuntimeError when on the last page. @@ -122,15 +111,13 @@ async def test_page_get_next_page_throws_exception_on_last_page( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - # Create just a few peers to ensure we're on the last page for i in range(3): - peer = await honcho_client.peer(id=f"last-page-test-peer-async-{i}") - await peer.get_metadata() + peer = await honcho_client.aio.peer(id=f"last-page-test-peer-async-{i}") + await peer.aio.get_metadata() # Get first page - first_page = await honcho_client.get_peers() + first_page = await honcho_client.aio.peers() assert isinstance(first_page, AsyncPage) # Should be on last page (or only page) @@ -141,15 +128,13 @@ async def test_page_get_next_page_throws_exception_on_last_page( except Exception as e: assert isinstance(e, RuntimeError) else: - assert isinstance(honcho_client, Honcho) - # Create just a few peers to ensure we're on the last page for i in range(3): peer = honcho_client.peer(id=f"last-page-test-peer-{i}") peer.get_metadata() # Get first page - first_page = honcho_client.get_peers() + first_page = honcho_client.peers() assert isinstance(first_page, SyncPage) # Should be on last page (or only page) @@ -163,7 +148,7 @@ async def test_page_get_next_page_throws_exception_on_last_page( @pytest.mark.asyncio async def test_page_manual_pagination( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests manual pagination with get_next_page. @@ -171,16 +156,16 @@ async def test_page_manual_pagination( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - # Create enough peers to ensure multiple pages for i in range(25): - peer = await honcho_client.peer(id=f"manual-pagination-test-peer-async-{i}") - await peer.get_metadata() + peer = await honcho_client.aio.peer( + id=f"manual-pagination-test-peer-async-{i}" + ) + await peer.aio.get_metadata() # Collect items via manual pagination manual_items = [] - manual_page = await honcho_client.get_peers() + manual_page = await honcho_client.aio.peers() page_count = 0 while manual_page is not None: @@ -194,11 +179,9 @@ async def test_page_manual_pagination( # Should have collected all items assert len(manual_items) >= 25 # pyright: ignore - # All items should be AsyncPeer instances - assert all(isinstance(item, AsyncPeer) for item in manual_items) # pyright: ignore + # All items should be Peer instances + assert all(isinstance(item, Peer) for item in manual_items) # pyright: ignore else: - assert isinstance(honcho_client, Honcho) - # Create enough peers to ensure multiple pages for i in range(25): peer = honcho_client.peer(id=f"manual-pagination-test-peer-{i}") @@ -206,7 +189,7 @@ async def test_page_manual_pagination( # Collect items via manual pagination manual_items = [] - manual_page = honcho_client.get_peers() + manual_page = honcho_client.peers() page_count = 0 while manual_page is not None: diff --git a/tests/sdk/test_peer.py b/tests/sdk/test_peer.py index 7e772846..dae19398 100644 --- a/tests/sdk/test_peer.py +++ b/tests/sdk/test_peer.py @@ -1,34 +1,35 @@ -from unittest.mock import AsyncMock, Mock, patch +from collections.abc import AsyncIterator, Iterator +from unittest.mock import patch import pytest -from sdks.python.src.honcho.async_client.client import AsyncHoncho -from sdks.python.src.honcho.async_client.peer import AsyncPeer from sdks.python.src.honcho.client import Honcho from sdks.python.src.honcho.peer import Peer -from sdks.python.src.honcho.types import DialecticStreamResponse +from sdks.python.src.honcho.session import Session +from sdks.python.src.honcho.types import ( + AsyncDialecticStreamResponse, + DialecticStreamResponse, +) @pytest.mark.asyncio -async def test_peer_metadata(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_peer_metadata(client_fixture: tuple[Honcho, str]): """ Tests creation and metadata operations for peers. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peer = await honcho_client.peer(id="test-peer-meta") - assert isinstance(peer, AsyncPeer) + peer = await honcho_client.aio.peer(id="test-peer-meta") + assert isinstance(peer, Peer) - metadata = await peer.get_metadata() + metadata = await peer.aio.get_metadata() assert metadata == {} - await peer.set_metadata({"foo": "bar"}) - metadata = await peer.get_metadata() + await peer.aio.set_metadata({"foo": "bar"}) + metadata = await peer.aio.get_metadata() assert metadata == {"foo": "bar"} else: - assert isinstance(honcho_client, Honcho) peer = honcho_client.peer(id="test-peer-meta") assert isinstance(peer, Peer) @@ -41,29 +42,27 @@ async def test_peer_metadata(client_fixture: tuple[Honcho | AsyncHoncho, str]): @pytest.mark.asyncio -async def test_peer_get_sessions(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_peer_sessions(client_fixture: tuple[Honcho, str]): """ Tests retrieving the sessions a peer is a member of. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peer = await honcho_client.peer(id="test-peer-sessions") - session1 = await honcho_client.session(id="s1") - session2 = await honcho_client.session(id="s2") + peer = await honcho_client.aio.peer(id="test-peer-sessions") + session1 = await honcho_client.aio.session(id="s1") + session2 = await honcho_client.aio.session(id="s2") - await session1.add_peers(peer) - await session2.add_peers(peer) + await session1.aio.add_peers(peer) + await session2.aio.add_peers(peer) - sessions_page = await peer.get_sessions() + sessions_page = await peer.aio.sessions() sessions = sessions_page.items assert len(sessions) == 2 session_ids = {s.id for s in sessions} assert "s1" in session_ids assert "s2" in session_ids else: - assert isinstance(honcho_client, Honcho) peer = honcho_client.peer(id="test-peer-sessions") session1 = honcho_client.session(id="s1") session2 = honcho_client.session(id="s2") @@ -71,7 +70,7 @@ async def test_peer_get_sessions(client_fixture: tuple[Honcho | AsyncHoncho, str session1.add_peers(peer) session2.add_peers(peer) - sessions_page = peer.get_sessions() + sessions_page = peer.sessions() sessions = list(sessions_page) assert len(sessions) == 2 session_ids = {s.id for s in sessions} @@ -80,25 +79,23 @@ async def test_peer_get_sessions(client_fixture: tuple[Honcho | AsyncHoncho, str @pytest.mark.asyncio -async def test_peer_card_global(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_peer_card_global(client_fixture: tuple[Honcho, str]): """ Tests getting a global peer card (no target). """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peer = await honcho_client.peer(id="test-card-global-peer") - session = await honcho_client.session(id="test-card-global-session") + peer = await honcho_client.aio.peer(id="test-card-global-peer") + session = await honcho_client.aio.session(id="test-card-global-session") # Add some messages to create context - await session.add_messages([peer.message("I like pizza")]) + await session.aio.add_messages([peer.message("I like pizza")]) # Get global peer card - card_response = await peer.card() - assert isinstance(card_response, str) + card_response = await peer.aio.card() + assert card_response is None or isinstance(card_response, list) else: - assert isinstance(honcho_client, Honcho) peer = honcho_client.peer(id="test-card-global-peer") session = honcho_client.session(id="test-card-global-session") @@ -107,36 +104,34 @@ async def test_peer_card_global(client_fixture: tuple[Honcho | AsyncHoncho, str] # Get global peer card card_response = peer.card() - assert isinstance(card_response, str) + assert card_response is None or isinstance(card_response, list) @pytest.mark.asyncio -async def test_peer_card_local(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_peer_card_local(client_fixture: tuple[Honcho, str]): """ Tests getting a local peer card (with target). """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - observer = await honcho_client.peer(id="test-card-local-observer") - target = await honcho_client.peer(id="test-card-local-target") - session = await honcho_client.session(id="test-card-local-session") + observer = await honcho_client.aio.peer(id="test-card-local-observer") + target = await honcho_client.aio.peer(id="test-card-local-target") + session = await honcho_client.aio.session(id="test-card-local-session") # Add messages from both peers - await session.add_messages( + await session.aio.add_messages( [observer.message("Hello"), target.message("Hi there")] ) # Get local peer card with target as Peer object - card_response = await observer.card(target=target) - assert isinstance(card_response, str) + card_response = await observer.aio.card(target=target) + assert card_response is None or isinstance(card_response, list) # Get local peer card with target as string - card_response = await observer.card(target=target.id) - assert isinstance(card_response, str) + card_response = await observer.aio.card(target=target.id) + assert card_response is None or isinstance(card_response, list) else: - assert isinstance(honcho_client, Honcho) observer = honcho_client.peer(id="test-card-local-observer") target = honcho_client.peer(id="test-card-local-target") session = honcho_client.session(id="test-card-local-session") @@ -146,74 +141,64 @@ async def test_peer_card_local(client_fixture: tuple[Honcho | AsyncHoncho, str]) # Get local peer card with target as Peer object card_response = observer.card(target=target) - assert isinstance(card_response, str) + assert card_response is None or isinstance(card_response, list) # Get local peer card with target as string card_response = observer.card(target=target.id) - assert isinstance(card_response, str) + assert card_response is None or isinstance(card_response, list) @pytest.mark.asyncio -async def test_peer_card_validation(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_peer_card_with_empty_target(client_fixture: tuple[Honcho, str]): """ - Tests peer card validation. + Tests peer card with empty target string. + + Empty strings are passed through to the API - validation is handled server-side. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peer = await honcho_client.peer(id="test-card-validation-peer") + peer = await honcho_client.aio.peer(id="test-card-validation-peer") - # Test with empty string - with pytest.raises(ValueError, match="target string cannot be empty"): - await peer.card(target="") + # Empty target is treated as no target (same as None) + result = await peer.aio.card(target="") + assert result is None or isinstance(result, list) else: - assert isinstance(honcho_client, Honcho) peer = honcho_client.peer(id="test-card-validation-peer") - # Test with empty string - with pytest.raises(ValueError, match="target string cannot be empty"): - peer.card(target="") + # Empty target is treated as no target (same as None) + result = peer.card(target="") + assert result is None or isinstance(result, list) @pytest.mark.asyncio -async def test_peer_chat_streaming(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_peer_chat_streaming(client_fixture: tuple[Honcho, str]): """ Tests streaming chat with mocked response generator. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peer = await honcho_client.peer(id="test-stream-async-peer") - session = await honcho_client.session(id="test-stream-async-session") + peer = await honcho_client.aio.peer(id="test-stream-async-peer") + session = await honcho_client.aio.session(id="test-stream-async-session") # Add some messages to create context - await session.add_messages([peer.message("I like pizza")]) + await session.aio.add_messages([peer.message("I like pizza")]) - # Mock the async streaming response - async def mock_aiter_lines(): - yield 'data: {"delta": {"content": "Hello"}}' - yield 'data: {"delta": {"content": " async"}}' - yield 'data: {"done": true}' + # Mock the async streaming response - mock _http.stream to return chunks + async def mock_astream(*args: object, **kwargs: object) -> AsyncIterator[bytes]: # pyright: ignore[reportUnusedParameter] + yield b'data: {"delta": {"content": "Hello"}}\n' + yield b'data: {"delta": {"content": " async"}}\n' + yield b'data: {"done": true}\n' - mock_http_response = Mock() - mock_http_response.raise_for_status = Mock() - - mock_response = AsyncMock() - mock_response.iter_lines = mock_aiter_lines - mock_response.http_response = mock_http_response - mock_response.__aenter__ = AsyncMock(return_value=mock_response) - mock_response.__aexit__ = AsyncMock(return_value=None) - - # Mock the with_streaming_response.chat call + # Mock the _http.stream method on the honcho's async http client with patch.object( - honcho_client.core.workspaces.peers.with_streaming_response, - "chat", - return_value=mock_response, + peer._honcho._async_http_client, # pyright: ignore[reportPrivateUsage] + "stream", + side_effect=mock_astream, ): - result = await peer.chat("Tell me something", stream=True) - assert isinstance(result, DialecticStreamResponse) + result = await peer.aio.chat_stream("Tell me something") + assert isinstance(result, AsyncDialecticStreamResponse) # Collect chunks chunks: list[str] = [] @@ -223,31 +208,25 @@ async def test_peer_chat_streaming(client_fixture: tuple[Honcho | AsyncHoncho, s assert chunks == ["Hello", " async"] assert result.get_final_response()["content"] == "Hello async" else: - assert isinstance(honcho_client, Honcho) peer = honcho_client.peer(id="test-stream-peer") session = honcho_client.session(id="test-stream-session") # Add some messages to create context session.add_messages([peer.message("I like pizza")]) - # Mock the streaming response - def mock_iter_lines(): - yield 'data: {"delta": {"content": "Hello"}}' - yield 'data: {"delta": {"content": " world"}}' - yield 'data: {"done": true}' + # Mock the streaming response - mock _http.stream to return chunks + def mock_stream(*args: object, **kwargs: object) -> Iterator[bytes]: # pyright: ignore[reportUnusedParameter] + yield b'data: {"delta": {"content": "Hello"}}\n' + yield b'data: {"delta": {"content": " world"}}\n' + yield b'data: {"done": true}\n' - mock_response = Mock() - mock_response.iter_lines = mock_iter_lines - mock_response.__enter__ = Mock(return_value=mock_response) - mock_response.__exit__ = Mock(return_value=None) - - # Mock the with_streaming_response.chat call + # Mock the _http.stream method on the peer's internal http client with patch.object( - honcho_client.core.workspaces.peers.with_streaming_response, - "chat", - return_value=mock_response, + peer._honcho._http, # pyright: ignore[reportPrivateUsage] + "stream", + side_effect=mock_stream, ): - result = peer.chat("Tell me something", stream=True) + result = peer.chat_stream("Tell me something") assert isinstance(result, DialecticStreamResponse) # Collect chunks @@ -258,7 +237,7 @@ async def test_peer_chat_streaming(client_fixture: tuple[Honcho | AsyncHoncho, s @pytest.mark.asyncio async def test_peer_chat_non_streaming( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests non-streaming chat (already using core SDK). @@ -266,19 +245,17 @@ async def test_peer_chat_non_streaming( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peer = await honcho_client.peer(id="test-non-stream-peer") - session = await honcho_client.session(id="test-non-stream-session") + peer = await honcho_client.aio.peer(id="test-non-stream-peer") + session = await honcho_client.aio.session(id="test-non-stream-session") # Add some messages - await session.add_messages([peer.message("I like pizza")]) + await session.aio.add_messages([peer.message("I like pizza")]) # Non-streaming chat - response = await peer.chat("What do I like?", stream=False) + response = await peer.aio.chat("What do I like?") # Response can be None or a string assert response is None or isinstance(response, str) else: - assert isinstance(honcho_client, Honcho) peer = honcho_client.peer(id="test-non-stream-peer") session = honcho_client.session(id="test-non-stream-session") @@ -286,33 +263,33 @@ async def test_peer_chat_non_streaming( session.add_messages([peer.message("I like pizza")]) # Non-streaming chat - response = peer.chat("What do I like?", stream=False) + response = peer.chat("What do I like?") # Response can be None or a string assert response is None or isinstance(response, str) @pytest.mark.asyncio -async def test_peer_get_representation_no_params( - client_fixture: tuple[Honcho | AsyncHoncho, str], +async def test_peer_representation_no_params( + client_fixture: tuple[Honcho, str], ): """ - Tests peer.get_representation() with no parameters (default behavior). + Tests peer.representation() with no parameters (default behavior). """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peer = await honcho_client.peer(id="test-working-rep-no-params") - session = await honcho_client.session(id="test-working-rep-session-no-params") + peer = await honcho_client.aio.peer(id="test-working-rep-no-params") + session = await honcho_client.aio.session( + id="test-working-rep-session-no-params" + ) # Add some messages to create context - await session.add_messages([peer.message("I enjoy hiking and nature")]) + await session.aio.add_messages([peer.message("I enjoy hiking and nature")]) # Get working representation with no parameters - result = await peer.get_representation() + result = await peer.aio.representation() assert isinstance(result, str) else: - assert isinstance(honcho_client, Honcho) peer = honcho_client.peer(id="test-working-rep-no-params") session = honcho_client.session(id="test-working-rep-session-no-params") @@ -320,32 +297,32 @@ async def test_peer_get_representation_no_params( session.add_messages([peer.message("I enjoy hiking and nature")]) # Get working representation with no parameters - result = peer.get_representation() + result = peer.representation() assert isinstance(result, str) @pytest.mark.asyncio -async def test_peer_get_representation_with_session_string( - client_fixture: tuple[Honcho | AsyncHoncho, str], +async def test_peer_representation_with_session_string( + client_fixture: tuple[Honcho, str], ): """ - Tests peer.get_representation() with session parameter as string. + Tests peer.representation() with session parameter as string. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peer = await honcho_client.peer(id="test-working-rep-session-str") - session = await honcho_client.session(id="test-working-rep-session-str-sess") + peer = await honcho_client.aio.peer(id="test-working-rep-session-str") + session = await honcho_client.aio.session( + id="test-working-rep-session-str-sess" + ) # Add some messages to the session - await session.add_messages([peer.message("I like reading books")]) + await session.aio.add_messages([peer.message("I like reading books")]) # Get working representation scoped to session (as string) - result = await peer.get_representation(session=session.id) + result = await peer.aio.representation(session=session.id) assert isinstance(result, str) else: - assert isinstance(honcho_client, Honcho) peer = honcho_client.peer(id="test-working-rep-session-str") session = honcho_client.session(id="test-working-rep-session-str-sess") @@ -353,66 +330,63 @@ async def test_peer_get_representation_with_session_string( session.add_messages([peer.message("I like reading books")]) # Get working representation scoped to session (as string) - result = peer.get_representation(session=session.id) + result = peer.representation(session=session.id) assert isinstance(result, str) @pytest.mark.asyncio -async def test_peer_get_representation_with_session_object( - client_fixture: tuple[Honcho | AsyncHoncho, str], +async def test_peer_representation_with_session_object( + client_fixture: tuple[Honcho, str], ): """ - Tests peer.get_representation() with session parameter as Session object. + Tests peer.representation() with session parameter as Session object. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peer = await honcho_client.peer(id="test-working-rep-session-obj") - session = await honcho_client.session(id="test-working-rep-session-obj-sess") - from sdks.python.src.honcho.async_client.session import AsyncSession - - assert isinstance(session, AsyncSession) + peer = await honcho_client.aio.peer(id="test-working-rep-session-obj") + session = await honcho_client.aio.session( + id="test-working-rep-session-obj-sess" + ) + assert isinstance(session, Session) # Add some messages to the session - await session.add_messages([peer.message("I prefer tea over coffee")]) + await session.aio.add_messages([peer.message("I prefer tea over coffee")]) # Get working representation scoped to session (as Session object) - result = await peer.get_representation(session=session) + result = await peer.aio.representation(session=session) assert isinstance(result, str) else: - assert isinstance(honcho_client, Honcho) peer = honcho_client.peer(id="test-working-rep-session-obj") session = honcho_client.session(id="test-working-rep-session-obj-sess") - from sdks.python.src.honcho.session import Session - assert isinstance(session, Session) # Add some messages to the session session.add_messages([peer.message("I prefer tea over coffee")]) # Get working representation scoped to session (as Session object) - result = peer.get_representation(session=session) + result = peer.representation(session=session) assert isinstance(result, str) @pytest.mark.asyncio -async def test_peer_get_representation_with_target_string( - client_fixture: tuple[Honcho | AsyncHoncho, str], +async def test_peer_representation_with_target_string( + client_fixture: tuple[Honcho, str], ): """ - Tests peer.get_representation() with target parameter as string. + Tests peer.representation() with target parameter as string. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - observer = await honcho_client.peer(id="test-working-rep-target-str-observer") - target = await honcho_client.peer(id="test-working-rep-target-str-target") - session = await honcho_client.session(id="test-working-rep-target-str-sess") + observer = await honcho_client.aio.peer( + id="test-working-rep-target-str-observer" + ) + target = await honcho_client.aio.peer(id="test-working-rep-target-str-target") + session = await honcho_client.aio.session(id="test-working-rep-target-str-sess") # Add messages from both peers - await session.add_messages( + await session.aio.add_messages( [ observer.message("Hello there"), target.message("Hi, how are you?"), @@ -420,10 +394,9 @@ async def test_peer_get_representation_with_target_string( ) # Get working representation of target from observer's perspective (as string) - result = await observer.get_representation(target=target.id) + result = await observer.aio.representation(target=target.id) assert isinstance(result, str) else: - assert isinstance(honcho_client, Honcho) observer = honcho_client.peer(id="test-working-rep-target-str-observer") target = honcho_client.peer(id="test-working-rep-target-str-target") session = honcho_client.session(id="test-working-rep-target-str-sess") @@ -437,30 +410,29 @@ async def test_peer_get_representation_with_target_string( ) # Get working representation of target from observer's perspective (as string) - result = observer.get_representation(target=target.id) + result = observer.representation(target=target.id) assert isinstance(result, str) @pytest.mark.asyncio -async def test_peer_get_representation_with_target_object( - client_fixture: tuple[Honcho | AsyncHoncho, str], +async def test_peer_representation_with_target_object( + client_fixture: tuple[Honcho, str], ): """ - Tests peer.get_representation() with target parameter as Peer object. + Tests peer.representation() with target parameter as Peer object. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - observer = await honcho_client.peer(id="test-working-rep-target-obj-observer") - target = await honcho_client.peer(id="test-working-rep-target-obj-target") - session = await honcho_client.session(id="test-working-rep-target-obj-sess") - from sdks.python.src.honcho.async_client.peer import AsyncPeer - - assert isinstance(target, AsyncPeer) + observer = await honcho_client.aio.peer( + id="test-working-rep-target-obj-observer" + ) + target = await honcho_client.aio.peer(id="test-working-rep-target-obj-target") + session = await honcho_client.aio.session(id="test-working-rep-target-obj-sess") + assert isinstance(target, Peer) # Add messages from both peers - await session.add_messages( + await session.aio.add_messages( [ observer.message("What do you think?"), target.message("I think it's great!"), @@ -468,15 +440,12 @@ async def test_peer_get_representation_with_target_object( ) # Get working representation of target from observer's perspective (as Peer object) - result = await observer.get_representation(target=target) + result = await observer.aio.representation(target=target) assert isinstance(result, str) else: - assert isinstance(honcho_client, Honcho) observer = honcho_client.peer(id="test-working-rep-target-obj-observer") target = honcho_client.peer(id="test-working-rep-target-obj-target") session = honcho_client.session(id="test-working-rep-target-obj-sess") - from sdks.python.src.honcho.peer import Peer - assert isinstance(target, Peer) # Add messages from both peers @@ -488,26 +457,27 @@ async def test_peer_get_representation_with_target_object( ) # Get working representation of target from observer's perspective (as Peer object) - result = observer.get_representation(target=target) + result = observer.representation(target=target) assert isinstance(result, str) @pytest.mark.asyncio -async def test_peer_get_representation_with_search_query( - client_fixture: tuple[Honcho | AsyncHoncho, str], +async def test_peer_representation_with_search_query( + client_fixture: tuple[Honcho, str], ): """ - Tests peer.get_representation() with search_query parameter. + Tests peer.representation() with search_query parameter. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peer = await honcho_client.peer(id="test-working-rep-search-query") - session = await honcho_client.session(id="test-working-rep-search-query-sess") + peer = await honcho_client.aio.peer(id="test-working-rep-search-query") + session = await honcho_client.aio.session( + id="test-working-rep-search-query-sess" + ) # Add some messages with different topics - await session.add_messages( + await session.aio.add_messages( [ peer.message("I love programming in Python"), peer.message("I also enjoy playing basketball"), @@ -515,10 +485,9 @@ async def test_peer_get_representation_with_search_query( ) # Get working representation with search query - result = await peer.get_representation(search_query="programming") + result = await peer.aio.representation(search_query="programming") assert isinstance(result, str) else: - assert isinstance(honcho_client, Honcho) peer = honcho_client.peer(id="test-working-rep-search-query") session = honcho_client.session(id="test-working-rep-search-query-sess") @@ -531,41 +500,39 @@ async def test_peer_get_representation_with_search_query( ) # Get working representation with search query - result = peer.get_representation(search_query="programming") + result = peer.representation(search_query="programming") assert isinstance(result, str) @pytest.mark.asyncio -async def test_peer_get_representation_with_size( - client_fixture: tuple[Honcho | AsyncHoncho, str], +async def test_peer_representation_with_size( + client_fixture: tuple[Honcho, str], ): """ - Tests peer.get_representation() with size parameter. + Tests peer.representation() with size parameter. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - peer = await honcho_client.peer(id="test-working-rep-size") - session = await honcho_client.session(id="test-working-rep-size-sess") + peer = await honcho_client.aio.peer(id="test-working-rep-size") + session = await honcho_client.aio.session(id="test-working-rep-size-sess") # Add multiple messages - await session.add_messages( + await session.aio.add_messages( [peer.message(f"Message number {i}") for i in range(10)] ) # Get working representation with custom max_conclusions - result = await peer.get_representation(max_conclusions=5) + result = await peer.aio.representation(max_conclusions=5) assert isinstance(result, str) # Test with different max_conclusions values - result = await peer.get_representation(max_conclusions=1) + result = await peer.aio.representation(max_conclusions=1) assert isinstance(result, str) - result = await peer.get_representation(max_conclusions=100) + result = await peer.aio.representation(max_conclusions=100) assert isinstance(result, str) else: - assert isinstance(honcho_client, Honcho) peer = honcho_client.peer(id="test-working-rep-size") session = honcho_client.session(id="test-working-rep-size-sess") @@ -573,34 +540,33 @@ async def test_peer_get_representation_with_size( session.add_messages([peer.message(f"Message number {i}") for i in range(10)]) # Get working representation with custom size - result = peer.get_representation(max_conclusions=5) + result = peer.representation(max_conclusions=5) assert isinstance(result, str) # Test with different max_conclusions values - result = peer.get_representation(max_conclusions=1) + result = peer.representation(max_conclusions=1) assert isinstance(result, str) - result = peer.get_representation(max_conclusions=100) + result = peer.representation(max_conclusions=100) assert isinstance(result, str) @pytest.mark.asyncio -async def test_peer_get_representation_with_all_params( - client_fixture: tuple[Honcho | AsyncHoncho, str], +async def test_peer_representation_with_all_params( + client_fixture: tuple[Honcho, str], ): """ - Tests peer.get_representation() with all parameters combined. + Tests peer.representation() with all parameters combined. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - observer = await honcho_client.peer(id="test-working-rep-all-observer") - target = await honcho_client.peer(id="test-working-rep-all-target") - session = await honcho_client.session(id="test-working-rep-all-sess") + observer = await honcho_client.aio.peer(id="test-working-rep-all-observer") + target = await honcho_client.aio.peer(id="test-working-rep-all-target") + session = await honcho_client.aio.session(id="test-working-rep-all-sess") # Add messages from both peers - await session.add_messages( + await session.aio.add_messages( [ observer.message("I think Python is great for data science"), target.message("I agree, especially with libraries like pandas"), @@ -610,13 +576,13 @@ async def test_peer_get_representation_with_all_params( ) # Get working representation with all parameters - result = await observer.get_representation( + result = await observer.aio.representation( session=session, target=target, search_query="Python", max_conclusions=10 ) assert isinstance(result, str) # Test with session as string and target as string - result = await observer.get_representation( + result = await observer.aio.representation( session=session.id, target=target.id, search_query="machine learning", @@ -624,7 +590,6 @@ async def test_peer_get_representation_with_all_params( ) assert isinstance(result, str) else: - assert isinstance(honcho_client, Honcho) observer = honcho_client.peer(id="test-working-rep-all-observer") target = honcho_client.peer(id="test-working-rep-all-target") session = honcho_client.session(id="test-working-rep-all-sess") @@ -640,13 +605,13 @@ async def test_peer_get_representation_with_all_params( ) # Get working representation with all parameters - result = observer.get_representation( + result = observer.representation( session=session, target=target, search_query="Python", max_conclusions=10 ) assert isinstance(result, str) # Test with session as string and target as string - result = observer.get_representation( + result = observer.representation( session=session.id, target=target.id, search_query="machine learning", diff --git a/tests/sdk/test_session.py b/tests/sdk/test_session.py index beed854c..04102d66 100644 --- a/tests/sdk/test_session.py +++ b/tests/sdk/test_session.py @@ -1,41 +1,30 @@ -from unittest.mock import AsyncMock, patch - import pytest -from honcho_core.types.workspaces import QueueStatusResponse -from sdks.python.src.honcho.async_client.client import AsyncHoncho -from sdks.python.src.honcho.async_client.peer import AsyncPeer -from sdks.python.src.honcho.async_client.session import ( - AsyncSession, -) -from sdks.python.src.honcho.async_client.session import ( - SessionPeerConfig as AsyncSessionPeerConfig, -) +from sdks.python.src.honcho.api_types import QueueStatusResponse from sdks.python.src.honcho.client import Honcho +from sdks.python.src.honcho.message import Message from sdks.python.src.honcho.peer import Peer from sdks.python.src.honcho.session import Session, SessionPeerConfig @pytest.mark.asyncio -async def test_session_metadata(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_session_metadata(client_fixture: tuple[Honcho, str]): """ Tests creation and metadata operations for sessions. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="test-session-meta") - assert isinstance(session, AsyncSession) + session = await honcho_client.aio.session(id="test-session-meta") + assert isinstance(session, Session) - metadata = await session.get_metadata() + metadata = await session.aio.get_metadata() assert metadata == {} - await session.set_metadata({"foo": "bar"}) - metadata = await session.get_metadata() + await session.aio.set_metadata({"foo": "bar"}) + metadata = await session.aio.get_metadata() assert metadata == {"foo": "bar"} else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="test-session-meta") assert isinstance(session, Session) @@ -49,7 +38,7 @@ async def test_session_metadata(client_fixture: tuple[Honcho | AsyncHoncho, str] @pytest.mark.asyncio async def test_session_peer_management( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests adding, setting, getting, and removing peers from a session. @@ -57,34 +46,32 @@ async def test_session_peer_management( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="test-session-peers") - assert isinstance(session, AsyncSession) - peer1 = await honcho_client.peer(id="p1") - assert isinstance(peer1, AsyncPeer) - peer2 = await honcho_client.peer(id="p2") - assert isinstance(peer2, AsyncPeer) - peer3 = await honcho_client.peer(id="p3") - assert isinstance(peer3, AsyncPeer) + session = await honcho_client.aio.session(id="test-session-peers") + assert isinstance(session, Session) + peer1 = await honcho_client.aio.peer(id="p1") + assert isinstance(peer1, Peer) + peer2 = await honcho_client.aio.peer(id="p2") + assert isinstance(peer2, Peer) + peer3 = await honcho_client.aio.peer(id="p3") + assert isinstance(peer3, Peer) - await session.add_peers([peer1, peer2]) - peers = await session.get_peers() + await session.aio.add_peers([peer1, peer2]) + peers = await session.aio.peers() assert len(peers) == 2 peer_ids = {p.id for p in peers} assert "p1" in peer_ids and "p2" in peer_ids - await session.set_peers([peer2, peer3]) - peers = await session.get_peers() + await session.aio.set_peers([peer2, peer3]) + peers = await session.aio.peers() assert len(peers) == 2 peer_ids = {p.id for p in peers} assert "p2" in peer_ids and "p3" in peer_ids - await session.remove_peers([peer2]) - peers = await session.get_peers() + await session.aio.remove_peers([peer2]) + peers = await session.aio.peers() assert len(peers) == 1 assert peers[0].id == "p3" else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="test-session-peers") assert isinstance(session, Session) peer1 = honcho_client.peer(id="p1") @@ -95,51 +82,49 @@ async def test_session_peer_management( assert isinstance(peer3, Peer) session.add_peers([peer1, peer2]) - peers = session.get_peers() + peers = session.peers() assert len(peers) == 2 peer_ids = {p.id for p in peers} assert "p1" in peer_ids and "p2" in peer_ids session.set_peers([peer2, peer3]) - peers = session.get_peers() + peers = session.peers() assert len(peers) == 2 peer_ids = {p.id for p in peers} assert "p2" in peer_ids and "p3" in peer_ids session.remove_peers([peer2]) - peers = session.get_peers() + peers = session.peers() assert len(peers) == 1 assert peers[0].id == "p3" @pytest.mark.asyncio -async def test_session_peer_config(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_session_peer_config(client_fixture: tuple[Honcho, str]): """ Tests getting and setting peer configurations in a session. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - config = AsyncSessionPeerConfig(observe_others=False, observe_me=False) - session = await honcho_client.session(id="test-session-config") - assert isinstance(session, AsyncSession) - peer = await honcho_client.peer(id="p-config") - assert isinstance(peer, AsyncPeer) - await session.add_peers([(peer, config)]) + config = SessionPeerConfig(observe_others=False, observe_me=False) + session = await honcho_client.aio.session(id="test-session-config") + assert isinstance(session, Session) + peer = await honcho_client.aio.peer(id="p-config") + assert isinstance(peer, Peer) + await session.aio.add_peers([(peer, config)]) - retrieved_config = await session.get_peer_config(peer) + retrieved_config = await session.aio.get_peer_configuration(peer) assert retrieved_config.observe_me is False assert retrieved_config.observe_others is False - await session.set_peer_config( - peer, AsyncSessionPeerConfig(observe_others=True, observe_me=True) + await session.aio.set_peer_configuration( + peer, SessionPeerConfig(observe_others=True, observe_me=True) ) - retrieved_config = await session.get_peer_config(peer) + retrieved_config = await session.aio.get_peer_configuration(peer) assert retrieved_config.observe_me is True assert retrieved_config.observe_others is True else: - assert isinstance(honcho_client, Honcho) config = SessionPeerConfig(observe_others=False, observe_me=False) session = honcho_client.session(id="test-session-config") assert isinstance(session, Session) @@ -147,50 +132,48 @@ async def test_session_peer_config(client_fixture: tuple[Honcho | AsyncHoncho, s assert isinstance(peer, Peer) session.add_peers([(peer, config)]) - retrieved_config = session.get_peer_config(peer) + retrieved_config = session.get_peer_configuration(peer) assert retrieved_config.observe_me is False assert retrieved_config.observe_others is False - session.set_peer_config( + session.set_peer_configuration( peer, SessionPeerConfig(observe_others=True, observe_me=True) ) - retrieved_config = session.get_peer_config(peer) + retrieved_config = session.get_peer_configuration(peer) assert retrieved_config.observe_me assert retrieved_config.observe_others @pytest.mark.asyncio -async def test_session_messages(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_session_messages(client_fixture: tuple[Honcho, str]): """ Tests adding and getting messages from a session. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="test-session-msg") - assert isinstance(session, AsyncSession) - user = await honcho_client.peer(id="user-msg") - assert isinstance(user, AsyncPeer) - assistant = await honcho_client.peer(id="assistant-msg") - assert isinstance(assistant, AsyncPeer) + session = await honcho_client.aio.session(id="test-session-msg") + assert isinstance(session, Session) + user = await honcho_client.aio.peer(id="user-msg") + assert isinstance(user, Peer) + assistant = await honcho_client.aio.peer(id="assistant-msg") + assert isinstance(assistant, Peer) - await session.add_messages( + await session.aio.add_messages( [ user.message("Hello assistant"), assistant.message("Hello user"), ] ) - messages_page = await session.get_messages() + messages_page = await session.aio.messages() messages = messages_page.items assert len(messages) == 2 - messages_page = await session.get_messages(filters={"peer_id": user.id}) + messages_page = await session.aio.messages(filters={"peer_id": user.id}) messages = messages_page.items assert len(messages) == 1 assert messages[0].content == "Hello assistant" else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="test-session-msg") assert isinstance(session, Session) user = honcho_client.peer(id="user-msg") @@ -204,47 +187,45 @@ async def test_session_messages(client_fixture: tuple[Honcho | AsyncHoncho, str] assistant.message("Hello user"), ] ) - messages_page = session.get_messages() + messages_page = session.messages() messages = list(messages_page) assert len(messages) == 2 - messages_page = session.get_messages(filters={"peer_id": user.id}) + messages_page = session.messages(filters={"peer_id": user.id}) messages = list(messages_page) assert len(messages) == 1 assert messages[0].content == "Hello assistant" @pytest.mark.asyncio -async def test_session_get_context(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_session_context(client_fixture: tuple[Honcho, str]): """ Tests getting the context of a session. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="test-session-ctx") - assert isinstance(session, AsyncSession) - user = await honcho_client.peer(id="user-ctx") - assert isinstance(user, AsyncPeer) - await session.add_messages([user.message("This is a context test.")]) - context = await session.get_context() + session = await honcho_client.aio.session(id="test-session-ctx") + assert isinstance(session, Session) + user = await honcho_client.aio.peer(id="user-ctx") + assert isinstance(user, Peer) + await session.aio.add_messages([user.message("This is a context test.")]) + context = await session.aio.context() assert len(context.messages) == 1 assert "context test" in context.messages[0].content else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="test-session-ctx") assert isinstance(session, Session) user = honcho_client.peer(id="user-ctx") assert isinstance(user, Peer) session.add_messages([user.message("This is a context test.")]) - context = session.get_context() + context = session.context() assert len(context.messages) == 1 assert "context test" in context.messages[0].content @pytest.mark.asyncio -async def test_session_search(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_session_search(client_fixture: tuple[Honcho, str]): """ Tests searching for messages in a session. """ @@ -252,19 +233,17 @@ async def test_session_search(client_fixture: tuple[Honcho | AsyncHoncho, str]): search_query = "a unique message for session search" if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="search-session-s") - assert isinstance(session, AsyncSession) - user = await honcho_client.peer(id="search-user-s") - assert isinstance(user, AsyncPeer) - await session.add_messages([user.message(search_query)]) + session = await honcho_client.aio.session(id="search-session-s") + assert isinstance(session, Session) + user = await honcho_client.aio.peer(id="search-user-s") + assert isinstance(user, Peer) + await session.aio.add_messages([user.message(search_query)]) - search_results = await session.search(search_query) + search_results = await session.aio.search(search_query) assert isinstance(search_results, list) assert len(search_results) >= 1 assert search_query in search_results[0].content else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="search-session-s") assert isinstance(session, Session) user = honcho_client.peer(id="search-user-s") @@ -279,7 +258,7 @@ async def test_session_search(client_fixture: tuple[Honcho | AsyncHoncho, str]): @pytest.mark.asyncio async def test_session_add_messages_return_value( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests that add_messages returns a list of Message objects. @@ -287,18 +266,16 @@ async def test_session_add_messages_return_value( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="test-session-add-msg-return") - assert isinstance(session, AsyncSession) - user = await honcho_client.peer(id="user-add-msg-return") - assert isinstance(user, AsyncPeer) - assistant = await honcho_client.peer(id="assistant-add-msg-return") - assert isinstance(assistant, AsyncPeer) + session = await honcho_client.aio.session(id="test-session-add-msg-return") + assert isinstance(session, Session) + user = await honcho_client.aio.peer(id="user-add-msg-return") + assert isinstance(user, Peer) + assistant = await honcho_client.aio.peer(id="assistant-add-msg-return") + assert isinstance(assistant, Peer) # Test single message return value - from honcho_core.types.workspaces.sessions.message import Message - result = await session.add_messages(user.message("Hello assistant")) + result = await session.aio.add_messages(user.message("Hello assistant")) assert isinstance(result, list) assert len(result) == 1 assert isinstance(result[0], Message) @@ -306,7 +283,7 @@ async def test_session_add_messages_return_value( assert result[0].peer_id == user.id # Test multiple messages return value - result = await session.add_messages( + result = await session.aio.add_messages( [ user.message("How are you?"), assistant.message("I'm doing well, thank you!"), @@ -320,7 +297,6 @@ async def test_session_add_messages_return_value( assert result[1].content == "I'm doing well, thank you!" assert result[1].peer_id == assistant.id else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="test-session-add-msg-return") assert isinstance(session, Session) user = honcho_client.peer(id="user-add-msg-return") @@ -329,7 +305,6 @@ async def test_session_add_messages_return_value( assert isinstance(assistant, Peer) # Test single message return value - from honcho_core.types.workspaces.sessions.message import Message result = session.add_messages(user.message("Hello assistant")) assert isinstance(result, list) @@ -355,8 +330,8 @@ async def test_session_add_messages_return_value( @pytest.mark.asyncio -async def test_session_get_representation( - client_fixture: tuple[Honcho | AsyncHoncho, str], +async def test_session_representation( + client_fixture: tuple[Honcho, str], ): """ Tests getting the working representation of a peer in a session. @@ -364,63 +339,59 @@ async def test_session_get_representation( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="test-session-wr") - assert isinstance(session, AsyncSession) - peer = await honcho_client.peer(id="peer-wr") - assert isinstance(peer, AsyncPeer) - await session.add_messages([peer.message("test message for working rep")]) - await session.get_representation(peer) + session = await honcho_client.aio.session(id="test-session-wr") + assert isinstance(session, Session) + peer = await honcho_client.aio.peer(id="peer-wr") + assert isinstance(peer, Peer) + await session.aio.add_messages([peer.message("test message for working rep")]) + await session.aio.representation(peer) else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="test-session-wr") assert isinstance(session, Session) peer = honcho_client.peer(id="peer-wr") assert isinstance(peer, Peer) session.add_messages([peer.message("test message for working rep")]) - session.get_representation(peer) + session.representation(peer) @pytest.mark.asyncio -async def test_session_delete(client_fixture: tuple[Honcho | AsyncHoncho, str]) -> None: +async def test_session_delete(client_fixture: tuple[Honcho, str]) -> None: """ Tests deleting a session and verifying all associated data is removed. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="test-session-delete") - assert isinstance(session, AsyncSession) + session = await honcho_client.aio.session(id="test-session-delete") + assert isinstance(session, Session) # Add a peer and messages to make the session have data - user = await honcho_client.peer(id="user-delete") - await session.add_peers([user]) - await session.add_messages( + user = await honcho_client.aio.peer(id="user-delete") + await session.aio.add_peers([user]) + await session.aio.add_messages( [user.message("Test message that should be deleted")] ) # Verify messages exist before deletion - messages_page = await session.get_messages() + messages_page = await session.aio.messages() messages = messages_page.items assert len(messages) == 1 # Delete should not raise an exception - await session.delete() + await session.aio.delete() # Verify session is removed from active sessions list - all_sessions_page = await honcho_client.get_sessions({"is_active": True}) + all_sessions_page = await honcho_client.aio.sessions({"is_active": True}) all_sessions = all_sessions_page.items all_session_ids = [s.id for s in all_sessions] assert "test-session-delete" not in all_session_ids # Verify session is also removed from all sessions (hard delete, not soft) - all_sessions_page = await honcho_client.get_sessions() + all_sessions_page = await honcho_client.aio.sessions() all_sessions = all_sessions_page.items all_session_ids = [s.id for s in all_sessions] assert "test-session-delete" not in all_session_ids else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="test-session-delete") assert isinstance(session, Session) @@ -430,7 +401,7 @@ async def test_session_delete(client_fixture: tuple[Honcho | AsyncHoncho, str]) session.add_messages([user.message("Test message that should be deleted")]) # Verify messages exist before deletion - messages_page = session.get_messages() + messages_page = session.messages() messages = list(messages_page) assert len(messages) == 1 @@ -438,21 +409,21 @@ async def test_session_delete(client_fixture: tuple[Honcho | AsyncHoncho, str]) session.delete() # Verify session is removed from active sessions list - all_sessions_page = honcho_client.get_sessions({"is_active": True}) + all_sessions_page = honcho_client.sessions({"is_active": True}) all_sessions = list(all_sessions_page) all_session_ids = [s.id for s in all_sessions] assert "test-session-delete" not in all_session_ids # Verify session is also removed from all sessions (hard delete, not soft) - all_sessions_page = honcho_client.get_sessions() + all_sessions_page = honcho_client.sessions() all_sessions = list(all_sessions_page) all_session_ids = [s.id for s in all_sessions] assert "test-session-delete" not in all_session_ids @pytest.mark.asyncio -async def test_session_get_queue_status( - client_fixture: tuple[Honcho | AsyncHoncho, str], +async def test_session_queue_status( + client_fixture: tuple[Honcho, str], ): """ Tests getting deriver status with various parameter combinations for sessions. @@ -460,11 +431,10 @@ async def test_session_get_queue_status( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="test-session-deriver-status") - assert isinstance(session, AsyncSession) + session = await honcho_client.aio.session(id="test-session-deriver-status") + assert isinstance(session, Session) - status = await session.get_queue_status() + status = await session.aio.queue_status() assert isinstance(status, QueueStatusResponse) assert hasattr(status, "total_work_units") assert hasattr(status, "completed_work_units") @@ -473,25 +443,24 @@ async def test_session_get_queue_status( assert status.sessions is None # Test with observer only - peer = await honcho_client.peer(id="test-peer-session-deriver") - await peer.get_metadata() # Create the peer - status = await session.get_queue_status(observer=peer.id) + peer = await honcho_client.aio.peer(id="test-peer-session-deriver") + await peer.aio.get_metadata() # Create the peer + status = await session.aio.queue_status(observer=peer.id) assert isinstance(status, QueueStatusResponse) # Test with sender only - status = await session.get_queue_status(sender=peer.id) + status = await session.aio.queue_status(sender=peer.id) assert isinstance(status, QueueStatusResponse) # Test with both observer and sender - status = await session.get_queue_status(observer=peer.id, sender=peer.id) + status = await session.aio.queue_status(observer=peer.id, sender=peer.id) assert isinstance(status, QueueStatusResponse) else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="test-session-deriver-status") assert isinstance(session, Session) # Test with no parameters - status = session.get_queue_status() + status = session.queue_status() assert isinstance(status, QueueStatusResponse) assert hasattr(status, "total_work_units") assert hasattr(status, "completed_work_units") @@ -502,98 +471,33 @@ async def test_session_get_queue_status( # Test with observer only peer = honcho_client.peer(id="test-peer-session-deriver") peer.get_metadata() # Create the peer - status = session.get_queue_status(observer=peer.id) + status = session.queue_status(observer=peer.id) assert isinstance(status, QueueStatusResponse) # Test with sender only - status = session.get_queue_status(sender=peer.id) + status = session.queue_status(sender=peer.id) assert isinstance(status, QueueStatusResponse) # Test with both observer and sender - status = session.get_queue_status(observer=peer.id, sender=peer.id) + status = session.queue_status(observer=peer.id, sender=peer.id) assert isinstance(status, QueueStatusResponse) @pytest.mark.asyncio -async def test_session_poll_queue_status( - client_fixture: tuple[Honcho | AsyncHoncho, str], -): - """ - Tests polling deriver status until completion for sessions. - """ - honcho_client, client_type = client_fixture - - # Mock the get_queue_status method to return a "completed" status - # to avoid infinite polling in tests - completed_status = QueueStatusResponse( - total_work_units=0, - completed_work_units=0, - in_progress_work_units=0, - pending_work_units=0, - ) - - if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="test-session-poll-queue") - assert isinstance(session, AsyncSession) - - with patch.object( - session.__class__, - "get_queue_status", - new=AsyncMock(return_value=completed_status), - ): - status = await session.poll_queue_status() - assert isinstance(status, QueueStatusResponse) - assert status.pending_work_units == 0 - assert status.in_progress_work_units == 0 - - # Test with parameters - peer = await honcho_client.peer(id="test-peer-session-poll") - with patch.object( - session.__class__, - "get_queue_status", - new=AsyncMock(return_value=completed_status), - ): - status = await session.poll_queue_status(observer=peer.id, sender=peer.id) - assert isinstance(status, QueueStatusResponse) - else: - assert isinstance(honcho_client, Honcho) - session = honcho_client.session(id="test-session-poll-queue") - assert isinstance(session, Session) - - with patch.object( - session.__class__, "get_queue_status", return_value=completed_status - ): - status = session.poll_queue_status() - assert isinstance(status, QueueStatusResponse) - assert status.pending_work_units == 0 - assert status.in_progress_work_units == 0 - - # Test with parameters - peer = honcho_client.peer(id="test-peer-session-poll") - with patch.object( - session.__class__, "get_queue_status", return_value=completed_status - ): - status = session.poll_queue_status(observer=peer.id, sender=peer.id) - assert isinstance(status, QueueStatusResponse) - - -@pytest.mark.asyncio -async def test_session_clone(client_fixture: tuple[Honcho | AsyncHoncho, str]): +async def test_session_clone(client_fixture: tuple[Honcho, str]): """ Tests cloning a session and verifying the cloned session has copied messages. """ honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="test-session-clone-async") - assert isinstance(session, AsyncSession) - user = await honcho_client.peer(id="user-clone-async") - assert isinstance(user, AsyncPeer) + session = await honcho_client.aio.session(id="test-session-clone-async") + assert isinstance(session, Session) + user = await honcho_client.aio.peer(id="user-clone-async") + assert isinstance(user, Peer) # Add messages to the session (implicitly creates session and adds peer) - await session.add_messages( + await session.aio.add_messages( [ user.message("First message"), user.message("Second message"), @@ -601,21 +505,20 @@ async def test_session_clone(client_fixture: tuple[Honcho | AsyncHoncho, str]): ) # Clone the entire session - cloned = await session.clone() - assert isinstance(cloned, AsyncSession) + cloned = await session.aio.clone() + assert isinstance(cloned, Session) assert cloned.id != session.id # Should have a different ID # Verify cloned session has the same messages - cloned_messages_page = await cloned.get_messages() + cloned_messages_page = await cloned.aio.messages() cloned_messages = cloned_messages_page.items assert len(cloned_messages) == 2 # Verify original session still has messages - original_messages_page = await session.get_messages() + original_messages_page = await session.aio.messages() original_messages = original_messages_page.items assert len(original_messages) == 2 else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="test-session-clone-sync") assert isinstance(session, Session) user = honcho_client.peer(id="user-clone-sync") @@ -635,19 +538,19 @@ async def test_session_clone(client_fixture: tuple[Honcho | AsyncHoncho, str]): assert cloned.id != session.id # Should have a different ID # Verify cloned session has the same messages - cloned_messages_page = cloned.get_messages() + cloned_messages_page = cloned.messages() cloned_messages = list(cloned_messages_page) assert len(cloned_messages) == 2 # Verify original session still has messages - original_messages_page = session.get_messages() + original_messages_page = session.messages() original_messages = list(original_messages_page) assert len(original_messages) == 2 @pytest.mark.asyncio async def test_session_clone_with_cutoff( - client_fixture: tuple[Honcho | AsyncHoncho, str], + client_fixture: tuple[Honcho, str], ): """ Tests cloning a session up to a specific message. @@ -655,14 +558,13 @@ async def test_session_clone_with_cutoff( honcho_client, client_type = client_fixture if client_type == "async": - assert isinstance(honcho_client, AsyncHoncho) - session = await honcho_client.session(id="test-session-clone-cutoff-async") - assert isinstance(session, AsyncSession) - user = await honcho_client.peer(id="user-clone-cutoff-async") - assert isinstance(user, AsyncPeer) + session = await honcho_client.aio.session(id="test-session-clone-cutoff-async") + assert isinstance(session, Session) + user = await honcho_client.aio.peer(id="user-clone-cutoff-async") + assert isinstance(user, Peer) # Add messages to the session (implicitly creates session and adds peer) - messages = await session.add_messages( + messages = await session.aio.add_messages( [ user.message("First message"), user.message("Second message"), @@ -672,22 +574,21 @@ async def test_session_clone_with_cutoff( # Clone up to the first message first_message_id = messages[0].id - cloned = await session.clone(message_id=first_message_id) - assert isinstance(cloned, AsyncSession) + cloned = await session.aio.clone(message_id=first_message_id) + assert isinstance(cloned, Session) assert cloned.id != session.id # Verify cloned session only has 1 message - cloned_messages_page = await cloned.get_messages() + cloned_messages_page = await cloned.aio.messages() cloned_messages = cloned_messages_page.items assert len(cloned_messages) == 1 assert cloned_messages[0].content == "First message" # Verify original session still has all 3 messages - original_messages_page = await session.get_messages() + original_messages_page = await session.aio.messages() original_messages = original_messages_page.items assert len(original_messages) == 3 else: - assert isinstance(honcho_client, Honcho) session = honcho_client.session(id="test-session-clone-cutoff-sync") assert isinstance(session, Session) user = honcho_client.peer(id="user-clone-cutoff-sync") @@ -709,12 +610,12 @@ async def test_session_clone_with_cutoff( assert cloned.id != session.id # Verify cloned session only has 1 message - cloned_messages_page = cloned.get_messages() + cloned_messages_page = cloned.messages() cloned_messages = list(cloned_messages_page) assert len(cloned_messages) == 1 assert cloned_messages[0].content == "First message" # Verify original session still has all 3 messages - original_messages_page = session.get_messages() + original_messages_page = session.messages() original_messages = list(original_messages_page) assert len(original_messages) == 3 diff --git a/tests/sdk_typescript/__init__.py b/tests/sdk_typescript/__init__.py new file mode 100644 index 00000000..d654a3c1 --- /dev/null +++ b/tests/sdk_typescript/__init__.py @@ -0,0 +1 @@ +# TypeScript SDK integration tests diff --git a/tests/sdk_typescript/conftest.py b/tests/sdk_typescript/conftest.py new file mode 100644 index 00000000..292b559a --- /dev/null +++ b/tests/sdk_typescript/conftest.py @@ -0,0 +1,141 @@ +""" +TypeScript SDK Integration Tests + +This module provides fixtures for running TypeScript SDK tests against a real HTTP server. +The server uses the same test database and mocks as the Python tests. +""" + +import socket +import threading +import time +from collections.abc import Generator +from contextlib import asynccontextmanager +from threading import Thread +from typing import Any +from unittest.mock import patch + +import pytest +import uvicorn +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker +from uvicorn.config import Config +from uvicorn.server import Server + +from src.dependencies import get_db +from src.main import app + + +class TestServer: + """A test server that runs uvicorn in a background thread.""" + + def __init__(self, app: Any, port: int): + self.config: Config = uvicorn.Config( + app, host="127.0.0.1", port=port, log_level="warning" + ) + self.server: Server = uvicorn.Server(self.config) + self.thread: Thread = threading.Thread(target=self.server.run, daemon=True) + + def start(self) -> None: + self.thread.start() + # Wait for server to be ready + deadline = time.time() + 10 # 10 second timeout + while not self.server.started and time.time() < deadline: + time.sleep(0.05) + if not self.server.started: + raise RuntimeError("Test server failed to start") + + def stop(self) -> None: + self.server.should_exit = True + self.thread.join(timeout=5) + + +def find_free_port() -> int: + """Find an available port on localhost.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +@pytest.fixture(scope="module") +def ts_db_session( + db_engine: AsyncEngine, +) -> Generator[async_sessionmaker[AsyncSession], None, None]: + """Create a session factory for the TypeScript test module.""" + Session = async_sessionmaker(bind=db_engine, expire_on_commit=False) + yield Session + + +# Store the session factory at module level so tracked_db override can access it +_ts_session_factory: async_sessionmaker[AsyncSession] | None = None + + +@pytest.fixture(scope="module") +def ts_test_server( + ts_db_session: async_sessionmaker[AsyncSession], +) -> Generator[str, None, None]: + """ + Start a real HTTP server for TypeScript SDK tests. + + This fixture: + 1. Uses the test database from db_engine + 2. Starts uvicorn on a random port + 3. Yields the server URL + 4. Cleans up on teardown + + Note: tracked_db mocking is handled by the mock_tracked_db fixture + which creates fresh sessions for concurrent requests. + """ + global _ts_session_factory + _ts_session_factory = ts_db_session + + port = find_free_port() + + # Override database dependency to use test database + async def override_get_db(): + async with ts_db_session() as session: + yield session + + app.dependency_overrides[get_db] = override_get_db + + # Start the server + server = TestServer(app, port) + server.start() + + yield f"http://127.0.0.1:{port}" + + # Cleanup + server.stop() + app.dependency_overrides.clear() + _ts_session_factory = None + + +@pytest.fixture(autouse=True) +def mock_tracked_db(ts_db_session: async_sessionmaker[AsyncSession]): + """ + Override the main conftest's mock_tracked_db fixture. + + The main fixture uses a single shared db_session which gets corrupted + by concurrent HTTP requests in the TypeScript tests. This override + creates fresh sessions for each tracked_db call instead. + """ + + # Create a tracked_db that uses fresh sessions (not shared) + @asynccontextmanager + async def ts_tracked_db(_: str | None = None): + async with ts_db_session() as session: + yield session + + with ( + patch("src.dependencies.tracked_db", ts_tracked_db), + patch("src.deriver.queue_manager.tracked_db", ts_tracked_db), + patch("src.deriver.consumer.tracked_db", ts_tracked_db), + patch("src.deriver.enqueue.tracked_db", ts_tracked_db), + patch("src.routers.sessions.tracked_db", ts_tracked_db), + patch("src.routers.peers.tracked_db", ts_tracked_db), + patch("src.crud.representation.tracked_db", ts_tracked_db), + patch("src.dreamer.dream_scheduler.tracked_db", ts_tracked_db), + patch("src.dreamer.orchestrator.tracked_db", ts_tracked_db), + 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), + ): + yield diff --git a/tests/sdk_typescript/test_sdk.py b/tests/sdk_typescript/test_sdk.py new file mode 100644 index 00000000..3f202237 --- /dev/null +++ b/tests/sdk_typescript/test_sdk.py @@ -0,0 +1,73 @@ +""" +TypeScript SDK Integration Tests + +Runs the TypeScript SDK test suite against a real Honcho server. +This ensures SDK changes don't break when server code changes. +""" + +import os +import subprocess +from pathlib import Path + +import pytest + +# Path to the TypeScript SDK +SDK_PATH = Path(__file__).parent.parent.parent / "sdks" / "typescript" + + +def test_typescript_sdk(ts_test_server: str): + """ + Run the TypeScript SDK tests against the test server. + + This test: + 1. Uses the ts_test_server fixture which starts a real HTTP server + 2. Passes the server URL to the TypeScript tests via environment variable + 3. Runs `bun test` and captures output + 4. Fails if any TypeScript tests fail + """ + env = { + **os.environ, + "HONCHO_TEST_URL": ts_test_server, + # Disable retries for faster test failures + "HONCHO_MAX_RETRIES": "0", + } + + result = subprocess.run( + ["bun", "test"], + cwd=str(SDK_PATH), + env=env, + capture_output=True, + text=True, + timeout=300, # 5 minute timeout + ) + + # Print output for debugging + if result.stdout: + print("\n=== TypeScript SDK Test Output ===") + print(result.stdout) + + if result.returncode != 0: + print("\n=== TypeScript SDK Test Errors ===") + print(result.stderr) + pytest.fail(f"TypeScript SDK tests failed with exit code {result.returncode}") + + +def test_typescript_sdk_typecheck(): + """ + Run TypeScript type checking on the SDK. + + This ensures the SDK's types are consistent with usage patterns. + """ + result = subprocess.run( + ["bun", "run", "typecheck"], + cwd=str(SDK_PATH), + capture_output=True, + text=True, + timeout=60, + ) + + if result.returncode != 0: + print("\n=== TypeScript Type Errors ===") + print(result.stdout) + print(result.stderr) + pytest.fail(f"TypeScript type check failed with exit code {result.returncode}") diff --git a/tests/test_advanced_filters.py b/tests/test_advanced_filters.py index 64d4633f..768f29bc 100644 --- a/tests/test_advanced_filters.py +++ b/tests/test_advanced_filters.py @@ -85,7 +85,7 @@ async def test_logical_operators_and_filters( # Create all peers for peer_config in peer_configs: - client.post(f"/v2/workspaces/{test_workspace.name}/peers", json=peer_config) + client.post(f"/v3/workspaces/{test_workspace.name}/peers", json=peer_config) # Test the filter configuration, but only consider the peers we created combined_filter = { @@ -96,7 +96,7 @@ async def test_logical_operators_and_filters( } response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": combined_filter}, ) assert response.status_code == 200, f"Failed testing {description}" @@ -171,7 +171,7 @@ async def test_comparison_operators_filters( # Create session with messages containing different metadata session_id = str(generate_nanoid()) session_response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) assert session_response.status_code == 201 @@ -200,14 +200,14 @@ async def test_comparison_operators_filters( ] messages_response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={"messages": message_configs}, ) assert messages_response.status_code == 201 # Test the filter configuration response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": filter_config}, ) assert response.status_code == 200, f"Failed testing {description}" @@ -247,17 +247,17 @@ async def test_wildcard_filters( peer2_name = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer1_name, "metadata": {"type": "bot"}}, ) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer2_name, "metadata": {"type": "human"}}, ) # Test wildcard for peer_id field response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={ "filters": { "AND": [ @@ -275,7 +275,7 @@ async def test_wildcard_filters( # Test wildcard in comparison operators response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={ "filters": { "id": {"in": ["*"]} # Wildcard in comparison should also match all @@ -298,13 +298,13 @@ async def test_complex_nested_filters( # Create session and messages for complex filtering session_id = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) # Create messages with various metadata combinations client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ { @@ -349,7 +349,7 @@ async def test_complex_nested_filters( # Complex filters: (urgent OR normal priority) AND open status AND NOT assigned to charlie response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={ "filters": { "AND": [ @@ -387,7 +387,7 @@ async def test_filters_across_different_models( # Test workspace filters workspace_name = str(generate_nanoid()) client.post( - "/v2/workspaces", + "/v3/workspaces", json={ "name": workspace_name, "metadata": {"environment": "production", "version": "2.0"}, @@ -395,7 +395,7 @@ async def test_filters_across_different_models( ) response = client.post( - "/v2/workspaces/list", + "/v3/workspaces/list", json={ "filters": { "AND": [ @@ -415,7 +415,7 @@ async def test_filters_across_different_models( session2_id = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session1_id, "peer_names": {test_peer.name: {}}, @@ -423,7 +423,7 @@ async def test_filters_across_different_models( }, ) client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session2_id, "peer_names": {test_peer.name: {}}, @@ -432,7 +432,7 @@ async def test_filters_across_different_models( ) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/list", + f"/v3/workspaces/{test_workspace.name}/sessions/list", json={ "filters": { "OR": [ @@ -458,7 +458,7 @@ async def test_filter_edge_cases( # Test empty logical operators response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={ "filters": { "AND": [] # Empty AND should not crash @@ -469,21 +469,21 @@ async def test_filter_edge_cases( # Test nested empty operators response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"OR": [{"AND": []}, {"id": test_peer.name}]}}, ) assert response.status_code == 200 # Test filter with non-existent columns (should be ignored gracefully) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"non_existent_column": "value"}}, ) assert response.status_code == 422 # Test mixed wildcards and regular values response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={ "filters": { "AND": [ @@ -510,7 +510,7 @@ async def test_backward_compatibility( # Create peer with metadata peer_name = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={ "name": peer_name, "metadata": {"role": "admin", "department": "engineering"}, @@ -519,7 +519,7 @@ async def test_backward_compatibility( # Test old-style simple equality filter response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"metadata": {"role": "admin"}}}, ) assert response.status_code == 200 @@ -529,7 +529,7 @@ async def test_backward_compatibility( # Test multiple field simple filter (implicit AND) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"metadata": {"role": "admin"}, "id": peer_name}}, ) assert response.status_code == 200 @@ -551,7 +551,7 @@ async def test_range_queries_with_dates( session3_id = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session1_id, "peer_names": {test_peer.name: {}}, @@ -559,7 +559,7 @@ async def test_range_queries_with_dates( }, ) client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session2_id, "peer_names": {test_peer.name: {}}, @@ -567,7 +567,7 @@ async def test_range_queries_with_dates( }, ) client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session3_id, "peer_names": {test_peer.name: {}}, @@ -577,7 +577,7 @@ async def test_range_queries_with_dates( # Test date range query response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/list", + f"/v3/workspaces/{test_workspace.name}/sessions/list", json={ "filters": { "metadata": {"created_date": {"gte": "2024-02-01", "lte": "2024-02-28"}} @@ -593,7 +593,7 @@ async def test_range_queries_with_dates( # Test combining date and numeric filters response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/list", + f"/v3/workspaces/{test_workspace.name}/sessions/list", json={ "filters": { "AND": [ @@ -619,7 +619,7 @@ async def test_all_workspace_columns_filtering(client: TestClient): workspace2_name = str(generate_nanoid()) client.post( - "/v2/workspaces", + "/v3/workspaces", json={ "name": workspace1_name, "metadata": {"env": "dev", "version": "1.0", "active": True}, @@ -627,7 +627,7 @@ async def test_all_workspace_columns_filtering(client: TestClient): }, ) client.post( - "/v2/workspaces", + "/v3/workspaces", json={ "name": workspace2_name, "metadata": {"env": "prod", "version": "2.0", "active": False}, @@ -637,7 +637,7 @@ async def test_all_workspace_columns_filtering(client: TestClient): # Test filtering by id (maps to name internally) response = client.post( - "/v2/workspaces/list", + "/v3/workspaces/list", json={"filters": {"id": workspace1_name}}, ) assert response.status_code == 200 @@ -647,7 +647,7 @@ async def test_all_workspace_columns_filtering(client: TestClient): # Test filtering by id with comparison operators response = client.post( - "/v2/workspaces/list", + "/v3/workspaces/list", json={"filters": {"id": {"in": [workspace1_name, workspace2_name]}}}, ) assert response.status_code == 200 @@ -658,7 +658,7 @@ async def test_all_workspace_columns_filtering(client: TestClient): # Test filtering by metadata (maps to h_metadata internally) response = client.post( - "/v2/workspaces/list", + "/v3/workspaces/list", json={"filters": {"metadata": {"env": "dev"}}}, ) assert response.status_code == 200 @@ -669,7 +669,7 @@ async def test_all_workspace_columns_filtering(client: TestClient): # Test filtering by metadata with comparison operators response = client.post( - "/v2/workspaces/list", + "/v3/workspaces/list", json={"filters": {"metadata": {"version": {"gte": "2.0"}}}}, ) assert response.status_code == 200 @@ -680,7 +680,7 @@ async def test_all_workspace_columns_filtering(client: TestClient): # Test filtering by created_at (datetime field) response = client.post( - "/v2/workspaces/list", + "/v3/workspaces/list", json={"filters": {"created_at": {"gte": "2020-01-01"}}}, ) assert response.status_code == 200 @@ -699,7 +699,7 @@ async def test_all_peer_columns_filtering( peer2_name = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={ "name": peer1_name, "metadata": {"role": "user", "level": 1, "active": True}, @@ -707,7 +707,7 @@ async def test_all_peer_columns_filtering( }, ) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={ "name": peer2_name, "metadata": {"role": "admin", "level": 5, "active": False}, @@ -717,7 +717,7 @@ async def test_all_peer_columns_filtering( # Test filtering by id (maps to name internally) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"id": peer1_name}}, ) assert response.status_code == 200 @@ -727,7 +727,7 @@ async def test_all_peer_columns_filtering( # Test filtering by workspace_id (maps to workspace_name internally) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"workspace_id": test_workspace.name}}, ) assert response.status_code == 200 @@ -737,7 +737,7 @@ async def test_all_peer_columns_filtering( # Test filtering by metadata response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"metadata": {"role": "admin"}}}, ) assert response.status_code == 200 @@ -748,7 +748,7 @@ async def test_all_peer_columns_filtering( # Test filtering by metadata with comparison operators response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"metadata": {"level": {"gte": 3}}}}, ) assert response.status_code == 200 @@ -759,7 +759,7 @@ async def test_all_peer_columns_filtering( # Test filtering by created_at response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"created_at": {"gte": "2020-01-01"}}}, ) assert response.status_code == 200 @@ -778,7 +778,7 @@ async def test_all_session_columns_filtering( session2_id = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session1_id, "peer_names": {test_peer.name: {}}, @@ -787,7 +787,7 @@ async def test_all_session_columns_filtering( }, ) client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session2_id, "peer_names": {test_peer.name: {}}, @@ -798,7 +798,7 @@ async def test_all_session_columns_filtering( # Test filtering by id (maps to name internally) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/list", + f"/v3/workspaces/{test_workspace.name}/sessions/list", json={"filters": {"id": session1_id}}, ) assert response.status_code == 200 @@ -808,7 +808,7 @@ async def test_all_session_columns_filtering( # Test filtering by workspace_id (maps to workspace_name internally) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/list", + f"/v3/workspaces/{test_workspace.name}/sessions/list", json={"filters": {"workspace_id": test_workspace.name}}, ) assert response.status_code == 200 @@ -818,7 +818,7 @@ async def test_all_session_columns_filtering( # Test filtering by is_active (boolean field) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/list", + f"/v3/workspaces/{test_workspace.name}/sessions/list", json={"filters": {"is_active": True}}, ) assert response.status_code == 200 @@ -829,7 +829,7 @@ async def test_all_session_columns_filtering( # Test filtering by metadata response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/list", + f"/v3/workspaces/{test_workspace.name}/sessions/list", json={"filters": {"metadata": {"type": "chat"}}}, ) assert response.status_code == 200 @@ -839,7 +839,7 @@ async def test_all_session_columns_filtering( # Test filtering by metadata with comparison operators response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/list", + f"/v3/workspaces/{test_workspace.name}/sessions/list", json={"filters": {"metadata": {"priority": {"gte": 3}}}}, ) assert response.status_code == 200 @@ -850,7 +850,7 @@ async def test_all_session_columns_filtering( # Test filtering by created_at response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/list", + f"/v3/workspaces/{test_workspace.name}/sessions/list", json={"filters": {"created_at": {"gte": "2020-01-01"}}}, ) assert response.status_code == 200 @@ -867,14 +867,14 @@ async def test_all_message_columns_filtering( # Create session and messages for testing session_id = str(generate_nanoid()) session_response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) assert session_response.status_code == 201 # Create messages with various data messages_response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ { @@ -899,7 +899,7 @@ async def test_all_message_columns_filtering( # Test filtering by session_id (maps to session_name internally) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"session_id": session_id}}, ) assert response.status_code == 200 @@ -908,7 +908,7 @@ async def test_all_message_columns_filtering( # Test filtering by peer_id (maps to peer_name internally) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"peer_id": test_peer.name}}, ) assert response.status_code == 200 @@ -917,7 +917,7 @@ async def test_all_message_columns_filtering( # Test filtering by workspace_id (maps to workspace_name internally) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"workspace_id": test_workspace.name}}, ) assert response.status_code == 200 @@ -926,21 +926,21 @@ async def test_all_message_columns_filtering( # Test filtering by content (text field) (not allowed) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"content": "Hello world message"}}, ) assert response.status_code == 422 # Test filtering by content with contains operator (not allowed) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"content": {"contains": "support"}}}, ) assert response.status_code == 422 # Test filtering by metadata response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"metadata": {"type": "greeting"}}}, ) assert response.status_code == 200 @@ -949,7 +949,7 @@ async def test_all_message_columns_filtering( # Test filtering by metadata with comparison operators response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"metadata": {"priority": {"gte": 3}}}}, ) assert response.status_code == 200 @@ -958,7 +958,7 @@ async def test_all_message_columns_filtering( # Test filtering by token_count (integer field) - this should exist after message creation response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"token_count": {"gte": 0}}}, ) assert response.status_code == 200 @@ -967,7 +967,7 @@ async def test_all_message_columns_filtering( # Test filtering by created_at response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"created_at": {"gte": "2020-01-01"}}}, ) assert response.status_code == 200 @@ -985,19 +985,19 @@ async def test_id_field_interpolation_consistency(client: TestClient): # Create workspace client.post( - "/v2/workspaces", + "/v3/workspaces", json={"name": workspace_name, "metadata": {"test": "value"}}, ) # Create peer client.post( - f"/v2/workspaces/{workspace_name}/peers", + f"/v3/workspaces/{workspace_name}/peers", json={"name": peer_name, "metadata": {"test": "value"}}, ) # Create session client.post( - f"/v2/workspaces/{workspace_name}/sessions", + f"/v3/workspaces/{workspace_name}/sessions", json={ "id": session_id, "peer_names": {peer_name: {}}, @@ -1007,7 +1007,7 @@ async def test_id_field_interpolation_consistency(client: TestClient): # Create message messages_response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/{session_id}/messages", + f"/v3/workspaces/{workspace_name}/sessions/{session_id}/messages", json={ "messages": [ { @@ -1024,7 +1024,7 @@ async def test_id_field_interpolation_consistency(client: TestClient): # Workspace: id should map to name response = client.post( - "/v2/workspaces/list", + "/v3/workspaces/list", json={"filters": {"id": workspace_name}}, ) assert response.status_code == 200 @@ -1034,7 +1034,7 @@ async def test_id_field_interpolation_consistency(client: TestClient): # Peer: id should map to name response = client.post( - f"/v2/workspaces/{workspace_name}/peers/list", + f"/v3/workspaces/{workspace_name}/peers/list", json={"filters": {"id": peer_name}}, ) assert response.status_code == 200 @@ -1044,7 +1044,7 @@ async def test_id_field_interpolation_consistency(client: TestClient): # Session: id should map to name response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/list", + f"/v3/workspaces/{workspace_name}/sessions/list", json={"filters": {"id": session_id}}, ) assert response.status_code == 200 @@ -1054,7 +1054,7 @@ async def test_id_field_interpolation_consistency(client: TestClient): # Message: id is not allowed to be filtered on response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/{session_id}/messages/list", json={"filters": {"id": message_id}}, ) assert response.status_code == 422 @@ -1072,12 +1072,12 @@ async def test_foreign_key_field_interpolation( session_id = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer_name, "metadata": {"role": "test"}}, ) client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={ "id": session_id, "peer_names": {peer_name: {}}, @@ -1087,7 +1087,7 @@ async def test_foreign_key_field_interpolation( # Create message client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ { @@ -1101,7 +1101,7 @@ async def test_foreign_key_field_interpolation( # Test workspace_id filtering for peers (maps to workspace_name) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"workspace_id": test_workspace.name}}, ) assert response.status_code == 200 @@ -1112,7 +1112,7 @@ async def test_foreign_key_field_interpolation( # Test workspace_id filtering for sessions (maps to workspace_name) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/list", + f"/v3/workspaces/{test_workspace.name}/sessions/list", json={"filters": {"workspace_id": test_workspace.name}}, ) assert response.status_code == 200 @@ -1122,7 +1122,7 @@ async def test_foreign_key_field_interpolation( # Test session_id filtering for messages (maps to session_name) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"session_id": session_id}}, ) assert response.status_code == 200 @@ -1131,7 +1131,7 @@ async def test_foreign_key_field_interpolation( # Test peer_id filtering for messages (maps to peer_name) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"peer_id": peer_name}}, ) assert response.status_code == 200 @@ -1140,7 +1140,7 @@ async def test_foreign_key_field_interpolation( # Test workspace_id filtering for messages (maps to workspace_name) response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"workspace_id": test_workspace.name}}, ) assert response.status_code == 200 @@ -1157,7 +1157,7 @@ async def test_metadata_field_interpolation(client: TestClient): session_id = str(generate_nanoid()) client.post( - "/v2/workspaces", + "/v3/workspaces", json={ "name": workspace_name, "metadata": {"env": "test", "version": "1.0", "active": True}, @@ -1165,7 +1165,7 @@ async def test_metadata_field_interpolation(client: TestClient): ) client.post( - f"/v2/workspaces/{workspace_name}/peers", + f"/v3/workspaces/{workspace_name}/peers", json={ "name": peer_name, "metadata": {"role": "user", "level": 5, "premium": True}, @@ -1173,7 +1173,7 @@ async def test_metadata_field_interpolation(client: TestClient): ) client.post( - f"/v2/workspaces/{workspace_name}/sessions", + f"/v3/workspaces/{workspace_name}/sessions", json={ "id": session_id, "peer_names": {peer_name: {}}, @@ -1182,7 +1182,7 @@ async def test_metadata_field_interpolation(client: TestClient): ) client.post( - f"/v2/workspaces/{workspace_name}/sessions/{session_id}/messages", + f"/v3/workspaces/{workspace_name}/sessions/{session_id}/messages", json={ "messages": [ { @@ -1202,7 +1202,7 @@ async def test_metadata_field_interpolation(client: TestClient): # Workspace metadata response = client.post( - "/v2/workspaces/list", + "/v3/workspaces/list", json={"filters": {"metadata": {"env": "test"}}}, ) assert response.status_code == 200 @@ -1212,7 +1212,7 @@ async def test_metadata_field_interpolation(client: TestClient): # Peer metadata response = client.post( - f"/v2/workspaces/{workspace_name}/peers/list", + f"/v3/workspaces/{workspace_name}/peers/list", json={"filters": {"metadata": {"role": "user"}}}, ) assert response.status_code == 200 @@ -1222,7 +1222,7 @@ async def test_metadata_field_interpolation(client: TestClient): # Session metadata response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/list", + f"/v3/workspaces/{workspace_name}/sessions/list", json={"filters": {"metadata": {"type": "chat"}}}, ) assert response.status_code == 200 @@ -1232,7 +1232,7 @@ async def test_metadata_field_interpolation(client: TestClient): # Message metadata response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/{session_id}/messages/list", json={"filters": {"metadata": {"sentiment": "positive"}}}, ) assert response.status_code == 200 @@ -1243,7 +1243,7 @@ async def test_metadata_field_interpolation(client: TestClient): # Numeric metadata comparison response = client.post( - f"/v2/workspaces/{workspace_name}/peers/list", + f"/v3/workspaces/{workspace_name}/peers/list", json={"filters": {"metadata": {"level": {"gte": 3}}}}, ) assert response.status_code == 200 @@ -1253,7 +1253,7 @@ async def test_metadata_field_interpolation(client: TestClient): # Boolean metadata comparison response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/list", + f"/v3/workspaces/{workspace_name}/sessions/list", json={"filters": {"metadata": {"archived": {"ne": True}}}}, ) assert response.status_code == 200 @@ -1263,7 +1263,7 @@ async def test_metadata_field_interpolation(client: TestClient): # Float metadata comparison response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/{session_id}/messages/list", json={"filters": {"metadata": {"confidence": {"gte": 0.9}}}}, ) assert response.status_code == 200 @@ -1281,20 +1281,20 @@ async def test_nonexistent_columns_ignored_gracefully( # Create test data peer_name = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer_name, "metadata": {"role": "test"}}, ) # Test filtering by non-existent columns response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"nonexistent_column": "value"}}, ) assert response.status_code == 422 # Test combining real and non-existent columns response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={ "filters": { "AND": [ @@ -1308,7 +1308,7 @@ async def test_nonexistent_columns_ignored_gracefully( # Test with complex nested filters containing non-existent columns response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={ "filters": { "OR": [ @@ -1334,21 +1334,21 @@ async def test_not_logic_correctness( peer3_name = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={ "name": peer1_name, "metadata": {"role": "admin", "department": "engineering"}, }, ) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={ "name": peer2_name, "metadata": {"role": "user", "department": "engineering"}, }, ) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer3_name, "metadata": {"role": "admin", "department": "sales"}}, ) @@ -1356,7 +1356,7 @@ async def test_not_logic_correctness( # User expectation: NOT admin AND NOT engineering = exclude admin users AND exclude engineering users # Current broken code: NOT(admin AND engineering) = exclude users who are BOTH admin AND engineering response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={ "filters": { "NOT": [ @@ -1390,13 +1390,13 @@ async def test_jsonb_type_casting_edge_cases( session_id = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) # Create messages with various data types in metadata client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ { @@ -1425,7 +1425,7 @@ async def test_jsonb_type_casting_edge_cases( # Test boolean comparisons - PostgreSQL stores booleans as "true"/"false" strings response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"metadata": {"active": {"ne": False}}}}, ) assert response.status_code == 200 @@ -1435,7 +1435,7 @@ async def test_jsonb_type_casting_edge_cases( # Test string vs numeric comparison response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"metadata": {"score": {"gt": 10}}}}, ) assert response.status_code == 200 @@ -1446,7 +1446,7 @@ async def test_jsonb_type_casting_edge_cases( # Test large number handling response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"metadata": {"score": {"gte": 999999999}}}}, ) assert response.status_code == 200 @@ -1467,7 +1467,7 @@ async def test_real_datetime_column_filtering( peer2_name = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer1_name, "metadata": {"created": "early"}}, ) @@ -1477,7 +1477,7 @@ async def test_real_datetime_column_filtering( time.sleep(0.1) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer2_name, "metadata": {"created": "late"}}, ) @@ -1487,7 +1487,7 @@ async def test_real_datetime_column_filtering( # Test ISO format response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"created_at": {"gte": one_minute_ago.isoformat()}}}, ) assert response.status_code == 200 @@ -1499,7 +1499,7 @@ async def test_real_datetime_column_filtering( # Test date-only format today = datetime.now(timezone.utc).date().isoformat() response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"created_at": {"gte": today}}}, ) assert response.status_code == 200 @@ -1522,7 +1522,7 @@ async def test_invalid_datetime_handling( for malicious_dt in malicious_datetimes: response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"created_at": {"gte": malicious_dt}}}, ) assert response.status_code == 422 @@ -1537,13 +1537,13 @@ async def test_nested_jsonb_filtering( session_id = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) # Create messages with deeply nested metadata client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ { @@ -1572,7 +1572,7 @@ async def test_nested_jsonb_filtering( # Test nested object filtering - this might not work as expected response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"metadata": {"user": {"profile": {"premium": True}}}}}, ) assert response.status_code == 200 @@ -1590,13 +1590,13 @@ async def test_multiple_operators_same_field( session_id = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) # Create messages with scores for range testing client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ { @@ -1625,7 +1625,7 @@ async def test_multiple_operators_same_field( # Test range query: score >= 3 AND score <= 8 response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"metadata": {"score": {"gte": 3, "lte": 8}}}}, ) assert response.status_code == 200 @@ -1643,7 +1643,7 @@ async def test_empty_and_null_filter_handling( # Test completely empty filter response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", json={"filters": {}} + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {}} ) assert response.status_code == 200 data = response.json() @@ -1652,13 +1652,13 @@ async def test_empty_and_null_filter_handling( # Test null filter response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", json={"filters": None} + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": None} ) assert response.status_code == 200 # Test empty comparison dict response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"metadata": {"role": {}}}}, ) assert response.status_code == 200 @@ -1674,7 +1674,7 @@ async def test_unicode_and_special_characters( # Create peer with unicode metadata # NOTE: peer names are validated to only contain alphanumerics client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={ "name": test_peer.name, "metadata": { @@ -1686,7 +1686,7 @@ async def test_unicode_and_special_characters( # Test unicode in metadata contains response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"metadata": {"description": {"icontains": "wörld"}}}}, ) assert response.status_code == 200 @@ -1707,14 +1707,14 @@ async def test_case_sensitivity_edge_cases( peer2_name = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={ "name": peer1_name, "metadata": {"Role": "Admin", "Department": "ENGINEERING"}, }, ) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={ "name": peer2_name, "metadata": {"role": "admin", "department": "engineering"}, @@ -1723,7 +1723,7 @@ async def test_case_sensitivity_edge_cases( # Test exact case matching (should be case sensitive for JSONB) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"metadata": {"Role": "Admin"}}}, ) assert response.status_code == 200 @@ -1734,7 +1734,7 @@ async def test_case_sensitivity_edge_cases( # Test icontains for case insensitive search response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": {"metadata": {"Department": {"icontains": "engineering"}}}}, ) assert response.status_code == 200 @@ -1763,7 +1763,7 @@ async def test_malformed_filter_structures( for malformed_filter in malformed_filters: response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": malformed_filter}, ) assert response.status_code == 422 @@ -1812,7 +1812,7 @@ async def test_performance_with_complex_filters( # This should complete without timeout response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": complex_filter}, ) assert response.status_code == 200 @@ -1847,12 +1847,12 @@ async def test_filter_precedence_and_grouping( ] for peer_data in peers_data: - client.post(f"/v2/workspaces/{test_workspace.name}/peers", json=peer_data) + client.post(f"/v3/workspaces/{test_workspace.name}/peers", json=peer_data) # Test: (admin OR user) AND (eng OR high level) # This should test that grouping works correctly response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={ "filters": { "AND": [ @@ -1894,13 +1894,13 @@ async def test_jsonb_contains_vs_equality_semantics( session_id = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) # Create messages with different JSONB structures client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ { @@ -1927,7 +1927,7 @@ async def test_jsonb_contains_vs_equality_semantics( # Test JSONB contains behavior - should match both exact and superset response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"metadata": {"role": "admin"}}}, ) assert response.status_code == 200 @@ -1947,13 +1947,13 @@ async def test_wildcard_edge_cases_comprehensive( peer_name = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/peers", + f"/v3/workspaces/{test_workspace.name}/peers", json={"name": peer_name, "metadata": {"role": "admin", "level": 5}}, ) # Test wildcard with comparison operators response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={ "filters": {"metadata": {"level": {"gte": "*"}}} # Wildcard in comparison }, @@ -1965,7 +1965,7 @@ async def test_wildcard_edge_cases_comprehensive( # Test wildcard in array (in operator) response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={ "filters": { "metadata": {"role": {"in": ["*", "admin"]}} @@ -1979,7 +1979,7 @@ async def test_wildcard_edge_cases_comprehensive( # Test multiple wildcards response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={ "filters": { "AND": [ @@ -2012,7 +2012,7 @@ async def test_error_logging_and_debugging( for filter_dict in problematic_filters: response = client.post( - f"/v2/workspaces/{test_workspace.name}/peers/list", + f"/v3/workspaces/{test_workspace.name}/peers/list", json={"filters": filter_dict}, ) assert response.status_code == 422 @@ -2027,7 +2027,7 @@ async def test_boundary_conditions_numeric( session_id = str(generate_nanoid()) client.post( - f"/v2/workspaces/{test_workspace.name}/sessions", + f"/v3/workspaces/{test_workspace.name}/sessions", json={"id": session_id, "peer_names": {test_peer.name: {}}}, ) @@ -2045,7 +2045,7 @@ async def test_boundary_conditions_numeric( ] client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", json={ "messages": [ { @@ -2060,7 +2060,7 @@ async def test_boundary_conditions_numeric( # Test boundary conditions response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"metadata": {"score": {"gte": 0}}}}, ) assert response.status_code == 200 @@ -2072,7 +2072,7 @@ async def test_boundary_conditions_numeric( # Test floating point precision response = client.post( - f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", json={"filters": {"metadata": {"score": {"gt": 3.14}}}}, ) assert response.status_code == 200 @@ -2089,18 +2089,18 @@ async def test_float_precision_edge_cases(client: TestClient): peer_name = str(generate_nanoid()) # Create workspace - response = client.post("/v2/workspaces", json={"name": workspace_name}) + response = client.post("/v3/workspaces", json={"name": workspace_name}) assert response.status_code == 201 # Create peer response = client.post( - f"/v2/workspaces/{workspace_name}/peers", json={"name": peer_name} + f"/v3/workspaces/{workspace_name}/peers", json={"name": peer_name} ) assert response.status_code == 201 # Create messages with problematic floating point values using correct endpoint messages_response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages", + f"/v3/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages", json={ "messages": [ { @@ -2164,7 +2164,7 @@ async def test_float_precision_edge_cases(client: TestClient): # Test exact equality response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages/list", json={"filters": {"metadata": {"value": 0.3}}}, ) assert response.status_code == 200 @@ -2177,7 +2177,7 @@ async def test_float_precision_edge_cases(client: TestClient): # Test near-equality using range queries (proper way to handle float precision) epsilon = 1e-10 response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages/list", json={ "filters": { "AND": [ @@ -2196,7 +2196,7 @@ async def test_float_precision_edge_cases(client: TestClient): # Test greater than with floating point precision response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages/list", json={"filters": {"metadata": {"value": {"gt": 0.3}}}}, ) assert response.status_code == 200 @@ -2207,7 +2207,7 @@ async def test_float_precision_edge_cases(client: TestClient): # Test very small numbers and scientific notation response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages/list", json={"filters": {"metadata": {"value": {"lt": 1e-9}}}}, ) assert response.status_code == 200 @@ -2217,7 +2217,7 @@ async def test_float_precision_edge_cases(client: TestClient): # Test large number precision response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages/list", json={"filters": {"metadata": {"value": {"gte": 999999.0}}}}, ) assert response.status_code == 200 @@ -2227,7 +2227,7 @@ async def test_float_precision_edge_cases(client: TestClient): # Test repeating decimal precision response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages/list", json={"filters": {"metadata": {"value": {"gte": 0.333}}}}, ) assert response.status_code == 200 @@ -2237,7 +2237,7 @@ async def test_float_precision_edge_cases(client: TestClient): # Test floating point comparison with string representation response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages/list", json={"filters": {"metadata": {"calculation": "0.1 + 0.2"}}}, ) assert response.status_code == 200 @@ -2254,18 +2254,18 @@ async def test_mixed_type_comparisons(client: TestClient): peer_name = str(generate_nanoid()) # Create workspace - response = client.post("/v2/workspaces", json={"name": workspace_name}) + response = client.post("/v3/workspaces", json={"name": workspace_name}) assert response.status_code == 201 # Create peer response = client.post( - f"/v2/workspaces/{workspace_name}/peers", json={"name": peer_name} + f"/v3/workspaces/{workspace_name}/peers", json={"name": peer_name} ) assert response.status_code == 201 # Create messages with mixed data types for the same logical field messages_response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/mixedtypes/messages", + f"/v3/workspaces/{workspace_name}/sessions/mixedtypes/messages", json={ "messages": [ { @@ -2335,7 +2335,7 @@ async def test_mixed_type_comparisons(client: TestClient): # Test string vs numeric equality response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", json={"filters": {"metadata": {"priority": 5}}}, ) assert response.status_code == 200 @@ -2347,7 +2347,7 @@ async def test_mixed_type_comparisons(client: TestClient): # Test string number comparison with numeric operator response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", json={"filters": {"metadata": {"priority": {"gte": 5}}}}, ) assert response.status_code == 200 @@ -2358,7 +2358,7 @@ async def test_mixed_type_comparisons(client: TestClient): # Test string boolean vs actual boolean response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", json={"filters": {"metadata": {"active": True}}}, ) assert response.status_code == 200 @@ -2369,7 +2369,7 @@ async def test_mixed_type_comparisons(client: TestClient): # Test explicit string matching response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", json={"filters": {"metadata": {"priority": "5"}}}, ) assert response.status_code == 200 @@ -2379,7 +2379,7 @@ async def test_mixed_type_comparisons(client: TestClient): # Test numeric comparison with string numbers response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", json={"filters": {"metadata": {"score": {"gt": 10}}}}, ) assert response.status_code == 200 @@ -2389,7 +2389,7 @@ async def test_mixed_type_comparisons(client: TestClient): # Test zero comparisons (string "0" vs integer 0) response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", json={"filters": {"metadata": {"count": 0}}}, ) assert response.status_code == 200 @@ -2399,7 +2399,7 @@ async def test_mixed_type_comparisons(client: TestClient): # Test null vs string "null" response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", json={"filters": {"metadata": {"count": None}}}, ) assert response.status_code == 200 @@ -2409,7 +2409,7 @@ async def test_mixed_type_comparisons(client: TestClient): # Test leading zeros handling response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", json={"filters": {"metadata": {"priority": "05"}}}, ) assert response.status_code == 200 @@ -2419,7 +2419,7 @@ async def test_mixed_type_comparisons(client: TestClient): # Test case sensitivity for string booleans response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", json={"filters": {"metadata": {"active": "TRUE"}}}, ) assert response.status_code == 200 @@ -2429,7 +2429,7 @@ async def test_mixed_type_comparisons(client: TestClient): # Test empty string vs other falsy values response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", json={"filters": {"metadata": {"priority": ""}}}, ) assert response.status_code == 200 @@ -2439,7 +2439,7 @@ async def test_mixed_type_comparisons(client: TestClient): # Test special string values response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", json={"filters": {"metadata": {"score": "NaN"}}}, ) assert response.status_code == 200 @@ -2449,7 +2449,7 @@ async def test_mixed_type_comparisons(client: TestClient): # Test mixed type in operator response = client.post( - f"/v2/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", + f"/v3/workspaces/{workspace_name}/sessions/mixedtypes/messages/list", json={"filters": {"metadata": {"priority": {"in": [5, "5", 5.0]}}}}, ) assert response.status_code == 200 diff --git a/tests/unified/runner.py b/tests/unified/runner.py index c146d29b..104f804c 100644 --- a/tests/unified/runner.py +++ b/tests/unified/runner.py @@ -10,22 +10,28 @@ from pathlib import Path from typing import Any, cast import httpx +import redis.asyncio as aioredis from anthropic import AsyncAnthropic -from honcho.async_client.session import AsyncSession +from honcho.api_types import ( + MessageCreateParams, + QueueStatusResponse, +) +from honcho.api_types import ( + SessionConfiguration as SDKSessionConfiguration, +) +from honcho.api_types import ( + WorkspaceConfiguration as SDKWorkspaceConfiguration, +) +from honcho.session import Session from honcho.session_context import SessionContext -from honcho_core.types.workspaces import QueueStatusResponse from pydantic import ValidationError # Adjust path to allow imports from tests.bench sys.path.insert(0, str(Path(__file__).parents[2])) -from honcho import AsyncHoncho -from honcho.async_client.session import SessionPeerConfig as SDKSessionPeerConfig +from honcho import Honcho from honcho.base import PeerBase -from honcho_core.types.workspaces.sessions.message_create_param import ( - Configuration, - MessageCreateParam, -) +from honcho.session import SessionPeerConfig as SDKSessionPeerConfig from tests.bench.harness import HonchoHarness from tests.unified.schema import ( @@ -173,19 +179,24 @@ async def save_results_to_s3( class UnifiedTestExecutor: def __init__( - self, honcho_client: AsyncHoncho, anthropic_client: AsyncAnthropic | None + self, + honcho_client: Honcho, + anthropic_client: AsyncAnthropic | None, + redis_url: str, ): - self.client: AsyncHoncho = honcho_client + self.client: Honcho = honcho_client self.anthropic: AsyncAnthropic | None = anthropic_client + self.redis_url: str = redis_url async def execute(self, test_def: TestDefinition, test_name: str) -> bool: logger.info(f"Starting test: {test_name}") # 1. Apply workspace config if present if test_def.workspace_config: - await self.client.set_config( + sdk_config = SDKWorkspaceConfiguration.model_validate( test_def.workspace_config.model_dump(exclude_none=True) ) + await self.client.aio.set_configuration(sdk_config) for i, step in enumerate(test_def.steps): logger.info(f"Executing step {i + 1}: {step.step_type}") @@ -200,58 +211,66 @@ class UnifiedTestExecutor: async def execute_step(self, step: Any): if isinstance(step, SetWorkspaceConfigAction): - await self.client.set_config(step.config.model_dump(exclude_none=True)) + sdk_config = SDKWorkspaceConfiguration.model_validate( + step.config.model_dump(exclude_none=True) + ) + await self.client.aio.set_configuration(sdk_config) elif isinstance(step, SetSessionConfigAction): - session = await self.client.session(id=step.session_id) - await session.set_config(step.config.model_dump(exclude_none=True)) + session = await self.client.aio.session(id=step.session_id) + sdk_config = SDKSessionConfiguration.model_validate( + step.config.model_dump(exclude_none=True) + ) + await session.aio.set_configuration(sdk_config) elif isinstance(step, CreateSessionAction): - session = await self.client.session( - id=step.session_id, - config=step.config.model_dump(exclude_none=True) - if step.config - else None, - ) - - if step.peer_configs: - peer_list: list[tuple[str | PeerBase, SDKSessionPeerConfig]] = [] - for peer_id, config in step.peer_configs.items(): - sdk_config = SDKSessionPeerConfig( - **config.model_dump(exclude_none=True) - ) - peer_list.append((peer_id, sdk_config)) - await session.add_peers(peer_list) - - elif isinstance(step, AddMessageAction): - session = await self.client.session(id=step.session_id) - peer = await self.client.peer(id=step.peer_id) - # TODO: NOT CURRENTLY RESPECTING MESSAGE CONFIG - - config = ( - cast( - Configuration, cast(Any, step.config.model_dump(exclude_none=True)) + sdk_config = ( + SDKSessionConfiguration.model_validate( + step.config.model_dump(exclude_none=True) ) if step.config else None ) + session = await self.client.aio.session( + id=step.session_id, + configuration=sdk_config, + ) - await session.add_messages( - [peer.message(step.content, created_at=step.created_at, config=config)] + if step.peer_configs: + peer_list: list[tuple[str | PeerBase, SDKSessionPeerConfig]] = [] + for peer_id, peer_config in step.peer_configs.items(): + sdk_config = SDKSessionPeerConfig( + **peer_config.model_dump(exclude_none=True) + ) + peer_list.append((peer_id, sdk_config)) + await session.aio.add_peers(peer_list) + + elif isinstance(step, AddMessageAction): + session = await self.client.aio.session(id=step.session_id) + peer = await self.client.aio.peer(id=step.peer_id) + # TODO: NOT CURRENTLY RESPECTING MESSAGE CONFIG + + config: dict[str, Any] | None = ( + step.config.model_dump(exclude_none=True) if step.config else None + ) + + await session.aio.add_messages( + [ + peer.message( + step.content, created_at=step.created_at, configuration=config + ) + ] ) elif isinstance(step, AddMessagesAction): - session = await self.client.session(id=step.session_id) - msgs: list[MessageCreateParam] = [] + session = await self.client.aio.session(id=step.session_id) + msgs: list[MessageCreateParams] = [] for msg_item in step.messages: - peer = await self.client.peer(id=msg_item.peer_id) + peer = await self.client.aio.peer(id=msg_item.peer_id) # TODO: NOT CURRENTLY RESPECTING MESSAGE CONFIG config = ( - cast( - Configuration, - cast(Any, msg_item.config.model_dump(exclude_none=True)), - ) + msg_item.config.model_dump(exclude_none=True) if msg_item.config else None ) @@ -260,25 +279,24 @@ class UnifiedTestExecutor: peer.message( msg_item.content, created_at=msg_item.created_at, - config=config, + configuration=config, ) ) - await session.add_messages(msgs) + await session.aio.add_messages(msgs) elif isinstance(step, WaitAction): if step.duration: await asyncio.sleep(step.duration) if step.target == "queue_empty": + if step.flush: + await self.flush_deriver_queue() await self.wait_for_queue(step.timeout) elif isinstance(step, ScheduleDreamAction): - # Use the core SDK to trigger a dream - await self.client.core.workspaces.schedule_dream( - workspace_id=self.client.workspace_id, - session_id=step.session_id, + await self.client.aio.schedule_dream( observer=step.observer, + session=step.session_id, observed=step.observed, - dream_type=step.dream_type.value, ) elif isinstance(step, QueryAction): @@ -286,13 +304,24 @@ class UnifiedTestExecutor: for assertion in step.assertions: await self.check_assertion(result, assertion) + async def flush_deriver_queue(self): + """Enable deriver flush mode to bypass batch token threshold.""" + # Use direct Redis connection to set the flush key + # This avoids issues with settings being loaded before env vars are set + redis_client = aioredis.from_url(self.redis_url) # pyright: ignore[reportUnknownMemberType] + try: + await redis_client.set("honcho:deriver:flush_mode", "1", ex=60) + logger.info("Enabled deriver flush mode") + finally: + await redis_client.aclose() + async def wait_for_queue(self, timeout: int): # Poll deriver status # Wait for potential background tasks to enqueue await asyncio.sleep(1) start = time.time() while time.time() - start < timeout: - status: QueueStatusResponse = await self.client.get_queue_status() + status: QueueStatusResponse = await self.client.aio.queue_status() # status structure from schema: DeriverStatus with pending_work_units, in_progress_work_units if status.pending_work_units == 0 and status.in_progress_work_units == 0: return @@ -306,18 +335,21 @@ class UnifiedTestExecutor: if step.input is None: raise ValueError("input required for chat") - peer = await self.client.peer(id=step.observer_peer_id) + peer = await self.client.aio.peer(id=step.observer_peer_id) - response = await peer.chat( - step.input, session=step.session_id, target=step.observed_peer_id + response = await peer.aio.chat( + step.input, + session=step.session_id, + target=step.observed_peer_id, + reasoning_level=step.reasoning_level, ) return response elif step.target == "get_context": if not step.session_id: raise ValueError("session_id required for get_context") - session: AsyncSession = await self.client.session(id=step.session_id) - context: SessionContext = await session.get_context( + session: Session = await self.client.aio.session(id=step.session_id) + context: SessionContext = await session.aio.context( summary=step.summary, tokens=step.max_tokens ) # Return the whole context object @@ -327,8 +359,8 @@ class UnifiedTestExecutor: if not step.observer_peer_id: raise ValueError("peer_id required for get_peer_card") - peer = await self.client.peer(id=step.observer_peer_id) - card = await peer.card( + peer = await self.client.aio.peer(id=step.observer_peer_id) + card = await peer.aio.card( step.observed_peer_id if step.observed_peer_id else step.observer_peer_id @@ -339,8 +371,8 @@ class UnifiedTestExecutor: if not step.observer_peer_id: raise ValueError("observer_peer_id required for get_representation") - peer = await self.client.peer(id=step.observer_peer_id) - representation = await peer.get_representation( + peer = await self.client.aio.peer(id=step.observer_peer_id) + representation = await peer.aio.representation( step.session_id, target=step.observed_peer_id, search_query=step.input ) return representation @@ -545,12 +577,13 @@ class UnifiedTestRunner: logger.info(f"Found {len(test_files)} test(s)") # 3. Execute Tests - client = AsyncHoncho( + client = Honcho( base_url=f"http://localhost:{self.harness.api_port}", workspace_id="default", # Will be overridden per test ) + redis_url = f"redis://localhost:{self.harness.redis_port}/0" - executor = UnifiedTestExecutor(client, self.anthropic) + executor = UnifiedTestExecutor(client, self.anthropic, redis_url) suite_start_time = time.time() @@ -564,7 +597,7 @@ class UnifiedTestRunner: data = json.load(f) test_def = TestDefinition(**data) - executor.client = AsyncHoncho( + executor.client = Honcho( base_url=f"http://localhost:{self.harness.api_port}", workspace_id=f"test_{test_name}_{int(time.time())}", ) diff --git a/tests/unified/schema.py b/tests/unified/schema.py index 0e59b723..aa4c78ad 100644 --- a/tests/unified/schema.py +++ b/tests/unified/schema.py @@ -3,6 +3,7 @@ from typing import Annotated, Any, Literal from pydantic import BaseModel, Field +from src.config import ReasoningLevel from src.schemas import ( DreamType, MessageConfiguration, @@ -72,6 +73,10 @@ class WaitAction(TestStep): ) target: Literal["queue_empty"] = "queue_empty" timeout: int = 60 + flush: bool = Field( + False, + description="Enable flush mode to bypass batch token threshold before waiting", + ) # --- Dream Actions --- @@ -141,6 +146,9 @@ class QueryAction(TestStep): observed_peer_id: str | None = None observer_peer_id: str | None = None + # for chat - reasoning level + reasoning_level: ReasoningLevel | None = None + assertions: list[ LLMJudgeAssertion | ContainsAssertion diff --git a/tests/unified/test_cases/config_deriver_hierarchy.json b/tests/unified/test_cases/config_deriver_hierarchy.json index 8eb7bc9d..1ab883db 100644 --- a/tests/unified/test_cases/config_deriver_hierarchy.json +++ b/tests/unified/test_cases/config_deriver_hierarchy.json @@ -34,7 +34,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", @@ -85,7 +86,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/config_message_positive_override.json b/tests/unified/test_cases/config_message_positive_override.json index a9a46623..911f21b5 100644 --- a/tests/unified/test_cases/config_message_positive_override.json +++ b/tests/unified/test_cases/config_message_positive_override.json @@ -36,7 +36,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/config_peercard_control.json b/tests/unified/test_cases/config_peercard_control.json index 04145446..11db312a 100644 --- a/tests/unified/test_cases/config_peercard_control.json +++ b/tests/unified/test_cases/config_peercard_control.json @@ -38,7 +38,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/config_summary_control.json b/tests/unified/test_cases/config_summary_control.json index 3b23c6d6..f71cdeee 100644 --- a/tests/unified/test_cases/config_summary_control.json +++ b/tests/unified/test_cases/config_summary_control.json @@ -77,7 +77,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/config_summary_control_deriver_off.json b/tests/unified/test_cases/config_summary_control_deriver_off.json index 82e10f64..7a788990 100644 --- a/tests/unified/test_cases/config_summary_control_deriver_off.json +++ b/tests/unified/test_cases/config_summary_control_deriver_off.json @@ -76,7 +76,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/dialectic_reasoning_levels.json b/tests/unified/test_cases/dialectic_reasoning_levels.json new file mode 100644 index 00000000..509e0c39 --- /dev/null +++ b/tests/unified/test_cases/dialectic_reasoning_levels.json @@ -0,0 +1,135 @@ +{ + "description": "Test all dialectic reasoning levels (minimal, low, medium, high, max) to ensure each level works correctly", + "workspace_config": {}, + "steps": [ + { + "step_type": "create_session", + "session_id": "reasoning_levels_test", + "peer_configs": { + "user": { + "observe_me": true, + "observe_others": false + }, + "assistant": { + "observe_me": false, + "observe_others": true + } + } + }, + { + "step_type": "add_messages", + "session_id": "reasoning_levels_test", + "messages": [ + { + "peer_id": "user", + "content": "Hi! My name is Jordan and I'm a software engineer from Seattle.", + "created_at": "2024-01-15T10:00:00" + }, + { + "peer_id": "assistant", + "content": "Nice to meet you, Jordan! Seattle is a great city for tech. What kind of software do you work on?", + "created_at": "2024-01-15T10:01:00" + }, + { + "peer_id": "user", + "content": "I mainly work on backend systems using Python and Go. I've been at my current company for about 3 years now.", + "created_at": "2024-01-15T10:02:00" + }, + { + "peer_id": "assistant", + "content": "That's a solid combination! Python and Go complement each other well - Python for rapid development and Go for performance-critical services.", + "created_at": "2024-01-15T10:03:00" + } + ] + }, + { + "step_type": "wait", + "target": "queue_empty", + "timeout": 120, + "flush": true + }, + { + "step_type": "query", + "description": "Test minimal reasoning level", + "target": "chat", + "session_id": "reasoning_levels_test", + "observer_peer_id": "assistant", + "observed_peer_id": "user", + "reasoning_level": "minimal", + "input": "What is the user's name?", + "assertions": [ + { + "assertion_type": "contains", + "text": "Jordan" + } + ] + }, + { + "step_type": "query", + "description": "Test low reasoning level", + "target": "chat", + "session_id": "reasoning_levels_test", + "observer_peer_id": "assistant", + "observed_peer_id": "user", + "reasoning_level": "low", + "input": "What programming languages does this person use?", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "Does the response mention Python and/or Go as programming languages the user works with?", + "pass_if": true + } + ] + }, + { + "step_type": "query", + "description": "Test medium reasoning level", + "target": "chat", + "session_id": "reasoning_levels_test", + "observer_peer_id": "assistant", + "observed_peer_id": "user", + "reasoning_level": "medium", + "input": "What city does this person live in?", + "assertions": [ + { + "assertion_type": "contains", + "text": "Seattle" + } + ] + }, + { + "step_type": "query", + "description": "Test high reasoning level", + "target": "chat", + "session_id": "reasoning_levels_test", + "observer_peer_id": "assistant", + "observed_peer_id": "user", + "reasoning_level": "high", + "input": "What is this person's profession?", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "Does the response indicate the person is a software engineer or works in software/tech?", + "pass_if": true + } + ] + }, + { + "step_type": "query", + "description": "Test max reasoning level", + "target": "chat", + "session_id": "reasoning_levels_test", + "observer_peer_id": "assistant", + "observed_peer_id": "user", + "reasoning_level": "max", + "input": "How long has this person been at their current job?", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "Does the response mention approximately 3 years of tenure at the current company?", + "pass_if": true + } + ] + } + ] +} diff --git a/tests/unified/test_cases/dream_consolidate_reduces_documents.json b/tests/unified/test_cases/dream_consolidate_reduces_documents.json deleted file mode 100644 index e77b3b73..00000000 --- a/tests/unified/test_cases/dream_consolidate_reduces_documents.json +++ /dev/null @@ -1,162 +0,0 @@ -{ - "description": "Test that manually triggering consolidate dream reduces document count by merging repetitive facts", - "workspace_config": { - "dream": { - "enabled": true - } - }, - "steps": [ - { - "step_type": "create_session", - "session_id": "session_dream_test", - "peer_configs": { - "user": { - "observe_me": true, - "observe_others": false - } - } - }, - { - "step_type": "add_messages", - "session_id": "session_dream_test", - "messages": [ - { - "peer_id": "user", - "content": "My favorite color is blue." - }, - { - "peer_id": "user", - "content": "I have a dog named Max." - }, - { - "peer_id": "user", - "content": "I live in San Francisco." - }, - { - "peer_id": "user", - "content": "I'm employed as a software engineer." - } - ] - }, - { - "step_type": "wait", - "target": "queue_empty" - }, - { - "step_type": "add_messages", - "session_id": "session_dream_test", - "messages": [ - { - "peer_id": "user", - "content": "I really love the color blue." - }, - { - "peer_id": "user", - "content": "I own a dog called Max." - }, - { - "peer_id": "user", - "content": "My home is in San Francisco." - }, - { - "peer_id": "user", - "content": "I work as a software engineer." - } - ] - }, - { - "step_type": "wait", - "target": "queue_empty" - }, - { - "step_type": "add_messages", - "session_id": "session_dream_test", - "messages": [ - { - "peer_id": "user", - "content": "My job is software engineering." - }, - { - "peer_id": "user", - "content": "Blue is my preferred color." - }, - { - "peer_id": "user", - "content": "My dog's name is Max." - }, - { - "peer_id": "user", - "content": "San Francisco is where I live." - } - ] - }, - { - "step_type": "wait", - "target": "queue_empty" - }, - { - "step_type": "query", - "target": "get_representation", - "observer_peer_id": "user", - "session_id": "session_dream_test", - "assertions": [ - { - "assertion_type": "llm_judge", - "prompt": "Check that the representation contains facts about blue being favorite color, having a dog named Max, living in San Francisco, and working as a software engineer. There should be multiple explicit observations since we added repetitive messages. Confirm there are at least 8 explicit observations.", - "pass_if": true - } - ] - }, - { - "step_type": "schedule_dream", - "observer": "user", - "dream_type": "omni", - "session_id": "session_dream_test" - }, - { - "step_type": "wait", - "target": "queue_empty" - }, - { - "step_type": "query", - "target": "get_representation", - "observer_peer_id": "user", - "session_id": "session_dream_test", - "assertions": [ - { - "assertion_type": "llm_judge", - "prompt": "Check that the representation still contains the core facts (blue favorite color, dog named Max, lives in San Francisco, works as software engineer) BUT now with FEWER explicit observations than before the consolidation. The consolidation should have merged repetitive facts. Confirm there are significantly fewer explicit observations.", - "pass_if": true - } - ] - }, - { - "step_type": "query", - "target": "get_representation", - "observer_peer_id": "user", - "session_id": "session_dream_test", - "assertions": [ - { - "assertion_type": "contains", - "text": "blue", - "case_sensitive": false - }, - { - "assertion_type": "contains", - "text": "max", - "case_sensitive": false - }, - { - "assertion_type": "contains", - "text": "san francisco", - "case_sensitive": false - }, - { - "assertion_type": "contains", - "text": "software engineer", - "case_sensitive": false - } - ] - } - ] -} diff --git a/tests/unified/test_cases/longmem_ancash.json b/tests/unified/test_cases/longmem_ancash.json index 0b4b1e06..ef455eb8 100644 --- a/tests/unified/test_cases/longmem_ancash.json +++ b/tests/unified/test_cases/longmem_ancash.json @@ -1,7 +1,6 @@ { "description": "LongMemEval test: single-session-assistant question", - "workspace_config": { - }, + "workspace_config": {}, "steps": [ { "step_type": "create_session", @@ -28,7 +27,7 @@ }, { "peer_id": "assistant", - "content": "Sure! How about \"Seco de Cordero\" (Lamb Stew) from Ancash:\n\nIngredients:\n- 2 lbs. lamb, cut into small pieces\n- 1 onion, chopped\n- 3 garlic cloves, minced\n- 1 tsp. cumin\n- 1 tsp. dried oregano\n- 2 tbsp. vegetable oil\n- 2 tbsp. ají amarillo paste\n- 1 cup beer\n- 2 cups water\n- 4 potatoes, peeled and cut into wedges\n- Salt and pepper, to taste\n- 1/4 cup chopped cilantro\n\nInstructions:\n1. In a large pot, heat the oil over medium-high heat. Add the lamb pieces and cook until browned on all sides, about 8-10 minutes.\n2. Remove the lamb from the pot and set it aside on a plate.\n3. In the same pot, add the chopped onion and garlic. Cook until translucent, about 5-7 minutes.\n4. Add the cumin, oregano, and ají amarillo paste to the pot. Stir to combine and cook for another minute or two.\n5. Add the beer, water, and lamb back to the pot. Bring the mixture to a boil and then reduce the heat to low. Cover and let simmer for about 1 hour, or until the lamb is tender.\n6. Add the potatoes to the pot and season with salt and pepper to taste. Cook until the potatoes are soft and the stew has thickened, about 30-45 minutes.\n7. To finish, stir in the chopped cilantro and serve hot with a side of rice.\n\nEnjoy your Seco de Cordero, a delicious and hearty dish from Ancash!", + "content": "Sure! How about \"Seco de Cordero\" (Lamb Stew) from Ancash:\n\nIngredients:\n- 2 lbs. lamb, cut into small pieces\n- 1 onion, chopped\n- 3 garlic cloves, minced\n- 1 tsp. cumin\n- 1 tsp. dried oregano\n- 2 tbsp. vegetable oil\n- 2 tbsp. aj\u00ed amarillo paste\n- 1 cup beer\n- 2 cups water\n- 4 potatoes, peeled and cut into wedges\n- Salt and pepper, to taste\n- 1/4 cup chopped cilantro\n\nInstructions:\n1. In a large pot, heat the oil over medium-high heat. Add the lamb pieces and cook until browned on all sides, about 8-10 minutes.\n2. Remove the lamb from the pot and set it aside on a plate.\n3. In the same pot, add the chopped onion and garlic. Cook until translucent, about 5-7 minutes.\n4. Add the cumin, oregano, and aj\u00ed amarillo paste to the pot. Stir to combine and cook for another minute or two.\n5. Add the beer, water, and lamb back to the pot. Bring the mixture to a boil and then reduce the heat to low. Cover and let simmer for about 1 hour, or until the lamb is tender.\n6. Add the potatoes to the pot and season with salt and pepper to taste. Cook until the potatoes are soft and the stew has thickened, about 30-45 minutes.\n7. To finish, stir in the chopped cilantro and serve hot with a side of rice.\n\nEnjoy your Seco de Cordero, a delicious and hearty dish from Ancash!", "created_at": "2023-05-20T00:37:00" }, { @@ -48,7 +47,7 @@ }, { "peer_id": "assistant", - "content": "Here are a few more tips to keep in mind as you prepare your Seco de Cordero:\n\n1. It's best to use a tough cut of lamb for this recipe, such as shoulder or leg, as the long cooking time will help to tenderize the meat. 2. When browning the lamb, make sure not to overcrowd the pot or the meat will steam instead of searing. To ensure a nice crust, work in batches if necessary. 3. If you can't find fresh ají amarillo peppers or paste, you can substitute with other chili paste or powder that has a similar heat level and flavor profile. 4. You can adjust the amount of liquid in the recipe to your preference. If you like your stew to be thicker, use less water or beer. 5. Once the potatoes are added, it's important to stir them gently to avoid breaking them apart. You can also add them halfway through the cooking process if you prefer firmer potatoes. 6. Don't forget to taste and adjust the seasoning at the end of the cooking process to make sure the stew is well-seasoned to your liking. I hope these tips are helpful, and I wish you a great cooking experience and a delicious meal!", + "content": "Here are a few more tips to keep in mind as you prepare your Seco de Cordero:\n\n1. It's best to use a tough cut of lamb for this recipe, such as shoulder or leg, as the long cooking time will help to tenderize the meat. 2. When browning the lamb, make sure not to overcrowd the pot or the meat will steam instead of searing. To ensure a nice crust, work in batches if necessary. 3. If you can't find fresh aj\u00ed amarillo peppers or paste, you can substitute with other chili paste or powder that has a similar heat level and flavor profile. 4. You can adjust the amount of liquid in the recipe to your preference. If you like your stew to be thicker, use less water or beer. 5. Once the potatoes are added, it's important to stir them gently to avoid breaking them apart. You can also add them halfway through the cooking process if you prefer firmer potatoes. 6. Don't forget to taste and adjust the seasoning at the end of the cooking process to make sure the stew is well-seasoned to your liking. I hope these tips are helpful, and I wish you a great cooking experience and a delicious meal!", "created_at": "2023-05-20T00:37:00" }, { @@ -68,7 +67,7 @@ }, { "peer_id": "assistant", - "content": "Yes, there are many delicious Peruvian dishes you can try! Here are some classic Peruvian dishes that you might want to add to your recipe collection:\n\n1. Ceviche - a refreshing dish made with fresh raw fish or seafood marinated in citrus juice and spices, served with sweet potato and corn. 2. Lomo Saltado - a popular stir-fry dish made with beef, onions, tomatoes, and spices, served with rice and french fries. 3. Ají de Gallina - a creamy chicken stew made with shredded chicken, bread, walnuts and aji amarillo pepper, served with boiled potatoes and rice. 4. Pollo a la Brasa - a succulent rotisserie chicken marinated in spices, served with fries, salad, and aji sauce. 5. Papa a la Huancaína - boiled potatoes served with a spicy creamy sauce made with cheese, ají amarillo pepper and evaporated milk, garnished with hard-boiled eggs and olives. 6. Anticuchos - skewers of marinated beef heart or chicken, grilled and served with boiled potatoes and aji sauce. All of these dishes are delicious and unique, and can give you an authentic taste of Peruvian cuisine. You can also explore other Peruvian dishes and find the ones that suit your taste buds. Enjoy!", + "content": "Yes, there are many delicious Peruvian dishes you can try! Here are some classic Peruvian dishes that you might want to add to your recipe collection:\n\n1. Ceviche - a refreshing dish made with fresh raw fish or seafood marinated in citrus juice and spices, served with sweet potato and corn. 2. Lomo Saltado - a popular stir-fry dish made with beef, onions, tomatoes, and spices, served with rice and french fries. 3. Aj\u00ed de Gallina - a creamy chicken stew made with shredded chicken, bread, walnuts and aji amarillo pepper, served with boiled potatoes and rice. 4. Pollo a la Brasa - a succulent rotisserie chicken marinated in spices, served with fries, salad, and aji sauce. 5. Papa a la Huanca\u00edna - boiled potatoes served with a spicy creamy sauce made with cheese, aj\u00ed amarillo pepper and evaporated milk, garnished with hard-boiled eggs and olives. 6. Anticuchos - skewers of marinated beef heart or chicken, grilled and served with boiled potatoes and aji sauce. All of these dishes are delicious and unique, and can give you an authentic taste of Peruvian cuisine. You can also explore other Peruvian dishes and find the ones that suit your taste buds. Enjoy!", "created_at": "2023-05-20T00:37:00" } ] @@ -76,7 +75,8 @@ { "step_type": "wait", "target": "queue_empty", - "timeout": 180 + "timeout": 180, + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/longmem_ancash_directional.json b/tests/unified/test_cases/longmem_ancash_directional.json index 1a364532..2871a9a0 100644 --- a/tests/unified/test_cases/longmem_ancash_directional.json +++ b/tests/unified/test_cases/longmem_ancash_directional.json @@ -1,7 +1,6 @@ { "description": "LongMemEval test: single-session-assistant question using directional representation (assistant observes user)", - "workspace_config": { - }, + "workspace_config": {}, "steps": [ { "step_type": "create_session", @@ -28,7 +27,7 @@ }, { "peer_id": "assistant", - "content": "Sure! How about \"Seco de Cordero\" (Lamb Stew) from Ancash:\n\nIngredients:\n- 2 lbs. lamb, cut into small pieces\n- 1 onion, chopped\n- 3 garlic cloves, minced\n- 1 tsp. cumin\n- 1 tsp. dried oregano\n- 2 tbsp. vegetable oil\n- 2 tbsp. ají amarillo paste\n- 1 cup beer\n- 2 cups water\n- 4 potatoes, peeled and cut into wedges\n- Salt and pepper, to taste\n- 1/4 cup chopped cilantro\n\nInstructions:\n1. In a large pot, heat the oil over medium-high heat. Add the lamb pieces and cook until browned on all sides, about 8-10 minutes.\n2. Remove the lamb from the pot and set it aside on a plate.\n3. In the same pot, add the chopped onion and garlic. Cook until translucent, about 5-7 minutes.\n4. Add the cumin, oregano, and ají amarillo paste to the pot. Stir to combine and cook for another minute or two.\n5. Add the beer, water, and lamb back to the pot. Bring the mixture to a boil and then reduce the heat to low. Cover and let simmer for about 1 hour, or until the lamb is tender.\n6. Add the potatoes to the pot and season with salt and pepper to taste. Cook until the potatoes are soft and the stew has thickened, about 30-45 minutes.\n7. To finish, stir in the chopped cilantro and serve hot with a side of rice.\n\nEnjoy your Seco de Cordero, a delicious and hearty dish from Ancash!", + "content": "Sure! How about \"Seco de Cordero\" (Lamb Stew) from Ancash:\n\nIngredients:\n- 2 lbs. lamb, cut into small pieces\n- 1 onion, chopped\n- 3 garlic cloves, minced\n- 1 tsp. cumin\n- 1 tsp. dried oregano\n- 2 tbsp. vegetable oil\n- 2 tbsp. aj\u00ed amarillo paste\n- 1 cup beer\n- 2 cups water\n- 4 potatoes, peeled and cut into wedges\n- Salt and pepper, to taste\n- 1/4 cup chopped cilantro\n\nInstructions:\n1. In a large pot, heat the oil over medium-high heat. Add the lamb pieces and cook until browned on all sides, about 8-10 minutes.\n2. Remove the lamb from the pot and set it aside on a plate.\n3. In the same pot, add the chopped onion and garlic. Cook until translucent, about 5-7 minutes.\n4. Add the cumin, oregano, and aj\u00ed amarillo paste to the pot. Stir to combine and cook for another minute or two.\n5. Add the beer, water, and lamb back to the pot. Bring the mixture to a boil and then reduce the heat to low. Cover and let simmer for about 1 hour, or until the lamb is tender.\n6. Add the potatoes to the pot and season with salt and pepper to taste. Cook until the potatoes are soft and the stew has thickened, about 30-45 minutes.\n7. To finish, stir in the chopped cilantro and serve hot with a side of rice.\n\nEnjoy your Seco de Cordero, a delicious and hearty dish from Ancash!", "created_at": "2023-05-20T00:37:00" }, { @@ -48,7 +47,7 @@ }, { "peer_id": "assistant", - "content": "Here are a few more tips to keep in mind as you prepare your Seco de Cordero:\n\n1. It's best to use a tough cut of lamb for this recipe, such as shoulder or leg, as the long cooking time will help to tenderize the meat. 2. When browning the lamb, make sure not to overcrowd the pot or the meat will steam instead of searing. To ensure a nice crust, work in batches if necessary. 3. If you can't find fresh ají amarillo peppers or paste, you can substitute with other chili paste or powder that has a similar heat level and flavor profile. 4. You can adjust the amount of liquid in the recipe to your preference. If you like your stew to be thicker, use less water or beer. 5. Once the potatoes are added, it's important to stir them gently to avoid breaking them apart. You can also add them halfway through the cooking process if you prefer firmer potatoes. 6. Don't forget to taste and adjust the seasoning at the end of the cooking process to make sure the stew is well-seasoned to your liking. I hope these tips are helpful, and I wish you a great cooking experience and a delicious meal!", + "content": "Here are a few more tips to keep in mind as you prepare your Seco de Cordero:\n\n1. It's best to use a tough cut of lamb for this recipe, such as shoulder or leg, as the long cooking time will help to tenderize the meat. 2. When browning the lamb, make sure not to overcrowd the pot or the meat will steam instead of searing. To ensure a nice crust, work in batches if necessary. 3. If you can't find fresh aj\u00ed amarillo peppers or paste, you can substitute with other chili paste or powder that has a similar heat level and flavor profile. 4. You can adjust the amount of liquid in the recipe to your preference. If you like your stew to be thicker, use less water or beer. 5. Once the potatoes are added, it's important to stir them gently to avoid breaking them apart. You can also add them halfway through the cooking process if you prefer firmer potatoes. 6. Don't forget to taste and adjust the seasoning at the end of the cooking process to make sure the stew is well-seasoned to your liking. I hope these tips are helpful, and I wish you a great cooking experience and a delicious meal!", "created_at": "2023-05-20T00:37:00" }, { @@ -68,14 +67,15 @@ }, { "peer_id": "assistant", - "content": "Yes, there are many delicious Peruvian dishes you can try! Here are some classic Peruvian dishes that you might want to add to your recipe collection:\n\n1. Ceviche - a refreshing dish made with fresh raw fish or seafood marinated in citrus juice and spices, served with sweet potato and corn. 2. Lomo Saltado - a popular stir-fry dish made with beef, onions, tomatoes, and spices, served with rice and french fries. 3. Ají de Gallina - a creamy chicken stew made with shredded chicken, bread, walnuts and aji amarillo pepper, served with boiled potatoes and rice. 4. Pollo a la Brasa - a succulent rotisserie chicken marinated in spices, served with fries, salad, and aji sauce. 5. Papa a la Huancaína - boiled potatoes served with a spicy creamy sauce made with cheese, ají amarillo pepper and evaporated milk, garnished with hard-boiled eggs and olives. 6. Anticuchos - skewers of marinated beef heart or chicken, grilled and served with boiled potatoes and aji sauce. All of these dishes are delicious and unique, and can give you an authentic taste of Peruvian cuisine. You can also explore other Peruvian dishes and find the ones that suit your taste buds. Enjoy!", + "content": "Yes, there are many delicious Peruvian dishes you can try! Here are some classic Peruvian dishes that you might want to add to your recipe collection:\n\n1. Ceviche - a refreshing dish made with fresh raw fish or seafood marinated in citrus juice and spices, served with sweet potato and corn. 2. Lomo Saltado - a popular stir-fry dish made with beef, onions, tomatoes, and spices, served with rice and french fries. 3. Aj\u00ed de Gallina - a creamy chicken stew made with shredded chicken, bread, walnuts and aji amarillo pepper, served with boiled potatoes and rice. 4. Pollo a la Brasa - a succulent rotisserie chicken marinated in spices, served with fries, salad, and aji sauce. 5. Papa a la Huanca\u00edna - boiled potatoes served with a spicy creamy sauce made with cheese, aj\u00ed amarillo pepper and evaporated milk, garnished with hard-boiled eggs and olives. 6. Anticuchos - skewers of marinated beef heart or chicken, grilled and served with boiled potatoes and aji sauce. All of these dishes are delicious and unique, and can give you an authentic taste of Peruvian cuisine. You can also explore other Peruvian dishes and find the ones that suit your taste buds. Enjoy!", "created_at": "2023-05-20T00:37:00" } ] }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/longmem_ancash_no_session.json b/tests/unified/test_cases/longmem_ancash_no_session.json index 334b790c..b0462d4a 100644 --- a/tests/unified/test_cases/longmem_ancash_no_session.json +++ b/tests/unified/test_cases/longmem_ancash_no_session.json @@ -1,95 +1,95 @@ { - "description": "LongMemEval test: single-session-assistant question", - "workspace_config": { - }, - "steps": [ - { - "step_type": "create_session", - "session_id": "answer_ultrachat_294807", - "peer_configs": { - "user": { - "observe_me": false, - "observe_others": false - }, - "assistant": { - "observe_me": true, - "observe_others": false - } + "description": "LongMemEval test: single-session-assistant question", + "workspace_config": {}, + "steps": [ + { + "step_type": "create_session", + "session_id": "answer_ultrachat_294807", + "peer_configs": { + "user": { + "observe_me": false, + "observe_others": false + }, + "assistant": { + "observe_me": true, + "observe_others": false } - }, - { - "step_type": "add_messages", - "session_id": "answer_ultrachat_294807", - "messages": [ - { - "peer_id": "user", - "content": "Could you suggest a recipe for a classic dish from Ancash?", - "created_at": "2023-05-20T00:37:00" - }, - { - "peer_id": "assistant", - "content": "Sure! How about \"Seco de Cordero\" (Lamb Stew) from Ancash:\n\nIngredients:\n- 2 lbs. lamb, cut into small pieces\n- 1 onion, chopped\n- 3 garlic cloves, minced\n- 1 tsp. cumin\n- 1 tsp. dried oregano\n- 2 tbsp. vegetable oil\n- 2 tbsp. ají amarillo paste\n- 1 cup beer\n- 2 cups water\n- 4 potatoes, peeled and cut into wedges\n- Salt and pepper, to taste\n- 1/4 cup chopped cilantro\n\nInstructions:\n1. In a large pot, heat the oil over medium-high heat. Add the lamb pieces and cook until browned on all sides, about 8-10 minutes.\n2. Remove the lamb from the pot and set it aside on a plate.\n3. In the same pot, add the chopped onion and garlic. Cook until translucent, about 5-7 minutes.\n4. Add the cumin, oregano, and ají amarillo paste to the pot. Stir to combine and cook for another minute or two.\n5. Add the beer, water, and lamb back to the pot. Bring the mixture to a boil and then reduce the heat to low. Cover and let simmer for about 1 hour, or until the lamb is tender.\n6. Add the potatoes to the pot and season with salt and pepper to taste. Cook until the potatoes are soft and the stew has thickened, about 30-45 minutes.\n7. To finish, stir in the chopped cilantro and serve hot with a side of rice.\n\nEnjoy your Seco de Cordero, a delicious and hearty dish from Ancash!", - "created_at": "2023-05-20T00:37:00" - }, - { - "peer_id": "user", - "content": "That sounds amazing! I've never tried a lamb stew before. Do you have any recommendations for a good type of beer to use in the recipe?", - "created_at": "2023-05-20T00:37:00" - }, - { - "peer_id": "assistant", - "content": "I don't have personal preferences, but in general, it's recommended to use a light or medium-bodied beer for this recipe to avoid overpowering the flavors of the lamb and spices. a pilsner or lager would work well, but you can also use a local beer from ancash to give the dish a more authentic touch. ultimately, it comes down to your personal preference, so you can choose a beer that you enjoy drinking and experiment with different types to find your favorite one. enjoy your delicious seco de cordero!", - "created_at": "2023-05-20T00:37:00" - }, - { - "peer_id": "user", - "content": "I'll definitely try this recipe with a Pilsner. Is there anything else I should know before I start cooking?", - "created_at": "2023-05-20T00:37:00" - }, - { - "peer_id": "assistant", - "content": "Here are a few more tips to keep in mind as you prepare your Seco de Cordero:\n\n1. It's best to use a tough cut of lamb for this recipe, such as shoulder or leg, as the long cooking time will help to tenderize the meat. 2. When browning the lamb, make sure not to overcrowd the pot or the meat will steam instead of searing. To ensure a nice crust, work in batches if necessary. 3. If you can't find fresh ají amarillo peppers or paste, you can substitute with other chili paste or powder that has a similar heat level and flavor profile. 4. You can adjust the amount of liquid in the recipe to your preference. If you like your stew to be thicker, use less water or beer. 5. Once the potatoes are added, it's important to stir them gently to avoid breaking them apart. You can also add them halfway through the cooking process if you prefer firmer potatoes. 6. Don't forget to taste and adjust the seasoning at the end of the cooking process to make sure the stew is well-seasoned to your liking. I hope these tips are helpful, and I wish you a great cooking experience and a delicious meal!", - "created_at": "2023-05-20T00:37:00" - }, - { - "peer_id": "user", - "content": "I'm really excited to try this recipe this weekend. Do you have any dessert suggestions that would pair well with the Seco de Cordero?", - "created_at": "2023-05-20T00:37:00" - }, - { - "peer_id": "assistant", - "content": "Absolutely! How about a classic Peruvian dessert - \"Arroz con Leche\" (Rice Pudding)? It's a creamy and comforting dessert that would complement the rich flavors of the lamb stew. Here's a simple recipe you can try:\n\nIngredients:\n- 1 cup white rice\n- 4 cups whole milk\n- 1 can (14 oz.) sweetened condensed milk\n- 1 cinnamon stick\n- 1 tsp. vanilla extract\n- 1/4 cup raisins (optional)\n- Ground cinnamon for dusting\n\nInstructions:\n1. Rinse the rice in cold water and drain. 2. In a large saucepan, combine the rice, milk, sweetened condensed milk, and cinnamon stick. Bring the mixture to a simmer over medium heat. 3. Reduce the heat to low and cook, stirring frequently, for about 30-40 minutes, or until the rice is tender and the mixture has thickened to a pudding-like consistency. 4. Remove the cinnamon stick and stir in the vanilla extract and raisins (if using). Let the rice pudding cool to room temperature, then chill in the fridge for at least an hour. 5. To serve, spoon the rice pudding into bowls and dust with some ground cinnamon. Enjoy your delicious Seco de Cordero followed by a comforting bowl of Arroz con Leche for dessert!", - "created_at": "2023-05-20T00:37:00" - }, - { - "peer_id": "user", - "content": "Yum, that sounds like the perfect way to end my Seco de Cordero dinner. Do you have any other classic Peruvian recipes you would recommend trying?", - "created_at": "2023-05-20T00:37:00" - }, - { - "peer_id": "assistant", - "content": "Yes, there are many delicious Peruvian dishes you can try! Here are some classic Peruvian dishes that you might want to add to your recipe collection:\n\n1. Ceviche - a refreshing dish made with fresh raw fish or seafood marinated in citrus juice and spices, served with sweet potato and corn. 2. Lomo Saltado - a popular stir-fry dish made with beef, onions, tomatoes, and spices, served with rice and french fries. 3. Ají de Gallina - a creamy chicken stew made with shredded chicken, bread, walnuts and aji amarillo pepper, served with boiled potatoes and rice. 4. Pollo a la Brasa - a succulent rotisserie chicken marinated in spices, served with fries, salad, and aji sauce. 5. Papa a la Huancaína - boiled potatoes served with a spicy creamy sauce made with cheese, ají amarillo pepper and evaporated milk, garnished with hard-boiled eggs and olives. 6. Anticuchos - skewers of marinated beef heart or chicken, grilled and served with boiled potatoes and aji sauce. All of these dishes are delicious and unique, and can give you an authentic taste of Peruvian cuisine. You can also explore other Peruvian dishes and find the ones that suit your taste buds. Enjoy!", - "created_at": "2023-05-20T00:37:00" - } - ] - }, - { - "step_type": "wait", - "target": "queue_empty" - }, - { - "step_type": "query", - "target": "chat", - "observer_peer_id": "assistant", - "observed_peer_id": "assistant", - "input": "[2023/05/23 (Tue) 07:05] I'm looking back at our previous conversation about the Seco de Cordero recipe from Ancash. You mentioned using a light or medium-bodied beer, but I was wondering if you could remind me what type of beer you specifically recommended?", - "assertions": [ - { - "assertion_type": "llm_judge", - "prompt": "Does the response indicate that a Pilsner or Lager was recommended for the beer in the recipe? The expected answer is 'I recommended using a Pilsner or Lager for the recipe.' Accept responses that convey this information even if worded differently.", - "pass_if": true - } - ] } - ] - } + }, + { + "step_type": "add_messages", + "session_id": "answer_ultrachat_294807", + "messages": [ + { + "peer_id": "user", + "content": "Could you suggest a recipe for a classic dish from Ancash?", + "created_at": "2023-05-20T00:37:00" + }, + { + "peer_id": "assistant", + "content": "Sure! How about \"Seco de Cordero\" (Lamb Stew) from Ancash:\n\nIngredients:\n- 2 lbs. lamb, cut into small pieces\n- 1 onion, chopped\n- 3 garlic cloves, minced\n- 1 tsp. cumin\n- 1 tsp. dried oregano\n- 2 tbsp. vegetable oil\n- 2 tbsp. aj\u00ed amarillo paste\n- 1 cup beer\n- 2 cups water\n- 4 potatoes, peeled and cut into wedges\n- Salt and pepper, to taste\n- 1/4 cup chopped cilantro\n\nInstructions:\n1. In a large pot, heat the oil over medium-high heat. Add the lamb pieces and cook until browned on all sides, about 8-10 minutes.\n2. Remove the lamb from the pot and set it aside on a plate.\n3. In the same pot, add the chopped onion and garlic. Cook until translucent, about 5-7 minutes.\n4. Add the cumin, oregano, and aj\u00ed amarillo paste to the pot. Stir to combine and cook for another minute or two.\n5. Add the beer, water, and lamb back to the pot. Bring the mixture to a boil and then reduce the heat to low. Cover and let simmer for about 1 hour, or until the lamb is tender.\n6. Add the potatoes to the pot and season with salt and pepper to taste. Cook until the potatoes are soft and the stew has thickened, about 30-45 minutes.\n7. To finish, stir in the chopped cilantro and serve hot with a side of rice.\n\nEnjoy your Seco de Cordero, a delicious and hearty dish from Ancash!", + "created_at": "2023-05-20T00:37:00" + }, + { + "peer_id": "user", + "content": "That sounds amazing! I've never tried a lamb stew before. Do you have any recommendations for a good type of beer to use in the recipe?", + "created_at": "2023-05-20T00:37:00" + }, + { + "peer_id": "assistant", + "content": "I don't have personal preferences, but in general, it's recommended to use a light or medium-bodied beer for this recipe to avoid overpowering the flavors of the lamb and spices. a pilsner or lager would work well, but you can also use a local beer from ancash to give the dish a more authentic touch. ultimately, it comes down to your personal preference, so you can choose a beer that you enjoy drinking and experiment with different types to find your favorite one. enjoy your delicious seco de cordero!", + "created_at": "2023-05-20T00:37:00" + }, + { + "peer_id": "user", + "content": "I'll definitely try this recipe with a Pilsner. Is there anything else I should know before I start cooking?", + "created_at": "2023-05-20T00:37:00" + }, + { + "peer_id": "assistant", + "content": "Here are a few more tips to keep in mind as you prepare your Seco de Cordero:\n\n1. It's best to use a tough cut of lamb for this recipe, such as shoulder or leg, as the long cooking time will help to tenderize the meat. 2. When browning the lamb, make sure not to overcrowd the pot or the meat will steam instead of searing. To ensure a nice crust, work in batches if necessary. 3. If you can't find fresh aj\u00ed amarillo peppers or paste, you can substitute with other chili paste or powder that has a similar heat level and flavor profile. 4. You can adjust the amount of liquid in the recipe to your preference. If you like your stew to be thicker, use less water or beer. 5. Once the potatoes are added, it's important to stir them gently to avoid breaking them apart. You can also add them halfway through the cooking process if you prefer firmer potatoes. 6. Don't forget to taste and adjust the seasoning at the end of the cooking process to make sure the stew is well-seasoned to your liking. I hope these tips are helpful, and I wish you a great cooking experience and a delicious meal!", + "created_at": "2023-05-20T00:37:00" + }, + { + "peer_id": "user", + "content": "I'm really excited to try this recipe this weekend. Do you have any dessert suggestions that would pair well with the Seco de Cordero?", + "created_at": "2023-05-20T00:37:00" + }, + { + "peer_id": "assistant", + "content": "Absolutely! How about a classic Peruvian dessert - \"Arroz con Leche\" (Rice Pudding)? It's a creamy and comforting dessert that would complement the rich flavors of the lamb stew. Here's a simple recipe you can try:\n\nIngredients:\n- 1 cup white rice\n- 4 cups whole milk\n- 1 can (14 oz.) sweetened condensed milk\n- 1 cinnamon stick\n- 1 tsp. vanilla extract\n- 1/4 cup raisins (optional)\n- Ground cinnamon for dusting\n\nInstructions:\n1. Rinse the rice in cold water and drain. 2. In a large saucepan, combine the rice, milk, sweetened condensed milk, and cinnamon stick. Bring the mixture to a simmer over medium heat. 3. Reduce the heat to low and cook, stirring frequently, for about 30-40 minutes, or until the rice is tender and the mixture has thickened to a pudding-like consistency. 4. Remove the cinnamon stick and stir in the vanilla extract and raisins (if using). Let the rice pudding cool to room temperature, then chill in the fridge for at least an hour. 5. To serve, spoon the rice pudding into bowls and dust with some ground cinnamon. Enjoy your delicious Seco de Cordero followed by a comforting bowl of Arroz con Leche for dessert!", + "created_at": "2023-05-20T00:37:00" + }, + { + "peer_id": "user", + "content": "Yum, that sounds like the perfect way to end my Seco de Cordero dinner. Do you have any other classic Peruvian recipes you would recommend trying?", + "created_at": "2023-05-20T00:37:00" + }, + { + "peer_id": "assistant", + "content": "Yes, there are many delicious Peruvian dishes you can try! Here are some classic Peruvian dishes that you might want to add to your recipe collection:\n\n1. Ceviche - a refreshing dish made with fresh raw fish or seafood marinated in citrus juice and spices, served with sweet potato and corn. 2. Lomo Saltado - a popular stir-fry dish made with beef, onions, tomatoes, and spices, served with rice and french fries. 3. Aj\u00ed de Gallina - a creamy chicken stew made with shredded chicken, bread, walnuts and aji amarillo pepper, served with boiled potatoes and rice. 4. Pollo a la Brasa - a succulent rotisserie chicken marinated in spices, served with fries, salad, and aji sauce. 5. Papa a la Huanca\u00edna - boiled potatoes served with a spicy creamy sauce made with cheese, aj\u00ed amarillo pepper and evaporated milk, garnished with hard-boiled eggs and olives. 6. Anticuchos - skewers of marinated beef heart or chicken, grilled and served with boiled potatoes and aji sauce. All of these dishes are delicious and unique, and can give you an authentic taste of Peruvian cuisine. You can also explore other Peruvian dishes and find the ones that suit your taste buds. Enjoy!", + "created_at": "2023-05-20T00:37:00" + } + ] + }, + { + "step_type": "wait", + "target": "queue_empty", + "flush": true + }, + { + "step_type": "query", + "target": "chat", + "observer_peer_id": "assistant", + "observed_peer_id": "assistant", + "input": "[2023/05/23 (Tue) 07:05] I'm looking back at our previous conversation about the Seco de Cordero recipe from Ancash. You mentioned using a light or medium-bodied beer, but I was wondering if you could remind me what type of beer you specifically recommended?", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "Does the response indicate that a Pilsner or Lager was recommended for the beer in the recipe? The expected answer is 'I recommended using a Pilsner or Lager for the recipe.' Accept responses that convey this information even if worded differently.", + "pass_if": true + } + ] + } + ] +} diff --git a/tests/unified/test_cases/longmem_giftcard.json b/tests/unified/test_cases/longmem_giftcard.json index 8991fae3..6ef43463 100644 --- a/tests/unified/test_cases/longmem_giftcard.json +++ b/tests/unified/test_cases/longmem_giftcard.json @@ -1,7 +1,6 @@ { "description": "LongMemEval test: single-session-user question", - "workspace_config": { - }, + "workspace_config": {}, "steps": [ { "step_type": "create_session", @@ -3664,7 +3663,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/longmem_plank.json b/tests/unified/test_cases/longmem_plank.json index ffce33f1..bbdcd765 100644 --- a/tests/unified/test_cases/longmem_plank.json +++ b/tests/unified/test_cases/longmem_plank.json @@ -1,7 +1,6 @@ { "description": "LongMemEval test: temporal-reasoning question", - "workspace_config": { - }, + "workspace_config": {}, "steps": [ { "step_type": "create_session", @@ -155,7 +154,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/longmem_triple_7161e7e2_single-session-assistant.json b/tests/unified/test_cases/longmem_triple_7161e7e2_single-session-assistant.json index de2c672d..1564b289 100644 --- a/tests/unified/test_cases/longmem_triple_7161e7e2_single-session-assistant.json +++ b/tests/unified/test_cases/longmem_triple_7161e7e2_single-session-assistant.json @@ -3718,7 +3718,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/longmem_triple_e47becba_single-session-user.json b/tests/unified/test_cases/longmem_triple_e47becba_single-session-user.json index 01165256..48911d89 100644 --- a/tests/unified/test_cases/longmem_triple_e47becba_single-session-user.json +++ b/tests/unified/test_cases/longmem_triple_e47becba_single-session-user.json @@ -3833,7 +3833,8 @@ { "step_type": "wait", "target": "queue_empty", - "timeout": 600 + "timeout": 600, + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/longmem_triple_gpt4_59149c77_temporal-reasoning.json b/tests/unified/test_cases/longmem_triple_gpt4_59149c77_temporal-reasoning.json index 65432dc2..ded356dd 100644 --- a/tests/unified/test_cases/longmem_triple_gpt4_59149c77_temporal-reasoning.json +++ b/tests/unified/test_cases/longmem_triple_gpt4_59149c77_temporal-reasoning.json @@ -3430,7 +3430,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/message_deriver_disabled.json b/tests/unified/test_cases/message_deriver_disabled.json index fa6937c9..315c0995 100644 --- a/tests/unified/test_cases/message_deriver_disabled.json +++ b/tests/unified/test_cases/message_deriver_disabled.json @@ -40,7 +40,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_2peer_bidirectional.json b/tests/unified/test_cases/observation_2peer_bidirectional.json index cd44c7c0..17d85a68 100644 --- a/tests/unified/test_cases/observation_2peer_bidirectional.json +++ b/tests/unified/test_cases/observation_2peer_bidirectional.json @@ -1,7 +1,6 @@ { "description": "Test bidirectional observation - both peers observe each other", - "workspace_config": { - }, + "workspace_config": {}, "steps": [ { "step_type": "create_session", @@ -41,7 +40,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_2peer_both_observe_me_false.json b/tests/unified/test_cases/observation_2peer_both_observe_me_false.json index 9519726c..5e33adb2 100644 --- a/tests/unified/test_cases/observation_2peer_both_observe_me_false.json +++ b/tests/unified/test_cases/observation_2peer_both_observe_me_false.json @@ -1,7 +1,6 @@ { "description": "Test that when both peers have observe_me=false, no local representations are created even with observe_others=true", - "workspace_config": { - }, + "workspace_config": {}, "steps": [ { "step_type": "create_session", @@ -41,7 +40,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_2peer_default.json b/tests/unified/test_cases/observation_2peer_default.json index 39913e4d..ba771615 100644 --- a/tests/unified/test_cases/observation_2peer_default.json +++ b/tests/unified/test_cases/observation_2peer_default.json @@ -1,7 +1,6 @@ { "description": "Test default observation behavior - no local representations should be created", - "workspace_config": { - }, + "workspace_config": {}, "steps": [ { "step_type": "create_session", @@ -41,7 +40,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_2peer_observe_me_false_blocks_observation.json b/tests/unified/test_cases/observation_2peer_observe_me_false_blocks_observation.json index 93f30f5e..f153c5ea 100644 --- a/tests/unified/test_cases/observation_2peer_observe_me_false_blocks_observation.json +++ b/tests/unified/test_cases/observation_2peer_observe_me_false_blocks_observation.json @@ -1,7 +1,6 @@ { "description": "Test that observe_me=false prevents local representation creation even when other peer has observe_others=true", - "workspace_config": { - }, + "workspace_config": {}, "steps": [ { "step_type": "create_session", @@ -41,7 +40,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_2peer_observe_me_false_but_can_still_observe_others.json b/tests/unified/test_cases/observation_2peer_observe_me_false_but_can_still_observe_others.json index ea935b6b..a5c3de23 100644 --- a/tests/unified/test_cases/observation_2peer_observe_me_false_but_can_still_observe_others.json +++ b/tests/unified/test_cases/observation_2peer_observe_me_false_but_can_still_observe_others.json @@ -1,7 +1,6 @@ { "description": "Test that a peer with observe_me=false can still observe others (observe_others=true)", - "workspace_config": { - }, + "workspace_config": {}, "steps": [ { "step_type": "create_session", @@ -41,7 +40,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_2peer_unidirectional_alice_observes_bob.json b/tests/unified/test_cases/observation_2peer_unidirectional_alice_observes_bob.json index e8b2b29b..944efacb 100644 --- a/tests/unified/test_cases/observation_2peer_unidirectional_alice_observes_bob.json +++ b/tests/unified/test_cases/observation_2peer_unidirectional_alice_observes_bob.json @@ -1,7 +1,6 @@ { "description": "Test unidirectional observation - Alice observes Bob, Bob does not observe Alice", - "workspace_config": { - }, + "workspace_config": {}, "steps": [ { "step_type": "create_session", @@ -41,7 +40,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_2peer_unidirectional_bob_observes_alice.json b/tests/unified/test_cases/observation_2peer_unidirectional_bob_observes_alice.json index 8040495b..99538fa4 100644 --- a/tests/unified/test_cases/observation_2peer_unidirectional_bob_observes_alice.json +++ b/tests/unified/test_cases/observation_2peer_unidirectional_bob_observes_alice.json @@ -1,7 +1,6 @@ { "description": "Test unidirectional observation - Bob observes Alice, Alice does not observe Bob", - "workspace_config": { - }, + "workspace_config": {}, "steps": [ { "step_type": "create_session", @@ -41,7 +40,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_3peer_all_observe_each_other.json b/tests/unified/test_cases/observation_3peer_all_observe_each_other.json index eef20c49..e201309c 100644 --- a/tests/unified/test_cases/observation_3peer_all_observe_each_other.json +++ b/tests/unified/test_cases/observation_3peer_all_observe_each_other.json @@ -1,7 +1,6 @@ { "description": "Test full mesh observation - all three peers observe each other", - "workspace_config": { - }, + "workspace_config": {}, "steps": [ { "step_type": "create_session", @@ -53,7 +52,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_3peer_circular.json b/tests/unified/test_cases/observation_3peer_circular.json index f22cae84..4ae12689 100644 --- a/tests/unified/test_cases/observation_3peer_circular.json +++ b/tests/unified/test_cases/observation_3peer_circular.json @@ -1,7 +1,6 @@ { "description": "Test circular observation - Alice observes Bob, Bob observes Charlie, Charlie observes Alice", - "workspace_config": { - }, + "workspace_config": {}, "steps": [ { "step_type": "create_session", @@ -53,7 +52,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_3peer_multiple_observers_one_observed.json b/tests/unified/test_cases/observation_3peer_multiple_observers_one_observed.json index aa032697..8449df1f 100644 --- a/tests/unified/test_cases/observation_3peer_multiple_observers_one_observed.json +++ b/tests/unified/test_cases/observation_3peer_multiple_observers_one_observed.json @@ -1,7 +1,6 @@ { "description": "Test multiple observers watching single peer - Bob and Charlie both observe Alice", - "workspace_config": { - }, + "workspace_config": {}, "steps": [ { "step_type": "create_session", @@ -49,7 +48,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", @@ -60,7 +60,7 @@ "assertions": [ { "assertion_type": "contains", - "text": "dancer", + "text": "dance", "case_sensitive": false } ] @@ -74,7 +74,7 @@ "assertions": [ { "assertion_type": "contains", - "text": "dancer", + "text": "dance", "case_sensitive": false } ] @@ -114,30 +114,24 @@ ] }, { - "step_type": "query", - "target": "get_peer_card", - "observer_peer_id": "bob", - "observed_peer_id": "alice", - "assertions": [ - { - "assertion_type": "contains", - "text": "dancer", - "case_sensitive": false - } - ] + "step_type": "schedule_dream", + "observer": "bob", + "observed": "alice", + "session_id": "session_all_watch_alice", + "dream_type": "omni" }, { - "step_type": "query", - "target": "get_peer_card", - "observer_peer_id": "charlie", - "observed_peer_id": "alice", - "assertions": [ - { - "assertion_type": "contains", - "text": "dancer", - "case_sensitive": false - } - ] + "step_type": "schedule_dream", + "observer": "charlie", + "observed": "alice", + "session_id": "session_all_watch_alice", + "dream_type": "omni" + }, + { + "step_type": "wait", + "target": "queue_empty", + "timeout": 180, + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_3peer_one_observer_multiple_observed.json b/tests/unified/test_cases/observation_3peer_one_observer_multiple_observed.json index 5c3e345d..e329b4bc 100644 --- a/tests/unified/test_cases/observation_3peer_one_observer_multiple_observed.json +++ b/tests/unified/test_cases/observation_3peer_one_observer_multiple_observed.json @@ -1,7 +1,6 @@ { "description": "Test single observer watching multiple peers - Alice observes both Bob and Charlie", - "workspace_config": { - }, + "workspace_config": {}, "steps": [ { "step_type": "create_session", @@ -49,7 +48,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_3peer_selective_observation.json b/tests/unified/test_cases/observation_3peer_selective_observation.json index 8f2c951b..b0c08325 100644 --- a/tests/unified/test_cases/observation_3peer_selective_observation.json +++ b/tests/unified/test_cases/observation_3peer_selective_observation.json @@ -1,7 +1,6 @@ { "description": "Test selective observation - Alice observes Bob (observe_me=true) but not Charlie (observe_me=false)", - "workspace_config": { - }, + "workspace_config": {}, "steps": [ { "step_type": "create_session", @@ -49,7 +48,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_4peer_complex_matrix.json b/tests/unified/test_cases/observation_4peer_complex_matrix.json index 17d723a2..23a4c70b 100644 --- a/tests/unified/test_cases/observation_4peer_complex_matrix.json +++ b/tests/unified/test_cases/observation_4peer_complex_matrix.json @@ -52,7 +52,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_asymmetric_visibility.json b/tests/unified/test_cases/observation_asymmetric_visibility.json index 46acf71a..24710477 100644 --- a/tests/unified/test_cases/observation_asymmetric_visibility.json +++ b/tests/unified/test_cases/observation_asymmetric_visibility.json @@ -40,7 +40,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "create_session", @@ -76,7 +77,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/observation_isolation_between_sessions.json b/tests/unified/test_cases/observation_isolation_between_sessions.json index dddb8eab..6ed6c0eb 100644 --- a/tests/unified/test_cases/observation_isolation_between_sessions.json +++ b/tests/unified/test_cases/observation_isolation_between_sessions.json @@ -40,7 +40,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "create_session", @@ -76,7 +77,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/session_deriver_disabled.json b/tests/unified/test_cases/session_deriver_disabled.json index 0af60379..2c613fb7 100644 --- a/tests/unified/test_cases/session_deriver_disabled.json +++ b/tests/unified/test_cases/session_deriver_disabled.json @@ -30,7 +30,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/tests/unified/test_cases/workspace_deriver_disabled.json b/tests/unified/test_cases/workspace_deriver_disabled.json index b30e9953..ce1a054c 100644 --- a/tests/unified/test_cases/workspace_deriver_disabled.json +++ b/tests/unified/test_cases/workspace_deriver_disabled.json @@ -30,7 +30,8 @@ }, { "step_type": "wait", - "target": "queue_empty" + "target": "queue_empty", + "flush": true }, { "step_type": "query", diff --git a/uv.lock b/uv.lock index cd16eece..f3250a51 100644 --- a/uv.lock +++ b/uv.lock @@ -1025,7 +1025,7 @@ wheels = [ [[package]] name = "honcho" -version = "2.5.1" +version = "3.0.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, @@ -1138,10 +1138,9 @@ dev = [ [[package]] name = "honcho-ai" -version = "1.6.0" +version = "2.0.0" source = { editable = "sdks/python" } dependencies = [ - { name = "honcho-core" }, { name = "httpx" }, { name = "pydantic" }, { name = "typing-extensions", marker = "python_full_version < '3.12'" }, @@ -1154,7 +1153,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "honcho-core", specifier = "==1.11.0" }, { 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" }, @@ -1163,23 +1161,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "ruff", specifier = ">=0.11.13" }] -[[package]] -name = "honcho-core" -version = "1.11.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/1b/82/7c7db37556930ecb9b908f911100159abc46e103aa252b3ec318c720b413/honcho_core-1.11.0.tar.gz", hash = "sha256:2898b18633930cda6d8414c7e001e4cb9da17327f448000a6c312b51372975f3", size = 144331, upload-time = "2026-01-13T19:53:59.582Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/08/4ada41526b74b11a332db0e48d75f4d93b4ed474d82a48c7251d78656dce/honcho_core-1.11.0-py3-none-any.whl", hash = "sha256:519c6a7759badfae1b1a3587fbf577cb5ab8188e5093dc0f4fd62602e8dfb523", size = 138588, upload-time = "2026-01-13T19:53:58.369Z" }, -] - [[package]] name = "httpcore" version = "1.0.9"