API/SDK updates: configurability, more parameters. Unified test harness (#283)

* feat: add better params to working representation fetch in SDKs, return messages when added

* fix: working representation routes now accepting all parameters properly, with tests

* feat: add metadata/config fields to SDK objects where viable

* fix: tests

* feat: refactor SDKs to use representation config; [TEMP STAINLESS BUILD] update API

* feat: add representation object to sdks

* fix: use stainless sdk on branch

* fix: update TypeScript SDK tsconfig to use node16 module resolution

* fix: add isolatedModules = true to tsconfig

* fix: lol

* chore: coderabbit review

* feat: make delete session real

* feat: add observations routes with delete endpoints for documents. make session deletion real.

* chore: type cleanup

* fix: tests

* chore: coderabbit review

* fix: namespace by workspace

* feat: add ability to customize messages_per_summary at both workspace and session level

* chore: tests for summary config

* chore: coderabbit cleanup

* feat: make session and workspace config totally customizeable

* feat: add search by peer knowledge (#250)

* feat: search by peer perspective

* fix: enforce workspace in filters, make messages distinct in join

* fix: batch and merge migration steps

* fix: add refresh, add config to workspace, add refresh function, make fields readonly

* fix: search distinct

* fix: merge migrations

* fix: merge migrations

* fix: batch deletions, improve comments, limit consolidate dream to 100 docs at a time, auth on observations routes

* chore: review

* chore: coderabbit

* chore: review

* chore: broken comment

* feat: add set peer card route to API

* feat: create advanced configuration parameters with message>session>workspace hierarchy

* [wip] build unified testing harness

* chore: lint

* fix: cache invalidation, naming things, etc

* feat: longmem tests

* chore: peer config refactor

* feat: consolidate dream working, refactor representation

* fix: Various CR Comment Fixes

* feat: Allow configurable Redis port for harness instances and update cleanup methods to be asynchronous.

* fix: version bump, api/sdk updates

* fix: observation endpoints, deletion queue, sdk observation implementation

* chore: Fix migration order

* fix: Use published stainless sdks

* chore: (docs) update api-reference

* fix: (docs) update based on api and sdk changes

* fix: Code Rabbit Comments

* fix: Code Rabbit Final Nits

* fix: dream scheduler

* fix: SDK model type consistency

---------

Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
This commit is contained in:
doria 2025-12-03 16:49:30 -05:00 committed by GitHub
parent b3e715d1d6
commit e3d345b961
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
139 changed files with 31536 additions and 2880 deletions

View File

@ -101,6 +101,8 @@ jobs:
SENTRY_ENABLED: false
LLM_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
LLM_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
LLM_OPENAI_COMPATIBLE_API_KEY: test-key
LLM_OPENAI_COMPATIBLE_BASE_URL: http://localhost:8000
DERIVER_PROVIDER: openai
DERIVER_MODEL: test
DIALECTIC_PROVIDER: openai

View File

@ -9,12 +9,7 @@
},
"favicon": "/favicon.svg",
"contextual": {
"options": [
"copy",
"view",
"chatgpt",
"claude"
]
"options": ["copy", "view", "chatgpt", "claude"]
},
"navigation": {
"versions": [
@ -68,20 +63,19 @@
"groups": [
{
"group": "Getting Started",
"pages": [
"v2/guides/overview"
]
"pages": ["v2/guides/overview"]
},
{
"group": "Integrations",
"pages": ["v2/integrations/crewai", "v2/integrations/langgraph", "v2/integrations/mcp"]
"pages": [
"v2/integrations/crewai",
"v2/integrations/langgraph",
"v2/integrations/mcp"
]
},
{
"group": "Application Interfaces",
"pages": [
"v2/guides/discord",
"v2/guides/telegram"
]
"pages": ["v2/guides/discord", "v2/guides/telegram"]
}
]
},
@ -90,9 +84,7 @@
"groups": [
{
"group": "API Documentation",
"pages": [
"v2/api-reference/introduction"
]
"pages": ["v2/api-reference/introduction"]
},
{
"group": "workspaces",
@ -102,7 +94,8 @@
"v2/api-reference/endpoint/workspaces/update-workspace",
"v2/api-reference/endpoint/workspaces/delete-workspace",
"v2/api-reference/endpoint/workspaces/search-workspace",
"v2/api-reference/endpoint/workspaces/get-deriver-status"
"v2/api-reference/endpoint/workspaces/get-deriver-status",
"v2/api-reference/endpoint/workspaces/trigger-dream"
]
},
{
@ -114,8 +107,10 @@
"v2/api-reference/endpoint/peers/get-sessions-for-peer",
"v2/api-reference/endpoint/peers/chat",
"v2/api-reference/endpoint/peers/get-working-representation",
"v2/api-reference/endpoint/peers/search-peer",
"v2/api-reference/endpoint/peers/get-peer-card"
"v2/api-reference/endpoint/peers/get-peer-card",
"v2/api-reference/endpoint/peers/set-peer-card",
"v2/api-reference/endpoint/peers/get-peer-context",
"v2/api-reference/endpoint/peers/search-peer"
]
},
{
@ -147,6 +142,14 @@
"v2/api-reference/endpoint/messages/create-messages-with-file"
]
},
{
"group": "observations",
"pages": [
"v2/api-reference/endpoint/observations/list-observations",
"v2/api-reference/endpoint/observations/query-observations",
"v2/api-reference/endpoint/observations/delete-observation"
]
},
{
"group": "webhooks",
"pages": [
@ -196,9 +199,7 @@
{
"version": "v1.1.0",
"api": {
"openapi": [
"openapi.json"
]
"openapi": ["openapi.json"]
},
"tabs": [
{
@ -228,23 +229,15 @@
"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"]
}
]
},
@ -253,9 +246,7 @@
"groups": [
{
"group": "API Documentation",
"pages": [
"v1/api-reference/introduction"
]
"pages": ["v1/api-reference/introduction"]
},
{
"group": "apps",
@ -303,9 +294,7 @@
},
{
"group": "keys",
"pages": [
"v1/api-reference/endpoint/keys/create-key"
]
"pages": ["v1/api-reference/endpoint/keys/create-key"]
},
{
"group": "metamessages",

View File

@ -5,7 +5,7 @@
"main": ".pnp.js",
"scripts": {
"dev": "mint dev",
"openapi": "npx @mintlify/scraping openapi-file openapi.documented.yml -o api-reference/endpoint",
"openapi": "npx @mintlify/scraping openapi-file v2/openapi.json -o v2/api-reference/endpoint",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",

View File

@ -0,0 +1,3 @@
---
openapi: delete /v2/workspaces/{workspace_id}/observations/{observation_id}
---

View File

@ -0,0 +1,3 @@
---
openapi: post /v2/workspaces/{workspace_id}/observations/list
---

View File

@ -0,0 +1,3 @@
---
openapi: post /v2/workspaces/{workspace_id}/observations/query
---

View File

@ -0,0 +1,3 @@
---
openapi: get /v2/workspaces/{workspace_id}/peers/{peer_id}/context
---

View File

@ -0,0 +1,3 @@
---
openapi: put /v2/workspaces/{workspace_id}/peers/{peer_id}/card
---

View File

@ -0,0 +1,3 @@
---
openapi: post /v2/workspaces/{workspace_id}/trigger_dream
---

View File

@ -1,14 +1,144 @@
---
title: 'Configure Reasoning'
description: 'Customizing how Honcho handles peers and sessions'
description: 'Customizing how Honcho handles peers, sessions, and messages'
icon: 'wrench'
---
Entities in Honcho can sometimes be configured to change the behavior of the deriver, which is responsible for generating and storing facts, summaries, and user representations.
Honcho's reasoning engine (the "deriver") can be configured at multiple levels to control how it processes messages, generates facts, creates summaries, and builds peer representations.
These configurations can be set at the peer, session, and session-peer level (AKA the state of a peer within a specific session).
Configuration follows a hierarchy: **message > session > workspace > global defaults**. Settings at lower levels override those at higher levels, giving you fine-grained control over behavior.
### Peer Configuration
## Configuration Hierarchy
Honcho uses a hierarchical configuration system where more specific settings override more general ones:
1. **Global Defaults**: Built-in system defaults
2. **Workspace Configuration**: Settings that apply to all sessions in a workspace
3. **Session Configuration**: Settings that apply to all messages in a session
4. **Message Configuration**: Settings that apply to a specific message
<Info>
All configuration fields are optional. If not specified, the value is inherited from the next level up in the hierarchy.
</Info>
## Configuration Options
### Deriver Configuration
Controls the core reasoning engine that extracts facts and insights from messages.
| Field | Type | Description |
|-------|------|-------------|
| `enabled` | `bool` | Whether to enable deriver functionality. When disabled, no facts or representations are generated. |
<CodeGroup>
```python Python
from honcho import Honcho
honcho = Honcho()
# Disable deriver at session level
session = honcho.session("private-session", config={
"deriver": {"enabled": False}
})
```
```typescript TypeScript
import { Honcho } from "@honcho-ai/sdk";
const honcho = new Honcho({});
// Disable deriver at session level
const session = await honcho.session("private-session", {
config: {
deriver: { enabled: false }
}
});
```
</CodeGroup>
### Peer Card Configuration
Controls how peer cards (concise summaries of what's known about a peer) are generated and used.
| Field | Type | Description |
|-------|------|-------------|
| `use` | `bool` | Whether to use peer cards during the deriver process. |
| `create` | `bool` | Whether to generate peer cards based on message content. |
<CodeGroup>
```python Python
# Disable peer card generation but still use existing cards
session = honcho.session("my-session", config={
"peer_card": {"create": False, "use": True}
})
```
```typescript TypeScript
// Disable peer card generation but still use existing cards
const session = await honcho.session("my-session", {
config: {
peer_card: { create: false, use: true }
}
});
```
</CodeGroup>
### Summary Configuration
Controls automatic conversation summarization. Available at workspace and session levels only.
| Field | Type | Description |
|-------|------|-------------|
| `enabled` | `bool` | Whether to enable summary functionality. |
| `messages_per_short_summary` | `int` | Number of messages between short summaries. Must be ≥ 10. |
| `messages_per_long_summary` | `int` | Number of messages between long summaries. Must be ≥ 20 and greater than `messages_per_short_summary`. |
<CodeGroup>
```python Python
# Customize summary frequency
session = honcho.session("verbose-session", config={
"summary": {
"enabled": True,
"messages_per_short_summary": 15,
"messages_per_long_summary": 45
}
})
```
```typescript TypeScript
// Customize summary frequency
const session = await honcho.session("verbose-session", {
config: {
summary: {
enabled: true,
messages_per_short_summary: 15,
messages_per_long_summary: 45
}
}
});
```
</CodeGroup>
### Dream Configuration
Controls the "dreaming" process that consolidates and refines representations. Available at workspace and session levels only.
| Field | Type | Description |
|-------|------|-------------|
| `enabled` | `bool` | Whether to enable dream functionality. Automatically disabled if deriver is disabled. |
<CodeGroup>
```python Python
# Disable dreams for a workspace
# (done via API when creating/updating workspace)
```
```typescript TypeScript
// Disable dreams for a workspace
// (done via API when creating/updating workspace)
```
</CodeGroup>
---
## Peer Configuration
By default, all peers are "observed" by Honcho. This means that Honcho will derive facts from messages sent by the peer and generate a representation of them. In most cases, this is why you use Honcho! However, sometimes an application requires a peer that should not be observed: for example, an assistant or game NPC that your program will never need to ask questions about.
@ -27,7 +157,7 @@ honcho = Honcho()
peer = honcho.peer("my-peer", config={"observe_me": False})
# Change peer's configuration
peer.set_peer_config({"observe_me": True})
peer.set_config({"observe_me": True})
# Note: creating the same peer again will also replace the configuration
peer = honcho.peer("my-peer", config={"observe_me": False})
@ -43,7 +173,7 @@ import { Honcho } from "@honcho-ai/sdk";
const peer = await honcho.peer("my-peer", { config: { observe_me: false } });
// Change peer's configuration
await peer.setPeerConfig({ observe_me: true });
await peer.setConfig({ observe_me: true });
// Note: creating the same peer again will also replace the configuration
await honcho.peer("my-peer", { config: { observe_me: false } });
@ -51,9 +181,9 @@ import { Honcho } from "@honcho-ai/sdk";
```
</CodeGroup>
### Session Configuration
## Session Configuration
By default, all sessions have the deriver enabled, much like peers. You may create a session that escapes the deriver's watchful eye by setting the `deriver_disabled` flag to `true`. You can update the flag by calling `get_or_create` on the session with a new configuration.
Sessions support the full configuration schema. You can disable the deriver entirely for a session, customize summary behavior, or adjust peer card settings.
<CodeGroup>
```python Python
@ -62,8 +192,18 @@ from honcho import Honcho
# Initialize client
honcho = Honcho()
# Create session with configuration
session = honcho.session("my-session", config={"deriver_disabled": True})
# Create session with deriver disabled
session = honcho.session("my-session", config={
"deriver": {"enabled": False}
})
# Create session with custom summary settings
session = honcho.session("detailed-session", config={
"summary": {
"messages_per_short_summary": 10,
"messages_per_long_summary": 30
}
})
```
```typescript TypeScript
import { Honcho } from "@honcho-ai/sdk";
@ -72,15 +212,73 @@ import { Honcho } from "@honcho-ai/sdk";
// Initialize client
const honcho = new Honcho({});
// Create session with configuration
const session = await honcho.session("my-session", { config: { deriver_disabled: true } });
// Create session with deriver disabled
const session = await honcho.session("my-session", {
config: { deriver: { enabled: false } }
});
// Create session with custom summary settings
const detailedSession = await honcho.session("detailed-session", {
config: {
summary: {
messages_per_short_summary: 10,
messages_per_long_summary: 30
}
}
});
})();
```
</CodeGroup>
### Session-Peer Configuration
## Message Configuration
Configuration at the session-peer level is the most common use case for configuration flags. You will often want to arrange a session such that certain peers observe others in order to form "local representations" of them. There are two flags that can be set at the session-peer level:
Individual messages can override session and workspace configuration for fine-grained control. This is useful for excluding specific messages from processing or adjusting behavior on a per-message basis.
<CodeGroup>
```python Python
from honcho import Honcho
honcho = Honcho()
session = honcho.session("my-session")
user = honcho.peer("user")
# Create a message that skips deriver processing
session.add_messages([
user.message("This message won't be analyzed", config={
"deriver": {"enabled": False}
})
])
# Create a message with custom peer card settings
session.add_messages([
user.message("Use existing card but don't update it", config={
"peer_card": {"use": True, "create": False}
})
])
```
```typescript TypeScript
import { Honcho } from "@honcho-ai/sdk";
(async () => {
const honcho = new Honcho({});
const session = await honcho.session("my-session");
const user = await honcho.peer("user");
// Create a message that skips deriver processing
await session.addMessages([
user.message("This message won't be analyzed", {
configuration: { deriver: { enabled: false } }
})
]);
})();
```
</CodeGroup>
## Session-Peer Configuration
Configuration at the session-peer level controls how peers observe each other within a specific session. This is the most common use case for enabling "local representations" — where one peer forms a model of another peer based only on what they observe in that session.
There are two flags that can be set at the session-peer level:
- `observe_me`: Whether this peer should *be observed* by others in the session. By default, this is `true`. This overrides the peer-level `observe_me` flag.
@ -96,7 +294,7 @@ You can dynamically change the configuration of a session-peer by calling `set_p
<CodeGroup>
```python Python
from honcho import Honcho
from honcho import Honcho, SessionPeerConfig
# Initialize client
honcho = Honcho()
@ -113,11 +311,11 @@ session.add_peers([alice, bob])
# Add another peer to the session with a custom configuration
charlie = honcho.peer("charlie")
session.add_peers([charlie, {"observe_me": False, "observe_others": True}])
session.add_peers([(charlie, SessionPeerConfig(observe_me=False, observe_others=True))])
# Set session-peer configuration
session.set_peer_config(alice, {"observe_others": True})
session.set_peer_config(bob, {"observe_me": False})
session.set_peer_config(alice, SessionPeerConfig(observe_others=True))
session.set_peer_config(bob, SessionPeerConfig(observe_me=False))
# Get session-peer configuration
charlie_config = session.get_peer_config(charlie)
@ -142,7 +340,7 @@ import { Honcho } from "@honcho-ai/sdk";
// Add another peer to the session with a custom configuration
const charlie = await honcho.peer("charlie");
await session.addPeers([charlie, { observe_me: false, observe_others: true }]);
await session.addPeers([[charlie, { observe_me: false, observe_others: true }]]);
// Set session-peer configuration
await session.setPeerConfig(alice, { observe_others: true });
@ -154,3 +352,45 @@ import { Honcho } from "@honcho-ai/sdk";
})();
```
</CodeGroup>
## Full Configuration Schema Reference
### Workspace & Session Configuration
```json
{
"deriver": {
"enabled": true
},
"peer_card": {
"use": true,
"create": true
},
"summary": {
"enabled": true,
"messages_per_short_summary": 20,
"messages_per_long_summary": 60
},
"dream": {
"enabled": true
}
}
```
### Message Configuration
```json
{
"deriver": {
"enabled": true
},
"peer_card": {
"use": true,
"create": true
}
}
```
<Note>
Message configuration only supports `deriver` and `peer_card` settings. Summary and dream configurations are session/workspace-level only.
</Note>

View File

@ -93,6 +93,127 @@ context = session.get_context(summary=False, tokens=2000)
```
</CodeGroup>
### Peer Representation in Context
You can include a peer's representation and peer card in the context by specifying `peer_target`. This is useful for providing the LLM with knowledge about a specific peer.
<CodeGroup>
```python Python
# Get context with peer representation included
context = session.get_context(
tokens=2000,
peer_target="user-123" # Include representation of user-123
)
# Access the representation and peer card
print(context.peer_representation) # String representation
print(context.peer_card) # List of peer card items
# Get representation from a specific peer's perspective
context = session.get_context(
tokens=2000,
peer_target="user-123",
peer_perspective="assistant" # From assistant's viewpoint
)
```
```typescript TypeScript
(async () => {
// Get context with peer representation included
const context = await session.getContext({
tokens: 2000,
peerTarget: "user-123" // Include representation of user-123
});
// Access the representation and peer card
console.log(context.peerRepresentation); // String representation
console.log(context.peerCard); // Array of peer card items
// Get representation from a specific peer's perspective
const perspectiveContext = await session.getContext({
tokens: 2000,
peerTarget: "user-123",
peerPerspective: "assistant" // From assistant's viewpoint
});
})();
```
</CodeGroup>
### Semantic Search with Last Message
Use `last_user_message` to fetch semantically relevant observations based on the most recent message:
<CodeGroup>
```python Python
# Get context with semantic search based on last message
context = session.get_context(
tokens=2000,
peer_target="user-123",
last_user_message="What are my account preferences?",
search_top_k=10, # Number of relevant observations
search_max_distance=0.8, # Max semantic distance (0.0-1.0)
include_most_derived=True, # Include most recent observations
max_observations=25 # Cap total observations
)
```
```typescript TypeScript
(async () => {
// Get context with semantic search based on last message
const context = await session.getContext({
tokens: 2000,
peerTarget: "user-123",
lastUserMessage: "What are my account preferences?",
searchTopK: 10, // Number of relevant observations
searchMaxDistance: 0.8, // Max semantic distance (0.0-1.0)
includeMostDerived: true, // Include most recent observations
maxObservations: 25 // Cap total observations
});
})();
```
</CodeGroup>
### Session-Scoped Representations
Use `limit_to_session` to only include observations from the current session:
<CodeGroup>
```python Python
# Get context limited to this session's observations only
context = session.get_context(
tokens=2000,
peer_target="user-123",
limit_to_session=True # Only observations from this session
)
```
```typescript TypeScript
(async () => {
// Get context limited to this session's observations only
const context = await session.getContext({
tokens: 2000,
peerTarget: "user-123",
limitToSession: true // Only observations from this session
});
})();
```
</CodeGroup>
### All Parameters Reference
| Parameter | Type | Description |
|-----------|------|-------------|
| `summary` | `bool` | Include summary in context (default: true) |
| `tokens` | `int` | Maximum tokens to include |
| `peer_target` | `str` | Peer ID to include representation for |
| `peer_perspective` | `str` | Peer ID for perspective (requires peer_target) |
| `last_user_message` | `str` | Message for semantic search (requires peer_target) |
| `limit_to_session` | `bool` | Limit to session observations only |
| `search_top_k` | `int` | Semantic search results to include (1-100) |
| `search_max_distance` | `float` | Max semantic distance (0.0-1.0) |
| `include_most_derived` | `bool` | Include most recently derived observations |
| `max_observations` | `int` | Maximum observations to include (1-100) |
## Converting to LLM Formats
The `SessionContext` object provides methods to convert the context into formats compatible with popular LLM APIs. When converting to OpenAI format, you must specify the assistant peer to format the context in such a way that the LLM can understand it.

View File

@ -18,7 +18,7 @@ A `Message` is sent by a `Peer` and saved in a `Session`
session = honcho.session("sample-session")
message = peer.message("Hello, world!", session_id=session.id)
message = peer.message("Hello, world!")
session.add_messages([message])
```

View File

@ -22,7 +22,7 @@ Working representations are automatically generated and cached through Honcho's
## Basic Usage
Working representations are accessed through the `working_rep()` method on Session objects:
Working representations are accessed through the `working_rep()` method on Session or Peer objects:
<CodeGroup>
```python Python
@ -50,6 +50,9 @@ response = user.chat("What is this user's main concern right now?", session_id=s
# Retrieve the cached working representation for the user
user_representation = session.working_rep("user-123")
print("Cached user representation:", user_representation)
# Or access from the peer directly
peer_representation = user.working_rep()
```
```typescript TypeScript
@ -77,7 +80,76 @@ const response = await user.chat("What is this user's main concern right now?",
// Retrieve the cached working representation for the user
const userRepresentation = await session.workingRep("user-123");
console.log("Cached user representation:", userRepresentation);
// Returns: { representation: Object }
// Or access from the peer directly
const peerRepresentation = await user.workingRep();
```
</CodeGroup>
## Semantic Search in Representations
Working representations support semantic search to retrieve the most relevant observations for a given query. This is useful when you want to focus the representation on specific topics.
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `search_query` | `str` | Semantic search query to filter relevant observations |
| `search_top_k` | `int` | Number of semantic search results to include (1-100) |
| `search_max_distance` | `float` | Maximum semantic distance threshold (0.0-1.0) |
| `include_most_derived` | `bool` | Whether to include the most recently derived observations |
| `max_observations` | `int` | Maximum number of observations to include (1-100) |
<CodeGroup>
```python Python
# Get representation focused on a specific topic
billing_rep = session.working_rep(
"user-123",
search_query="billing and payment issues",
search_top_k=10,
search_max_distance=0.8,
include_most_derived=True,
max_observations=25
)
# Get representation from peer with target
# What user-123 knows about the assistant
local_rep = session.working_rep(
"user-123",
target="ai-assistant",
search_query="support interactions"
)
# Access from peer object with semantic search
user_rep = user.working_rep(
session=session,
search_query="preferences",
search_top_k=5
)
```
```typescript TypeScript
// Get representation focused on a specific topic
const billingRep = await session.workingRep("user-123", {
searchQuery: "billing and payment issues",
searchTopK: 10,
searchMaxDistance: 0.8,
includeMostDerived: true,
maxObservations: 25
});
// Get representation from peer with target
// What user-123 knows about the assistant
const localRep = await session.workingRep("user-123", {
target: "ai-assistant",
searchQuery: "support interactions"
});
// Access from peer object with semantic search
const userRep = await user.workingRep(session, undefined, {
searchQuery: "preferences",
searchTopK: 5
});
```
</CodeGroup>

View File

@ -278,6 +278,17 @@ results = alice.search("programming")
metadata = alice.get_metadata()
metadata["location"] = "Paris"
alice.set_metadata(metadata)
# Get peer context (representation + peer card in one call)
context = alice.get_context()
context = alice.get_context(target="bob") # What alice knows about bob
# Get working representation with semantic search
rep = alice.working_rep(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
```
```typescript TypeScript
@ -318,6 +329,109 @@ await alice.setMetadata({
...metadata,
location: "Paris"
});
// Get peer context (representation + peer card in one call)
const context = await alice.getContext();
const targetContext = await alice.getContext("bob"); // What alice knows about bob
// Get working representation with semantic search
const rep = await alice.workingRep(undefined, undefined, {
searchQuery: "preferences",
searchTopK: 10
});
// Access observations
const selfObs = await alice.observations.list(); // Self-observations
const bobObs = await alice.observationsOf("bob").list(); // Observations of bob
```
</CodeGroup>
### Peer Context
The `get_context()` method on peers retrieves both the working representation and peer card in a single API call:
<CodeGroup>
```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
# 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_derived=True,
max_observations=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,
includeMostDerived: true,
maxObservations: 50
});
```
</CodeGroup>
### Observations
Peers can access their observations (facts derived from messages) through the `observations` property and `observations_of()` method:
<CodeGroup>
```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");
```
</CodeGroup>
@ -361,12 +475,42 @@ messages = session.get_messages()
# Get conversation context
context = session.get_context(summary=True, tokens=2000)
# Get context with peer representation included
context = session.get_context(
tokens=2000,
peer_target="user",
peer_perspective="assistant",
last_user_message="What are my preferences?",
limit_to_session=True,
search_top_k=10,
search_max_distance=0.8,
include_most_derived=True,
max_observations=25
)
# Search session content
results = session.search("help")
# Working representation queries
# Working representation queries with semantic search
global_rep = session.working_rep("alice")
targeted_rep = session.working_rep(alice, bob)
targeted_rep = session.working_rep(alice, target=bob)
searched_rep = session.working_rep(
"alice",
search_query="preferences",
search_top_k=10,
include_most_derived=True
)
# Upload a file to create messages
messages = session.upload_file(
file=open("document.pdf", "rb"),
peer_id="user",
metadata={"source": "upload"},
created_at="2024-01-15T10:30:00Z"
)
# Delete session (async - returns 202)
session.delete()
# Metadata management
session.set_metadata({"topic": "product planning", "status": "active"})
@ -402,12 +546,43 @@ const messages = await session.getMessages();
// Get conversation context
const context = await session.getContext({ summary: true, tokens: 2000 });
// Get context with peer representation included
const richContext = await session.getContext({
tokens: 2000,
peerTarget: "user",
peerPerspective: "assistant",
lastUserMessage: "What are my preferences?",
limitToSession: true,
searchTopK: 10,
searchMaxDistance: 0.8,
includeMostDerived: true,
maxObservations: 25
});
// Search session content
const results = await session.search("help");
// Working representation queries
// Working representation queries with semantic search
const globalRep = await session.workingRep("alice");
const targetedRep = await session.workingRep(alice, bob);
const targetedRep = await session.workingRep(alice, { target: bob });
const searchedRep = await session.workingRep("alice", undefined, {
searchQuery: "preferences",
searchTopK: 10,
includeMostDerived: true
});
// Upload a file to create messages
const messages = await session.uploadFile(
fileBuffer,
"user",
{
metadata: { source: "upload" },
createdAt: "2024-01-15T10:30:00Z"
}
);
// Delete session (async - returns 202)
await session.delete();
// Metadata management
await session.setMetadata({
@ -494,10 +669,27 @@ The SessionContext object has the following structure:
"message_id": 123,
"summary_type": "short|long",
"created_at": "2024-01-15T10:30:00Z"
}
},
"peer_representation": "string (optional)",
"peer_card": ["string"] // optional, included when peer_target is provided
}
```
**Session Context Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `summary` | `bool` | Whether to include summary (default: true) |
| `tokens` | `int` | Maximum tokens to include |
| `peer_target` | `str` | Peer ID to get representation for |
| `peer_perspective` | `str` | Peer ID for perspective (requires peer_target) |
| `last_user_message` | `str` | Most recent message for semantic search |
| `limit_to_session` | `bool` | Limit representation to session only |
| `search_top_k` | `int` | Number of semantic search results (1-100) |
| `search_max_distance` | `float` | Max semantic distance (0.0-1.0) |
| `include_most_derived` | `bool` | Include most derived observations |
| `max_observations` | `int` | Max observations to include (1-100) |
## Advanced Usage
### Multi-Party Conversations

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,114 @@
"""rename_deriver_disabled_to_deriver_enabled
Revision ID: 110bdf470272
Revises: baa22cad81e2
Create Date: 2025-10-31 13:04:31.029856
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from migrations.utils import get_schema
# revision identifiers, used by Alembic.
revision: str = "110bdf470272"
down_revision: str | None = "baa22cad81e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
schema = get_schema()
def upgrade() -> None:
"""
Convert deriver_disabled to deriver_enabled in configuration JSONB.
- deriver_disabled: true -> deriver_enabled: false
- deriver_disabled: false -> deriver_enabled: true
- Remove deriver_disabled key from configuration
"""
# Update sessions table in batches
# Combine all cases in one pass: set deriver_enabled based on deriver_disabled value,
# or just remove deriver_disabled if it's null or other value
bind = op.get_bind()
batch_size = 5000
while True:
result = bind.execute(
sa.text(
f"""
WITH batch AS (
SELECT id
FROM "{schema}".sessions
WHERE configuration ? 'deriver_disabled'
LIMIT :batch_size
)
UPDATE "{schema}".sessions s
SET configuration = CASE
WHEN s.configuration->>'deriver_disabled' = 'true' THEN
s.configuration - 'deriver_disabled' || jsonb_build_object('deriver_enabled', false)
WHEN s.configuration->>'deriver_disabled' = 'false' THEN
s.configuration - 'deriver_disabled' || jsonb_build_object('deriver_enabled', true)
ELSE
s.configuration - 'deriver_disabled'
END
FROM batch b
WHERE s.id = b.id
AND s.configuration ? 'deriver_disabled'
"""
),
{"batch_size": batch_size},
)
rowcount = result.rowcount
result.close()
if rowcount == 0:
break
def downgrade() -> None:
"""
Convert deriver_enabled back to deriver_disabled in configuration JSONB.
- deriver_enabled: false -> deriver_disabled: true
- deriver_enabled: true -> deriver_disabled: false
- Remove deriver_enabled key from configuration
"""
# Update sessions table in batches
# Combine all cases in one pass: set deriver_disabled based on deriver_enabled value,
# or just remove deriver_enabled if it's null or other value
bind = op.get_bind()
batch_size = 5000
while True:
result = bind.execute(
sa.text(
f"""
WITH batch AS (
SELECT id
FROM "{schema}".sessions
WHERE configuration ? 'deriver_enabled'
LIMIT :batch_size
)
UPDATE "{schema}".sessions s
SET configuration = CASE
WHEN s.configuration->>'deriver_enabled' = 'false' THEN
s.configuration - 'deriver_enabled' || jsonb_build_object('deriver_disabled', true)
WHEN s.configuration->>'deriver_enabled' = 'true' THEN
s.configuration - 'deriver_enabled' || jsonb_build_object('deriver_disabled', false)
ELSE
s.configuration - 'deriver_enabled'
END
FROM batch b
WHERE s.id = b.id
AND s.configuration ? 'deriver_enabled'
"""
),
{"batch_size": batch_size},
)
rowcount = result.rowcount
result.close()
if rowcount == 0:
break

View File

@ -1,6 +1,6 @@
[project]
name = "honcho"
version = "2.4.3"
version = "2.5.0"
description = "Honcho Server"
authors = [
{name = "Plastic Labs", email = "hello@plasticlabs.ai"},

View File

@ -1,37 +0,0 @@
import random
import uuid
from honcho import Honcho
# Create a Honcho client with the default workspace
honcho = Honcho(environment="local")
peers = [
honcho.peer("alice"),
honcho.peer("bob"),
honcho.peer("charlie"),
]
# Create a new session
session = honcho.session("context_test_" + str(uuid.uuid4()))
# Generate some random messages from alice, bob, and charlie and add them to the session
messages = []
for i in range(10):
random_peer = random.choice(peers)
messages.append(
random_peer.message(f"Hello from {random_peer}! This is message {i}.")
)
session.add_messages(messages)
alice = peers[0]
bob = peers[1]
# Get alice's working representation in the session
representation = session.working_rep(alice)
print("working representation returned:", representation)
# Get alice's working representation *of bob* in the session
representation = session.working_rep(alice, target=bob)
print("working representation returned:", representation)

View File

@ -8,7 +8,7 @@ authors = [
{ name = "Plastic Labs", email = "hello@plasticlabs.ai" },
]
dependencies = [
"honcho-core>=1.5.1",
"honcho-core>=1.6.0",
"httpx>=0.28.0, <1",
"pydantic>=2.0.0, <3",
"typing-extensions>=4.12.0; python_version < \"3.12\"",

View File

@ -41,11 +41,18 @@ from .async_client import (
AsyncSession,
)
from .client import Honcho
from .observations import AsyncObservationScope, Observation, ObservationScope
from .pagination import SyncPage
from .peer import Peer
from .session import Session
from .session_context import SessionContext, SessionSummaries, Summary
from .types import DialecticStreamResponse
from .types import (
DeductiveObservation,
DialecticStreamResponse,
ExplicitObservation,
PeerContext,
Representation,
)
__version__ = "1.5.0"
__author__ = "Plastic Labs"
@ -53,15 +60,22 @@ __email__ = "hello@plasticlabs.ai"
__all__ = [
"AsyncHoncho",
"AsyncObservationScope",
"AsyncPeer",
"AsyncSession",
"AsyncPage",
"Honcho",
"Observation",
"ObservationScope",
"Peer",
"PeerContext",
"Session",
"SessionContext",
"SessionSummaries",
"Summary",
"SyncPage",
"DialecticStreamResponse",
"Representation",
"ExplicitObservation",
"DeductiveObservation",
]

View File

@ -33,9 +33,11 @@ class AsyncHoncho(BaseModel):
`core` property to use functionality not exposed through this SDK.
Attributes:
api_key: API key for authentication
base_url: Base URL for the Honcho API
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
"""
@ -46,8 +48,20 @@ class AsyncHoncho(BaseModel):
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:
"""
@ -218,7 +232,14 @@ class AsyncHoncho(BaseModel):
workspace_id=self.workspace_id, filters=filters
)
return AsyncPage(
peers_page, lambda peer: AsyncPeer(peer.id, self.workspace_id, self._client)
peers_page,
lambda peer: AsyncPeer(
peer.id,
self.workspace_id,
self._client,
metadata=peer.metadata,
config=peer.configuration,
),
)
@validate_call
@ -283,7 +304,13 @@ class AsyncHoncho(BaseModel):
)
return AsyncPage(
sessions_page,
lambda session: AsyncSession(session.id, self.workspace_id, self._client),
lambda session: AsyncSession(
session.id,
self.workspace_id,
self._client,
metadata=session.metadata,
config=session.configuration,
),
)
async def get_metadata(self) -> dict[str, object]:
@ -292,14 +319,16 @@ class AsyncHoncho(BaseModel):
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.
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)
return workspace.metadata or {}
self._metadata = workspace.metadata or {}
return self._metadata
@validate_call
async def set_metadata(
@ -311,12 +340,64 @@ class AsyncHoncho(BaseModel):
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

View File

@ -1,21 +1,27 @@
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, cast
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_working_representation_response import (
PeerWorkingRepresentationResponse,
)
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 BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from ..types import DialecticStreamResponse
from .pagination import AsyncPage
if TYPE_CHECKING:
from ..observations import AsyncObservationScope
from ..types import PeerContext, Representation
from .session import AsyncSession
@ -29,15 +35,31 @@ class AsyncPeer(BaseModel):
Attributes:
id: Unique identifier for this peer
_client: Reference to the parent AsyncHoncho client instance
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.
"""
id: str = Field(..., min_length=1, description="Unique identifier for this peer")
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 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,
@ -52,6 +74,9 @@ class AsyncPeer(BaseModel):
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.
@ -60,9 +85,16 @@ class AsyncPeer(BaseModel):
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)
super().__init__(
id=peer_id,
workspace_id=workspace_id,
)
self._client = client
self._metadata = metadata
self._configuration = config
@classmethod
async def create(
@ -92,17 +124,22 @@ class AsyncPeer(BaseModel):
Returns:
A new AsyncPeer instance
"""
peer = cls(peer_id, workspace_id, client)
if config is not None or metadata is not None:
await client.workspaces.peers.get_or_create(
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 peer
return cls(peer_id, workspace_id, client)
async def chat(
self,
@ -208,6 +245,10 @@ class AsyncPeer(BaseModel):
..., 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"
),
@ -238,6 +279,7 @@ class AsyncPeer(BaseModel):
return MessageCreateParam(
peer_id=self.id,
content=content,
configuration=config,
metadata=metadata,
created_at=created_at_str,
)
@ -248,7 +290,7 @@ class AsyncPeer(BaseModel):
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.
associated with the peer. This method also updates the cached metadata attribute.
Returns:
A dictionary containing the peer's metadata. Returns an empty dictionary
@ -258,7 +300,8 @@ class AsyncPeer(BaseModel):
workspace_id=self.workspace_id,
id=self.id,
)
return peer.metadata or {}
self._metadata = peer.metadata or {}
return self._metadata
@validate_call
async def set_metadata(
@ -272,6 +315,7 @@ class AsyncPeer(BaseModel):
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.
@ -282,13 +326,15 @@ class AsyncPeer(BaseModel):
workspace_id=self.workspace_id,
metadata=metadata,
)
self._metadata = metadata
async def get_peer_config(self) -> dict[str, object]:
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
@ -297,10 +343,11 @@ class AsyncPeer(BaseModel):
workspace_id=self.workspace_id,
id=self.id,
)
return peer.configuration or {}
self._configuration = peer.configuration or {}
return self._configuration
@validate_call
async def set_peer_config(
async def set_config(
self,
config: dict[str, object] = Field(
..., description="Configuration dictionary to associate with this peer"
@ -313,6 +360,7 @@ class AsyncPeer(BaseModel):
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.
@ -323,6 +371,51 @@ class AsyncPeer(BaseModel):
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(
@ -391,6 +484,212 @@ class AsyncPeer(BaseModel):
items: list[str] = response.peer_card
return "\n".join(items)
async def working_rep(
self,
session: str | AsyncSession | None = None,
target: str | AsyncPeer | None = None,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "Representation":
"""
Get a working representation for this 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 observations
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_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
Returns:
A Representation object containing explicit and deductive observations
Example:
```python
# Get global representation
rep = await peer.working_rep()
print(rep)
# Get representation scoped to a session
session_rep = await peer.working_rep(session='session-123')
# Get representation with semantic search
searched_rep = await peer.working_rep(
search_query='preferences',
search_top_k=10,
max_observations=50
)
```
"""
from ..types import Representation as _Representation
session_id = (
None
if session is None
else session
if isinstance(session, str)
else session.id
)
data: PeerWorkingRepresentationResponse = (
await self._client.workspaces.peers.working_representation(
peer_id=self.id,
workspace_id=self.workspace_id,
session_id=session_id,
target=str(target.id) if isinstance(target, AsyncPeer) else target,
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_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations
if max_observations is not None
else omit,
)
)
representation = data.get("representation")
if representation is not None:
return _Representation.from_dict(cast(dict[str, object], representation))
else:
return _Representation.from_dict(data)
async def get_context(
self,
target: str | AsyncPeer | None = None,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "PeerContext":
"""
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 observations
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_derived: Whether to include the most derived observations
max_observations: Maximum number of observations 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
)
```
"""
from ..types import PeerContext as _PeerContext
target_id = str(target.id) if isinstance(target, AsyncPeer) else target
response = await self._client.workspaces.peers.get_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_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
return _PeerContext.from_api_response(response)
@property
def observations(self) -> "AsyncObservationScope":
"""
Access this peer's self-observations (where observer == observed == self).
This property provides a convenient way to access observations that this peer
has made about themselves. Use this for self-observation scenarios.
Returns:
An AsyncObservationScope scoped to this peer's self-observations
Example:
```python
# List self-observations
obs_list = await peer.observations.list()
# Search self-observations
results = await peer.observations.query("preferences")
# Delete a self-observation
await peer.observations.delete("obs-123")
```
"""
from ..observations import AsyncObservationScope as _AsyncObservationScope
return _AsyncObservationScope(self._client, self.workspace_id, self.id, self.id)
def observations_of(self, target: str | AsyncPeer) -> "AsyncObservationScope":
"""
Access observations this peer has made about another peer.
This method provides scoped access to observations 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 AsyncObservationScope scoped to this peer's observations of the target
Example:
```python
# Get observations about another peer
bob_observations = peer.observations_of("bob")
# List observations
obs_list = await bob_observations.list()
# Search observations
results = await bob_observations.query("work history")
# Get the representation from these observations
rep = await bob_observations.get_representation()
```
"""
from ..observations import AsyncObservationScope as _AsyncObservationScope
target_id = target.id if isinstance(target, AsyncPeer) else target
return _AsyncObservationScope(
self._client, self.workspace_id, self.id, target_id
)
def __repr__(self) -> str:
"""
Return a string representation of the AsyncPeer.

View File

@ -1,15 +1,18 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import asyncio
import time
import logging
import time
from typing import TYPE_CHECKING, Any
import json
from datetime import datetime
from honcho_core import AsyncHoncho as AsyncHonchoCore
from honcho_core._types import omit
from honcho_core.types import DeriverStatus
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 ..session_context import SessionContext, SessionSummaries, Summary
@ -17,6 +20,7 @@ from ..utils import prepare_file_for_upload
from .pagination import AsyncPage
if TYPE_CHECKING:
from ..types import Representation
from .peer import AsyncPeer
logger = logging.getLogger(__name__)
@ -43,17 +47,31 @@ class AsyncSession(BaseModel):
Attributes:
id: Unique identifier for this session
_client: Reference to the parent AsyncHoncho client instance
anonymous: Whether this is an anonymous session
summarize: Whether automatic summarization is enabled
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.
"""
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"
)
_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,
@ -66,6 +84,9 @@ class AsyncSession(BaseModel):
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.
@ -74,12 +95,16 @@ class AsyncSession(BaseModel):
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(
@ -109,17 +134,22 @@ class AsyncSession(BaseModel):
Returns:
A new AsyncSession instance
"""
session = cls(session_id, workspace_id, client)
if config is not None or metadata is not None:
await client.workspaces.sessions.get_or_create(
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 session
return cls(session_id, workspace_id, client)
async def add_peers(
self,
@ -314,7 +344,7 @@ class AsyncSession(BaseModel):
messages: MessageCreateParam | list[MessageCreateParam] = Field(
..., description="Messages to add to the session"
),
) -> None:
) -> list[Message]:
"""
Add one or more messages to this session.
@ -330,7 +360,7 @@ class AsyncSession(BaseModel):
if not isinstance(messages, list):
messages = [messages]
await self._client.workspaces.sessions.messages.create(
return await self._client.workspaces.sessions.messages.create(
session_id=self.id,
workspace_id=self.workspace_id,
messages=[MessageCreateParam(**message) for message in messages],
@ -370,9 +400,16 @@ class AsyncSession(BaseModel):
async def delete(self) -> None:
"""
Delete this session.
Delete this session and all associated data.
Makes an async API call to delete this session.
Makes an async API call to permanently delete this session and all related data including:
- Messages
- Message embeddings
- Observations
- Session-Peer associations
- Background processing queue items
This action cannot be undone.
"""
await self._client.workspaces.sessions.delete(
session_id=self.id,
@ -385,6 +422,7 @@ class AsyncSession(BaseModel):
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
@ -394,7 +432,8 @@ class AsyncSession(BaseModel):
workspace_id=self.workspace_id,
id=self.id,
)
return session.metadata or {}
self._metadata = session.metadata or {}
return self._metadata
@validate_call
async def set_metadata(
@ -408,6 +447,7 @@ class AsyncSession(BaseModel):
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.
@ -418,6 +458,65 @@ class AsyncSession(BaseModel):
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(
@ -439,6 +538,32 @@ class AsyncSession(BaseModel):
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 observations 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_derived: bool | None = Field(
None,
description="Whether to include the most derived observations in the representation.",
),
max_observations: int | None = Field(
None,
ge=1,
le=100,
description="Maximum number of observations to include in the representation.",
),
) -> SessionContext:
"""
Get optimized context for this session within a token limit.
@ -455,6 +580,11 @@ class AsyncSession(BaseModel):
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 observations 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 observations 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_derived: Whether to include the most derived observations in the representation.
max_observations: Maximum number of observations to include in the representation.
Returns:
A SessionContext object containing the optimized message history and
@ -491,6 +621,15 @@ class AsyncSession(BaseModel):
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_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
# Convert the honcho_core summary to our Summary if it exists
@ -608,6 +747,18 @@ class AsyncSession(BaseModel):
description="File to upload. Can be a file object, (filename, bytes, content_type) tuple, or (filename, fileobj, content_type) tuple.",
),
peer_id: str = Field(..., description="ID of the peer creating the messages"),
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.
@ -625,6 +776,9 @@ class AsyncSession(BaseModel):
- a tuple (filename, bytes, content_type)
- a tuple (filename, fileobj, content_type)
peer_id: ID of the peer who will be attributed as the creator of the messages
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
@ -638,12 +792,26 @@ class AsyncSession(BaseModel):
# Prepare file for upload using shared utility
filename, content_bytes, content_type = prepare_file_for_upload(file)
# Call the upload endpoint
# 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=peer_id,
extra_body=extra_body_data if extra_body_data else None,
)
return [Message.model_validate(msg) for msg in response]
@ -653,7 +821,12 @@ class AsyncSession(BaseModel):
peer: str | AsyncPeer,
*,
target: str | AsyncPeer | None = None,
) -> dict[str, object]:
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "Representation":
"""
Get the current working representation of the peer in this session.
@ -661,18 +834,51 @@ class AsyncSession(BaseModel):
peer: Peer to get the working 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 observations
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_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
Returns:
A dictionary containing information about the peer.
"""
from .peer import AsyncPeer
A Representation object containing explicit and deductive observations
return await self._client.workspaces.peers.working_representation(
str(peer.id) if isinstance(peer, AsyncPeer) else peer,
Example:
```python
# Get peer's representation in this session
rep = await session.working_rep('user123')
print(rep)
# Get what user123 knows about assistant in this session
local_rep = await session.working_rep('user123', target='assistant')
# Get representation with semantic search
searched_rep = await session.working_rep(
'user123',
search_query='preferences',
search_top_k=10
)
```
"""
from ..types import Representation as _Representation
from .peer import AsyncPeer as _AsyncPeer
data = await self._client.workspaces.peers.working_representation(
str(peer.id) if isinstance(peer, _AsyncPeer) else peer,
workspace_id=self.workspace_id,
session_id=self.id,
target=str(target.id) if isinstance(target, AsyncPeer) else target,
target=str(target.id) if isinstance(target, _AsyncPeer) else target,
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_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
return _Representation.from_dict(data) # type: ignore
@validate_call
async def get_deriver_status(

View File

@ -31,9 +31,11 @@ class Honcho(BaseModel):
`core` property to use functionality not exposed through this SDK.
Attributes:
api_key: API key for authentication
base_url: Base URL for the Honcho API
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
"""
@ -44,8 +46,20 @@ class Honcho(BaseModel):
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: HonchoCore = 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) -> HonchoCore:
"""
@ -183,6 +197,7 @@ 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
)
@ -204,7 +219,14 @@ class Honcho(BaseModel):
workspace_id=self.workspace_id, filters=filters
)
return SyncPage(
peers_page, lambda peer: Peer(peer.id, self.workspace_id, self._client)
peers_page,
lambda peer: Peer(
peer.id,
self.workspace_id,
self._client,
metadata=peer.metadata,
config=peer.configuration,
),
)
@validate_call
@ -267,7 +289,13 @@ class Honcho(BaseModel):
)
return SyncPage(
sessions_page,
lambda session: Session(session.id, self.workspace_id, self._client),
lambda session: Session(
session.id,
self.workspace_id,
self._client,
metadata=session.metadata,
config=session.configuration,
),
)
def get_metadata(self) -> dict[str, object]:
@ -276,14 +304,16 @@ class Honcho(BaseModel):
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.
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 = self._client.workspaces.get_or_create(id=self.workspace_id)
return workspace.metadata or {}
self._metadata = workspace.metadata or {}
return self._metadata
@validate_call
def set_metadata(
@ -295,12 +325,62 @@ class Honcho(BaseModel):
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]:
"""

View File

@ -0,0 +1,450 @@
"""Observation types and scoped access for the Honcho SDK."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from .types import Representation
class Observation:
"""
An observation from the theory-of-mind system.
Observations are facts derived from messages that help build a representation
of a peer.
Attributes:
id: Unique identifier for this observation
content: The observation content/text
observer_id: The peer who made the observation
observed_id: The peer being observed
session_id: The session where this observation was made
created_at: When the observation was created
"""
id: str
content: str
observer_id: str
observed_id: str
session_id: str
created_at: str
def __init__(
self,
id: str,
content: str,
observer_id: str,
observed_id: str,
session_id: str,
created_at: str,
):
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: dict[str, Any]) -> "Observation":
"""Create an Observation from an API response dict."""
return cls(
id=data.get("id", ""),
content=data.get("content", ""),
observer_id=data.get("observer_id", ""),
observed_id=data.get("observed_id", ""),
session_id=data.get("session_id", ""),
created_at=data.get("created_at", ""),
)
def __repr__(self) -> str:
truncated = (
f"{self.content[:50]}..." if len(self.content) > 50 else self.content
)
return f"Observation(id={self.id!r}, content={truncated!r})"
class ObservationScope:
"""
Scoped access to observations for a specific observer/observed relationship.
This class provides convenient methods to list, query, and delete observations
that are automatically scoped to a specific observer/observed pair.
Typically accessed via `peer.observations` (for self-observations) or
`peer.observations_of(target)` (for observations about another peer).
Example:
```python
# Get self-observations
observations = peer.observations
obs_list = observations.list()
search_results = observations.query("preferences")
# Get observations about another peer
bob_observations = peer.observations_of("bob")
bob_list = bob_observations.list()
```
Note:
This class requires the core Honcho SDK to support observation endpoints.
The observation endpoints are:
- POST /workspaces/{workspace_id}/observations/list
- POST /workspaces/{workspace_id}/observations/query
- DELETE /workspaces/{workspace_id}/observations/{observation_id}
"""
_client: Any
workspace_id: str
observer: str
observed: str
def __init__(
self,
client: Any,
workspace_id: str,
observer: str,
observed: str,
):
"""
Initialize an ObservationScope.
Args:
client: The Honcho 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
def list(
self,
page: int = 1,
size: int = 50,
session_id: str | None = None,
) -> list[Observation]:
"""
List observations in this scope.
Args:
page: Page number (1-indexed)
size: Number of results per page
session_id: Optional session ID to filter by
Returns:
List of Observation objects
"""
filters: dict[str, Any] = {
"observer": self.observer,
"observed": self.observed,
}
if session_id:
filters["session_id"] = session_id
# Note: This requires the core SDK to support observations.list()
response = self._client.workspaces.observations.list(
workspace_id=self.workspace_id,
filters=filters,
page=page,
size=size,
)
return [Observation.from_api_response(item) for item in response.items]
def query(
self,
query: str,
top_k: int = 10,
distance: float | None = None,
) -> list[Observation]:
"""
Semantic search for observations 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 Observation objects
"""
filters: dict[str, Any] = {
"observer": self.observer,
"observed": self.observed,
}
# Note: This requires the core SDK to support observations.query()
response = self._client.workspaces.observations.query(
workspace_id=self.workspace_id,
query=query,
top_k=top_k,
distance=distance,
filters=filters,
)
return [Observation.from_api_response(item) for item in response]
def delete(self, observation_id: str) -> None:
"""
Delete an observation by ID.
Args:
observation_id: The ID of the observation to delete
"""
# Note: This requires the core SDK to support observations.delete()
self._client.workspaces.observations.delete(
workspace_id=self.workspace_id,
observation_id=observation_id,
)
def get_representation(
self,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "Representation":
"""
Get the computed representation for this scope.
This returns the working representation (narrative) built from the
observations 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_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
Returns:
A Representation object containing explicit and deductive observations
"""
from honcho_core._types import omit
from .types import Representation
response = self._client.workspaces.peers.working_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_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
representation = response.get("representation")
if representation is not None:
return Representation.from_dict(cast(dict[str, Any], representation))
else:
return Representation.from_dict(response)
def __repr__(self) -> str:
return (
f"ObservationScope(workspace_id={self.workspace_id!r}, "
f"observer={self.observer!r}, observed={self.observed!r})"
)
class AsyncObservationScope:
"""
Async scoped access to observations for a specific observer/observed relationship.
This class provides convenient async methods to list, query, and delete observations
that are automatically scoped to a specific observer/observed pair.
Typically accessed via `peer.observations` (for self-observations) or
`peer.observations_of(target)` (for observations about another peer).
Example:
```python
# Get self-observations
observations = peer.observations
obs_list = await observations.list()
search_results = await observations.query("preferences")
# Get observations about another peer
bob_observations = peer.observations_of("bob")
bob_list = await bob_observations.list()
```
Note:
This class requires the core Honcho SDK to support observation endpoints.
The observation endpoints are:
- POST /workspaces/{workspace_id}/observations/list
- POST /workspaces/{workspace_id}/observations/query
- DELETE /workspaces/{workspace_id}/observations/{observation_id}
"""
_client: Any
workspace_id: str
observer: str
observed: str
def __init__(
self,
client: Any,
workspace_id: str,
observer: str,
observed: str,
):
"""
Initialize an AsyncObservationScope.
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_id: str | None = None,
) -> list[Observation]:
"""
List observations in this scope.
Args:
page: Page number (1-indexed)
size: Number of results per page
session_id: Optional session ID to filter by
Returns:
List of Observation objects
"""
filters: dict[str, Any] = {
"observer": self.observer,
"observed": self.observed,
}
if session_id:
filters["session_id"] = session_id
# Note: This requires the core SDK to support observations.list()
response = await self._client.workspaces.observations.list(
workspace_id=self.workspace_id,
filters=filters,
page=page,
size=size,
)
return [Observation.from_api_response(item) for item in response.items]
async def query(
self,
query: str,
top_k: int = 10,
distance: float | None = None,
) -> list[Observation]:
"""
Semantic search for observations 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 Observation objects
"""
filters: dict[str, Any] = {
"observer": self.observer,
"observed": self.observed,
}
# Note: This requires the core SDK to support observations.query()
response = await self._client.workspaces.observations.query(
workspace_id=self.workspace_id,
query=query,
top_k=top_k,
distance=distance,
filters=filters,
)
return [Observation.from_api_response(item) for item in response]
async def delete(self, observation_id: str) -> None:
"""
Delete an observation by ID.
Args:
observation_id: The ID of the observation to delete
"""
# Note: This requires the core SDK to support observations.delete()
await self._client.workspaces.observations.delete(
workspace_id=self.workspace_id,
observation_id=observation_id,
)
async def get_representation(
self,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "Representation":
"""
Get the computed representation for this scope.
This returns the working representation (narrative) built from the
observations 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_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
Returns:
A Representation object containing explicit and deductive observations
"""
from honcho_core._types import omit
from .types import Representation
response = await self._client.workspaces.peers.working_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_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
representation = response.get("representation")
if representation is not None:
return Representation.from_dict(cast(dict[str, Any], representation))
else:
return Representation.from_dict(response)
def __repr__(self) -> str:
return (
f"AsyncObservationScope(workspace_id={self.workspace_id!r}, "
f"observer={self.observer!r}, observed={self.observed!r})"
)

View File

@ -1,8 +1,8 @@
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from collections.abc import Generator
from typing import TYPE_CHECKING, cast
from honcho_core import Honcho as HonchoCore
from honcho_core._types import omit
@ -10,13 +10,16 @@ from honcho_core.types.workspaces import PeerCardResponse
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 BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from .types import DialecticStreamResponse
from .pagination import SyncPage
from .types import DialecticStreamResponse
if TYPE_CHECKING:
from .observations import ObservationScope
from .session import Session
from .types import PeerContext, Representation
class Peer(BaseModel):
@ -29,15 +32,31 @@ class Peer(BaseModel):
Attributes:
id: Unique identifier for this peer
_client: Reference to the parent Honcho client instance
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.
"""
id: str = Field(..., min_length=1, description="Unique identifier for this peer")
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: HonchoCore = 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,
@ -77,16 +96,24 @@ class Peer(BaseModel):
config: 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)
super().__init__(
id=peer_id,
workspace_id=workspace_id,
)
self._client = client
self._metadata = metadata
self._configuration = config
if config is not None or metadata is not None:
self._client.workspaces.peers.get_or_create(
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,
)
# Update cached values with API response
self._metadata = peer_data.metadata
self._configuration = peer_data.configuration
def chat(
self,
@ -194,6 +221,10 @@ class Peer(BaseModel):
metadata: dict[str, object] | None = Field(
None, description="Optional metadata dictionary"
),
config: Configuration | None = Field(
None,
description="Optional configuration dictionary to associate with the message",
),
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.",
@ -221,6 +252,7 @@ class Peer(BaseModel):
return MessageCreateParam(
peer_id=self.id,
content=content,
configuration=config,
metadata=metadata,
created_at=created_at_str,
)
@ -231,7 +263,7 @@ class Peer(BaseModel):
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.
associated with the peer. This method also updates the cached metadata attribute.
Returns:
A dictionary containing the peer's metadata. Returns an empty dictionary
@ -241,7 +273,8 @@ class Peer(BaseModel):
workspace_id=self.workspace_id,
id=self.id,
)
return peer.metadata or {}
self._metadata = peer.metadata or {}
return self._metadata
@validate_call
def set_metadata(
@ -255,6 +288,7 @@ class Peer(BaseModel):
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.
@ -265,13 +299,15 @@ class Peer(BaseModel):
workspace_id=self.workspace_id,
metadata=metadata,
)
self._metadata = metadata
def get_peer_config(self) -> dict[str, object]:
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
@ -280,10 +316,11 @@ class Peer(BaseModel):
workspace_id=self.workspace_id,
id=self.id,
)
return peer.configuration or {}
self._configuration = peer.configuration or {}
return self._configuration
@validate_call
def set_peer_config(
def set_config(
self,
config: dict[str, object] = Field(
..., description="Configuration dictionary to associate with this peer"
@ -296,6 +333,7 @@ class Peer(BaseModel):
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.
@ -306,6 +344,51 @@ class Peer(BaseModel):
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(
@ -374,6 +457,206 @@ class Peer(BaseModel):
return "\n".join(items)
def working_rep(
self,
session: str | Session | None = None,
target: str | Peer | None = None,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "Representation":
"""
Get a working representation for this 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 observations
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_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
Returns:
A Representation object containing explicit and deductive observations
Example:
```python
# Get global representation
rep = peer.working_rep()
print(rep)
# Get representation scoped to a session
session_rep = peer.working_rep(session='session-123')
# Get representation with semantic search
searched_rep = peer.working_rep(
search_query='preferences',
search_top_k=10,
max_observations=50
)
```
"""
from .types import Representation as _Representation
session_id = (
None
if session is None
else session
if isinstance(session, str)
else session.id
)
data = self._client.workspaces.peers.working_representation(
peer_id=self.id,
workspace_id=self.workspace_id,
session_id=session_id,
target=str(target.id) if isinstance(target, Peer) else target,
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_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
representation = data.get("representation")
if representation is not None:
return _Representation.from_dict(cast(dict[str, object], representation))
else:
return _Representation.from_dict(data)
def get_context(
self,
target: str | Peer | None = None,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "PeerContext":
"""
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 a Peer object or peer ID string.
search_query: Semantic search query to filter relevant observations
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_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
Returns:
A PeerContext object containing the representation and peer card
Example:
```python
# Get own context
context = peer.get_context()
print(context.representation)
print(context.peer_card)
# Get context for another peer
context = peer.get_context(target='other-peer-id')
# Get context with semantic search
context = peer.get_context(
search_query='preferences',
search_top_k=10
)
```
"""
from .types import PeerContext as _PeerContext
target_id = str(target.id) if isinstance(target, Peer) else target
response = self._client.workspaces.peers.get_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_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
return _PeerContext.from_api_response(response)
@property
def observations(self) -> "ObservationScope":
"""
Access this peer's self-observations (where observer == observed == self).
This property provides a convenient way to access observations that this peer
has made about themselves. Use this for self-observation scenarios.
Returns:
An ObservationScope scoped to this peer's self-observations
Example:
```python
# List self-observations
obs_list = peer.observations.list()
# Search self-observations
results = peer.observations.query("preferences")
# Delete a self-observation
peer.observations.delete("obs-123")
```
"""
from .observations import ObservationScope as _ObservationScope
return _ObservationScope(self._client, self.workspace_id, self.id, self.id)
def observations_of(self, target: str | Peer) -> "ObservationScope":
"""
Access observations this peer has made about another peer.
This method provides scoped access to observations where this peer is the
observer and the target is the observed peer.
Args:
target: The target peer (either a Peer object or peer ID string)
Returns:
An ObservationScope scoped to this peer's observations of the target
Example:
```python
# Get observations about another peer
bob_observations = peer.observations_of("bob")
# List observations
obs_list = bob_observations.list()
# Search observations
results = bob_observations.query("work history")
# Get the representation from these observations
rep = bob_observations.get_representation()
```
"""
from .observations import ObservationScope as _ObservationScope
target_id = target.id if isinstance(target, Peer) else target
return _ObservationScope(self._client, self.workspace_id, self.id, target_id)
def __repr__(self) -> str:
"""
Return a string representation of the Peer.

View File

@ -3,12 +3,15 @@ from __future__ import annotations
import logging
import time
from typing import TYPE_CHECKING, Any
import json
from datetime import datetime
from honcho_core import Honcho as HonchoCore
from honcho_core._types import omit
from honcho_core.types import DeriverStatus
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 .pagination import SyncPage
@ -17,6 +20,7 @@ from .utils import prepare_file_for_upload
if TYPE_CHECKING:
from .peer import Peer
from .types import Representation
logger = logging.getLogger(__name__)
@ -42,17 +46,31 @@ class Session(BaseModel):
Attributes:
id: Unique identifier for this session
_honcho: Reference to the parent Honcho client instance
anonymous: Whether this is an anonymous session
summarize: Whether automatic summarization is enabled
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.
"""
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"
)
_metadata: dict[str, object] | None = PrivateAttr(default=None)
_configuration: dict[str, object] | None = PrivateAttr(default=None)
_client: HonchoCore = 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,
@ -95,14 +113,19 @@ class Session(BaseModel):
workspace_id=workspace_id,
)
self._client = client
self._metadata = metadata
self._configuration = config
if config is not None or metadata is not None:
self._client.workspaces.sessions.get_or_create(
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,
)
# Update cached values with API response
self._metadata = session_data.metadata
self._configuration = session_data.configuration
def add_peers(
self,
@ -292,7 +315,7 @@ class Session(BaseModel):
messages: MessageCreateParam | list[MessageCreateParam] = Field(
..., description="Messages to add to the session"
),
) -> None:
) -> list[Message]:
"""
Add one or more messages to this session.
@ -308,7 +331,7 @@ class Session(BaseModel):
if not isinstance(messages, list):
messages = [messages]
self._client.workspaces.sessions.messages.create(
return self._client.workspaces.sessions.messages.create(
session_id=self.id,
workspace_id=self.workspace_id,
messages=[MessageCreateParam(**message) for message in messages],
@ -352,24 +375,31 @@ class Session(BaseModel):
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.
Returns:
A dictionary containing the session's metadata. Returns an empty dictionary
if no metadata is set
"""
return (
self._client.workspaces.sessions.get_or_create(
workspace_id=self.workspace_id,
id=self.id,
).metadata
or {}
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
def delete(self) -> None:
"""
Delete this session
Delete this session and all associated data.
Makes an API call to mark this session as inactive.
Makes an API call to permanently delete this session and all related data including:
- Messages
- Message embeddings
- Observations
- Session-Peer associations
- Background processing queue items
This action cannot be undone.
"""
self._client.workspaces.sessions.delete(
session_id=self.id,
@ -388,6 +418,7 @@ class Session(BaseModel):
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.
@ -398,6 +429,65 @@ class Session(BaseModel):
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(
@ -419,6 +509,32 @@ class Session(BaseModel):
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 observations 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_derived: bool | None = Field(
None,
description="Whether to include the most derived observations in the representation.",
),
max_observations: int | None = Field(
None,
ge=1,
le=100,
description="Maximum number of observations to include in the representation.",
),
) -> SessionContext:
"""
Get optimized context for this session within a token limit.
@ -435,6 +551,11 @@ class Session(BaseModel):
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 observations 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 observations 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_derived: Whether to include the most derived observations in the representation.
max_observations: Maximum number of observations to include in the representation.
Returns:
A SessionContext object containing the optimized message history and
@ -471,6 +592,15 @@ class Session(BaseModel):
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_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
# Convert the honcho_core summary to our Summary if it exists
@ -588,6 +718,18 @@ class Session(BaseModel):
description="File to upload. Can be a file object, (filename, bytes, content_type) tuple, or (filename, fileobj, content_type) tuple.",
),
peer_id: str = Field(..., description="ID of the peer creating the messages"),
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.
@ -605,6 +747,9 @@ class Session(BaseModel):
- a tuple (filename, bytes, content_type)
- a tuple (filename, fileobj, content_type)
peer_id: ID of the peer who will be attributed as the creator of the messages
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
@ -618,12 +763,26 @@ class Session(BaseModel):
# Prepare file for upload using shared utility
filename, content_bytes, content_type = prepare_file_for_upload(file)
# Call the upload endpoint
# 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 = self._client.workspaces.sessions.messages.upload(
session_id=self.id,
workspace_id=self.workspace_id,
file=(filename, content_bytes, content_type),
peer_id=peer_id,
extra_body=extra_body_data if extra_body_data else None,
)
return [Message.model_validate(msg) for msg in response]
@ -633,7 +792,12 @@ class Session(BaseModel):
peer: str | Peer,
*,
target: str | Peer | None = None,
) -> dict[str, object]:
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "Representation":
"""
Get the current working representation of the peer in this session.
@ -641,18 +805,51 @@ class Session(BaseModel):
peer: Peer to get the working 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 observations
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_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
Returns:
A dictionary containing information about the peer.
"""
from .peer import Peer
A Representation object containing explicit and deductive observations
return self._client.workspaces.peers.working_representation(
str(peer.id) if isinstance(peer, Peer) else peer,
Example:
```python
# Get peer's representation in this session
rep = session.working_rep('user123')
print(rep)
# Get what user123 knows about assistant in this session
local_rep = session.working_rep('user123', target='assistant')
# Get representation with semantic search
searched_rep = session.working_rep(
'user123',
search_query='preferences',
search_top_k=10
)
```
"""
from .peer import Peer as _Peer
from .types import Representation as _Representation
data = self._client.workspaces.peers.working_representation(
str(peer.id) if isinstance(peer, _Peer) else peer,
workspace_id=self.workspace_id,
session_id=self.id,
target=str(target.id) if isinstance(target, Peer) else target,
target=str(target.id) if isinstance(target, _Peer) else target,
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_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
return _Representation.from_dict(data) # type: ignore
@validate_call
def get_deriver_status(

View File

@ -2,7 +2,322 @@
from __future__ import annotations
from collections.abc import Iterator, AsyncIterator
from collections.abc import AsyncIterator, Iterator
from datetime import datetime
from typing import Any, cast
from pydantic import BaseModel, Field
# Re-export observation types from dedicated module
from .observations import AsyncObservationScope, Observation, ObservationScope
__all__ = [
"AsyncObservationScope",
"DeductiveObservation",
"DeductiveObservationBase",
"DialecticStreamResponse",
"ExplicitObservation",
"ExplicitObservationBase",
"Observation",
"ObservationMetadata",
"ObservationScope",
"PeerContext",
"Representation",
]
class ObservationMetadata(BaseModel):
"""Metadata associated with an observation."""
created_at: datetime
message_ids: list[int]
session_name: str
class ExplicitObservationBase(BaseModel):
"""Base model for explicit observations - facts literally stated."""
content: str = Field(description="The explicit observation")
class DeductiveObservationBase(BaseModel):
"""Base model for deductive observations - logical conclusions."""
premises: list[str] = Field(
description="Supporting premises or evidence for this conclusion",
default_factory=list,
)
conclusion: str = Field(description="The deductive conclusion")
class ExplicitObservation(ExplicitObservationBase, ObservationMetadata):
"""
Explicit observation with content and metadata.
Represents facts LITERALLY stated - direct quotes or clear paraphrases only.
"""
def __str__(self) -> str:
"""Format observation with timestamp and content."""
return f"[{self.created_at.replace(microsecond=0)}] {self.content}"
def __hash__(self) -> int:
"""
Make ExplicitObservation hashable for use in sets.
"""
return hash((self.content, self.created_at, self.session_name))
def __eq__(self, other: object) -> bool:
"""
Define equality for ExplicitObservation objects.
Two observations are equal if content, created_at, and session_name match.
NOTE: message_ids are not included in the equality check.
"""
if not isinstance(other, ExplicitObservation):
return False
return (
self.content == other.content
and self.created_at == other.created_at
and self.session_name == other.session_name
)
class DeductiveObservation(DeductiveObservationBase, ObservationMetadata):
"""
Deductive observation with multiple premises and one conclusion, plus metadata.
Represents conclusions that MUST be true given explicit facts and premises.
"""
def __str__(self) -> str:
"""Format observation with timestamp, conclusion, and premises."""
premises_text = "\n".join(f" - {premise}" for premise in self.premises)
return f"[{self.created_at.replace(microsecond=0)}] {self.conclusion}\n{premises_text}"
def str_no_timestamps(self) -> str:
"""Format observation without timestamps."""
premises_text = "\n".join(f" - {premise}" for premise in self.premises)
return f"{self.conclusion}\n{premises_text}"
def __hash__(self) -> int:
"""
Make DeductiveObservation hashable for use in sets.
NOTE: premises are not included in the hash.
"""
return hash((self.conclusion, self.created_at, self.session_name))
def __eq__(self, other: object) -> bool:
"""
Define equality for DeductiveObservation objects.
Two observations are equal if all their fields match.
NOTE: premises are not included in the equality check.
"""
if not isinstance(other, DeductiveObservation):
return False
return (
self.conclusion == other.conclusion
and self.created_at == other.created_at
and self.session_name == other.session_name
)
class Representation(BaseModel):
"""
A Representation is a traversable and diffable map of observations.
At the base, we have a list of explicit observations, derived from a peer's messages.
From there, deductive observations can be made by establishing logical relationships
between explicit observations.
All of a peer's observations 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 observations within a single session.
A representation can have a maximum number of observations, which is applied
individually to each level of reasoning. If a maximum is set, observations are
added and removed in FIFO order.
"""
explicit: list[ExplicitObservation] = Field(
description="Facts LITERALLY stated by the user - direct quotes or clear paraphrases only, no interpretation or inference",
default_factory=list,
)
deductive: list[DeductiveObservation] = Field(
description="Conclusions that MUST be true given explicit facts and premises - strict logical necessities",
default_factory=list,
)
def is_empty(self) -> bool:
"""
Check if the representation is empty.
"""
return len(self.explicit) == 0 and len(self.deductive) == 0
def diff_representation(self, other: "Representation") -> "Representation":
"""
Given this and another representation, return a new representation with only
observations that are unique to the other.
Note: This only removes literal duplicates, not semantically equivalent ones.
Args:
other: The representation to compare against
Returns:
A new Representation containing only observations unique to other
"""
diff = Representation()
diff.explicit = [o for o in other.explicit if o not in self.explicit]
diff.deductive = [o for o in other.deductive if o not in self.deductive]
return diff
def merge_representation(
self, other: "Representation", max_observations: int | None = None
) -> None:
"""
Merge another representation object into this one.
This will automatically deduplicate explicit and deductive observations.
This *preserves order* of observations so that they retain FIFO order.
NOTE: observations with the *same* timestamp will not have order preserved.
That's fine though, because they are from the same timestamp...
Args:
other: The representation to merge into this one
max_observations: Optional maximum number of observations to keep per type
"""
# removing duplicates by going list->set->list
self.explicit = list(set(self.explicit + other.explicit))
self.deductive = list(set(self.deductive + other.deductive))
# sort by created_at
self.explicit.sort(key=lambda x: x.created_at)
self.deductive.sort(key=lambda x: x.created_at)
if max_observations:
self.explicit = self.explicit[-max_observations:]
self.deductive = self.deductive[-max_observations:]
def __str__(self) -> str:
"""
Format representation into a clean, readable string for LLM prompts.
NOTE: we always strip subsecond precision from the timestamps.
Returns:
Formatted string with clear sections and bullet points including temporal metadata
Example:
EXPLICIT:
1. [2025-01-01 12:00:00] The user has a dog named Rover
2. [2025-01-01 12:01:00] The user's dog is 5 years old
DEDUCTIVE:
1. [2025-01-01 12:01:00] Rover is 5 years old
- The user has a dog named Rover
- The user's dog is 5 years old
"""
parts: list[str] = []
parts.append("EXPLICIT:\n")
for i, observation in enumerate(self.explicit, 1):
parts.append(f"{i}. {observation}")
parts.append("")
parts.append("DEDUCTIVE:\n")
for i, observation in enumerate(self.deductive, 1):
parts.append(f"{i}. {observation}")
parts.append("")
return "\n".join(parts)
def str_no_timestamps(self) -> str:
"""
Format representation into a clean, readable string for LLM prompts... but without timestamps.
Returns:
Formatted string with clear sections and bullet points 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
"""
parts: list[str] = []
parts.append("EXPLICIT:\n")
for i, observation in enumerate(self.explicit, 1):
parts.append(f"{i}. {observation.content}")
parts.append("")
parts.append("DEDUCTIVE:\n")
for i, observation in enumerate(self.deductive, 1):
parts.append(f"{i}. {observation.str_no_timestamps()}")
parts.append("")
return "\n".join(parts)
def format_as_markdown(self) -> str:
"""
Format a Representation object as markdown.
NOTE: we always strip subsecond precision from the timestamps.
Returns:
Formatted markdown string with headers and lists
"""
parts: list[str] = []
# Add explicit observations
parts.append("## Explicit Observations\n")
for i, obs in enumerate(self.explicit, 1):
parts.append(f"{i}. {obs}")
parts.append("")
# Add deductive observations
parts.append("## Deductive Observations\n")
for i, obs in enumerate(self.deductive, 1):
parts.append(f"{i}. **Conclusion**: {obs.conclusion}")
if obs.premises:
parts.append(" **Premises**:")
for premise in obs.premises:
parts.append(f" - {premise}")
parts.append("")
parts.append("")
return "\n".join(parts)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Representation":
"""
Create a Representation from a dictionary (typically from API response).
Args:
data: Dictionary containing 'explicit' and 'deductive' observation lists
Returns:
A new Representation instance
Raises:
ValidationError: If observation data is missing required fields
"""
explicit_data: Any = data.get("explicit", [])
deductive_data: Any = data.get("deductive", [])
explicit_list = cast(
list[Any], explicit_data if isinstance(explicit_data, list) else []
)
deductive_list = cast(
list[Any], deductive_data if isinstance(deductive_data, list) else []
)
return cls(
explicit=[ExplicitObservation(**obs) for obs in explicit_list],
deductive=[DeductiveObservation(**obs) for obs in deductive_list],
)
class DialecticStreamResponse:
@ -62,7 +377,7 @@ class DialecticStreamResponse:
try:
if not isinstance(self._iterator, Iterator):
raise TypeError("iterator must be an sync iterator, got async iterator")
chunk = next(self._iterator) # type: ignore
chunk = next(self._iterator)
self._accumulated_content.append(chunk)
return chunk
except StopIteration:
@ -80,7 +395,7 @@ class DialecticStreamResponse:
try:
if not isinstance(self._iterator, AsyncIterator):
raise TypeError("iterator must be an async iterator, got sync iterator")
chunk = await self._iterator.__anext__() # type: ignore
chunk = await self._iterator.__anext__()
self._accumulated_content.append(chunk)
return chunk
except StopAsyncIteration:
@ -104,3 +419,81 @@ class DialecticStreamResponse:
def is_complete(self) -> bool:
"""Check if the stream has finished."""
return self._is_complete
class PeerContext:
"""
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 get_context API call.
Attributes:
peer_id: The ID of the observer peer
target_id: The ID of the target peer being observed
representation: The working representation (may be None if no observations exist)
peer_card: List of peer card strings (may be None if no card exists)
"""
peer_id: str
target_id: str
representation: Representation | None
peer_card: list[str] | None
def __init__(
self,
peer_id: str,
target_id: str,
representation: Representation | None = None,
peer_card: list[str] | None = None,
):
self.peer_id = peer_id
self.target_id = target_id
self.representation = representation
self.peer_card = peer_card
@classmethod
def from_api_response(cls, response: Any) -> "PeerContext":
"""
Create a PeerContext from an API response.
Args:
response: API response object with peer_id, target_id, representation, and peer_card
Returns:
A new PeerContext instance
"""
peer_id = getattr(response, "peer_id", "") or ""
target_id = getattr(response, "target_id", "") or ""
representation = None
rep_data = getattr(response, "representation", None)
if rep_data is not None:
if isinstance(rep_data, dict):
representation = Representation.from_dict(
cast(dict[str, Any], rep_data)
)
elif hasattr(rep_data, "explicit") and hasattr(rep_data, "deductive"):
representation = Representation.from_dict(
{
"explicit": rep_data.explicit,
"deductive": rep_data.deductive,
}
)
peer_card = getattr(response, "peer_card", None)
return cls(
peer_id=peer_id,
target_id=target_id,
representation=representation,
peer_card=peer_card,
)
def __repr__(self) -> str:
has_rep = self.representation is not None
has_card = self.peer_card is not None and len(self.peer_card) > 0
return (
f"PeerContext(peer_id={self.peer_id!r}, target_id={self.target_id!r}, "
f"has_representation={has_rep}, has_peer_card={has_card})"
)

View File

@ -1,509 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.8"
resolution-markers = [
"python_full_version >= '3.9'",
"python_full_version < '3.9'",
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.5.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.9'",
]
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.9'" },
{ name = "idna", marker = "python_full_version < '3.9'" },
{ name = "sniffio", marker = "python_full_version < '3.9'" },
{ name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4d/f9/9a7ce600ebe7804daf90d4d48b1c0510a4561ddce43a596be46676f82343/anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b", size = 171293, upload-time = "2024-10-13T22:18:03.307Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/b4/f7e396030e3b11394436358ca258a81d6010106582422f23443c16ca1873/anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f", size = 89766, upload-time = "2024-10-13T22:18:01.524Z" },
]
[[package]]
name = "anyio"
version = "4.10.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9'",
]
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" },
{ name = "idna", marker = "python_full_version >= '3.9'" },
{ name = "sniffio", marker = "python_full_version >= '3.9'" },
{ name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" },
]
[[package]]
name = "certifi"
version = "2025.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
]
[[package]]
name = "distro"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "honcho-ai"
version = "1.4.0"
source = { editable = "." }
dependencies = [
{ name = "honcho-core" },
{ name = "httpx" },
{ name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "pydantic", version = "2.11.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "honcho-core", specifier = ">=1.4.0" },
{ name = "httpx", specifier = ">=0.28.0,<1" },
{ name = "pydantic", specifier = ">=2.0.0,<3" },
]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.11.13" }]
[[package]]
name = "honcho-core"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", version = "4.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "anyio", version = "4.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
{ name = "distro" },
{ name = "httpx" },
{ name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "pydantic", version = "2.11.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
{ name = "sniffio" },
{ name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/c2/4d3737d5c0a75e2f59f324bed7428a9593c8edfcb7500cf402c46ad7378a/honcho_core-1.4.0.tar.gz", hash = "sha256:b30de9247763c01c3c37ed22b5d04d3c611440dd1983168f3d7d93f2cc0ecf7d", size = 126164, upload-time = "2025-08-12T19:06:54.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/ac/6eb2e38376736bb1dfd26bbdfd96490bfbb6f0ddcb00b39913e93799e7d8/honcho_core-1.4.0-py3-none-any.whl", hash = "sha256:a84397fd9daf546a04f5458d7233ebd5eeafcb97af75ec60a9327db89abdaf03", size = 117602, upload-time = "2025-08-12T19:06:53.667Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", version = "4.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "anyio", version = "4.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "pydantic"
version = "2.10.6"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.9'",
]
dependencies = [
{ name = "annotated-types", marker = "python_full_version < '3.9'" },
{ name = "pydantic-core", version = "2.27.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681, upload-time = "2025-01-24T01:42:12.693Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696, upload-time = "2025-01-24T01:42:10.371Z" },
]
[[package]]
name = "pydantic"
version = "2.11.7"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9'",
]
dependencies = [
{ name = "annotated-types", marker = "python_full_version >= '3.9'" },
{ name = "pydantic-core", version = "2.33.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
{ name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
{ name = "typing-inspection", marker = "python_full_version >= '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
]
[[package]]
name = "pydantic-core"
version = "2.27.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.9'",
]
dependencies = [
{ name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443, upload-time = "2024-12-18T11:31:54.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938, upload-time = "2024-12-18T11:27:14.406Z" },
{ url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684, upload-time = "2024-12-18T11:27:16.489Z" },
{ url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169, upload-time = "2024-12-18T11:27:22.16Z" },
{ url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227, upload-time = "2024-12-18T11:27:25.097Z" },
{ url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695, upload-time = "2024-12-18T11:27:28.656Z" },
{ url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662, upload-time = "2024-12-18T11:27:30.798Z" },
{ url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370, upload-time = "2024-12-18T11:27:33.692Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813, upload-time = "2024-12-18T11:27:37.111Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287, upload-time = "2024-12-18T11:27:40.566Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414, upload-time = "2024-12-18T11:27:43.757Z" },
{ url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301, upload-time = "2024-12-18T11:27:47.36Z" },
{ url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685, upload-time = "2024-12-18T11:27:50.508Z" },
{ url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876, upload-time = "2024-12-18T11:27:53.54Z" },
{ url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421, upload-time = "2024-12-18T11:27:55.409Z" },
{ url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998, upload-time = "2024-12-18T11:27:57.252Z" },
{ url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167, upload-time = "2024-12-18T11:27:59.146Z" },
{ url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071, upload-time = "2024-12-18T11:28:02.625Z" },
{ url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244, upload-time = "2024-12-18T11:28:04.442Z" },
{ url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470, upload-time = "2024-12-18T11:28:07.679Z" },
{ url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291, upload-time = "2024-12-18T11:28:10.297Z" },
{ url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613, upload-time = "2024-12-18T11:28:13.362Z" },
{ url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355, upload-time = "2024-12-18T11:28:16.587Z" },
{ url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661, upload-time = "2024-12-18T11:28:18.407Z" },
{ url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261, upload-time = "2024-12-18T11:28:21.471Z" },
{ url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361, upload-time = "2024-12-18T11:28:23.53Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484, upload-time = "2024-12-18T11:28:25.391Z" },
{ url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102, upload-time = "2024-12-18T11:28:28.593Z" },
{ url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127, upload-time = "2024-12-18T11:28:30.346Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340, upload-time = "2024-12-18T11:28:32.521Z" },
{ url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900, upload-time = "2024-12-18T11:28:34.507Z" },
{ url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177, upload-time = "2024-12-18T11:28:36.488Z" },
{ url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046, upload-time = "2024-12-18T11:28:39.409Z" },
{ url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386, upload-time = "2024-12-18T11:28:41.221Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060, upload-time = "2024-12-18T11:28:44.709Z" },
{ url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870, upload-time = "2024-12-18T11:28:46.839Z" },
{ url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822, upload-time = "2024-12-18T11:28:48.896Z" },
{ url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364, upload-time = "2024-12-18T11:28:50.755Z" },
{ url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303, upload-time = "2024-12-18T11:28:54.122Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064, upload-time = "2024-12-18T11:28:56.074Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046, upload-time = "2024-12-18T11:28:58.107Z" },
{ url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092, upload-time = "2024-12-18T11:29:01.335Z" },
{ url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709, upload-time = "2024-12-18T11:29:03.193Z" },
{ url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273, upload-time = "2024-12-18T11:29:05.306Z" },
{ url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027, upload-time = "2024-12-18T11:29:07.294Z" },
{ url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888, upload-time = "2024-12-18T11:29:09.249Z" },
{ url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738, upload-time = "2024-12-18T11:29:11.23Z" },
{ url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138, upload-time = "2024-12-18T11:29:16.396Z" },
{ url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025, upload-time = "2024-12-18T11:29:20.25Z" },
{ url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633, upload-time = "2024-12-18T11:29:23.877Z" },
{ url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404, upload-time = "2024-12-18T11:29:25.872Z" },
{ url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130, upload-time = "2024-12-18T11:29:29.252Z" },
{ url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946, upload-time = "2024-12-18T11:29:31.338Z" },
{ url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387, upload-time = "2024-12-18T11:29:33.481Z" },
{ url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453, upload-time = "2024-12-18T11:29:35.533Z" },
{ url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186, upload-time = "2024-12-18T11:29:37.649Z" },
{ url = "https://files.pythonhosted.org/packages/43/53/13e9917fc69c0a4aea06fd63ed6a8d6cda9cf140ca9584d49c1650b0ef5e/pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506", size = 1899595, upload-time = "2024-12-18T11:29:40.887Z" },
{ url = "https://files.pythonhosted.org/packages/f4/20/26c549249769ed84877f862f7bb93f89a6ee08b4bee1ed8781616b7fbb5e/pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320", size = 1775010, upload-time = "2024-12-18T11:29:44.823Z" },
{ url = "https://files.pythonhosted.org/packages/35/eb/8234e05452d92d2b102ffa1b56d801c3567e628fdc63f02080fdfc68fd5e/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145", size = 1830727, upload-time = "2024-12-18T11:29:46.904Z" },
{ url = "https://files.pythonhosted.org/packages/8f/df/59f915c8b929d5f61e5a46accf748a87110ba145156f9326d1a7d28912b2/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1", size = 1868393, upload-time = "2024-12-18T11:29:49.098Z" },
{ url = "https://files.pythonhosted.org/packages/d5/52/81cf4071dca654d485c277c581db368b0c95b2b883f4d7b736ab54f72ddf/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228", size = 2040300, upload-time = "2024-12-18T11:29:51.43Z" },
{ url = "https://files.pythonhosted.org/packages/9c/00/05197ce1614f5c08d7a06e1d39d5d8e704dc81971b2719af134b844e2eaf/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046", size = 2738785, upload-time = "2024-12-18T11:29:55.001Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a3/5f19bc495793546825ab160e530330c2afcee2281c02b5ffafd0b32ac05e/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5", size = 1996493, upload-time = "2024-12-18T11:29:57.13Z" },
{ url = "https://files.pythonhosted.org/packages/ed/e8/e0102c2ec153dc3eed88aea03990e1b06cfbca532916b8a48173245afe60/pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a", size = 1998544, upload-time = "2024-12-18T11:30:00.681Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a3/4be70845b555bd80aaee9f9812a7cf3df81550bce6dadb3cfee9c5d8421d/pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d", size = 2007449, upload-time = "2024-12-18T11:30:02.985Z" },
{ url = "https://files.pythonhosted.org/packages/e3/9f/b779ed2480ba355c054e6d7ea77792467631d674b13d8257085a4bc7dcda/pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9", size = 2129460, upload-time = "2024-12-18T11:30:06.55Z" },
{ url = "https://files.pythonhosted.org/packages/a0/f0/a6ab0681f6e95260c7fbf552874af7302f2ea37b459f9b7f00698f875492/pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da", size = 2159609, upload-time = "2024-12-18T11:30:09.428Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2b/e1059506795104349712fbca647b18b3f4a7fd541c099e6259717441e1e0/pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b", size = 1819886, upload-time = "2024-12-18T11:30:11.777Z" },
{ url = "https://files.pythonhosted.org/packages/aa/6d/df49c17f024dfc58db0bacc7b03610058018dd2ea2eaf748ccbada4c3d06/pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad", size = 1980773, upload-time = "2024-12-18T11:30:14.828Z" },
{ url = "https://files.pythonhosted.org/packages/27/97/3aef1ddb65c5ccd6eda9050036c956ff6ecbfe66cb7eb40f280f121a5bb0/pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993", size = 1896475, upload-time = "2024-12-18T11:30:18.316Z" },
{ url = "https://files.pythonhosted.org/packages/ad/d3/5668da70e373c9904ed2f372cb52c0b996426f302e0dee2e65634c92007d/pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308", size = 1772279, upload-time = "2024-12-18T11:30:20.547Z" },
{ url = "https://files.pythonhosted.org/packages/8a/9e/e44b8cb0edf04a2f0a1f6425a65ee089c1d6f9c4c2dcab0209127b6fdfc2/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4", size = 1829112, upload-time = "2024-12-18T11:30:23.255Z" },
{ url = "https://files.pythonhosted.org/packages/1c/90/1160d7ac700102effe11616e8119e268770f2a2aa5afb935f3ee6832987d/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf", size = 1866780, upload-time = "2024-12-18T11:30:25.742Z" },
{ url = "https://files.pythonhosted.org/packages/ee/33/13983426df09a36d22c15980008f8d9c77674fc319351813b5a2739b70f3/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76", size = 2037943, upload-time = "2024-12-18T11:30:28.036Z" },
{ url = "https://files.pythonhosted.org/packages/01/d7/ced164e376f6747e9158c89988c293cd524ab8d215ae4e185e9929655d5c/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118", size = 2740492, upload-time = "2024-12-18T11:30:30.412Z" },
{ url = "https://files.pythonhosted.org/packages/8b/1f/3dc6e769d5b7461040778816aab2b00422427bcaa4b56cc89e9c653b2605/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630", size = 1995714, upload-time = "2024-12-18T11:30:34.358Z" },
{ url = "https://files.pythonhosted.org/packages/07/d7/a0bd09bc39283530b3f7c27033a814ef254ba3bd0b5cfd040b7abf1fe5da/pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54", size = 1997163, upload-time = "2024-12-18T11:30:37.979Z" },
{ url = "https://files.pythonhosted.org/packages/2d/bb/2db4ad1762e1c5699d9b857eeb41959191980de6feb054e70f93085e1bcd/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f", size = 2005217, upload-time = "2024-12-18T11:30:40.367Z" },
{ url = "https://files.pythonhosted.org/packages/53/5f/23a5a3e7b8403f8dd8fc8a6f8b49f6b55c7d715b77dcf1f8ae919eeb5628/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362", size = 2127899, upload-time = "2024-12-18T11:30:42.737Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ae/aa38bb8dd3d89c2f1d8362dd890ee8f3b967330821d03bbe08fa01ce3766/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96", size = 2155726, upload-time = "2024-12-18T11:30:45.279Z" },
{ url = "https://files.pythonhosted.org/packages/98/61/4f784608cc9e98f70839187117ce840480f768fed5d386f924074bf6213c/pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e", size = 1817219, upload-time = "2024-12-18T11:30:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/57/82/bb16a68e4a1a858bb3768c2c8f1ff8d8978014e16598f001ea29a25bf1d1/pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67", size = 1985382, upload-time = "2024-12-18T11:30:51.871Z" },
{ url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159, upload-time = "2024-12-18T11:30:54.382Z" },
{ url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331, upload-time = "2024-12-18T11:30:58.178Z" },
{ url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467, upload-time = "2024-12-18T11:31:00.6Z" },
{ url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797, upload-time = "2024-12-18T11:31:07.243Z" },
{ url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839, upload-time = "2024-12-18T11:31:09.775Z" },
{ url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861, upload-time = "2024-12-18T11:31:13.469Z" },
{ url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582, upload-time = "2024-12-18T11:31:17.423Z" },
{ url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985, upload-time = "2024-12-18T11:31:19.901Z" },
{ url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715, upload-time = "2024-12-18T11:31:22.821Z" },
{ url = "https://files.pythonhosted.org/packages/29/0e/dcaea00c9dbd0348b723cae82b0e0c122e0fa2b43fa933e1622fd237a3ee/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656", size = 1891733, upload-time = "2024-12-18T11:31:26.876Z" },
{ url = "https://files.pythonhosted.org/packages/86/d3/e797bba8860ce650272bda6383a9d8cad1d1c9a75a640c9d0e848076f85e/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278", size = 1768375, upload-time = "2024-12-18T11:31:29.276Z" },
{ url = "https://files.pythonhosted.org/packages/41/f7/f847b15fb14978ca2b30262548f5fc4872b2724e90f116393eb69008299d/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb", size = 1822307, upload-time = "2024-12-18T11:31:33.123Z" },
{ url = "https://files.pythonhosted.org/packages/9c/63/ed80ec8255b587b2f108e514dc03eed1546cd00f0af281e699797f373f38/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd", size = 1979971, upload-time = "2024-12-18T11:31:35.755Z" },
{ url = "https://files.pythonhosted.org/packages/a9/6d/6d18308a45454a0de0e975d70171cadaf454bc7a0bf86b9c7688e313f0bb/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc", size = 1987616, upload-time = "2024-12-18T11:31:38.534Z" },
{ url = "https://files.pythonhosted.org/packages/82/8a/05f8780f2c1081b800a7ca54c1971e291c2d07d1a50fb23c7e4aef4ed403/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b", size = 1998943, upload-time = "2024-12-18T11:31:41.853Z" },
{ url = "https://files.pythonhosted.org/packages/5e/3e/fe5b6613d9e4c0038434396b46c5303f5ade871166900b357ada4766c5b7/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b", size = 2116654, upload-time = "2024-12-18T11:31:44.756Z" },
{ url = "https://files.pythonhosted.org/packages/db/ad/28869f58938fad8cc84739c4e592989730bfb69b7c90a8fff138dff18e1e/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2", size = 2152292, upload-time = "2024-12-18T11:31:48.613Z" },
{ url = "https://files.pythonhosted.org/packages/a1/0c/c5c5cd3689c32ed1fe8c5d234b079c12c281c051759770c05b8bed6412b5/pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35", size = 2004961, upload-time = "2024-12-18T11:31:52.446Z" },
]
[[package]]
name = "pydantic-core"
version = "2.33.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9'",
]
dependencies = [
{ name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" },
{ url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" },
{ url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" },
{ url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" },
{ url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" },
{ url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" },
{ url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" },
{ url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" },
{ url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" },
{ url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" },
{ url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" },
{ url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" },
{ url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" },
{ url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" },
{ url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" },
{ url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" },
{ url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" },
{ url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" },
{ url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" },
{ url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" },
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
{ url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677, upload-time = "2025-04-23T18:32:27.227Z" },
{ url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735, upload-time = "2025-04-23T18:32:29.019Z" },
{ url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467, upload-time = "2025-04-23T18:32:31.119Z" },
{ url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041, upload-time = "2025-04-23T18:32:33.655Z" },
{ url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503, upload-time = "2025-04-23T18:32:35.519Z" },
{ url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079, upload-time = "2025-04-23T18:32:37.659Z" },
{ url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508, upload-time = "2025-04-23T18:32:39.637Z" },
{ url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693, upload-time = "2025-04-23T18:32:41.818Z" },
{ url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224, upload-time = "2025-04-23T18:32:44.033Z" },
{ url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403, upload-time = "2025-04-23T18:32:45.836Z" },
{ url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331, upload-time = "2025-04-23T18:32:47.618Z" },
{ url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571, upload-time = "2025-04-23T18:32:49.401Z" },
{ url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504, upload-time = "2025-04-23T18:32:51.287Z" },
{ url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" },
{ url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" },
{ url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" },
{ url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" },
{ url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" },
{ url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" },
{ url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" },
{ url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" },
{ url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" },
{ url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" },
{ url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" },
{ url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" },
{ url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" },
{ url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" },
{ url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" },
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" },
{ url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034, upload-time = "2025-04-23T18:33:32.843Z" },
{ url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578, upload-time = "2025-04-23T18:33:34.912Z" },
{ url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858, upload-time = "2025-04-23T18:33:36.933Z" },
{ url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498, upload-time = "2025-04-23T18:33:38.997Z" },
{ url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428, upload-time = "2025-04-23T18:33:41.18Z" },
{ url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854, upload-time = "2025-04-23T18:33:43.446Z" },
{ url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859, upload-time = "2025-04-23T18:33:45.56Z" },
{ url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059, upload-time = "2025-04-23T18:33:47.735Z" },
{ url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661, upload-time = "2025-04-23T18:33:49.995Z" },
]
[[package]]
name = "ruff"
version = "0.12.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4b/da/5bd7565be729e86e1442dad2c9a364ceeff82227c2dece7c29697a9795eb/ruff-0.12.8.tar.gz", hash = "sha256:4cb3a45525176e1009b2b64126acf5f9444ea59066262791febf55e40493a033", size = 5242373, upload-time = "2025-08-07T19:05:47.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/1e/c843bfa8ad1114fab3eb2b78235dda76acd66384c663a4e0415ecc13aa1e/ruff-0.12.8-py3-none-linux_armv6l.whl", hash = "sha256:63cb5a5e933fc913e5823a0dfdc3c99add73f52d139d6cd5cc8639d0e0465513", size = 11675315, upload-time = "2025-08-07T19:05:06.15Z" },
{ url = "https://files.pythonhosted.org/packages/24/ee/af6e5c2a8ca3a81676d5480a1025494fd104b8896266502bb4de2a0e8388/ruff-0.12.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a9bbe28f9f551accf84a24c366c1aa8774d6748438b47174f8e8565ab9dedbc", size = 12456653, upload-time = "2025-08-07T19:05:09.759Z" },
{ url = "https://files.pythonhosted.org/packages/99/9d/e91f84dfe3866fa648c10512904991ecc326fd0b66578b324ee6ecb8f725/ruff-0.12.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2fae54e752a3150f7ee0e09bce2e133caf10ce9d971510a9b925392dc98d2fec", size = 11659690, upload-time = "2025-08-07T19:05:12.551Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ac/a363d25ec53040408ebdd4efcee929d48547665858ede0505d1d8041b2e5/ruff-0.12.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0acbcf01206df963d9331b5838fb31f3b44fa979ee7fa368b9b9057d89f4a53", size = 11896923, upload-time = "2025-08-07T19:05:14.821Z" },
{ url = "https://files.pythonhosted.org/packages/58/9f/ea356cd87c395f6ade9bb81365bd909ff60860975ca1bc39f0e59de3da37/ruff-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ae3e7504666ad4c62f9ac8eedb52a93f9ebdeb34742b8b71cd3cccd24912719f", size = 11477612, upload-time = "2025-08-07T19:05:16.712Z" },
{ url = "https://files.pythonhosted.org/packages/1a/46/92e8fa3c9dcfd49175225c09053916cb97bb7204f9f899c2f2baca69e450/ruff-0.12.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb82efb5d35d07497813a1c5647867390a7d83304562607f3579602fa3d7d46f", size = 13182745, upload-time = "2025-08-07T19:05:18.709Z" },
{ url = "https://files.pythonhosted.org/packages/5e/c4/f2176a310f26e6160deaf661ef60db6c3bb62b7a35e57ae28f27a09a7d63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:dbea798fc0065ad0b84a2947b0aff4233f0cb30f226f00a2c5850ca4393de609", size = 14206885, upload-time = "2025-08-07T19:05:21.025Z" },
{ url = "https://files.pythonhosted.org/packages/87/9d/98e162f3eeeb6689acbedbae5050b4b3220754554526c50c292b611d3a63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49ebcaccc2bdad86fd51b7864e3d808aad404aab8df33d469b6e65584656263a", size = 13639381, upload-time = "2025-08-07T19:05:23.423Z" },
{ url = "https://files.pythonhosted.org/packages/81/4e/1b7478b072fcde5161b48f64774d6edd59d6d198e4ba8918d9f4702b8043/ruff-0.12.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ac9c570634b98c71c88cb17badd90f13fc076a472ba6ef1d113d8ed3df109fb", size = 12613271, upload-time = "2025-08-07T19:05:25.507Z" },
{ url = "https://files.pythonhosted.org/packages/e8/67/0c3c9179a3ad19791ef1b8f7138aa27d4578c78700551c60d9260b2c660d/ruff-0.12.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:560e0cd641e45591a3e42cb50ef61ce07162b9c233786663fdce2d8557d99818", size = 12847783, upload-time = "2025-08-07T19:05:28.14Z" },
{ url = "https://files.pythonhosted.org/packages/4e/2a/0b6ac3dd045acf8aa229b12c9c17bb35508191b71a14904baf99573a21bd/ruff-0.12.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:71c83121512e7743fba5a8848c261dcc454cafb3ef2934a43f1b7a4eb5a447ea", size = 11702672, upload-time = "2025-08-07T19:05:30.413Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ee/f9fdc9f341b0430110de8b39a6ee5fa68c5706dc7c0aa940817947d6937e/ruff-0.12.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:de4429ef2ba091ecddedd300f4c3f24bca875d3d8b23340728c3cb0da81072c3", size = 11440626, upload-time = "2025-08-07T19:05:32.492Z" },
{ url = "https://files.pythonhosted.org/packages/89/fb/b3aa2d482d05f44e4d197d1de5e3863feb13067b22c571b9561085c999dc/ruff-0.12.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a2cab5f60d5b65b50fba39a8950c8746df1627d54ba1197f970763917184b161", size = 12462162, upload-time = "2025-08-07T19:05:34.449Z" },
{ url = "https://files.pythonhosted.org/packages/18/9f/5c5d93e1d00d854d5013c96e1a92c33b703a0332707a7cdbd0a4880a84fb/ruff-0.12.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:45c32487e14f60b88aad6be9fd5da5093dbefb0e3e1224131cb1d441d7cb7d46", size = 12913212, upload-time = "2025-08-07T19:05:36.541Z" },
{ url = "https://files.pythonhosted.org/packages/71/13/ab9120add1c0e4604c71bfc2e4ef7d63bebece0cfe617013da289539cef8/ruff-0.12.8-py3-none-win32.whl", hash = "sha256:daf3475060a617fd5bc80638aeaf2f5937f10af3ec44464e280a9d2218e720d3", size = 11694382, upload-time = "2025-08-07T19:05:38.468Z" },
{ url = "https://files.pythonhosted.org/packages/f6/dc/a2873b7c5001c62f46266685863bee2888caf469d1edac84bf3242074be2/ruff-0.12.8-py3-none-win_amd64.whl", hash = "sha256:7209531f1a1fcfbe8e46bcd7ab30e2f43604d8ba1c49029bb420b103d0b5f76e", size = 12740482, upload-time = "2025-08-07T19:05:40.391Z" },
{ url = "https://files.pythonhosted.org/packages/cb/5c/799a1efb8b5abab56e8a9f2a0b72d12bd64bb55815e9476c7d0a2887d2f7/ruff-0.12.8-py3-none-win_arm64.whl", hash = "sha256:c90e1a334683ce41b0e7a04f41790c429bf5073b62c1ae701c9dc5b3d14f0749", size = 11884718, upload-time = "2025-08-07T19:05:42.866Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "typing-extensions"
version = "4.13.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.9'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" },
]
[[package]]
name = "typing-extensions"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9'",
]
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
]

View File

@ -104,6 +104,12 @@ describe('Honcho Client', () => {
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(
@ -176,6 +182,12 @@ describe('Honcho Client', () => {
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(

View File

@ -3,6 +3,7 @@ import { Peer } from '../src/peer'
import { Session } from '../src/session'
import { SessionContext } from '../src/session_context'
import { Page } from '../src/pagination'
import { Representation } from '../src/representation'
// Mock the @honcho-ai/core module
let mockWorkspacesApi: any
@ -369,16 +370,35 @@ describe('Honcho SDK Integration Tests', () => {
})
it('should handle working representation queries', async () => {
const mockWorkingRep = {
peer_id: 'alice',
knowledge: 'Alice likes coffee and works as a developer',
relationships: ['bob', 'charlie'],
context: 'session-specific context',
const mockWorkingRepData = {
explicit: [
{
content: 'Alice likes coffee',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'working-rep-session',
},
{
content: 'Alice works as a developer',
created_at: '2024-01-01T00:01:00Z',
message_ids: [[3, 4]],
session_name: 'working-rep-session',
},
],
deductive: [
{
conclusion: 'Alice is a coffee-drinking developer',
premises: ['Alice likes coffee', 'Alice works as a developer'],
created_at: '2024-01-01T00:02:00Z',
message_ids: [[5, 6]],
session_name: 'working-rep-session',
},
],
}
mockWorkspacesApi.workspaces.peers.workingRepresentation.mockResolvedValue(
mockWorkingRep
)
mockWorkspacesApi.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockWorkingRepData,
})
const session = await honcho.session('working-rep-session')
const alice = await honcho.peer('alice')
@ -386,7 +406,12 @@ describe('Honcho SDK Integration Tests', () => {
// Test working representation without target
const globalRep = await session.workingRep('alice')
expect(globalRep).toEqual(mockWorkingRep)
expect(globalRep).toBeInstanceOf(Representation)
expect(globalRep.explicit).toHaveLength(2)
expect(globalRep.explicit[0].content).toBe('Alice likes coffee')
expect(globalRep.explicit[1].content).toBe('Alice works as a developer')
expect(globalRep.deductive).toHaveLength(1)
expect(globalRep.deductive[0].conclusion).toBe('Alice is a coffee-drinking developer')
expect(
mockWorkspacesApi.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('integration-test-workspace', 'alice', {
@ -395,7 +420,8 @@ describe('Honcho SDK Integration Tests', () => {
})
// Test working representation with target
await session.workingRep(alice, bob)
const targetRep = await session.workingRep(alice, bob)
expect(targetRep).toBeInstanceOf(Representation)
expect(
mockWorkspacesApi.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('integration-test-workspace', 'alice', {

View File

@ -0,0 +1,565 @@
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
});
});
});

View File

@ -2,6 +2,7 @@ import { Peer } from '../src/peer';
import { Session } from '../src/session';
import { Page } from '../src/pagination';
import { Honcho } from '../src/client';
import { Representation } from '../src/representation';
// Mock the @honcho-ai/core module
jest.mock('@honcho-ai/core', () => {
@ -208,6 +209,8 @@ describe('Peer', () => {
peer_id: 'test-peer',
content: 'Test content',
metadata: undefined,
configuration: undefined,
created_at: undefined,
});
});
@ -219,6 +222,39 @@ describe('Peer', () => {
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',
});
});
@ -229,6 +265,8 @@ describe('Peer', () => {
peer_id: 'test-peer',
content: '',
metadata: undefined,
configuration: undefined,
created_at: undefined,
});
});
});
@ -514,4 +552,423 @@ describe('Peer', () => {
await expect(peer.card()).rejects.toThrow('Card fetch failed');
});
});
describe('workingRep', () => {
beforeEach(() => {
mockClient.workspaces.peers.workingRepresentation = jest.fn();
});
it('should get working representation with no parameters', async () => {
const mockRepresentationData = {
explicit: [
{
content: 'Observation 1',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
{
content: 'Observation 2',
created_at: '2024-01-01T00:01:00Z',
message_ids: [[3, 4]],
session_name: 'test-session',
},
],
deductive: [
{
conclusion: 'Conclusion 1',
premises: ['Observation 1', 'Observation 2'],
created_at: '2024-01-01T00:02:00Z',
message_ids: [[5, 6]],
session_name: 'test-session',
},
],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep();
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(2);
expect(result.explicit[0].content).toBe('Observation 1');
expect(result.explicit[1].content).toBe('Observation 2');
expect(result.deductive).toHaveLength(1);
expect(result.deductive[0].conclusion).toBe('Conclusion 1');
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
});
});
it('should get working representation with session as string', async () => {
const mockRepresentationData = {
explicit: [
{
content: 'Session-scoped observation',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'session-123',
},
],
deductive: [],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep('session-123');
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe('Session-scoped observation');
expect(result.deductive).toHaveLength(0);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: 'session-123',
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
});
});
it('should get working representation with session as Session object', async () => {
const session = new Session('session-123', 'test-workspace', mockClient);
const mockRepresentationData = {
explicit: [
{
content: 'Session object observation',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'session-123',
},
],
deductive: [],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep(session);
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe('Session object observation');
expect(result.deductive).toHaveLength(0);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: 'session-123',
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
});
});
it('should get working representation with target as string', async () => {
const mockRepresentationData = {
explicit: [
{
content: "Observer's view of target",
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep(undefined, 'target-peer');
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe("Observer's view of target");
expect(result.deductive).toHaveLength(0);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: 'target-peer',
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
});
});
it('should get working representation with target as Peer object', async () => {
const targetPeer = new Peer('target-peer', 'test-workspace', mockClient);
const mockRepresentationData = {
explicit: [
{
content: "Observer's view of target peer object",
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep(undefined, targetPeer);
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe("Observer's view of target peer object");
expect(result.deductive).toHaveLength(0);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: 'target-peer',
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
});
});
it('should get working representation with search query', async () => {
const mockRepresentationData = {
explicit: [
{
content: 'Query-curated observation',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep(
undefined,
undefined,
{ searchQuery: 'programming' }
);
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe('Query-curated observation');
expect(result.deductive).toHaveLength(0);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: undefined,
search_query: 'programming',
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
});
});
it('should get working representation with custom size', async () => {
const mockRepresentationData = {
explicit: [
{
content: 'Limited observations',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep(undefined, undefined, { maxObservations: 10 });
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe('Limited observations');
expect(result.deductive).toHaveLength(0);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: 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 mockRepresentationData = {
explicit: [
{
content: 'Fully parameterized observation',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'session-123',
},
],
deductive: [
{
conclusion: 'Conclusion with all params',
premises: ['Fully parameterized observation'],
created_at: '2024-01-01T00:01:00Z',
message_ids: [[3, 4]],
session_name: 'session-123',
},
],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep(
session,
targetPeer,
{ searchQuery: 'Python programming', maxObservations: 25 }
);
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe('Fully parameterized observation');
expect(result.deductive).toHaveLength(1);
expect(result.deductive[0].conclusion).toBe('Conclusion with all params');
expect(
mockClient.workspaces.peers.workingRepresentation
).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_derived: undefined,
max_observations: 25,
});
});
it('should get working representation with string session and string target', async () => {
const mockRepresentationData = {
explicit: [
{
content: 'String params observation',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'session-456',
},
],
deductive: [],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep(
'session-456',
'target-peer-123',
{ searchQuery: 'machine learning', maxObservations: 50 }
);
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe('String params observation');
expect(result.deductive).toHaveLength(0);
expect(
mockClient.workspaces.peers.workingRepresentation
).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_derived: undefined,
max_observations: 50,
});
});
it('should handle boundary size values', async () => {
const mockRepresentationData = {
explicit: [
{
content: 'Boundary test',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
// Test size = 1
const result1 = await peer.workingRep(undefined, undefined, { maxObservations: 1 });
expect(result1).toBeInstanceOf(Representation);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenLastCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: 1,
});
// Test size = 100
const result2 = await peer.workingRep(undefined, undefined, { maxObservations: 100 });
expect(result2).toBeInstanceOf(Representation);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenLastCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: 100,
});
});
it('should handle API errors', async () => {
mockClient.workspaces.peers.workingRepresentation.mockRejectedValue(
new Error('Working representation fetch failed')
);
await expect(peer.workingRep()).rejects.toThrow(
'Working representation fetch failed'
);
});
});
});

View File

@ -3,6 +3,7 @@ import { Peer } from '../src/peer'
import { Page } from '../src/pagination'
import { SessionContext } from '../src/session_context'
import { Honcho } from '../src/client'
import { Representation } from '../src/representation'
// Mock the @honcho-ai/core module
jest.mock('@honcho-ai/core', () => {
@ -568,6 +569,62 @@ describe('Session', () => {
],
})
})
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')
})
})
describe('getMessages', () => {
@ -847,18 +904,27 @@ describe('Session', () => {
describe('workingRep', () => {
it('should get working representation with peer string', async () => {
const mockRepresentation = {
peer_id: 'peer1',
knowledge: 'Some knowledge about the peer',
relationships: ['peer2', 'peer3'],
const mockRepresentationData = {
explicit: [
{
content: 'Some knowledge about the peer',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
}
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue(
mockRepresentation
)
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
})
const result = await session.workingRep('peer1')
expect(result).toEqual(mockRepresentation)
expect(result).toBeInstanceOf(Representation)
expect(result.explicit).toHaveLength(1)
expect(result.explicit[0].content).toBe('Some knowledge about the peer')
expect(result.deductive).toHaveLength(0)
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'peer1', {
@ -869,17 +935,27 @@ describe('Session', () => {
it('should get working representation with Peer object', async () => {
const peer = new Peer('peer1', 'test-workspace', mockClient)
const mockRepresentation = {
peer_id: 'peer1',
knowledge: 'Some knowledge',
const mockRepresentationData = {
explicit: [
{
content: 'Some knowledge',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
}
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue(
mockRepresentation
)
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
})
const result = await session.workingRep(peer)
expect(result).toEqual(mockRepresentation)
expect(result).toBeInstanceOf(Representation)
expect(result.explicit).toHaveLength(1)
expect(result.explicit[0].content).toBe('Some knowledge')
expect(result.deductive).toHaveLength(0)
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'peer1', {
@ -889,17 +965,27 @@ describe('Session', () => {
})
it('should get working representation with target peer string', async () => {
const mockRepresentation = {
peer_id: 'peer1',
target_knowledge: 'What peer1 knows about target',
const mockRepresentationData = {
explicit: [
{
content: 'What peer1 knows about target',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
}
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue(
mockRepresentation
)
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
})
const result = await session.workingRep('peer1', 'target-peer')
expect(result).toEqual(mockRepresentation)
expect(result).toBeInstanceOf(Representation)
expect(result.explicit).toHaveLength(1)
expect(result.explicit[0].content).toBe('What peer1 knows about target')
expect(result.deductive).toHaveLength(0)
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'peer1', {
@ -911,17 +997,27 @@ describe('Session', () => {
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 mockRepresentation = {
peer_id: 'peer1',
target_knowledge: 'What peer1 knows about target',
const mockRepresentationData = {
explicit: [
{
content: 'What peer1 knows about target',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
}
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue(
mockRepresentation
)
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
})
const result = await session.workingRep(peer, target)
expect(result).toEqual(mockRepresentation)
expect(result).toBeInstanceOf(Representation)
expect(result.explicit).toHaveLength(1)
expect(result.explicit[0].content).toBe('What peer1 knows about target')
expect(result.deductive).toHaveLength(0)
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'peer1', {

View File

@ -4,7 +4,7 @@
"": {
"name": "@honcho-ai/sdk",
"dependencies": {
"@honcho-ai/core": "^1.5.1",
"@honcho-ai/core": "^1.6.0",
"@types/node": "^24.0.1",
"zod": "4.0.0",
},
@ -20,11 +20,11 @@
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.4", "", {}, "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw=="],
"@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
"@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="],
"@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
"@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
"@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
@ -38,13 +38,13 @@
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
"@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
"@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@babel/plugin-syntax-async-generators": ["@babel/plugin-syntax-async-generators@7.8.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw=="],
@ -82,33 +82,31 @@
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="],
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
"@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
"@biomejs/biome": ["@biomejs/biome@2.2.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.4", "@biomejs/cli-darwin-x64": "2.2.4", "@biomejs/cli-linux-arm64": "2.2.4", "@biomejs/cli-linux-arm64-musl": "2.2.4", "@biomejs/cli-linux-x64": "2.2.4", "@biomejs/cli-linux-x64-musl": "2.2.4", "@biomejs/cli-win32-arm64": "2.2.4", "@biomejs/cli-win32-x64": "2.2.4" }, "bin": { "biome": "bin/biome" } }, "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg=="],
"@biomejs/biome": ["@biomejs/biome@2.3.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.8", "@biomejs/cli-darwin-x64": "2.3.8", "@biomejs/cli-linux-arm64": "2.3.8", "@biomejs/cli-linux-arm64-musl": "2.3.8", "@biomejs/cli-linux-x64": "2.3.8", "@biomejs/cli-linux-x64-musl": "2.3.8", "@biomejs/cli-win32-arm64": "2.3.8", "@biomejs/cli-win32-x64": "2.3.8" }, "bin": { "biome": "bin/biome" } }, "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
"@honcho-ai/core": ["@honcho-ai/core@1.5.1", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-lbYtMTcL2AxdcIl5ZKenogeTlVMnE7buJWvAFOCLp0yQxcezyA/R9FPvLz2UGRApLsiplLNWhftML+3QBjIIJA=="],
"@honcho-ai/core": ["@honcho-ai/core@1.6.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-NqUWIs9FLt8dG6LF7rEx/uSY7HPdS67AGTceZPm48l60daddU3+e64glQHkGlmsNQ6TJc/z/BGrVVtQxyNilyw=="],
"@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="],
@ -158,40 +156,6 @@
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
"@swc/core": ["@swc/core@1.13.5", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.24" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.13.5", "@swc/core-darwin-x64": "1.13.5", "@swc/core-linux-arm-gnueabihf": "1.13.5", "@swc/core-linux-arm64-gnu": "1.13.5", "@swc/core-linux-arm64-musl": "1.13.5", "@swc/core-linux-x64-gnu": "1.13.5", "@swc/core-linux-x64-musl": "1.13.5", "@swc/core-win32-arm64-msvc": "1.13.5", "@swc/core-win32-ia32-msvc": "1.13.5", "@swc/core-win32-x64-msvc": "1.13.5" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ=="],
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.13.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ=="],
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.13.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng=="],
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.13.5", "", { "os": "linux", "cpu": "arm" }, "sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ=="],
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.13.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw=="],
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.13.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ=="],
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.13.5", "", { "os": "linux", "cpu": "x64" }, "sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA=="],
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.13.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q=="],
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.13.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw=="],
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.13.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw=="],
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.13.5", "", { "os": "win32", "cpu": "x64" }, "sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q=="],
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="],
"@tsconfig/node10": ["@tsconfig/node10@1.0.11", "", {}, "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw=="],
"@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="],
"@tsconfig/node14": ["@tsconfig/node14@1.0.3", "", {}, "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow=="],
"@tsconfig/node16": ["@tsconfig/node16@1.0.4", "", {}, "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@ -210,22 +174,18 @@
"@types/jest": ["@types/jest@29.5.14", "", { "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" } }, "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ=="],
"@types/node": ["@types/node@24.6.1", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw=="],
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
"@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
"@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="],
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
@ -236,8 +196,6 @@
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
@ -254,13 +212,13 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-hY/u2lxLrbecMEWSB0IpGzGyDyeoMFQhCvZd2jGFSE5I17Fh01sYUBPCJtkWERw7zrac9+cIghxm/ytJa2X8iA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.26.2", "", { "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", "electron-to-chromium": "^1.5.218", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A=="],
"browserslist": ["browserslist@4.28.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", "electron-to-chromium": "^1.5.249", "node-releases": "^2.0.27", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ=="],
"bs-logger": ["bs-logger@0.2.6", "", { "dependencies": { "fast-json-stable-stringify": "2.x" } }, "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog=="],
@ -274,7 +232,7 @@
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001746", "", {}, "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001757", "", {}, "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@ -288,7 +246,7 @@
"co": ["co@4.6.0", "", {}, "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ=="],
"collect-v8-coverage": ["collect-v8-coverage@1.0.2", "", {}, "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q=="],
"collect-v8-coverage": ["collect-v8-coverage@1.0.3", "", {}, "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@ -302,8 +260,6 @@
"create-jest": ["create-jest@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", "exit": "^0.1.2", "graceful-fs": "^4.2.9", "jest-config": "^29.7.0", "jest-util": "^29.7.0", "prompts": "^2.0.1" }, "bin": { "create-jest": "bin/create-jest.js" } }, "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q=="],
"create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@ -316,13 +272,11 @@
"detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="],
"diff": ["diff@4.0.2", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="],
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.228", "", {}, "sha512-nxkiyuqAn4MJ1QbobwqJILiDtu/jk14hEAWaMiJmNPh1Z+jqoFlBFZjdXwLWGeVSeu9hGLg6+2G9yJaW8rBIFA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.263", "", {}, "sha512-DrqJ11Knd+lo+dv+lltvfMDLU27g14LMdH2b0O3Pio4uk0x+z7OR+JrmyacTPN2M8w3BrZ7/RTwG3R9B7irPlg=="],
"emittery": ["emittery@0.13.1", "", {}, "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ=="],
@ -360,7 +314,7 @@
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
@ -492,7 +446,7 @@
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@ -546,7 +500,7 @@
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
"node-releases": ["node-releases@2.0.21", "", {}, "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@ -590,7 +544,7 @@
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-cwd": ["resolve-cwd@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg=="],
@ -598,7 +552,7 @@
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@ -642,9 +596,7 @@
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"ts-jest": ["ts-jest@29.4.4", "", { "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", "handlebars": "^4.7.8", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", "semver": "^7.7.2", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", "@jest/transform": "^29.0.0 || ^30.0.0", "@jest/types": "^29.0.0 || ^30.0.0", "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <6" }, "optionalPeers": ["@babel/core", "@jest/transform", "@jest/types", "babel-jest", "jest-util"], "bin": { "ts-jest": "cli.js" } }, "sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw=="],
"ts-node": ["ts-node@10.9.2", "", { "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", "acorn": "^8.4.1", "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "peerDependencies": { "@swc/core": ">=1.2.50", "@swc/wasm": ">=1.2.50", "@types/node": "*", "typescript": ">=2.7" }, "optionalPeers": ["@swc/core", "@swc/wasm"], "bin": { "ts-node": "dist/bin.js", "ts-script": "dist/bin-script-deprecated.js", "ts-node-cwd": "dist/bin-cwd.js", "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js" } }, "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ=="],
"ts-jest": ["ts-jest@29.4.6", "", { "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", "handlebars": "^4.7.8", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", "semver": "^7.7.3", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", "@jest/transform": "^29.0.0 || ^30.0.0", "@jest/types": "^29.0.0 || ^30.0.0", "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <6" }, "optionalPeers": ["@babel/core", "@jest/transform", "@jest/types", "babel-jest", "jest-util"], "bin": { "ts-jest": "cli.js" } }, "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA=="],
"type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="],
@ -654,11 +606,9 @@
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
"undici-types": ["undici-types@7.13.0", "", {}, "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
"v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="],
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
"v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="],
@ -688,8 +638,6 @@
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.0.0", "", {}, "sha512-9diLdTPc/L7w/5jI4C3gHYNiGHDV9IZYxo1e5LSD8cabi65WVTWWb+g2BGPEpUUCOxR4D+6O5B0AzyMdUAXwrw=="],
@ -698,9 +646,7 @@
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@honcho-ai/core/@types/node": ["@types/node@18.19.129", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A=="],
"@honcho-ai/core/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],

BIN
sdks/typescript/dist.tar.gz Normal file

Binary file not shown.

View File

@ -20,7 +20,7 @@
"test:coverage": "jest --coverage"
},
"dependencies": {
"@honcho-ai/core": "^1.5.1",
"@honcho-ai/core": "^1.6.0",
"@types/node": "^24.0.1",
"zod": "4.0.0"
},

View File

@ -28,6 +28,8 @@ import {
SessionIdSchema,
type SessionMetadata,
SessionMetadataSchema,
type WorkspaceConfig,
WorkspaceConfigSchema,
type WorkspaceMetadata,
WorkspaceMetadataSchema,
} from './validation'
@ -62,6 +64,36 @@ export class Honcho {
* Reference to the core Honcho client instance.
*/
private _client: HonchoCore
/**
* Private cached metadata for this workspace.
*/
private _metadata?: Record<string, unknown>
/**
* Private cached configuration for this workspace.
*/
private _configuration?: Record<string, unknown>
/**
* Cached metadata for this workspace. May be stale if the workspace
* was not recently fetched from the API.
*
* Call getMetadata() to get the latest metadata from the server,
* which will also update this cached value.
*/
get metadata(): Record<string, unknown> | undefined {
return this._metadata
}
/**
* 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,
* which will also update this cached value.
*/
get configuration(): Record<string, unknown> | undefined {
return this._configuration
}
/**
* Access the underlying @honcho-ai/core client. The @honcho-ai/core client is the raw Stainless-generated client,
@ -155,17 +187,26 @@ export class Honcho {
const validatedConfig = options?.config
? PeerConfigSchema.parse(options.config)
: undefined
const peer = new Peer(validatedId, this.workspaceId, this._client)
if (validatedConfig || validatedMetadata) {
await this._client.workspaces.peers.getOrCreate(this.workspaceId, {
id: peer.id,
configuration: validatedConfig,
metadata: validatedMetadata,
})
const peerData = await this._client.workspaces.peers.getOrCreate(
this.workspaceId,
{
id: validatedId,
configuration: validatedConfig,
metadata: validatedMetadata,
}
)
return new Peer(
validatedId,
this.workspaceId,
this._client,
peerData.metadata ?? undefined,
peerData.configuration ?? undefined
)
}
return peer
return new Peer(validatedId, this.workspaceId, this._client)
}
/**
@ -185,7 +226,14 @@ export class Honcho {
)
return new Page(
peersPage,
(peer) => new Peer(peer.id, this.workspaceId, this._client)
(peer) =>
new Peer(
peer.id,
this.workspaceId,
this._client,
peer.metadata ?? undefined,
peer.configuration ?? undefined
)
)
}
@ -224,17 +272,26 @@ export class Honcho {
const validatedConfig = options?.config
? SessionConfigSchema.parse(options.config)
: undefined
const session = new Session(validatedId, this.workspaceId, this._client)
if (validatedConfig || validatedMetadata) {
await this._client.workspaces.sessions.getOrCreate(this.workspaceId, {
id: session.id,
configuration: validatedConfig,
metadata: validatedMetadata,
})
const sessionData = await this._client.workspaces.sessions.getOrCreate(
this.workspaceId,
{
id: validatedId,
configuration: validatedConfig,
metadata: validatedMetadata,
}
)
return new Session(
validatedId,
this.workspaceId,
this._client,
sessionData.metadata ?? undefined,
sessionData.configuration ?? undefined
)
}
return session
return new Session(validatedId, this.workspaceId, this._client)
}
/**
@ -255,7 +312,14 @@ export class Honcho {
)
return new Page(
sessionsPage,
(session) => new Session(session.id, this.workspaceId, this._client)
(session) =>
new Session(
session.id,
this.workspaceId,
this._client,
session.metadata ?? undefined,
session.configuration ?? undefined
)
)
}
@ -264,7 +328,8 @@ export class Honcho {
*
* 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.
* key-value data associated with the workspace. This method also updates the
* cached metadata property.
*
* @returns Promise resolving to a dictionary containing the workspace's metadata.
* Returns an empty dictionary if no metadata is set
@ -273,7 +338,8 @@ export class Honcho {
const workspace = await this._client.workspaces.getOrCreate({
id: this.workspaceId,
})
return workspace.metadata || {}
this._metadata = workspace.metadata || {}
return this._metadata
}
/**
@ -281,6 +347,7 @@ export class Honcho {
*
* 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 property.
*
* @param metadata - A dictionary of metadata to associate with the workspace.
* Keys must be strings, values can be any JSON-serializable type
@ -290,6 +357,57 @@ export class Honcho {
await this._client.workspaces.update(this.workspaceId, {
metadata: validatedMetadata,
})
this._metadata = validatedMetadata
}
/**
* 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 property.
*
* @returns Promise resolving to a dictionary containing the workspace's configuration.
* Returns an empty dictionary if no configuration is set
*/
async getConfig(): Promise<Record<string, unknown>> {
const workspace = await this._client.workspaces.getOrCreate({
id: this.workspaceId,
})
this._configuration = workspace.configuration || {}
return this._configuration
}
/**
* 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 property.
*
* @param configuration - A dictionary of configuration to associate with the workspace.
* Keys must be strings, values can be any JSON-serializable type
*/
async setConfig(configuration: WorkspaceConfig): Promise<void> {
const validatedConfig = WorkspaceConfigSchema.parse(configuration)
await this._client.workspaces.update(this.workspaceId, {
configuration: validatedConfig,
})
this._configuration = validatedConfig
}
/**
* 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 properties.
*/
async refresh(): Promise<void> {
const workspace = await this._client.workspaces.getOrCreate({
id: this.workspaceId,
})
this._metadata = workspace.metadata || {}
this._configuration = workspace.configuration || {}
}
/**

View File

@ -3,19 +3,22 @@
export type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages'
export { Honcho } from './client'
export { Observation, ObservationScope } from './observations'
export { Page } from './pagination'
export { Peer } from './peer'
export { Peer, PeerContext } from './peer'
export { Session, SessionPeerConfig } from './session'
export {
SessionContext,
SessionSummaries,
Summary,
SummaryData,
type SummaryData,
} from './session_context'
export {
type DialecticStreamChunk,
type DialecticStreamDelta,
DialecticStreamResponse,
type Observation as ObservationData,
type ObservationQueryParams,
} from './types'
// Export validation types for advanced usage
@ -35,5 +38,6 @@ export type {
SessionConfig,
SessionMetadata,
WorkingRepParams,
WorkspaceConfig,
WorkspaceMetadata,
} from './validation'

View File

@ -0,0 +1,283 @@
import type HonchoCore from '@honcho-ai/core'
import {
Representation,
type RepresentationData,
type RepresentationOptions,
} from './representation'
// Re-export for consumers who import from this module
export type { RepresentationOptions }
/**
* An observation from the theory-of-mind system.
*
* Observations are facts derived from messages that help build a representation
* of a peer.
*/
export class Observation {
/**
* Unique identifier for this observation.
*/
readonly id: string
/**
* The observation content/text.
*/
readonly content: string
/**
* The peer who made the observation.
*/
readonly observerId: string
/**
* The peer being observed.
*/
readonly observedId: string
/**
* The session where this observation was made.
*/
readonly sessionId: string
/**
* When the observation was created.
*/
readonly createdAt: string
constructor(
id: string,
content: string,
observerId: string,
observedId: string,
sessionId: string,
createdAt: string
) {
this.id = id
this.content = content
this.observerId = observerId
this.observedId = observedId
this.sessionId = sessionId
this.createdAt = createdAt
}
/**
* Create an Observation from an API response object.
*
* @param data - API response data
* @returns A new Observation instance
*/
static fromApiResponse(data: Record<string, unknown>): Observation {
return new Observation(
(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) ?? ''
)
}
/**
* Return a string representation of the Observation.
*/
toString(): string {
const truncatedContent =
this.content.length > 50
? `${this.content.slice(0, 50)}...`
: this.content
return `Observation(id='${this.id}', content='${truncatedContent}')`
}
}
/**
* Scoped access to observations for a specific observer/observed relationship.
*
* This class provides convenient methods to list, query, and delete observations
* that are automatically scoped to a specific observer/observed pair.
*
* Typically accessed via `peer.observations` (for self-observations) or
* `peer.observationsOf(target)` (for observations about another peer).
*
* @example
* ```typescript
* // Get self-observations
* const observations = peer.observations
* const obsList = await observations.list()
* const searchResults = await observations.query('preferences')
*
* // Get observations about another peer
* const bobObservations = peer.observationsOf('bob')
* const bobList = await bobObservations.list()
* ```
*
* @note
* This class requires the core Honcho SDK to support observation endpoints.
* The observation endpoints are:
* - POST /workspaces/{workspace_id}/observations/list
* - POST /workspaces/{workspace_id}/observations/query
* - DELETE /workspaces/{workspace_id}/observations/{observation_id}
*/
export class ObservationScope {
private _client: HonchoCore
/**
* The workspace ID.
*/
readonly workspaceId: string
/**
* The observer peer ID.
*/
readonly observer: string
/**
* The observed peer ID.
*/
readonly observed: string
/**
* Initialize an ObservationScope.
*
* @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,
workspaceId: string,
observer: string,
observed: string
) {
this._client = client
this.workspaceId = workspaceId
this.observer = observer
this.observed = observed
}
/**
* List observations in this scope.
*
* @param page - Page number (1-indexed)
* @param size - Number of results per page
* @param sessionId - Optional session ID to filter by
* @returns Promise resolving to list of Observation objects
*/
async list(
page: number = 1,
size: number = 50,
sessionId?: string
): Promise<Observation[]> {
const filters: Record<string, unknown> = {
observer: this.observer,
observed: this.observed,
}
if (sessionId) {
filters.session_id = sessionId
}
// biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include observations
const response = await (this._client.workspaces as any).observations.list(
this.workspaceId,
{
filters,
page,
size,
}
)
return (response.items ?? []).map((item: unknown) =>
Observation.fromApiResponse(item as Record<string, unknown>)
)
}
/**
* Semantic search for observations 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 Observation objects
*/
async query(
query: string,
topK: number = 10,
distance?: number
): Promise<Observation[]> {
const filters: Record<string, unknown> = {
observer: this.observer,
observed: this.observed,
}
// biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include observations
const response = await (this._client.workspaces as any).observations.query(
this.workspaceId,
{
query,
top_k: topK,
distance,
filters,
}
)
return (response ?? []).map((item: unknown) =>
Observation.fromApiResponse(item as Record<string, unknown>)
)
}
/**
* Delete an observation by ID.
*
* @param observationId - The ID of the observation to delete
*/
async delete(observationId: string): Promise<void> {
// biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include observations
await (this._client.workspaces as any).observations.delete(
this.workspaceId,
observationId
)
}
/**
* Get the computed representation for this scope.
*
* This returns the working representation (narrative) built from the
* observations in this scope.
*
* @param options - Optional options to configure the representation
* @returns Promise resolving to a Representation object
*/
async getRepresentation(
options?: RepresentationOptions
): Promise<Representation> {
const response = await this._client.workspaces.peers.workingRepresentation(
this.workspaceId,
this.observer,
{
target: this.observed,
search_query: options?.searchQuery,
search_top_k: options?.searchTopK,
search_max_distance: options?.searchMaxDistance,
include_most_derived: options?.includeMostDerived,
max_observations: options?.maxObservations,
}
)
const maybe = response as
| RepresentationData
| { representation?: RepresentationData | null }
| null
const rep = (maybe && typeof maybe === 'object' && 'representation' in maybe
? (maybe as { representation?: RepresentationData | null }).representation
: maybe) ?? { explicit: [], deductive: [] }
return Representation.fromData(rep as RepresentationData)
}
/**
* Return a string representation of the ObservationScope.
*/
toString(): string {
return `ObservationScope(workspaceId='${this.workspaceId}', observer='${this.observer}', observed='${this.observed}')`
}
}

View File

@ -5,6 +5,7 @@ import type { Page as CorePage } from '@honcho-ai/core/pagination'
* Provides async iteration and transformation capabilities while preserving
* pagination functionality from the underlying core Page.
*/
// biome-ignore lint/suspicious/noExplicitAny: Generic type parameter with reasonable default for internal transform
export class Page<T, TOriginal = any> implements AsyncIterable<T> {
private _originalPage: CorePage<TOriginal>
private _transformFunc?: (item: TOriginal) => T

View File

@ -1,6 +1,12 @@
import type HonchoCore from '@honcho-ai/core'
import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages'
import { ObservationScope } from './observations'
import { Page } from './pagination'
import {
Representation,
type RepresentationData,
type RepresentationOptions,
} from './representation'
import { Session } from './session'
import { type DialecticStreamChunk, DialecticStreamResponse } from './types'
import {
@ -10,6 +16,7 @@ import {
LimitSchema,
MessageContentSchema,
MessageMetadataSchema,
PeerWorkingRepParamsSchema,
SearchQuerySchema,
type MessageCreate as ValidatedMessageCreate,
} from './validation'
@ -34,6 +41,36 @@ export class Peer {
* Reference to the parent Honcho client instance.
*/
private _client: HonchoCore
/**
* Private cached metadata for this peer.
*/
private _metadata?: Record<string, unknown>
/**
* Private cached configuration for this peer.
*/
private _configuration?: Record<string, unknown>
/**
* Cached metadata for this peer. May be stale if the peer
* was not recently fetched from the API.
*
* Call getMetadata() to get the latest metadata from the server,
* which will also update this cached value.
*/
get metadata(): Record<string, unknown> | undefined {
return this._metadata
}
/**
* 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,
* which will also update this cached value.
*/
get configuration(): Record<string, unknown> | undefined {
return this._configuration
}
/**
* Initialize a new Peer. **Do not call this directly, use the client.peer() method instead.**
@ -41,11 +78,21 @@ 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 metadata - Optional metadata to initialize the cached value
* @param configuration - Optional configuration to initialize the cached value
*/
constructor(id: string, workspaceId: string, client: HonchoCore) {
constructor(
id: string,
workspaceId: string,
client: HonchoCore,
metadata?: Record<string, unknown>,
configuration?: Record<string, unknown>
) {
this.id = id
this.workspaceId = workspaceId
this._client = client
this._metadata = metadata
this._configuration = configuration
}
/**
@ -211,14 +258,16 @@ export class Peer {
* The created message object can then be added to sessions or used in other operations.
*
* @param content - The text content for the message
* @param metadata - Optional metadata to associate with the message
* @param created_at - Optional ISO 8601 timestamp for the message
* @param options.metadata - Optional metadata to associate with the message
* @param options.configuration - Optional message-level configuration (e.g., deriver 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
*/
message(
content: string,
options?: {
metadata?: Record<string, unknown>
configuration?: Record<string, unknown>
created_at?: string | Date
}
): ValidatedMessageCreate {
@ -236,6 +285,7 @@ export class Peer {
peer_id: this.id,
content: validatedContent,
metadata: validatedMetadata,
configuration: options?.configuration,
created_at: createdAt,
}
}
@ -245,7 +295,7 @@ export class 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.
* associated with the peer. This method also updates the cached metadata property.
*
* @returns Promise resolving to a dictionary containing the peer's metadata.
* Returns an empty dictionary if no metadata is set
@ -255,7 +305,8 @@ export class Peer {
this.workspaceId,
{ id: this.id }
)
return peer.metadata || {}
this._metadata = peer.metadata || {}
return this._metadata
}
/**
@ -263,6 +314,7 @@ export class 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 property.
*
* @param metadata - A dictionary of metadata to associate with this peer.
* Keys must be strings, values can be any JSON-serializable type
@ -271,6 +323,7 @@ export class Peer {
await this._client.workspaces.peers.update(this.workspaceId, this.id, {
metadata,
})
this._metadata = metadata
}
/**
@ -278,15 +331,17 @@ export class 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 property.
*
* @returns Promise resolving to a dictionary containing the peer's configuration
*/
async getPeerConfig(): Promise<Record<string, unknown>> {
async getConfig(): Promise<Record<string, unknown>> {
const peer = await this._client.workspaces.peers.getOrCreate(
this.workspaceId,
{ id: this.id }
)
return peer.configuration || {}
this._configuration = peer.configuration || {}
return this._configuration
}
/**
@ -296,14 +351,51 @@ export class Peer {
*
* 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
*/
async setPeerConfig(config: Record<string, unknown>): Promise<void> {
async setConfig(config: Record<string, unknown>): Promise<void> {
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<Record<string, unknown>> {
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<string, unknown>): Promise<void> {
return this.setConfig(config)
}
/**
* 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 properties.
*/
async refresh(): Promise<void> {
const peer = await this._client.workspaces.peers.getOrCreate(
this.workspaceId,
{ id: this.id }
)
this._metadata = peer.metadata || {}
this._configuration = peer.configuration || {}
}
/**
@ -383,6 +475,197 @@ export class Peer {
return items.join('\n')
}
/**
* Get a working representation for this peer.
*
* Makes an API call to retrieve the working 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 observations
*
* @example
* ```typescript
* // Get global representation
* const globalRep = await peer.workingRep()
* console.log(globalRep.toString())
*
* // Get representation scoped to a session
* const sessionRep = await peer.workingRep('session-123')
*
* // Get representation with semantic search
* const searchedRep = await peer.workingRep(undefined, undefined, {
* searchQuery: 'preferences',
* searchTopK: 10,
* maxObservations: 50
* })
* ```
*/
async workingRep(
session?: string | Session,
target?: string | Peer,
options?: RepresentationOptions
): Promise<Representation> {
const workingRepParams = PeerWorkingRepParamsSchema.parse({
session,
target,
options,
})
const sessionId = workingRepParams.session
? typeof workingRepParams.session === 'string'
? workingRepParams.session
: workingRepParams.session.id
: undefined
const targetId = workingRepParams.target
? typeof workingRepParams.target === 'string'
? workingRepParams.target
: workingRepParams.target.id
: undefined
const response = await this._client.workspaces.peers.workingRepresentation(
this.workspaceId,
this.id,
{
session_id: sessionId,
target: targetId,
search_query: workingRepParams.options?.searchQuery,
search_top_k: workingRepParams.options?.searchTopK,
search_max_distance: workingRepParams.options?.searchMaxDistance,
include_most_derived: workingRepParams.options?.includeMostDerived,
max_observations: workingRepParams.options?.maxObservations,
}
)
const maybe = response as
| RepresentationData
| { representation?: RepresentationData | null }
| null
const rep = (maybe && typeof maybe === 'object' && 'representation' in maybe
? (maybe as { representation?: RepresentationData | null }).representation
: maybe) ?? { explicit: [], deductive: [] }
return Representation.fromData(rep as RepresentationData)
}
/**
* 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.
*
* @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
* @returns Promise resolving to a PeerContext object containing representation and peer card
*
* @example
* ```typescript
* // Get own context
* const context = await peer.getContext()
* console.log(context.representation?.toString())
* console.log(context.peerCard)
*
* // Get context for another peer
* const context = await peer.getContext('other-peer-id')
*
* // Get context with semantic search
* const context = await peer.getContext(undefined, {
* searchQuery: 'preferences',
* searchTopK: 10
* })
* ```
*/
async getContext(
target?: string | Peer,
options?: RepresentationOptions
): Promise<PeerContext> {
const targetId = target
? typeof target === 'string'
? target
: target.id
: undefined
const response = await this._client.workspaces.peers.getContext(
this.workspaceId,
this.id,
{
target: targetId,
search_query: options?.searchQuery,
search_top_k: options?.searchTopK,
search_max_distance: options?.searchMaxDistance,
include_most_derived: options?.includeMostDerived,
max_observations: options?.maxObservations,
}
)
return PeerContext.fromApiResponse(
response as unknown as Record<string, unknown>
)
}
/**
* Access this peer's self-observations (where observer == observed == self).
*
* This property provides a convenient way to access observations that this peer
* has made about themselves. Use this for self-observation scenarios.
*
* @returns An ObservationScope scoped to this peer's self-observations
*
* @example
* ```typescript
* // List self-observations
* const obsList = await peer.observations.list()
*
* // Search self-observations
* const results = await peer.observations.query('preferences')
*
* // Delete a self-observation
* await peer.observations.delete('obs-123')
* ```
*/
get observations(): ObservationScope {
return new ObservationScope(
this._client,
this.workspaceId,
this.id,
this.id
)
}
/**
* Access observations this peer has made about another peer.
*
* This method provides scoped access to observations where this peer is the
* observer and the target is the observed peer.
*
* @param target - The target peer (either a Peer object or peer ID string)
* @returns An ObservationScope scoped to this peer's observations of the target
*
* @example
* ```typescript
* // Get observations about another peer
* const bobObservations = peer.observationsOf('bob')
*
* // List observations
* const obsList = await bobObservations.list()
*
* // Search observations
* const results = await bobObservations.query('work history')
*
* // Get the representation from these observations
* const rep = await bobObservations.getRepresentation()
* ```
*/
observationsOf(target: string | Peer): ObservationScope {
const targetId = typeof target === 'string' ? target : target.id
return new ObservationScope(
this._client,
this.workspaceId,
this.id,
targetId
)
}
/**
* Return a string representation of the Peer.
*
@ -392,3 +675,76 @@ 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 observations 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<string, unknown>): 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})`
}
}

View File

@ -0,0 +1,390 @@
/**
* Options for representation retrieval.
*/
export interface RepresentationOptions {
/**
* Semantic search query to filter relevant observations.
*/
searchQuery?: string
/**
* Number of semantically relevant facts to return.
*/
searchTopK?: number
/**
* Maximum semantic distance for search results (0.0-1.0).
*/
searchMaxDistance?: number
/**
* Whether to include the most derived observations.
*/
includeMostDerived?: boolean
/**
* Maximum number of observations to include.
*/
maxObservations?: number
}
/**
* Metadata associated with an observation.
*/
export interface ObservationMetadata {
created_at: string
message_ids: Array<[number, number]>
session_name: string
}
/**
* An explicit observation with full metadata.
* Represents facts LITERALLY stated - direct quotes or clear paraphrases only.
*/
export interface ExplicitObservationBase {
content: string
}
/**
* Base interface for deductive observations - logical conclusions.
*/
export interface DeductiveObservationBase {
premises: string[]
conclusion: string
}
export interface ExplicitObservation
extends ExplicitObservationBase,
ObservationMetadata {}
/**
* A deductive observation with full metadata.
* Represents conclusions that MUST be true given explicit facts and premises.
*/
export interface DeductiveObservation
extends DeductiveObservationBase,
ObservationMetadata {}
/**
* Raw representation data structure returned from the API.
*/
export interface RepresentationData {
explicit: ExplicitObservation[]
deductive: DeductiveObservation[]
}
/**
* A Representation is a traversable and diffable map of observations.
*
* At the base, we have a list of explicit observations, derived from a peer's messages.
* From there, deductive observations can be made by establishing logical relationships
* between explicit observations.
*
* All of a peer's observations 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 observations within a single session.
*/
export class Representation {
/**
* Facts LITERALLY stated - direct quotes or clear paraphrases only, no interpretation or inference.
*/
explicit: ExplicitObservation[]
/**
* Conclusions that MUST be true given explicit facts and premises - strict logical necessities.
*/
deductive: DeductiveObservation[]
/**
* Create a new Representation from observation lists.
*
* @param explicit - List of explicit observations
* @param deductive - List of deductive observations
*/
constructor(
explicit: ExplicitObservation[] = [],
deductive: DeductiveObservation[] = []
) {
this.explicit = explicit
this.deductive = deductive
}
/**
* Check if the representation is empty.
*
* @returns True if both explicit and deductive observation 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
* observations 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 observations 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 observations.
* Preserves order of observations to retain FIFO order.
*
* Note: Observations 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 maxObservations - Optional maximum number of observations to keep per type
*/
merge(other: Representation, maxObservations?: number): void {
// Deduplicate by converting to Set using hash, then back to array
const explicitMap = new Map<string, ExplicitObservation>()
const deductiveMap = new Map<string, DeductiveObservation>()
// Add existing observations
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 observations (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 observations limit if specified
if (maxObservations !== undefined) {
this.explicit = this.explicit.slice(-maxObservations)
this.deductive = this.deductive.slice(-maxObservations)
}
}
/**
* 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 Observations\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 Observations\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 observation for deduplication.
* Based on content, created_at, and session_name.
*/
private _hashExplicit(obs: ExplicitObservation): string {
return JSON.stringify({
content: obs.content,
created_at: obs.created_at,
session_name: obs.session_name,
})
}
/**
* Create a hash string for a deductive observation for deduplication.
* Based on conclusion, created_at, and session_name (premises not included).
*/
private _hashDeductive(obs: DeductiveObservation): 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
}
}
}

View File

@ -7,7 +7,14 @@ import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/mess
import type { Uploadable } from '@honcho-ai/core/uploads'
import { Page } from './pagination'
import { Peer } from './peer'
import {
Representation,
type RepresentationData,
type RepresentationOptions,
} from './representation'
import { SessionContext, SessionSummaries, Summary } from './session_context'
// Disabled: observations not ready for release
// import type { Observation, ObservationQueryParams } from './types'
import {
ContextParamsSchema,
type DeriverStatusOptions,
@ -18,6 +25,7 @@ import {
LimitSchema,
type MessageAddition,
MessageAdditionSchema,
// ObservationQueryParamsSchema, // Disabled: observations not ready for release
type PeerAddition,
PeerAdditionSchema,
type PeerRemoval,
@ -47,7 +55,7 @@ export class SessionPeerConfig {
* of other peers in the session. When false, this peer will not build local
* representations of other peers within this session.
*/
observe_others?: boolean
observe_others?: boolean | null
/**
* Initialize SessionPeerConfig with observation settings.
@ -55,7 +63,7 @@ export class SessionPeerConfig {
* @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) {
constructor(observe_me?: boolean | null, observe_others?: boolean | null) {
const validatedConfig = SessionPeerConfigSchema.parse({
observe_me,
observe_others,
@ -112,6 +120,36 @@ export class Session {
* Reference to the parent Honcho client instance.
*/
private _client: HonchoCore
/**
* Private cached metadata for this session.
*/
private _metadata?: Record<string, unknown>
/**
* Private cached configuration for this session.
*/
private _configuration?: Record<string, unknown>
/**
* Cached metadata for this session. May be stale if the session
* was not recently fetched from the API.
*
* Call getMetadata() to get the latest metadata from the server,
* which will also update this cached value.
*/
get metadata(): Record<string, unknown> | undefined {
return this._metadata
}
/**
* 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,
* which will also update this cached value.
*/
get configuration(): Record<string, unknown> | undefined {
return this._configuration
}
/**
* Initialize a new Session. **Do not call this directly, use the client.session() method instead.**
@ -119,11 +157,21 @@ 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 metadata - Optional metadata to initialize the cached value
* @param configuration - Optional configuration to initialize the cached value
*/
constructor(id: string, workspaceId: string, client: HonchoCore) {
constructor(
id: string,
workspaceId: string,
client: HonchoCore,
metadata?: Record<string, unknown>,
configuration?: Record<string, unknown>
) {
this.id = id
this.workspaceId = workspaceId
this._client = client
this._metadata = metadata
this._configuration = configuration
}
/**
@ -364,12 +412,12 @@ export class Session {
* })
* ```
*/
async addMessages(messages: MessageAddition): Promise<void> {
async addMessages(messages: MessageAddition): Promise<Message[]> {
const validatedMessages = MessageAdditionSchema.parse(messages)
const messagesList = Array.isArray(validatedMessages)
? validatedMessages
: [validatedMessages]
await this._client.workspaces.sessions.messages.create(
return await this._client.workspaces.sessions.messages.create(
this.workspaceId,
this.id,
{
@ -403,7 +451,8 @@ export class 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.
* that provides context about the 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
@ -413,7 +462,8 @@ export class Session {
this.workspaceId,
{ id: this.id }
)
return session.metadata || {}
this._metadata = session.metadata || {}
return this._metadata
}
/**
@ -422,7 +472,8 @@ export class 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.
* contextual information about the session. 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
@ -431,12 +482,71 @@ export class Session {
await this._client.workspaces.sessions.update(this.workspaceId, this.id, {
metadata,
})
this._metadata = metadata
}
/**
* Delete this session.
* Get configuration for this session.
*
* Makes an API call to mark this session as inactive.
* 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.
*
* @returns Promise resolving to a dictionary containing the session's configuration.
* Returns an empty dictionary if no configuration is set
*/
async getConfig(): Promise<Record<string, unknown>> {
const session = await this._client.workspaces.sessions.getOrCreate(
this.workspaceId,
{ id: this.id }
)
this._configuration = session.configuration || {}
return this._configuration
}
/**
* 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 property.
*
* @param configuration - A dictionary of configuration to associate with this session.
* Keys must be strings, values can be any JSON-serializable type
*/
async setConfig(configuration: Record<string, unknown>): Promise<void> {
await this._client.workspaces.sessions.update(this.workspaceId, this.id, {
configuration,
})
this._configuration = configuration
}
/**
* 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 properties.
*/
async refresh(): Promise<void> {
const session = await this._client.workspaces.sessions.getOrCreate(
this.workspaceId,
{ id: this.id }
)
this._metadata = session.metadata || {}
this._configuration = 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
* - Observations
* - Session-Peer associations
* - Background processing queue items
*
* This action cannot be undone.
*/
async delete(): Promise<void> {
await this._client.workspaces.sessions.delete(this.workspaceId, this.id)
@ -478,7 +588,8 @@ export class Session {
tokens?: number,
peerTarget?: string | Peer,
lastUserMessage?: string | Message,
peerPerspective?: string | Peer
peerPerspective?: string | Peer,
representationOptions?: RepresentationOptions
): Promise<SessionContext>
async getContext(options?: {
summary?: boolean
@ -486,6 +597,8 @@ export class Session {
peerTarget?: string | Peer
lastUserMessage?: string | Message
peerPerspective?: string | Peer
limitToSession?: boolean
representationOptions?: RepresentationOptions
}): Promise<SessionContext>
async getContext(
summaryOrOptions?:
@ -496,11 +609,14 @@ export class Session {
peerTarget?: string | Peer
lastUserMessage?: string | Message
peerPerspective?: string | Peer
limitToSession?: boolean
representationOptions?: RepresentationOptions
},
tokens?: number,
peerTarget?: string | Peer,
lastUserMessage?: string | Message,
peerPerspective?: string | Peer
peerPerspective?: string | Peer,
representationOptions?: RepresentationOptions
): Promise<SessionContext> {
// Normalize positional arguments into options object
let options: {
@ -509,10 +625,13 @@ export class Session {
peerTarget?: string
lastUserMessage?: string
peerPerspective?: string
limitToSession?: boolean
representationOptions?: RepresentationOptions
}
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
@ -528,6 +647,7 @@ export class Session {
typeof peerPerspective === 'object'
? peerPerspective.id
: peerPerspective,
representationOptions,
}
} else {
// Options object pattern
@ -540,6 +660,8 @@ export class Session {
peerTarget: options.peerTarget,
lastUserMessage: options.lastUserMessage,
peerPerspective: options.peerPerspective,
limitToSession: options.limitToSession,
representationOptions: options.representationOptions,
})
// Extract message ID if lastUserMessage is a Message object
@ -557,6 +679,13 @@ export class Session {
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_derived:
contextParams.representationOptions?.includeMostDerived,
max_observations: contextParams.representationOptions?.maxObservations,
}
)
// Convert the summary response to Summary object if present
@ -636,6 +765,91 @@ export class Session {
)
}
/**
* List all observations for this session.
*
* Observations are theory-of-mind data (documents) that peers have formed about each other.
* Returns paginated results that can be filtered by observer_id and observed_id.
*
* @param filters - Optional filters to scope the observations: see [filters documentation](https://docs.honcho.dev/v2/guides/using-filters).
* @returns A paginated list of Observation objects.
*
* @example
* ```typescript
* const observations = await session.listObservations()
* for await (const observation of observations) {
* console.log(`${observation.observer_id} observed: ${observation.content}`)
* }
* ```
*/
// Disabled: observations not ready for release
// async listObservations(filters?: Filters): Promise<Page<Observation>> {
// const validatedFilters = filters ? FilterSchema.parse(filters) : undefined
// const response = await this._client.workspaces.sessions.observations.list(
// this.workspaceId,
// this.id,
// { filters: validatedFilters }
// )
// return new Page(response)
// }
/**
* Query observations using semantic search.
*
* Performs vector similarity search on observations to find semantically relevant results.
* Use this to find observations related to a specific topic or concept.
*
* @param params - Query parameters
* @param params.query - The semantic search query
* @param params.top_k - Number of results to return (1-100, default: 10)
* @param params.distance - Maximum cosine distance threshold for results (0.0-1.0)
* @param params.filters - Optional filters to scope the query
* @returns A list of Observation objects matching the query
*
* @example
* ```typescript
* const observations = await session.queryObservations({
* query: "user preferences about music",
* top_k: 5,
* distance: 0.8
* })
* ```
*/
// Disabled: observations not ready for release
// async queryObservations(
// params: ObservationQueryParams
// ): Promise<Observation[]> {
// const validated = ObservationQueryParamsSchema.parse(params)
// return await this._client.workspaces.sessions.observations.query(
// this.workspaceId,
// this.id,
// validated
// )
// }
/**
* Delete a specific observation by ID.
*
* This permanently deletes the observation (document) from the theory-of-mind system.
* This action cannot be undone.
*
* @param observationId - The ID of the observation to delete
* @returns A promise that resolves when the observation is deleted
*
* @example
* ```typescript
* await session.deleteObservation('obs_123abc')
* ```
*/
// Disabled: observations not ready for release
// async deleteObservation(observationId: string): Promise<void> {
// await this._client.workspaces.sessions.observations.delete(
// this.workspaceId,
// this.id,
// observationId
// )
// }
/**
* Get the deriver processing status for this session, optionally scoped to an observer or sender.
*
@ -755,6 +969,10 @@ export class Session {
* - Buffer or Uint8Array with filename and content_type
* - { filename: string, content: Buffer | Uint8Array, content_type: string }
* @param peerId - The peer ID 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.
@ -766,17 +984,57 @@ export class Session {
* // Upload a file
* const messages = await session.uploadFile(fileInput.files[0], 'user123')
* console.log(`Created ${messages.length} messages from file`)
*
* // 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'
* })
* ```
*/
async uploadFile(file: Uploadable, peerId: string): Promise<Message[]> {
const uploadParams = FileUploadSchema.parse({ file, peerId })
async uploadFile(
file: Uploadable,
peerId: string,
options?: {
metadata?: Record<string, unknown>
configuration?: Record<string, unknown>
created_at?: string | Date
}
): Promise<Message[]> {
const createdAt =
options?.created_at instanceof Date
? options.created_at.toISOString()
: options?.created_at
const uploadParams = FileUploadSchema.parse({
file,
peerId,
metadata: options?.metadata,
configuration: options?.configuration,
created_at: createdAt,
})
// Build body with file and peer_id, plus optional fields as JSON strings
const body = {
file: uploadParams.file,
peer_id: uploadParams.peerId,
...(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 response = await this._client.workspaces.sessions.messages.upload(
this.workspaceId,
this.id,
{
file: uploadParams.file,
peer_id: uploadParams.peerId,
}
body
)
return response
@ -792,23 +1050,41 @@ export class Session {
* @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
* @returns Promise resolving to a dictionary containing the peer's representation information,
* including facts, characteristics, and contextual knowledge
* @param options - Optional representation options to filter and configure the results
* @returns Promise resolving to a Representation object containing explicit and deductive observations
*
* @example
* ```typescript
* // Get peer's global representation in this session
* const globalRep = await session.workingRep('user123')
* console.log(globalRep.toString())
*
* // Get what user123 knows about assistant in this session
* const localRep = await session.workingRep('user123', 'assistant')
*
* // Get representation with semantic search
* const searchedRep = await session.workingRep('user123', undefined, {
* searchQuery: 'preferences',
* searchTopK: 10
* })
* ```
*/
async workingRep(
peer: string | Peer,
target?: string | Peer
): Promise<Record<string, unknown>> {
const workingRepParams = WorkingRepParamsSchema.parse({ peer, target })
target?: string | Peer,
options?: {
searchQuery?: string
searchTopK?: number
searchMaxDistance?: number
includeMostDerived?: boolean
maxObservations?: number
}
): Promise<Representation> {
const workingRepParams = WorkingRepParamsSchema.parse({
peer,
target,
options,
})
const peerId =
typeof workingRepParams.peer === 'string'
? workingRepParams.peer
@ -819,14 +1095,27 @@ export class Session {
: workingRepParams.target.id
: undefined
return await this._client.workspaces.peers.workingRepresentation(
const response = await this._client.workspaces.peers.workingRepresentation(
this.workspaceId,
peerId,
{
session_id: this.id,
target: targetId,
search_query: workingRepParams.options?.searchQuery,
search_top_k: workingRepParams.options?.searchTopK,
search_max_distance: workingRepParams.options?.searchMaxDistance,
include_most_derived: workingRepParams.options?.includeMostDerived,
max_observations: workingRepParams.options?.maxObservations,
}
)
const maybe = response as
| RepresentationData
| { representation?: RepresentationData | null }
| null
const rep = (maybe && typeof maybe === 'object' && 'representation' in maybe
? (maybe as { representation?: RepresentationData | null }).representation
: maybe) ?? { explicit: [], deductive: [] }
return Representation.fromData(rep as RepresentationData)
}
/**

View File

@ -2,6 +2,28 @@
* Shared types for the Honcho TypeScript SDK.
*/
/**
* Observation - external view of a document (theory-of-mind data).
*/
export interface Observation {
id: string
content: string
observer_id: string
observed_id: string
session_id: string
created_at: string
}
/**
* Parameters for semantic search of observations.
*/
export interface ObservationQueryParams {
query: string
top_k?: number
distance?: number
filters?: Record<string, unknown>
}
/**
* Delta object for streaming dialectic responses.
*/

View File

@ -68,7 +68,7 @@ export const SessionIdSchema = z
*/
export const SessionPeerConfigSchema = z.object({
observe_me: z.boolean().nullable().optional(),
observe_others: z.boolean().optional(),
observe_others: z.boolean().nullable().optional(),
})
/**
@ -88,6 +88,15 @@ export const MessageMetadataSchema = z
.record(z.string(), z.unknown())
.optional()
/**
* Schema for message configuration.
* Configuration can include deriver and peer_card settings.
*/
export const MessageConfigurationSchema = z
.record(z.string(), z.unknown())
.nullable()
.optional()
/**
* Schema for message creation.
*/
@ -95,6 +104,7 @@ export const MessageCreateSchema = z.object({
peer_id: PeerIdSchema,
content: MessageContentSchema,
metadata: MessageMetadataSchema,
configuration: MessageConfigurationSchema,
created_at: z.string().nullable().optional(),
})
@ -138,6 +148,30 @@ const MessageSchema: z.ZodType<Message> = z.object({
metadata: z.record(z.string(), z.unknown()).optional(),
}) as z.ZodType<Message>
/**
* Schema for representation options.
*/
export const RepresentationOptionsSchema = z.object({
searchTopK: z
.number()
.int()
.min(1, 'searchTopK must be at least 1')
.max(100, 'searchTopK must be at most 100')
.optional(),
searchMaxDistance: z
.number()
.min(0.0, 'searchMaxDistance must be at least 0.0')
.max(1.0, 'searchMaxDistance must be at most 1.0')
.optional(),
includeMostDerived: z.boolean().optional(),
maxObservations: z
.number()
.int()
.min(1, 'maxObservations must be at least 1')
.max(100, 'maxObservations must be at most 100')
.optional(),
})
/**
* Schema for context retrieval parameters.
*/
@ -156,6 +190,8 @@ export const ContextParamsSchema = z
.optional(),
peerTarget: PeerIdSchema.optional(),
peerPerspective: PeerIdSchema.optional(),
limitToSession: z.boolean().optional(),
representationOptions: RepresentationOptionsSchema.optional(),
})
.superRefine((data, ctx) => {
if (data.lastUserMessage && !data.peerTarget) {
@ -217,6 +253,9 @@ export const FileUploadSchema = z.object({
),
]),
peerId: PeerIdSchema,
metadata: MessageMetadataSchema,
configuration: z.record(z.string(), z.unknown()).optional(),
created_at: z.string().nullable().optional(),
})
/**
@ -225,6 +264,20 @@ export const FileUploadSchema = z.object({
export const WorkingRepParamsSchema = 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(),
})
/**
* Schema for peer working representation parameters.
*/
export const PeerWorkingRepParamsSchema = 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(),
})
/**
@ -271,6 +324,11 @@ export const MessageAdditionSchema = z.union([
*/
export const WorkspaceMetadataSchema = z.record(z.string(), z.unknown())
/**
* Schema for workspace configuration.
*/
export const WorkspaceConfigSchema = z.record(z.string(), z.unknown())
/**
* Schema for limit.
*/
@ -280,6 +338,25 @@ export const LimitSchema = z
.min(1, 'Limit must be a positive integer')
.max(100, 'Limit must be less than or equal to 100')
/**
* Schema for observation query parameters.
*/
export const ObservationQueryParamsSchema = z.object({
query: SearchQuerySchema,
top_k: z
.number()
.int()
.min(1, 'top_k must be at least 1')
.max(100, 'top_k must be at most 100')
.optional(),
distance: z
.number()
.min(0.0, 'distance must be at least 0.0')
.max(1.0, 'distance must be at most 1.0')
.optional(),
filters: FilterSchema,
})
/**
* Type exports for use throughout the SDK.
*/
@ -296,8 +373,13 @@ export type ContextParams = z.infer<typeof ContextParamsSchema>
export type DeriverStatusOptions = z.infer<typeof DeriverStatusOptionsSchema>
export type FileUpload = z.infer<typeof FileUploadSchema>
export type WorkingRepParams = z.infer<typeof WorkingRepParamsSchema>
export type PeerWorkingRepParams = z.infer<typeof PeerWorkingRepParamsSchema>
export type PeerAddition = z.infer<typeof PeerAdditionSchema>
export type PeerRemoval = z.infer<typeof PeerRemovalSchema>
export type MessageAddition = z.infer<typeof MessageAdditionSchema>
export type WorkspaceMetadata = z.infer<typeof WorkspaceMetadataSchema>
export type WorkspaceConfig = z.infer<typeof WorkspaceConfigSchema>
export type Limit = z.infer<typeof LimitSchema>
export type ObservationQueryParams = z.infer<
typeof ObservationQueryParamsSchema
>

View File

@ -1,7 +1,9 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"module": "node16",
"moduleResolution": "node16",
"isolatedModules": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",

View File

@ -136,8 +136,8 @@ class BackupLLMSettingsMixin:
both fields are set together or both are None.
"""
BACKUP_PROVIDER: SupportedProviders | None = None
BACKUP_MODEL: str | None = None
BACKUP_PROVIDER: SupportedProviders | None = "custom"
BACKUP_MODEL: str | None = "x-ai/grok-4-fast"
@model_validator(mode="after")
def _validate_backup_configuration(self):
@ -236,7 +236,7 @@ class DeriverSettings(BackupLLMSettingsMixin, HonchoSettings):
# Thinking budget tokens are only applied when using Anthropic as provider
THINKING_BUDGET_TOKENS: Annotated[int, Field(default=1024, gt=0, le=5000)] = 1024
# Maximum number of observations to store in working representation
# Maximum number of observations to return in working representation
# This is applied to both explicit and deductive observations
WORKING_REPRESENTATION_MAX_OBSERVATIONS: Annotated[
int, Field(default=50, gt=0, le=500)
@ -351,8 +351,8 @@ class DreamSettings(BackupLLMSettingsMixin, HonchoSettings):
ENABLED_TYPES: list[str] = ["consolidate"]
# LLM settings for dream processing
PROVIDER: SupportedProviders = "openai"
MODEL: str = "gpt-4o-mini-2024-07-18"
PROVIDER: SupportedProviders = "google"
MODEL: str = "gemini-2.5-flash"
MAX_OUTPUT_TOKENS: Annotated[int, Field(default=2000, gt=0, le=10_000)] = 2000

View File

@ -2,7 +2,10 @@ from .collection import get_collection, get_or_create_collection
from .deriver import get_deriver_status
from .document import (
create_documents,
delete_document,
delete_document_by_id,
get_all_documents,
get_documents_with_filters,
query_documents,
)
from .message import (
@ -47,6 +50,7 @@ from .workspace import (
delete_workspace,
get_all_workspaces,
get_or_create_workspace,
get_workspace,
update_workspace,
)
@ -59,7 +63,10 @@ __all__ = [
# Document
"create_documents",
"get_all_documents",
"get_documents_with_filters",
"query_documents",
"delete_document",
"delete_document_by_id",
# Message
"create_messages",
"get_messages",
@ -98,6 +105,7 @@ __all__ = [
# Workspace
"delete_workspace",
"get_or_create_workspace",
"get_workspace",
"get_all_workspaces",
"update_workspace",
]

View File

@ -98,10 +98,12 @@ def _build_queue_status_query(
models.QueueItem.work_unit_key == models.ActiveQueueSession.work_unit_key,
)
stmt = stmt.join(models.Session, models.QueueItem.session_id == models.Session.id)
stmt = stmt.where(models.Session.workspace_name == workspace_name)
stmt = stmt.where(models.QueueItem.workspace_name == workspace_name)
if session_name is not None:
stmt = stmt.join(
models.Session, models.QueueItem.session_id == models.Session.id
)
stmt = stmt.where(models.Session.name == session_name)
peer_conditions = []

View File

@ -2,42 +2,101 @@ from collections.abc import Sequence
from logging import getLogger
from typing import Any
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import Select
from src import models, schemas
from src.config import settings
from src.embedding_client import embedding_client
from src.exceptions import ValidationException
from src.exceptions import ResourceNotFoundException, ValidationException
from src.utils.filter import apply_filter
logger = getLogger(__name__)
async def get_all_documents(
db: AsyncSession,
def get_all_documents(
workspace_name: str,
*,
observer: str,
observed: str,
limit: int = 1000,
) -> Sequence[models.Document]:
filters: dict[str, Any] | None = None,
reverse: bool = False,
limit: int | None = None,
) -> Select[tuple[models.Document]]:
"""
Get all documents in a collection.
NOTE: Order is nondeterministic. Also this may return a massive amount of documents. Don't use this on large collections.
TODO: add pagination and update dreaming logic to deduplicate more effectively
Returns a Select query for pagination support via apaginate().
Results are ordered by created_at timestamp.
Args:
workspace_name: Name of the workspace
observer: Name of the observing peer
observed: Name of the observed peer
filters: Optional filters to apply
reverse: Whether to reverse the order (oldest first)
Returns:
Select query for documents
"""
stmt = (
select(models.Document)
.limit(limit)
.where(models.Document.workspace_name == workspace_name)
.where(models.Document.observer == observer)
.where(models.Document.observed == observed)
)
result = await db.execute(stmt)
return result.scalars().all()
# Apply additional filters if provided
stmt = apply_filter(stmt, models.Document, filters)
# Order by created_at (newest first by default)
if reverse:
stmt = stmt.order_by(models.Document.created_at.asc())
else:
stmt = stmt.order_by(models.Document.created_at.desc())
if limit is not None:
stmt = stmt.limit(limit)
return stmt
def get_documents_with_filters(
workspace_name: str,
*,
filters: dict[str, Any] | None = None,
reverse: bool = False,
) -> Select[tuple[models.Document]]:
"""
Get all documents using custom filters.
Returns a Select query for pagination support via apaginate().
Results are ordered by created_at timestamp.
Args:
workspace_name: Name of the workspace
filters: Optional filters to apply
reverse: Whether to reverse the order (oldest first)
Returns:
Select query for documents
"""
stmt = select(models.Document).where(
models.Document.workspace_name == workspace_name
)
# Apply additional filters if provided
stmt = apply_filter(stmt, models.Document, filters)
# Order by created_at (newest first by default)
if reverse:
stmt = stmt.order_by(models.Document.created_at.asc())
else:
stmt = stmt.order_by(models.Document.created_at.desc())
return stmt
async def query_documents(
@ -162,6 +221,79 @@ async def create_documents(
return len(honcho_documents)
async def delete_document(
db: AsyncSession,
workspace_name: str,
document_id: str,
*,
observer: str,
observed: str,
session_name: str | None = None,
) -> None:
"""
Delete a single document by ID.
Args:
db: Database session
workspace_name: Name of the workspace
document_id: ID of the document to delete
observer: Name of the observing peer (for authorization)
observed: Name of the observed peer (for authorization)
session_name: Optional session name to verify document belongs to session
Raises:
ResourceNotFoundException: If document not found or doesn't match criteria
"""
stmt = delete(models.Document).where(
models.Document.id == document_id,
models.Document.workspace_name == workspace_name,
models.Document.observer == observer,
models.Document.observed == observed,
)
# If session is specified, ensure document belongs to that session
if session_name is not None:
stmt = stmt.where(models.Document.session_name == session_name)
result = await db.execute(stmt)
await db.commit()
if result.rowcount == 0:
raise ResourceNotFoundException(
f"Document {document_id} not found or does not belong to the specified collection/session"
)
async def delete_document_by_id(
db: AsyncSession,
workspace_name: str,
document_id: str,
) -> None:
"""
Delete a single document by ID and workspace.
Args:
db: Database session
workspace_name: Name of the workspace
document_id: ID of the document to delete
Raises:
ResourceNotFoundException: If document not found or doesn't belong to the workspace
"""
stmt = delete(models.Document).where(
models.Document.id == document_id,
models.Document.workspace_name == workspace_name,
)
result = await db.execute(stmt)
await db.commit()
if result.rowcount == 0:
raise ResourceNotFoundException(
f"Document {document_id} not found or does not belong to workspace {workspace_name}"
)
async def is_rejected_duplicate(
db: AsyncSession,
doc: schemas.DocumentCreate,

View File

@ -13,6 +13,7 @@ from src.config import settings
from src.dependencies import tracked_db
from src.dreamer.dream_scheduler import check_and_schedule_dream
from src.embedding_client import embedding_client
from src.schemas import ResolvedConfiguration
from src.utils.formatting import format_datetime_utc
from src.utils.logging import accumulate_metric
from src.utils.representation import (
@ -44,9 +45,10 @@ class RepresentationManager:
async def save_representation(
self,
representation: Representation,
message_id_range: tuple[int, int],
message_ids: list[int],
session_name: str,
message_created_at: datetime.datetime,
message_level_configuration: ResolvedConfiguration,
) -> int:
"""
Save Representation objects to the collection as a set of documents.
@ -85,7 +87,7 @@ class RepresentationManager:
batch_embed_duration = (time.perf_counter() - batch_embed_start) * 1000
accumulate_metric(
f"deriver_{message_id_range[1]}_{self.observer}",
f"deriver_{message_ids[-1]}_{self.observer}",
"embed_new_observations",
batch_embed_duration,
"ms",
@ -98,14 +100,15 @@ class RepresentationManager:
db,
all_observations,
embeddings,
message_id_range,
message_ids,
session_name,
message_created_at,
message_level_configuration,
)
create_document_duration = (time.perf_counter() - create_document_start) * 1000
accumulate_metric(
f"deriver_{message_id_range[1]}_{self.observer}",
f"deriver_{message_ids[-1]}_{self.observer}",
"save_new_observations",
create_document_duration,
"ms",
@ -118,9 +121,10 @@ class RepresentationManager:
db: AsyncSession,
all_observations: list[ExplicitObservation | DeductiveObservation],
embeddings: list[list[float]],
message_id_range: tuple[int, int],
message_ids: list[int],
session_name: str,
message_created_at: datetime.datetime,
message_level_configuration: ResolvedConfiguration,
) -> int:
# get_or_create_collection already handles IntegrityError with rollback and a retry
collection = await crud.get_or_create_collection(
@ -144,7 +148,7 @@ class RepresentationManager:
obs_premises = None
metadata: schemas.DocumentMetadata = schemas.DocumentMetadata(
message_ids=[message_id_range],
message_ids=message_ids,
premises=obs_premises,
message_created_at=format_datetime_utc(message_created_at),
)
@ -169,10 +173,11 @@ class RepresentationManager:
deduplicate=settings.DERIVER.DEDUPLICATE,
)
try:
await check_and_schedule_dream(db, collection)
except Exception as e:
logger.warning(f"Failed to check dream scheduling: {e}")
if message_level_configuration.dream.enabled:
try:
await check_and_schedule_dream(db, collection)
except Exception as e:
logger.warning(f"Failed to check dream scheduling: {e}")
return new_documents

View File

@ -3,7 +3,7 @@ from typing import Any
from cashews import NOT_NONE
from nanoid import generate as generate_nanoid
from sqlalchemy import Select, case, cast, func, insert, select, update
from sqlalchemy import Select, and_, case, cast, delete, func, insert, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@ -83,9 +83,13 @@ async def get_sessions(
filters: dict[str, Any] | None = None,
) -> Select[tuple[models.Session]]:
"""
Get all sessions in a workspace.
Get all active sessions in a workspace.
"""
stmt = select(models.Session).where(models.Session.workspace_name == workspace_name)
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.is_active == True) # noqa: E712
)
stmt = apply_filter(stmt, models.Session, filters)
@ -126,6 +130,12 @@ async def get_or_create_session(
if honcho_session is not None:
honcho_session = await db.merge(honcho_session, load=False)
# Reject operations on inactive sessions (marked for deletion)
if not honcho_session.is_active:
raise ResourceNotFoundException(
f"Session {session.name} not found in workspace {workspace_name}"
)
# Track if we need to update cache
needs_cache_update = False
@ -148,7 +158,9 @@ async def get_or_create_session(
workspace_name=workspace_name,
name=session.name,
h_metadata=session.metadata or {},
configuration=session.configuration or {},
configuration=session.configuration.model_dump(exclude_none=True)
if session.configuration
else {},
)
try:
db.add(honcho_session)
@ -174,12 +186,14 @@ async def get_or_create_session(
):
honcho_session.h_metadata = session.metadata
needs_cache_update = True
if (
session.configuration is not None
and honcho_session.configuration != session.configuration
):
honcho_session.configuration = session.configuration
needs_cache_update = True
if session.configuration is not None:
# Merge configuration instead of replacing to preserve existing keys
existing_config = (honcho_session.configuration or {}).copy()
incoming_config = session.configuration.model_dump(exclude_none=True)
merged_config = {**existing_config, **incoming_config}
if honcho_session.configuration != merged_config:
honcho_session.configuration = merged_config
needs_cache_update = True
# Add all peers to session
if session.peer_names:
@ -217,6 +231,8 @@ async def get_session(
db: AsyncSession,
session_name: str,
workspace_name: str,
*,
include_inactive: bool = False,
) -> models.Session:
"""
Get a session in a workspace.
@ -225,12 +241,14 @@ async def get_session(
db: Database session
session_name: Name of the session
workspace_name: Name of the workspace
include_inactive: If True, return sessions even if they are marked for deletion.
This should only be used for internal operations like the deletion task.
Returns:
The session
Raises:
ResourceNotFoundException: If the session does not exist
ResourceNotFoundException: If the session does not exist or is inactive
"""
session = await _fetch_session(db, workspace_name, session_name)
@ -239,6 +257,12 @@ async def get_session(
f"Session {session_name} not found in workspace {workspace_name}"
)
# Check if session is active (unless include_inactive is True)
if not include_inactive and not session.is_active:
raise ResourceNotFoundException(
f"Session {session_name} not found in workspace {workspace_name}"
)
# Merge cached object into session (cached objects are detached)
session = await db.merge(session, load=False)
@ -277,12 +301,16 @@ async def update_session(
honcho_session.h_metadata = session.metadata
needs_update = True
if (
session.configuration is not None
and honcho_session.configuration != session.configuration
):
honcho_session.configuration = session.configuration
needs_update = True
if session.configuration is not None:
# Merge configuration instead of replacing to preserve existing keys
base_config = (honcho_session.configuration or {}).copy()
merged_config = {
**base_config,
**session.configuration.model_dump(exclude_none=True),
}
if honcho_session.configuration != merged_config:
honcho_session.configuration = merged_config
needs_update = True
if not needs_update:
logger.debug(
@ -303,11 +331,56 @@ async def update_session(
return honcho_session
async def _batch_delete_matching(
db: AsyncSession,
model: Any,
filter_conditions: list[Any],
batch_size: int = 5000,
) -> int:
"""
Delete records in batches that match the given filter conditions.
Args:
db: Database session
model: SQLAlchemy model class
filter_conditions: List of SQLAlchemy filter conditions
batch_size: Number of records to delete per batch
Returns:
Total number of records deleted
"""
total_deleted = 0
primary_key_column = model.__table__.primary_key.columns.values()[0]
while True:
subquery = (
select(primary_key_column).where(and_(*filter_conditions)).limit(batch_size)
)
delete_stmt = delete(model).where(primary_key_column.in_(subquery))
delete_result = await db.execute(delete_stmt)
batch_deleted = delete_result.rowcount or 0
total_deleted += batch_deleted
if batch_deleted == 0:
break
return total_deleted
async def delete_session(
db: AsyncSession, workspace_name: str, session_name: str
) -> bool:
"""
Mark a session as inactive (soft delete).
Delete a session and all associated data (hard delete).
This performs cascading deletes for all session-related data including:
- Active queue sessions
- Queue items
- Message embeddings (batched)
- Documents (theory-of-mind data, batched)
- Messages (batched)
- Session peer associations
- The session itself
Args:
db: Database session
@ -320,17 +393,83 @@ async def delete_session(
Raises:
ResourceNotFoundException: If the session does not exist
"""
honcho_session = await get_session(db, session_name, workspace_name)
honcho_session = await get_session(
db, session_name, workspace_name, include_inactive=True
)
honcho_session.is_active = False
await db.commit()
await db.refresh(honcho_session)
# Perform cascading deletes in order
# Order is important to avoid foreign key constraint violations
try:
# Delete ActiveQueueSession entries
# Work unit keys have format: {task_type}:{workspace_name}:{session_name}:{...}
await db.execute(
delete(models.ActiveQueueSession).where(
and_(
func.split_part(models.ActiveQueueSession.work_unit_key, ":", 2)
== workspace_name,
func.split_part(models.ActiveQueueSession.work_unit_key, ":", 3)
== session_name,
)
)
)
# Invalidate cache - read-through pattern
cache_key = session_cache_key(workspace_name, session_name)
await cache.delete(cache_key)
# Delete QueueItem entries
await db.execute(
delete(models.QueueItem).where(
models.QueueItem.session_id == honcho_session.id
)
)
# Delete MessageEmbedding entries in batches
await _batch_delete_matching(
db,
models.MessageEmbedding,
[
models.MessageEmbedding.session_name == session_name,
models.MessageEmbedding.workspace_name == workspace_name,
],
batch_size=5000,
)
# Delete Document entries associated with this session in batches
await _batch_delete_matching(
db,
models.Document,
[
models.Document.session_name == session_name,
models.Document.workspace_name == workspace_name,
],
batch_size=5000,
)
# Delete Message entries in batches
await _batch_delete_matching(
db,
models.Message,
[
models.Message.session_name == session_name,
models.Message.workspace_name == workspace_name,
],
batch_size=5000,
)
# Delete SessionPeer associations
await db.execute(
delete(models.SessionPeer).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
)
)
# Finally, delete the session itself
await db.delete(honcho_session)
await db.commit()
logger.debug("Session %s and all associated data deleted", session_name)
except Exception as e:
logger.error("Failed to delete session %s: %s", session_name, e)
await db.rollback()
raise e
logger.debug("Session %s marked as inactive", session_name)
return True
@ -353,11 +492,12 @@ async def clone_session(
Returns:
The newly created session
"""
# Get the original session
# Get the original session (must be active)
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == original_session_name)
.where(models.Session.is_active == True) # noqa: E712
)
result = await db.execute(stmt)
original_session = result.scalar_one_or_none()

View File

@ -83,7 +83,7 @@ async def get_or_create_workspace(
honcho_workspace = models.Workspace(
name=workspace.name,
h_metadata=workspace.metadata,
configuration=workspace.configuration,
configuration=workspace.configuration.model_dump(exclude_none=True),
)
try:
db.add(honcho_workspace)
@ -182,12 +182,16 @@ async def update_workspace(
honcho_workspace.h_metadata = workspace.metadata
needs_update = True
if (
workspace.configuration is not None
and honcho_workspace.configuration != workspace.configuration
):
honcho_workspace.configuration = workspace.configuration
needs_update = True
if workspace.configuration is not None:
# Merge configuration instead of replacing to preserve existing keys
base_config = (honcho_workspace.configuration or {}).copy()
merged_config = {
**base_config,
**workspace.configuration.model_dump(exclude_none=True),
}
if honcho_workspace.configuration != merged_config:
honcho_workspace.configuration = merged_config
needs_update = True
# Early exit if unchanged
if not needs_update:

View File

@ -5,14 +5,17 @@ from pydantic import ValidationError
from rich.console import Console
from sqlalchemy import select
from src import models
from src import crud, models
from src.dependencies import tracked_db
from src.deriver.deriver import process_representation_tasks_batch
from src.dreamer.dreamer import process_dream
from src.exceptions import ResourceNotFoundException
from src.models import Message
from src.schemas import ResolvedConfiguration
from src.utils import summarizer
from src.utils.logging import log_performance_metrics
from src.utils.queue_payload import (
DeletionPayload,
DreamPayload,
SummaryPayload,
WebhookPayload,
@ -89,6 +92,7 @@ async def process_item(queue_item: models.QueueItem) -> None:
message_id,
validated.message_seq_in_session,
message_public_id,
validated.configuration,
)
log_performance_metrics("summary", f"{workspace_name}_{message_id}")
@ -104,12 +108,27 @@ async def process_item(queue_item: models.QueueItem) -> None:
)
raise ValueError(f"Invalid payload structure: {str(e)}") from e
await process_dream(validated, workspace_name)
elif task_type == "deletion":
with sentry_sdk.start_transaction(name="process_deletion_task", op="deriver"):
try:
validated = DeletionPayload(**queue_payload)
except ValidationError as e:
logger.error(
"Invalid deletion payload received: %s. Payload: %s",
str(e),
queue_payload,
)
raise ValueError(f"Invalid payload structure: {str(e)}") from e
await process_deletion(validated, workspace_name)
else:
raise ValueError(f"Invalid task type: {task_type}")
async def process_representation_batch(
messages: list[Message],
message_level_configuration: ResolvedConfiguration | None,
*,
observer: str | None,
observed: str | None,
@ -133,5 +152,76 @@ async def process_representation_batch(
)
await process_representation_tasks_batch(
messages, observer=observer, observed=observed
messages,
message_level_configuration,
observer=observer,
observed=observed,
)
async def process_deletion(
payload: DeletionPayload,
workspace_name: str,
) -> None:
"""
Process a deletion task from the queue.
This function handles the actual deletion of resources based on the deletion type.
It is designed to be idempotent - deleting an already-deleted resource is a no-op.
Args:
payload: The deletion payload containing deletion_type and resource_id
workspace_name: The workspace name for scoping the deletion
Raises:
ValueError: If the deletion type is not supported
"""
deletion_type = payload.deletion_type
resource_id = payload.resource_id
logger.info(
"Processing deletion task: type=%s, resource_id=%s, workspace=%s",
deletion_type,
resource_id,
workspace_name,
)
async with tracked_db("process_deletion") as db:
if deletion_type == "session":
try:
await crud.delete_session(
db, workspace_name=workspace_name, session_name=resource_id
)
logger.info(
"Successfully deleted session %s in workspace %s",
resource_id,
workspace_name,
)
except ResourceNotFoundException as e:
# Session not found - may have already been deleted, treat as success
logger.warning(
"Session %s not found during deletion (may already be deleted): %s",
resource_id,
str(e),
)
elif deletion_type == "observation":
try:
await crud.delete_document_by_id(
db, workspace_name=workspace_name, document_id=resource_id
)
logger.info(
"Successfully deleted observation %s in workspace %s",
resource_id,
workspace_name,
)
except ResourceNotFoundException as e:
# Document not found - may have already been deleted, treat as success
logger.warning(
"Observation %s not found during deletion (may already be deleted): %s",
resource_id,
str(e),
)
else:
raise ValueError(f"Unsupported deletion type: {deletion_type}")

View File

@ -9,8 +9,10 @@ from src.config import settings
from src.crud.representation import RepresentationManager
from src.dependencies import tracked_db
from src.models import Message
from src.schemas import ResolvedConfiguration
from src.utils import summarizer
from src.utils.clients import honcho_llm_call
from src.utils.config_helpers import get_configuration
from src.utils.formatting import format_new_turn_with_timestamp
from src.utils.logging import (
accumulate_metric,
@ -126,6 +128,7 @@ async def peer_card_call(
@with_sentry_transaction("process_representation_tasks_batch", op="deriver")
async def process_representation_tasks_batch(
messages: list[Message],
message_level_configuration: ResolvedConfiguration | None,
*,
observer: str,
observed: str,
@ -171,16 +174,29 @@ async def process_representation_tasks_batch(
# include_most_derived=False,
)
if settings.PEER_CARD.ENABLED:
async with tracked_db("deriver.get_peer_card") as db:
speaker_peer_card: list[str] | None = await crud.get_peer_card(
async with tracked_db("deriver.get_peer_card") as db:
if message_level_configuration is None:
message_level_configuration = get_configuration(
None,
await crud.get_session(
db, latest_message.session_name, latest_message.workspace_name
),
await crud.get_workspace(
db, workspace_name=latest_message.workspace_name
),
)
if message_level_configuration.peer_card.use is False:
speaker_peer_card = None
else:
speaker_peer_card = await crud.get_peer_card(
db,
latest_message.workspace_name,
observer=observer,
observed=observed,
)
else:
speaker_peer_card = None
if message_level_configuration.deriver.enabled is False:
return
# Estimate tokens for deriver input
peer_card_tokens = estimate_tokens(speaker_peer_card)
@ -279,6 +295,7 @@ async def process_representation_tasks_batch(
ctx=messages,
observed=observed,
observer=observer,
message_level_configuration=message_level_configuration,
)
# Run single-pass reasoning
@ -321,6 +338,7 @@ class CertaintyReasoner:
ctx: list[Message]
observer: str
observed: str
message_level_configuration: ResolvedConfiguration
def __init__(
self,
@ -329,11 +347,13 @@ class CertaintyReasoner:
*,
observed: str,
observer: str,
message_level_configuration: ResolvedConfiguration,
) -> None:
self.representation_manager = representation_manager
self.ctx = ctx
self.observed = observed
self.observer = observer
self.message_level_configuration = message_level_configuration
@conditional_observe(name="Deriver")
@sentry_sdk.trace
@ -352,6 +372,7 @@ class CertaintyReasoner:
"""
analysis_start = time.perf_counter()
message_ids = [m.id for m in self.ctx]
earliest_message = self.ctx[0]
latest_message = self.ctx[-1]
@ -385,7 +406,7 @@ class CertaintyReasoner:
reasoning_response = Representation.from_prompt_representation(
reasoning_response,
(earliest_message.id, latest_message.id),
[earliest_message.id, latest_message.id],
latest_message.session_name,
latest_message.created_at,
)
@ -405,20 +426,13 @@ class CertaintyReasoner:
if not new_observations.is_empty():
await self.representation_manager.save_representation(
new_observations,
(earliest_message.id, latest_message.id),
message_ids,
latest_message.session_name,
latest_message.created_at,
self.message_level_configuration,
)
# not currently deduplicating at the save_representation step, so this isn't useful
# accumulate_metric(
# f"deriver_{latest_payload.message_id}_{latest_payload.observer}",
# "new_observation_count",
# new_observations_saved,
# "count",
# )
if settings.PEER_CARD.ENABLED:
if self.message_level_configuration.peer_card.create:
update_peer_card_start = time.perf_counter()
if not new_observations.is_empty():
await self._update_peer_card(speaker_peer_card, new_observations)
@ -458,7 +472,9 @@ class CertaintyReasoner:
]
accumulate_metric(
f"deriver_{self.ctx[-1].id}_{self.observer}",
"new_peer_card",
"new_peer_card"
if self.observer == self.observed
else f"new_{self.observed}_peer_card",
"\n".join(new_peer_card),
"blob",
)

View File

@ -1,16 +1,23 @@
import logging
from typing import Any
from datetime import datetime, timezone
from typing import Any, Literal
from sqlalchemy import insert
from sqlalchemy import insert, update
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, schemas
from src import crud, models, schemas
from src.config import settings
from src.dependencies import tracked_db
from src.dreamer.dream_scheduler import get_affected_dream_keys, get_dream_scheduler
from src.exceptions import ValidationException
from src.models import QueueItem
from src.utils.queue_payload import create_payload
from src.schemas import MessageConfiguration, ResolvedConfiguration
from src.utils.config_helpers import get_configuration
from src.utils.queue_payload import (
create_deletion_payload,
create_dream_payload,
create_payload,
)
from src.utils.work_unit import construct_work_unit_key
logger = logging.getLogger(__name__)
@ -92,7 +99,11 @@ async def handle_session(
workspace_name=workspace_name,
)
deriver_disabled = bool(session.configuration.get("deriver_disabled"))
# Fetch workspace for configuration resolution
workspace = await crud.get_workspace(db_session, workspace_name=workspace_name)
# Resolve summary configuration with hierarchical fallback
session_level_configuration = get_configuration(None, session, workspace)
peers_with_configuration = await get_peers_with_configuration(
db_session, workspace_name, session_name
@ -101,13 +112,20 @@ async def handle_session(
queue_records: list[dict[str, Any]] = []
for message in payload:
message_config: MessageConfiguration | None = message.get("configuration")
if message_config is not None:
message_level_configuration = get_configuration(
message_config, session, workspace
)
else:
message_level_configuration = session_level_configuration
queue_records.extend(
await generate_queue_records(
db_session,
message,
peers_with_configuration,
session.id,
deriver_disabled=deriver_disabled,
message_level_configuration,
)
)
return queue_records
@ -144,6 +162,7 @@ async def get_peers_with_configuration(
def create_representation_record(
message: dict[str, Any],
conf: ResolvedConfiguration,
session_id: str | None = None,
*,
observer: str,
@ -154,9 +173,10 @@ def create_representation_record(
Args:
message: The message payload
conf: Resolved configuration for this particular message
session_id: Optional session ID
observed: Name of the sender
observer: Name of the target
session_id: Optional session ID
Returns:
Queue record dictionary with workspace_name and message_id as separate fields
@ -169,8 +189,9 @@ def create_representation_record(
if not isinstance(message_id, int):
raise TypeError("message_id is required and must be an integer")
processed_payload = create_payload(
processed_payload: dict[str, Any] = create_payload(
message=message,
configuration=conf,
task_type="representation",
observer=observer,
observed=observed,
@ -187,6 +208,7 @@ def create_representation_record(
def create_summary_record(
message: dict[str, Any],
configuration: ResolvedConfiguration,
session_id: str,
message_seq_in_session: int,
) -> dict[str, Any]:
@ -211,6 +233,7 @@ def create_summary_record(
processed_payload = create_payload(
message=message,
configuration=configuration,
task_type="summary",
message_seq_in_session=message_seq_in_session,
)
@ -255,7 +278,11 @@ def get_effective_observe_me(
return sender_session_peer_config.observe_me
# Otherwise use peer config
return sender_peer_config.observe_me
return (
sender_peer_config.observe_me
if sender_peer_config.observe_me is not None
else True
)
async def generate_queue_records(
@ -263,8 +290,7 @@ async def generate_queue_records(
message: dict[str, Any],
peers_with_configuration: dict[str, list[dict[str, Any]]],
session_id: str,
*,
deriver_disabled: bool,
conf: ResolvedConfiguration,
) -> list[dict[str, Any]]:
"""
Process a single message and generate queue records based on configurations.
@ -272,10 +298,9 @@ async def generate_queue_records(
Args:
db_session: The database session
message: The message payload
deriver_disabled: Whether deriver is disabled for the session
peers_with_configuration: Dictionary of peer configurations
session_id: Session ID
message_seq_map: Optional pre-fetched mapping of message_id to sequence number
configuration: Resolved configuration for this particular message
Returns:
List of queue records for this message
@ -295,19 +320,20 @@ async def generate_queue_records(
records: list[dict[str, Any]] = []
if settings.SUMMARY.ENABLED and (
message_seq_in_session % settings.SUMMARY.MESSAGES_PER_SHORT_SUMMARY == 0
or message_seq_in_session % settings.SUMMARY.MESSAGES_PER_LONG_SUMMARY == 0
if conf.summary.enabled and (
message_seq_in_session % conf.summary.messages_per_short_summary == 0
or message_seq_in_session % conf.summary.messages_per_long_summary == 0
):
records.append(
create_summary_record(
message,
configuration=conf,
session_id=session_id,
message_seq_in_session=message_seq_in_session,
)
)
if deriver_disabled:
if not conf.deriver.enabled:
return records
if get_effective_observe_me(observed, peers_with_configuration):
@ -315,24 +341,23 @@ async def generate_queue_records(
records.append(
create_representation_record(
message,
conf,
observed=observed,
observer=observed,
session_id=session_id,
)
)
for peer_name, configuration in peers_with_configuration.items():
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 not configuration[2]:
if not peer_conf[2]:
continue
session_peer_config = (
schemas.SessionPeerConfig(**configuration[1])
if configuration[1]
else None
schemas.SessionPeerConfig(**peer_conf[1]) if peer_conf[1] else None
)
if session_peer_config is None or not session_peer_config.observe_others:
@ -342,6 +367,7 @@ async def generate_queue_records(
# peer representation task
create_representation_record(
message,
conf,
observed=observed,
observer=peer_name,
session_id=session_id,
@ -361,3 +387,198 @@ async def generate_queue_records(
)
return records
def create_dream_record(
workspace_name: str,
*,
observer: str,
observed: str,
dream_type: schemas.DreamType,
) -> dict[str, Any]:
"""
Create a queue record for a dream task.
Args:
workspace_name: Name of the workspace
observer: Name of the observer peer
observed: Name of the observed peer
dream_type: Type of dream to execute
Returns:
Queue record dictionary with workspace_name and other fields
"""
dream_payload = create_dream_payload(
dream_type,
observer=observer,
observed=observed,
)
return {
"work_unit_key": construct_work_unit_key(workspace_name, dream_payload),
"payload": dream_payload,
"session_id": None,
"task_type": "dream",
"workspace_name": workspace_name,
"message_id": None,
}
async def enqueue_dream(
workspace_name: str,
observer: str,
observed: str,
dream_type: schemas.DreamType,
document_count: int,
) -> None:
"""
Enqueue a dream task for immediate processing by the deriver.
Args:
workspace_name: Name of the workspace
observer: Name of the observer peer
observed: Name of the observed peer
dream_type: Type of dream to execute
document_count: Current document count for metadata update
"""
async with tracked_db("dream_enqueue") as db_session:
try:
# Create the dream queue record
dream_record = create_dream_record(
workspace_name,
observer=observer,
observed=observed,
dream_type=dream_type,
)
# Insert into queue
stmt = insert(QueueItem).returning(QueueItem)
await db_session.execute(stmt, [dream_record])
# Update collection metadata
now_iso = datetime.now(timezone.utc).isoformat()
update_stmt = (
update(models.Collection)
.where(
models.Collection.workspace_name == workspace_name,
models.Collection.observer == observer,
models.Collection.observed == observed,
)
.values(
internal_metadata=models.Collection.internal_metadata.op("||")(
{
"dream": {
"last_dream_document_count": document_count,
"last_dream_at": now_iso,
}
}
)
)
)
await db_session.execute(update_stmt)
await db_session.commit()
logger.info(
"Enqueued dream task for %s/%s/%s (type: %s)",
workspace_name,
observer,
observed,
dream_type.value,
)
except Exception as e:
logger.exception("Failed to enqueue dream task!")
if settings.SENTRY.ENABLED:
import sentry_sdk
sentry_sdk.capture_exception(e)
raise
def create_deletion_record(
workspace_name: str,
deletion_type: Literal["session", "observation"],
resource_id: str,
) -> dict[str, Any]:
"""
Create a queue record for a deletion task.
Args:
workspace_name: Name of the workspace
deletion_type: Type of resource to delete ("session" or "observation")
resource_id: ID of the resource to delete
Returns:
Queue record dictionary for insertion into the queue
"""
deletion_payload = create_deletion_payload(
deletion_type=deletion_type,
resource_id=resource_id,
)
return {
"work_unit_key": construct_work_unit_key(workspace_name, deletion_payload),
"payload": deletion_payload,
"session_id": None,
"task_type": "deletion",
"workspace_name": workspace_name,
"message_id": None,
}
async def enqueue_deletion(
workspace_name: str,
deletion_type: Literal["session", "observation"],
resource_id: str,
db_session: AsyncSession | None = None,
) -> None:
"""
Enqueue a deletion task for processing by the deriver.
This function adds a deletion task to the queue for asynchronous processing.
The deletion will be handled by the queue consumer with retry support.
Args:
workspace_name: Name of the workspace
deletion_type: Type of resource to delete ("session" or "observation")
resource_id: ID of the resource to delete
db_session: Optional database session. If provided, uses this session
instead of creating a new one. The caller is responsible for committing.
"""
async def _do_enqueue(session: AsyncSession, should_commit: bool) -> None:
deletion_record = create_deletion_record(
workspace_name,
deletion_type,
resource_id,
)
stmt = insert(QueueItem).returning(QueueItem)
await session.execute(stmt, [deletion_record])
if should_commit:
await session.commit()
logger.info(
"Enqueued deletion task: type=%s, resource_id=%s, workspace=%s",
deletion_type,
resource_id,
workspace_name,
)
try:
if db_session is not None:
# Use the provided session - caller is responsible for committing
await _do_enqueue(db_session, should_commit=False)
else:
# Create a new session and commit
async with tracked_db("deletion_enqueue") as new_session:
await _do_enqueue(new_session, should_commit=True)
except Exception as e:
logger.exception("Failed to enqueue deletion task!")
if settings.SENTRY.ENABLED:
import sentry_sdk
sentry_sdk.capture_exception(e)
raise

View File

@ -29,6 +29,7 @@ from src.dreamer.dream_scheduler import (
set_dream_scheduler,
)
from src.models import QueueItem
from src.schemas import ResolvedConfiguration
from src.sentry import initialize_sentry
from src.utils.work_unit import parse_work_unit_key
from src.webhooks.events import (
@ -423,6 +424,7 @@ class QueueManager:
(
messages_context,
items_to_process,
message_level_configuration,
) = await self.get_queue_item_batch(
work_unit.task_type, work_unit_key, ownership.aqs_id
)
@ -438,6 +440,7 @@ class QueueManager:
try:
await process_representation_batch(
messages_context,
message_level_configuration,
observer=work_unit.observer,
observed=work_unit.observed,
)
@ -574,7 +577,7 @@ class QueueManager:
task_type: str,
work_unit_key: str,
aqs_id: str,
) -> tuple[list[models.Message], list[QueueItem]]:
) -> tuple[list[models.Message], list[QueueItem], ResolvedConfiguration | None]:
"""
Representation-only: returns a tuple of (messages_context, items_to_process).
- messages_context: unique Message rows (conversation turns) forming the context window
@ -598,7 +601,7 @@ class QueueManager:
if not ownership_check.scalar_one_or_none():
# Worker lost ownership, return empty
await db.commit()
return [], []
return [], [], None
# Step 2: Build a single SQL query that:
# 1. Finds the earliest unprocessed message for this work_unit_key
@ -670,7 +673,7 @@ class QueueManager:
rows = result.all()
if not rows:
await db.commit()
return [], []
return [], [], None
messages_context: list[models.Message] = []
items_to_process: list[QueueItem] = []
@ -682,6 +685,33 @@ class QueueManager:
if qi is not None:
items_to_process.append(qi)
if items_to_process:
# Enforce homogeneous peer_card_config in the batch
# We stop collecting items as soon as we encounter a different configuration
payload = items_to_process[0].payload
raw_config = payload.get("configuration")
if raw_config is None:
resolved_config = None
else:
resolved_config = ResolvedConfiguration.model_validate(raw_config)
valid_items: list[QueueItem] = []
for item in items_to_process:
item_raw_config = item.payload.get("configuration")
if item_raw_config is None:
item_config = None
else:
item_config = ResolvedConfiguration.model_validate(
item_raw_config
)
if item_config != resolved_config:
break
valid_items.append(item)
items_to_process = valid_items
else:
resolved_config = None
if items_to_process:
max_queue_item_message_id = max(
[
@ -696,7 +726,7 @@ class QueueManager:
await db.commit()
return messages_context, items_to_process
return messages_context, items_to_process, resolved_config
async def mark_queue_items_as_processed(
self, items: list[QueueItem], work_unit_key: str

17
src/dreamer/agent.py Normal file
View File

@ -0,0 +1,17 @@
import logging
from src.utils.queue_payload import DreamPayload
logger = logging.getLogger(__name__)
async def process_agent_dream(payload: DreamPayload, workspace_name: str) -> None:
"""
Process an agent dream task.
Args:
payload: The dream task payload containing workspace, peer, and dream type information
"""
logger.info(
f"Processing agent dream for {workspace_name}/{payload.observer}/{payload.observed}"
)

242
src/dreamer/consolidate.py Normal file
View File

@ -0,0 +1,242 @@
import logging
from inspect import cleandoc as c
from sqlalchemy import delete
from src import crud, models, schemas
from src.config import settings
from src.dependencies import tracked_db
from src.embedding_client import embedding_client
from src.exceptions import ResourceNotFoundException
from src.utils.clients import honcho_llm_call
from src.utils.formatting import format_datetime_utc
from src.utils.logging import conditional_observe
from src.utils.queue_payload import DreamPayload
from src.utils.representation import (
ExplicitObservation,
Representation,
)
logger = logging.getLogger(__name__)
def consolidation_prompt(
representation: Representation,
) -> str:
"""
Generate the prompt for user representation consolidation.
Args:
representation: The user representation to consolidate
Returns:
A prompt string for the LLM to consolidate the representation
"""
representation_as_json = representation.model_dump_json(indent=2)
return c(
f"""
You are an agent that consolidates observations about an entity. You will be presented with a list of EXPLICIT and DEDUCTIVE observations. **Reduce** the number of observations, if possible, by combining similar observations. **ONLY** include information that is **GIVEN**. Create the highest-quality observations with the given information. Observations must always be maximally concise.
{representation_as_json}
"""
)
@conditional_observe(name="[Dream] Consolidate Call")
async def _consolidate_call(
representation: Representation,
) -> Representation:
prompt = consolidation_prompt(representation)
response = await honcho_llm_call(
llm_settings=settings.DREAM,
prompt=prompt,
max_tokens=settings.DREAM.MAX_OUTPUT_TOKENS,
track_name="Dream Call",
response_model=Representation,
enable_retry=True,
retry_attempts=3,
)
return response.content
async def process_consolidate_dream(payload: DreamPayload, workspace_name: str) -> None:
"""
Process a consolidation dream task.
Consolidation means taking all the documents in a collection and merging
similar observations into a single, best-quality observation document.
"""
logger.info(
"Starting consolidate dream for workspace=%s, observer=%s, observed=%s",
workspace_name,
payload.observer,
payload.observed,
)
# grab 100 recent documents in the collection
# in the future, we can perform clustering on documents by semantic similarity and do
# multiple clusters at once. for now, can just sample documents and do what we can.
async with tracked_db("dream_consolidate") as db:
# First verify the collection exists
try:
collection = await crud.get_collection(
db,
workspace_name,
observer=payload.observer,
observed=payload.observed,
)
logger.debug(
"Found collection id=%s for workspace=%s, observer=%s, observed=%s",
collection.id,
workspace_name,
payload.observer,
payload.observed,
)
except ResourceNotFoundException:
logger.warning(
"Collection does not exist for workspace=%s, observer=%s, observed=%s",
workspace_name,
payload.observer,
payload.observed,
)
return
documents_query = crud.get_all_documents(
workspace_name,
observer=payload.observer,
observed=payload.observed,
limit=100,
)
logger.debug(
"Executing document query: %s",
str(documents_query.compile(compile_kwargs={"literal_binds": True})),
)
result = await db.execute(documents_query)
documents = result.scalars().all()
if not documents:
return
logger.info("consolidating %d documents", len(documents))
# Pre-calculate data structures needed for processing so we don't need attached objects
cluster_representation = Representation.from_documents(documents)
document_ids = [doc.id for doc in documents]
total_times_derived = sum(doc.times_derived for doc in documents)
# We treat all fetched documents as a single cluster for now
clusters = [(cluster_representation, document_ids, total_times_derived)]
# for each cluster, call llm to consolidate the representation if possible
for representation, doc_ids, times_derived in clusters:
await _consolidate_cluster(
representation,
doc_ids,
times_derived,
workspace_name,
observer=payload.observer,
observed=payload.observed,
)
async def _consolidate_cluster(
representation: Representation,
document_ids: list[str],
total_times_derived: int,
workspace_name: str,
*,
observer: str,
observed: str,
) -> None:
"""
Consolidate a cluster of documents, treated as a Representation, into a smaller one.
Removes old documents and replaces them with consolidated versions while preserving metadata.
"""
if len(document_ids) <= 1:
logger.info(
"Cluster has %d documents, skipping consolidation", len(document_ids)
)
return
logger.info("unconsolidated representation:\n%s", representation)
consolidated_representation = await _consolidate_call(representation)
logger.info("consolidated representation:\n%s", consolidated_representation)
new_documents = [
*consolidated_representation.explicit,
*consolidated_representation.deductive,
]
if not new_documents:
return
# Collect all contents for batch embedding
contents: list[str] = []
for obs in new_documents:
if isinstance(obs, ExplicitObservation):
contents.append(obs.content)
else:
contents.append(obs.conclusion)
# Batch embed all contents at once for better performance
embeddings = await embedding_client.simple_batch_embed(contents)
documents_to_create: list[schemas.DocumentCreate] = []
for i, obs in enumerate(new_documents):
if isinstance(obs, ExplicitObservation):
content = obs.content
level = "explicit"
premises = None
else:
content = obs.conclusion
level = "deductive"
premises = obs.premises
# NOTE: other kinds of observations here in the future
metadata = schemas.DocumentMetadata(
message_ids=obs.message_ids,
message_created_at=format_datetime_utc(obs.created_at),
premises=premises,
)
documents_to_create.append(
schemas.DocumentCreate(
content=content,
session_name=obs.session_name,
level=level,
times_derived=total_times_derived,
metadata=metadata,
embedding=embeddings[i],
)
)
async with tracked_db("dream_consolidate_write") as db:
# bulk create documents
await crud.create_documents(
db,
documents_to_create,
workspace_name,
observer=observer,
observed=observed,
)
# delete old documents
await db.execute(
delete(models.Document).where(models.Document.id.in_(document_ids))
)
await db.commit()
logger.info(
"consolidated %d documents into %d new documents",
len(document_ids),
len(new_documents),
)

View File

@ -5,13 +5,13 @@ from logging import getLogger
from typing import Any
import sentry_sdk
from sqlalchemy import func, insert, select, update
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.config import settings
from src.dependencies import tracked_db
from src.utils.queue_payload import create_dream_payload
from src.schemas import DreamType
from src.utils.work_unit import construct_work_unit_key, parse_work_unit_key
logger = getLogger(__name__)
@ -47,17 +47,21 @@ def get_affected_dream_keys(message: dict[str, Any]) -> list[str]:
if not workspace_name or not peer_name:
return []
# Generate dream work unit key for this peer's collection
dream_key = construct_work_unit_key(
workspace_name,
{
"task_type": "dream",
"observer": peer_name,
"observed": peer_name,
},
)
# Generate dream work unit keys for each enabled dream type
dream_keys: list[str] = []
for dream_type in settings.DREAM.ENABLED_TYPES:
dream_key = construct_work_unit_key(
workspace_name,
{
"task_type": "dream",
"observer": peer_name,
"observed": peer_name,
"dream_type": dream_type,
},
)
dream_keys.append(dream_key)
return [dream_key]
return dream_keys
class DreamScheduler:
@ -87,6 +91,7 @@ class DreamScheduler:
workspace_name: str,
document_count: int,
delay_minutes: int,
dream_type: DreamType,
*,
observer: str,
observed: str,
@ -104,6 +109,7 @@ class DreamScheduler:
workspace_name,
document_count,
delay_minutes,
dream_type,
observer=observer,
observed=observed,
)
@ -128,6 +134,7 @@ class DreamScheduler:
workspace_name: str,
document_count: int,
delay_minutes: int,
dream_type: DreamType,
*,
observer: str,
observed: str,
@ -139,10 +146,10 @@ class DreamScheduler:
if await self._should_execute_dream(
workspace_name, observer=observer, observed=observed
):
await self._execute_dream(
work_unit_key,
await self.execute_dream(
workspace_name,
document_count,
dream_type,
observer=observer,
observed=observed,
)
@ -182,63 +189,25 @@ class DreamScheduler:
return True
async def _execute_dream(
async def execute_dream(
self,
work_unit_key: str,
workspace_name: str,
document_count: int,
dream_type: DreamType,
*,
observer: str,
observed: str,
) -> None:
"""Execute the dream by enqueueing it and updating collection metadata."""
dream_payload = create_dream_payload(
dream_type="consolidate",
# Import here to avoid circular dependency
from src.deriver.enqueue import enqueue_dream
await enqueue_dream(
workspace_name,
observer=observer,
observed=observed,
)
async with tracked_db("dream_execute") as db:
dream_record = {
"work_unit_key": work_unit_key,
"payload": dream_payload,
"session_id": None,
"task_type": "dream",
"workspace_name": workspace_name,
"message_id": None, # Dreams don't have a message_id
}
await db.execute(insert(models.QueueItem), [dream_record])
now_iso = datetime.now(timezone.utc).isoformat()
stmt = (
update(models.Collection)
.where(
models.Collection.workspace_name == workspace_name,
models.Collection.observer == observer,
models.Collection.observed == observed,
)
.values(
internal_metadata=models.Collection.internal_metadata.op("||")(
{
"dream": {
"last_dream_document_count": document_count,
"last_dream_at": now_iso,
}
}
)
)
)
await db.execute(stmt)
await db.commit()
logger.info(
"Enqueued dream task",
extra={
"workspace_name": workspace_name,
"observer": observer,
"observed": observed,
},
dream_type=dream_type,
document_count=document_count,
)
async def shutdown(self) -> None:
@ -326,33 +295,38 @@ async def check_and_schedule_dream(
dream_scheduler = get_dream_scheduler()
if dream_scheduler:
collection_work_unit_key = construct_work_unit_key(
collection.workspace_name,
{
"task_type": "dream",
"observer": collection.observer,
"observed": collection.observed,
},
)
await dream_scheduler.schedule_dream(
collection_work_unit_key,
collection.workspace_name,
current_document_count,
settings.DREAM.IDLE_TIMEOUT_MINUTES,
observer=collection.observer,
observed=collection.observed,
)
logger.info(
"Scheduled dream",
extra={
"workspace_name": collection.workspace_name,
"observer": collection.observer,
"observed": collection.observed,
"documents_since_last_dream": documents_since_last_dream,
"document_threshold": settings.DREAM.DOCUMENT_THRESHOLD,
},
)
enabled_dream_types = settings.DREAM.ENABLED_TYPES
for dream_type in enabled_dream_types:
# Include dream_type in key so each dream type can be tracked independently
dream_work_unit_key = construct_work_unit_key(
collection.workspace_name,
{
"task_type": "dream",
"observer": collection.observer,
"observed": collection.observed,
"dream_type": dream_type,
},
)
await dream_scheduler.schedule_dream(
dream_work_unit_key,
collection.workspace_name,
current_document_count,
settings.DREAM.IDLE_TIMEOUT_MINUTES,
dream_type=DreamType(dream_type),
observer=collection.observer,
observed=collection.observed,
)
logger.info(
"Scheduled dream",
extra={
"workspace_name": collection.workspace_name,
"observer": collection.observer,
"observed": collection.observed,
"documents_since_last_dream": documents_since_last_dream,
"document_threshold": settings.DREAM.DOCUMENT_THRESHOLD,
"dream_type": dream_type,
},
)
return True
return False

View File

@ -1,22 +1,12 @@
import logging
from collections.abc import Sequence
import sentry_sdk
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models, schemas
from src.config import settings
from src.dependencies import tracked_db
from src.dreamer.prompts import consolidation_prompt
from src.embedding_client import embedding_client
from src.utils.clients import honcho_llm_call
from src.utils.formatting import format_datetime_utc
from src.utils.logging import conditional_observe
from src.dreamer.agent import process_agent_dream
from src.dreamer.consolidate import process_consolidate_dream
from src.schemas import DreamType
from src.utils.queue_payload import DreamPayload
from src.utils.representation import (
ExplicitObservation,
Representation,
)
logger = logging.getLogger(__name__)
@ -33,13 +23,18 @@ async def process_dream(
payload: The dream task payload containing workspace, peer, and dream type information
"""
logger.info(
f"Processing dream task: {payload.dream_type} for {workspace_name}/{payload.observer}/{payload.observed}"
f"""
(- - ς) z 𐰁 z 𐰁 z 𐰁\n
DREAM: {payload.dream_type} documents for {workspace_name}/{payload.observer}/{payload.observed}\n
𐰁 z 𐰁 z 𐰁 z (- - ς)"""
)
try:
if payload.dream_type == "consolidate":
await _process_consolidate_dream(payload, workspace_name)
## TODO other dream types
match payload.dream_type:
case DreamType.CONSOLIDATE:
await process_consolidate_dream(payload, workspace_name)
case DreamType.AGENT:
await process_agent_dream(payload, workspace_name)
except Exception as e:
logger.error(
@ -49,149 +44,3 @@ async def process_dream(
if settings.SENTRY.ENABLED:
sentry_sdk.capture_exception(e)
# Don't re-raise - we want to mark the dream task as processed even if it fails
async def _process_consolidate_dream(
payload: DreamPayload, workspace_name: str
) -> None:
"""
Process a consolidation dream task.
Consolidation means taking all the documents in a collection and merging
similar observations into a single, best-quality observation document.
TODO: need to determine a way to do this on a subset of documents since
collections will grow very large.
"""
logger.info(
f"""
(- - ς) z 𐰁 z 𐰁 z 𐰁\n
DREAM: consolidating documents for {workspace_name}/{payload.observer}/{payload.observed}\n
𐰁 z 𐰁 z 𐰁 z (- - ς)"""
)
# get all documents in the collection
async with tracked_db("dream_consolidate") as db:
documents = await crud.get_all_documents(
db,
workspace_name,
observer=payload.observer,
observed=payload.observed,
)
logger.info("found %d documents to consolidate", len(documents))
# TODO: create clusters of documents based on cosine similarity
# clusters = await create_document_clusters(documents)
# logger.info("created %d clusters", len(clusters))
clusters = [documents]
# for each cluster, call llm to consolidate the representation if possible
for cluster in clusters:
await _consolidate_cluster(
cluster,
workspace_name,
db,
observer=payload.observer,
observed=payload.observed,
)
async def _consolidate_cluster(
cluster: Sequence[models.Document],
workspace_name: str,
db: AsyncSession,
*,
observer: str,
observed: str,
) -> None:
"""
Consolidate a cluster of documents, treated as a Representation, into a smaller one.
Removes old documents and replaces them with consolidated versions while preserving metadata.
"""
if len(cluster) <= 1:
logger.info("Cluster has %d documents, skipping consolidation", len(cluster))
return
cluster_representation = Representation.from_documents(cluster)
logger.info("unconsolidated representation:\n%s", cluster_representation)
consolidated_representation = await consolidate_call(cluster_representation)
logger.info("consolidated representation:\n%s", consolidated_representation)
# TODO: less hacky preservation of times_derived
total_times_derived = sum(doc.times_derived for doc in cluster)
new_documents = [
*consolidated_representation.explicit,
*consolidated_representation.deductive,
]
documents_to_create: list[schemas.DocumentCreate] = []
for obs in new_documents:
if isinstance(obs, ExplicitObservation):
content = obs.content
level = "explicit"
premises = None
else:
content = obs.conclusion
level = "deductive"
premises = obs.premises
# NOTE: other kinds of observations here in the future
metadata = schemas.DocumentMetadata(
message_ids=obs.message_ids,
message_created_at=format_datetime_utc(obs.created_at),
premises=premises,
)
embedding = await embedding_client.embed(content)
documents_to_create.append(
schemas.DocumentCreate(
content=content,
session_name=obs.session_name,
level=level,
times_derived=total_times_derived,
metadata=metadata,
embedding=embedding,
)
)
# bulk create documents
await crud.create_documents(
db, documents_to_create, workspace_name, observer=observer, observed=observed
)
# delete old documents
for doc in cluster:
await db.delete(doc)
await db.commit()
logger.info(
"consolidated %d documents into %d new documents",
len(cluster),
len(new_documents),
)
@conditional_observe(name="[Dream] Consolidate Call")
async def consolidate_call(
representation: Representation,
) -> Representation:
prompt = consolidation_prompt(representation)
response = await honcho_llm_call(
llm_settings=settings.DREAM,
prompt=prompt,
max_tokens=settings.DREAM.MAX_OUTPUT_TOKENS,
track_name="Dream Call",
response_model=Representation,
enable_retry=True,
retry_attempts=3,
)
return response.content

View File

@ -1,26 +0,0 @@
from inspect import cleandoc as c
from src.utils.representation import Representation
def consolidation_prompt(
representation: Representation,
) -> str:
"""
Generate the prompt for user representation consolidation.
Args:
representation: The user representation to consolidate
Returns:
A consolidated user representation
"""
representation_as_json = representation.model_dump_json(indent=2)
return c(
f"""
You are an agent that consolidates observations about an entity. You will be presented with a list of EXPLICIT and DEDUCTIVE observations. **Reduce** the number of observations, if possible, by combining similar observations. **ONLY** include information that is **GIVEN**. Create the highest-quality observations with the given information. Observations must always be maximally concise.
{representation_as_json}
"""
)

View File

@ -23,6 +23,7 @@ from src.exceptions import HonchoException
from src.routers import (
keys,
messages,
observations,
peers,
sessions,
webhooks,
@ -138,7 +139,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.4.3",
version="2.5.0",
contact={
"name": "Plastic Labs",
"url": "https://honcho.dev",
@ -173,6 +174,7 @@ 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(observations.router, prefix="/v2")
app.include_router(keys.router, prefix="/v2")
app.include_router(webhooks.router, prefix="/v2")

View File

@ -35,9 +35,48 @@ router = APIRouter(
)
async def parse_upload_form(peer_id: str = Form(...)) -> schemas.MessageUploadCreate:
async def parse_upload_form(
peer_id: str = Form(...),
metadata: str | None = Form(None),
configuration: str | None = Form(None),
created_at: str | None = Form(None),
) -> schemas.MessageUploadCreate:
"""Parse form data for file upload requests"""
return schemas.MessageUploadCreate(peer_id=peer_id)
import json
from datetime import datetime
parsed_metadata = None
if metadata:
try:
parsed_metadata = json.loads(metadata)
except json.JSONDecodeError:
logger.warning(f"Failed to parse metadata JSON: {metadata}")
parsed_metadata = None
parsed_configuration = None
if configuration:
try:
parsed_configuration = json.loads(configuration)
except json.JSONDecodeError:
logger.warning(f"Failed to parse configuration JSON: {configuration}")
parsed_configuration = None
parsed_created_at = None
if created_at:
try:
parsed_created_at = datetime.fromisoformat(
created_at.replace("Z", "+00:00")
)
except (ValueError, AttributeError):
logger.warning(f"Failed to parse created_at: {created_at}")
parsed_created_at = None
return schemas.MessageUploadCreate(
peer_id=peer_id,
metadata=parsed_metadata,
configuration=parsed_configuration,
created_at=parsed_created_at,
)
@router.post("/", response_model=list[schemas.Message])
@ -48,7 +87,7 @@ async def create_messages_for_session(
session_id: str = Path(...),
db: AsyncSession = db,
):
"""Create messages for a session with JSON data (original functionality)."""
"""Add new message(s) to a session."""
try:
created_messages = await crud.create_messages(
db,
@ -72,8 +111,11 @@ async def create_messages_for_session(
"created_at": message.created_at,
"message_public_id": message.public_id,
"seq_in_session": message.seq_in_session,
"configuration": original.configuration,
}
for message in created_messages
for message, original in zip(
created_messages, messages.messages, strict=True
)
]
# Enqueue all messages in one call
@ -106,6 +148,9 @@ async def create_messages_with_file(
all_message_data = await process_file_uploads_for_messages(
file=file,
peer_id=form_data.peer_id,
metadata=form_data.metadata,
configuration=form_data.configuration,
created_at=form_data.created_at,
)
# Create messages
@ -136,6 +181,7 @@ async def create_messages_with_file(
"created_at": message.created_at,
"message_public_id": message.public_id,
"seq_in_session": message.seq_in_session,
"configuration": form_data.configuration,
}
for message in created_messages
]

132
src/routers/observations.py Normal file
View File

@ -0,0 +1,132 @@
import logging
from fastapi import APIRouter, Body, Depends, Path, Query
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import apaginate
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, schemas
from src.dependencies import db
from src.exceptions import ResourceNotFoundException, ValidationException
from src.security import require_auth
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/workspaces/{workspace_id}/observations",
tags=["observations"],
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
)
@router.post(
"/list",
response_model=Page[schemas.Observation],
)
async def list_observations(
workspace_id: str = Path(..., description="ID of the workspace"),
options: schemas.ObservationGet | None = Body(
None, description="Filtering options for the observations list"
),
reverse: bool | None = Query(
False, description="Whether to reverse the order of results"
),
db: AsyncSession = db,
):
"""
List all observations using custom filters. Observations are listed by recency unless `reverse` is set to `true`.
Observations can be filtered by session_id, observer_id and observed_id using the filters parameter.
"""
try:
filters = None
if options and hasattr(options, "filters"):
filters = options.filters
if filters == {}:
filters = None
stmt = crud.get_documents_with_filters(
workspace_name=workspace_id,
filters=filters,
reverse=reverse or False,
)
return await apaginate(db, stmt)
except ValueError as e:
logger.warning(f"Failed to list observations: {str(e)}")
raise ResourceNotFoundException("Session not found") from e
@router.post(
"/query",
response_model=list[schemas.Observation],
)
async def query_observations(
workspace_id: str = Path(..., description="ID of the workspace"),
body: schemas.ObservationQuery = Body(
..., description="Semantic search parameters for observations"
),
db: AsyncSession = db,
) -> list[schemas.Observation]:
"""
Query observations using semantic search.
Performs vector similarity search on observations to find semantically relevant results.
Observer and observed are required for semantic search and must be provided in filters.
"""
# Extract observer and observed from filters if provided
observer = None
observed = None
if body.filters:
observer = body.filters.get("observer") or body.filters.get("observer_id")
observed = body.filters.get("observed") or body.filters.get("observed_id")
# If no observer/observed specified, we need to query across all session documents
# For now, we'll require these to be specified for semantic search
if not observer or not observed:
raise ValidationException(
"observer and observed must be specified for semantic search"
)
else:
# Query specific observer/observed pair
documents = await crud.query_documents(
db,
workspace_name=workspace_id,
query=body.query,
observer=observer,
observed=observed,
filters=body.filters,
max_distance=body.distance,
top_k=body.top_k,
)
return [schemas.Observation.model_validate(doc) for doc in documents]
@router.delete(
"/{observation_id}",
)
async def delete_observation(
workspace_id: str = Path(..., description="ID of the workspace"),
observation_id: str = Path(..., description="ID of the observation to delete"),
db: AsyncSession = db,
):
"""
Delete a specific observation.
This permanently deletes the observation (document) from the theory-of-mind system.
This action cannot be undone.
"""
try:
await crud.delete_document_by_id(
db,
workspace_name=workspace_id,
document_id=observation_id,
)
logger.debug("Observation %s deleted successfully", observation_id)
return {"message": "Observation deleted successfully"}
except ResourceNotFoundException:
raise
except ValueError as e:
logger.warning(f"Failed to delete observation {observation_id}: {str(e)}")
raise ResourceNotFoundException("Observation not found") from e

View File

@ -9,6 +9,7 @@ from fastapi_pagination.ext.sqlalchemy import apaginate
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, prometheus, schemas
from src.config import settings
from src.dependencies import db, tracked_db
from src.dialectic import chat as dialectic_chat
from src.exceptions import AuthenticationException, ResourceNotFoundException
@ -253,6 +254,15 @@ async def get_working_representation(
observer=peer_id,
observed=options.target if options.target is not None else peer_id,
session_name=options.session_id,
include_semantic_query=options.search_query,
semantic_search_top_k=options.search_top_k,
semantic_search_max_distance=options.search_max_distance,
include_most_derived=options.include_most_derived
if options.include_most_derived is not None
else False,
max_observations=options.max_observations
if options.max_observations is not None
else settings.DERIVER.WORKING_REPRESENTATION_MAX_OBSERVATIONS,
)
return {"representation": representation}
except ValueError as e:
@ -292,6 +302,136 @@ async def get_peer_card(
return schemas.PeerCardResponse(peer_card=peer_card)
@router.put(
"/{peer_id}/card",
response_model=schemas.PeerCardResponse,
dependencies=[
Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id"))
],
)
async def set_peer_card(
workspace_id: str = Path(..., description="ID of the workspace"),
peer_id: str = Path(..., description="ID of the observer peer"),
peer_card_data: schemas.PeerCardSet = Body(
..., description="Peer card data to set"
),
target: str | None = Query(
None,
description="The peer whose card to set. If not provided, sets the observer's own card",
),
db: AsyncSession = db,
):
"""Set a peer card for a specific peer relationship.
Sets the peer card that the observer peer has for the target peer.
If no target is specified, sets the observer's own peer card.
"""
# If no target specified, set the observer's own card
observed = target if target is not None else peer_id
await crud.set_peer_card(
db,
workspace_id,
peer_card=peer_card_data.peer_card,
observer=peer_id,
observed=observed,
)
# Return the updated peer card
peer_card = await crud.get_peer_card(
db, workspace_id, observer=peer_id, observed=observed
)
return schemas.PeerCardResponse(peer_card=peer_card)
@router.get(
"/{peer_id}/context",
response_model=schemas.PeerContext,
dependencies=[
Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id"))
],
)
async def get_peer_context(
workspace_id: str = Path(..., description="ID of the workspace"),
peer_id: str = Path(..., description="ID of the peer (observer)"),
target: str | None = Query(
None,
description="The target peer to get context for. If not provided, returns the peer's own context (self-observation)",
),
search_query: str | None = Query(
None,
description="Optional query to curate the representation around semantic search results",
),
search_top_k: int | None = Query(
None,
ge=1,
le=100,
description="Only used if `search_query` is provided. Number of semantic-search-retrieved observations to include",
),
search_max_distance: float | None = Query(
None,
ge=0.0,
le=1.0,
description="Only used if `search_query` is provided. Maximum distance for semantically relevant observations",
),
include_most_derived: bool = Query(
default=True,
description="Whether to include the most derived observations in the representation",
),
max_observations: int | None = Query(
None,
ge=1,
le=100,
description="Maximum number of observations to include in the representation",
),
db: AsyncSession = db,
):
"""
Get context for a peer, including their representation and peer card.
This endpoint returns the working representation and peer card for a peer.
If a target is specified, returns the context for the target from the
observer peer's perspective. If no target is specified, returns the
peer's own context (self-observation).
This is useful for getting all the context needed about a peer without
making multiple API calls.
"""
# If no target specified, get the peer's own context (self-observation)
observed = target if target is not None else peer_id
try:
# Get the working representation
representation = await crud.get_working_representation(
workspace_id,
observer=peer_id,
observed=observed,
session_name=None, # Peer context is global, not session-scoped
include_semantic_query=search_query,
semantic_search_top_k=search_top_k,
semantic_search_max_distance=search_max_distance,
include_most_derived=include_most_derived,
max_observations=max_observations
if max_observations is not None
else settings.DERIVER.WORKING_REPRESENTATION_MAX_OBSERVATIONS,
)
# Get the peer card
peer_card = await crud.get_peer_card(
db, workspace_id, observer=peer_id, observed=observed
)
return schemas.PeerContext(
peer_id=peer_id,
target_id=observed,
representation=representation,
peer_card=peer_card,
)
except ValueError as e:
logger.warning(f"Failed to get context for peer {peer_id}: {str(e)}")
raise ResourceNotFoundException("Peer not found") from e
@router.post(
"/{peer_id}/search",
response_model=list[schemas.Message],

View File

@ -1,6 +1,4 @@
import asyncio
import logging
from typing import cast
from fastapi import APIRouter, Body, Depends, Path, Query, Response
from fastapi_pagination import Page
@ -9,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import config, crud, schemas
from src.dependencies import db, tracked_db
from src.deriver.enqueue import enqueue_deletion
from src.exceptions import (
AuthenticationException,
ResourceNotFoundException,
@ -34,6 +33,11 @@ async def _get_working_representation_task(
*,
observer: str,
observed: str,
session_name: str | None,
search_top_k: int | None,
search_max_distance: float | None,
include_most_derived: bool,
max_observations: int | None,
) -> Representation:
"""
Atomic task to get working representation using tracked_db.
@ -43,16 +47,27 @@ async def _get_working_representation_task(
last_message: Optional last message for semantic query
observer: Name of the observer peer
observed: Name of the observed peer
session_name: Optional session to filter by
search_top_k: Number of semantic-search-retrieved observations to include in the representation
search_max_distance: Maximum distance to search for semantically relevant observations
include_most_derived: Whether to include the most derived observations in the representation
max_observations: Maximum number of observations to include in the representation
Returns:
The working representation
"""
return await crud.get_working_representation(
workspace_name=workspace_id,
include_semantic_query=last_message,
include_most_derived=True,
observer=observer,
observed=observed,
session_name=session_name,
include_semantic_query=last_message,
semantic_search_top_k=search_top_k,
semantic_search_max_distance=search_max_distance,
include_most_derived=include_most_derived,
max_observations=max_observations
if max_observations is not None
else config.settings.DERIVER.WORKING_REPRESENTATION_MAX_OBSERVATIONS,
)
@ -214,6 +229,7 @@ async def update_session(
@router.delete(
"/{session_id}",
status_code=202,
dependencies=[
Depends(require_auth(workspace_name="workspace_id", session_name="session_id"))
],
@ -223,12 +239,32 @@ async def delete_session(
session_id: str = Path(..., description="ID of the session to delete"),
db: AsyncSession = db,
):
"""Delete a session by marking it as inactive"""
"""
Delete a session and all associated data.
The session is marked as inactive immediately and returns 202 Accepted. The actual
deletion of all related data (messages, embeddings, documents, etc.) happens
asynchronously via the queue with retry support.
This action cannot be undone.
"""
try:
await crud.delete_session(
db, workspace_name=workspace_id, session_name=session_id
# Mark session as inactive immediately (fast operation)
session = await crud.get_session(db, session_id, workspace_id)
session.is_active = False
# Enqueue deletion task for processing with retry support
# Pass db session so it's all in one transaction
await enqueue_deletion(
workspace_name=workspace_id,
deletion_type="session",
resource_id=session_id,
db_session=db,
)
logger.debug("Session %s deleted successfully", session_id)
await db.commit()
logger.debug("Session %s marked as inactive, deletion enqueued", session_id)
return {"message": "Session deleted successfully"}
except ValueError as e:
logger.warning(f"Failed to delete session {session_id}: {str(e)}")
@ -481,6 +517,32 @@ async def get_session_context(
None,
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`.",
),
limit_to_session: bool = Query(
default=False,
description="Only used if `last_message` is provided. Whether to limit the representation to the session (as opposed to everything known about the target peer)",
),
search_top_k: int | None = Query(
None,
ge=1,
le=100,
description="Only used if `last_message` is provided. The number of semantic-search-retrieved observations to include in the representation",
),
search_max_distance: float | None = Query(
None,
ge=0.0,
le=1.0,
description="Only used if `last_message` is provided. The maximum distance to search for semantically relevant observations",
),
include_most_derived: bool = Query(
default=False,
description="Only used if `last_message` is provided. Whether to include the most derived observations in the representation",
),
max_observations: int | None = Query(
None,
ge=1,
le=100,
description="Only used if `last_message` is provided. The maximum number of observations to include in the representation",
),
):
"""
Produce a context object from the session. The caller provides an optional token limit which the entire context must fit into.
@ -511,24 +573,20 @@ async def get_session_context(
observer = peer_perspective or peer_target
observed = peer_target
# Run representation and card tasks in parallel
representation, card = await asyncio.gather(
_get_working_representation_task(
workspace_id, last_message, observer=observer, observed=observed
),
_get_peer_card_task(workspace_id, observer=observer, observed=observed),
return_exceptions=True,
# Run representation and card tasks sequentially to avoid event loop issues
# with tracked_db creating separate database sessions
representation = await _get_working_representation_task(
workspace_id,
last_message,
observer=observer,
observed=observed,
session_name=session_id if limit_to_session else None,
search_top_k=search_top_k,
search_max_distance=search_max_distance,
include_most_derived=include_most_derived,
max_observations=max_observations,
)
# Handle any exceptions from the parallel tasks
if isinstance(representation, Exception):
raise representation
if isinstance(card, Exception):
raise card
# At this point, we know the types are correct - cast to help type checker
representation = cast(Representation, representation)
card = cast(list[str] | None, card)
card = await _get_peer_card_task(workspace_id, observer=observer, observed=observed)
# adjust token limit downward to account for approximate token count of representation and card
# TODO determine if this impacts performance too much

View File

@ -3,10 +3,13 @@ import logging
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import apaginate
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, schemas
from src import crud, models, schemas
from src.config import settings
from src.dependencies import db
from src.deriver.enqueue import enqueue_dream
from src.exceptions import AuthenticationException
from src.security import JWTParams, require_auth
from src.utils.search import search
@ -152,3 +155,55 @@ async def get_deriver_status(
except ValueError as e:
logger.warning(f"Invalid request parameters: {str(e)}")
raise HTTPException(status_code=400, detail=str(e)) from e
@router.post(
"/{workspace_id}/trigger_dream",
status_code=204,
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
)
async def trigger_dream(
workspace_id: str = Path(..., description="ID of the workspace"),
request: schemas.TriggerDreamRequest = Body(
..., description="Dream trigger parameters"
),
db: AsyncSession = db,
):
"""
Manually trigger a dream task immediately for a specific collection.
This endpoint bypasses all automatic dream conditions (document threshold,
minimum hours between dreams) and executes the dream task immediately without delay.
"""
# Check if dreams are enabled
if not settings.DREAM.ENABLED:
raise HTTPException(
status_code=400,
detail="Dreams are not enabled in the system configuration",
)
# Default observed to observer if not provided
observer = request.observer
observed = request.observed if request.observed is not None else request.observer
dream_type = request.dream_type
# Count documents in the collection
count_stmt = select(func.count(models.Document.id)).where(
models.Document.workspace_name == workspace_id,
models.Document.observer == observer,
models.Document.observed == observed,
)
document_count = int(await db.scalar(count_stmt) or 0)
# Enqueue the dream task for immediate processing
await enqueue_dream(
workspace_id,
observer=observer,
observed=observed,
dream_type=dream_type,
document_count=document_count,
)
logger.info(
f"Manually triggered dream: {dream_type.value} for {workspace_id}/{observer}/{observed}"
)

View File

@ -1,5 +1,6 @@
import datetime
import ipaddress
from enum import Enum
from typing import Annotated, Any, Self
from urllib.parse import urlparse
@ -20,6 +21,171 @@ from src.utils.types import DocumentLevel
RESOURCE_NAME_PATTERN = r"^[a-zA-Z0-9_-]+$"
class DreamType(str, Enum):
"""Types of dreams that can be triggered."""
CONSOLIDATE = "consolidate"
AGENT = "agent"
class DeriverConfiguration(BaseModel):
enabled: bool | None = Field(
default=None,
description="Whether to enable deriver functionality.",
)
custom_instructions: str | None = Field(
default=None,
description="TODO: currently unused. Custom instructions to use for the deriver on this workspace/session/message.",
)
class PeerCardConfiguration(BaseModel):
use: bool | None = Field(
default=None,
description="Whether to use peer card related to this peer during deriver process.",
)
create: bool | None = Field(
default=None,
description="Whether to generate peer card based on content.",
)
class SummaryConfiguration(BaseModel):
enabled: bool | None = Field(
default=None,
description="Whether to enable summary functionality.",
)
messages_per_short_summary: int | None = Field(
default=None,
ge=10,
description="Number of messages per short summary. Must be positive, greater than or equal to 10, and less than messages_per_long_summary.",
)
messages_per_long_summary: int | None = Field(
default=None,
ge=20,
description="Number of messages per long summary. Must be positive, greater than or equal to 20, and greater than messages_per_short_summary.",
)
@model_validator(mode="after")
def validate_summary_thresholds(self) -> Self:
"""Validate that short summary threshold <= long summary threshold."""
short = self.messages_per_short_summary
long = self.messages_per_long_summary
if short is not None and long is not None and short >= long:
raise ValueError(
"messages_per_short_summary must be less than messages_per_long_summary"
)
return self
class DreamConfiguration(BaseModel):
enabled: bool | None = Field(
default=None,
description="Whether to enable dream functionality. If deriver is disabled, dreams will also be disabled and this setting will be ignored.",
)
class WorkspaceConfiguration(BaseModel):
"""
The set of options that can be in a workspace DB-level configuration dictionary.
All fields are optional. Session-level configuration overrides workspace-level configuration, which overrides global configuration.
"""
model_config = ConfigDict(extra="allow") # pyright: ignore
deriver: DeriverConfiguration | None = Field(
default=None,
description="Configuration for deriver functionality.",
)
peer_card: PeerCardConfiguration | None = Field(
default=None,
description="Configuration for peer card functionality. If deriver is disabled, peer cards will also be disabled and these settings will be ignored.",
)
summary: SummaryConfiguration | None = Field(
default=None,
description="Configuration for summary functionality.",
)
dream: DreamConfiguration | None = Field(
default=None,
description="Configuration for dream functionality. If deriver is disabled, dreams will also be disabled and these settings will be ignored.",
)
class SessionConfiguration(WorkspaceConfiguration):
"""
The set of options that can be in a session DB-level configuration dictionary.
All fields are optional. Session-level configuration overrides workspace-level configuration, which overrides global configuration.
"""
pass
class MessageConfiguration(BaseModel):
"""
The set of options that can be in a message DB-level configuration dictionary.
All fields are optional. Message-level configuration overrides all other configurations.
"""
deriver: DeriverConfiguration | None = Field(
default=None,
description="Configuration for deriver functionality.",
)
peer_card: PeerCardConfiguration | None = Field(
default=None,
description="Configuration for peer card functionality. If deriver is disabled, peer cards will also be disabled and these settings will be ignored.",
)
class ResolvedDeriverConfiguration(BaseModel):
enabled: bool
class ResolvedPeerCardConfiguration(BaseModel):
use: bool
create: bool
class ResolvedSummaryConfiguration(BaseModel):
enabled: bool
messages_per_short_summary: int
messages_per_long_summary: int
class ResolvedDreamConfiguration(BaseModel):
enabled: bool
class ResolvedConfiguration(BaseModel):
"""
The final resolved configuration for a given message.
Hierarchy: message > session > workspace > global configuration
"""
deriver: ResolvedDeriverConfiguration
peer_card: ResolvedPeerCardConfiguration
summary: ResolvedSummaryConfiguration
dream: ResolvedDreamConfiguration
class PeerConfig(BaseModel):
observe_me: bool | None = Field(
default=None,
description="Whether honcho should form a global theory-of-mind representation of this peer",
)
class SessionPeerConfig(PeerConfig):
observe_others: bool | None = Field(
default=None,
description="Whether this peer should form a session-level theory-of-mind representation of other peers in the session",
)
class WorkspaceBase(BaseModel):
pass
@ -30,7 +196,9 @@ class WorkspaceCreate(WorkspaceBase):
Field(alias="id", min_length=1, max_length=100, pattern=RESOURCE_NAME_PATTERN),
]
metadata: dict[str, Any] = {}
configuration: dict[str, Any] = {}
configuration: WorkspaceConfiguration = Field(
default_factory=WorkspaceConfiguration
)
model_config = ConfigDict(populate_by_name=True) # pyright: ignore
@ -41,7 +209,7 @@ class WorkspaceGet(WorkspaceBase):
class WorkspaceUpdate(WorkspaceBase):
metadata: dict[str, Any] | None = None
configuration: dict[str, Any] | None = None
configuration: WorkspaceConfiguration | None = None
class Workspace(WorkspaceBase):
@ -103,6 +271,32 @@ class PeerRepresentationGet(BaseModel):
None,
description="Optional peer ID to get the representation for, from the perspective of this peer",
)
search_query: str | None = Field(
None,
description="Optional input to curate the representation around semantic search results",
)
search_top_k: int | None = Field(
None,
ge=1,
le=100,
description="Only used if `search_query` is provided. Number of semantic-search-retrieved observations to include in the representation",
)
search_max_distance: float | None = Field(
None,
ge=0.0,
le=1.0,
description="Only used if `search_query` is provided. Maximum distance to search for semantically relevant observations",
)
include_most_derived: bool | None = Field(
default=None,
description="Only used if `search_query` is provided. Whether to include the most derived observations in the representation",
)
max_observations: int | None = Field(
default=25,
ge=1,
le=100,
description="Only used if `search_query` is provided. Maximum number of observations to include in the representation",
)
class PeerCardResponse(BaseModel):
@ -111,11 +305,8 @@ class PeerCardResponse(BaseModel):
)
class PeerConfig(BaseModel):
observe_me: bool = Field(
default=True,
description="Whether honcho should form a global theory-of-mind representation of this peer",
)
class PeerCardSet(BaseModel):
peer_card: list[str] = Field(..., description="The peer card content to set")
class MessageBase(BaseModel):
@ -126,6 +317,7 @@ class MessageCreate(MessageBase):
content: Annotated[str, Field(min_length=0, max_length=settings.MAX_MESSAGE_SIZE)]
peer_name: str = Field(alias="peer_id")
metadata: dict[str, Any] | None = None
configuration: MessageConfiguration | None = None
created_at: datetime.datetime | None = None
_encoded_message: list[int] = PrivateAttr(default=[])
@ -178,6 +370,9 @@ class MessageUploadCreate(BaseModel):
"""Schema for message creation from file uploads"""
peer_id: str = Field(..., description="ID of the peer creating the message")
metadata: dict[str, Any] | None = None
configuration: MessageConfiguration | None = None
created_at: datetime.datetime | None = None
model_config = ConfigDict(populate_by_name=True) # pyright: ignore
@ -186,17 +381,6 @@ class SessionBase(BaseModel):
pass
class SessionPeerConfig(BaseModel):
observe_others: bool = Field(
default=False,
description="Whether this peer should form a session-level theory-of-mind representation of other peers in the session",
)
observe_me: bool | None = Field(
default=None,
description="Whether other peers in this session should try to form a session-level theory-of-mind representation of this peer",
)
class SessionCreate(SessionBase):
name: Annotated[
str,
@ -204,7 +388,7 @@ class SessionCreate(SessionBase):
]
metadata: dict[str, Any] | None = None
peer_names: dict[str, SessionPeerConfig] | None = Field(default=None, alias="peers")
configuration: dict[str, Any] | None = None
configuration: SessionConfiguration | None = None
model_config = ConfigDict(populate_by_name=True) # pyright: ignore
@ -215,7 +399,7 @@ class SessionGet(SessionBase):
class SessionUpdate(SessionBase):
metadata: dict[str, Any] | None = None
configuration: dict[str, Any] | None = None
configuration: SessionConfiguration | None = None
class Session(SessionBase):
@ -270,6 +454,21 @@ class SessionContext(SessionBase):
)
class PeerContext(BaseModel):
"""Context for a peer, including representation and peer card."""
peer_id: str = Field(description="The ID of the peer")
target_id: str = Field(description="The ID of the target peer being observed")
representation: Representation | None = Field(
default=None,
description="The working representation of the target peer from the observer's perspective",
)
peer_card: list[str] | None = Field(
default=None,
description="The peer card for the target peer from the observer's perspective",
)
class SessionSummaries(SessionBase):
name: str = Field(serialization_alias="id")
short_summary: Summary | None = Field(
@ -289,7 +488,7 @@ class DocumentBase(BaseModel):
class DocumentMetadata(BaseModel):
message_ids: list[tuple[int, int]] = Field(
message_ids: list[int] = Field(
description="The ID range(s) of the messages that this document was derived from. Acts as a link to the primary source of the document. Note that as a document gets deduplicated, additional ranges will be added, because the same document could be derived from completely separate message ranges."
)
message_created_at: str = Field(
@ -319,6 +518,50 @@ class DocumentCreate(DocumentBase):
embedding: list[float] = Field()
class ObservationGet(BaseModel):
"""Schema for listing observations with optional filters"""
filters: dict[str, Any] | None = None
class Observation(BaseModel):
"""Observation response - external view of a document"""
id: str
content: str
observer: str = Field(
description="The peer who made the observation",
serialization_alias="observer_id",
)
observed: str = Field(
description="The peer being observed", serialization_alias="observed_id"
)
session_name: str = Field(serialization_alias="session_id")
created_at: datetime.datetime
model_config = ConfigDict( # pyright: ignore
from_attributes=True, populate_by_name=True
)
class ObservationQuery(BaseModel):
"""Query parameters for semantic search of observations"""
query: str = Field(..., description="Semantic search query")
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, Any] | None = Field(
default=None, description="Additional filters to apply"
)
class MessageSearchOptions(BaseModel):
query: str = Field(..., description="Search query")
filters: dict[str, Any] | None = Field(
@ -399,14 +642,6 @@ class QueueStatusRow(BaseModel):
session_pending: int
class PeerConfigResult(BaseModel):
"""Result from querying peer configuration data."""
peer_name: str
peer_configuration: dict[str, Any]
session_peer_configuration: dict[str, Any]
class SessionPeerData(BaseModel):
"""Data for managing session peer relationships."""
@ -445,6 +680,15 @@ class DeriverStatus(BaseModel):
)
# Dream trigger schema
class TriggerDreamRequest(BaseModel):
observer: str = Field(..., description="Observer peer name")
observed: str | None = Field(
None, description="Observed peer name (defaults to observer if not specified)"
)
dream_type: DreamType = Field(..., description="Type of dream to trigger")
# Webhook endpoint schemas
class WebhookEndpointBase(BaseModel):
pass

View File

@ -76,6 +76,27 @@ for provider_name, provider_value in SELECTED_PROVIDERS:
if provider_value not in CLIENTS:
raise ValueError(f"Missing client for {provider_name}: {provider_value}")
# Validate backup providers are initialized if configured
BACKUP_PROVIDERS = [
("Deriver", settings.DERIVER),
("PeerCard", settings.PEER_CARD),
("Dialectic", settings.DIALECTIC),
("Summary", settings.SUMMARY),
("Dream", settings.DREAM),
]
for component_name, component_settings in BACKUP_PROVIDERS:
if (
hasattr(component_settings, "BACKUP_PROVIDER")
and component_settings.BACKUP_PROVIDER is not None
and component_settings.BACKUP_PROVIDER not in CLIENTS
):
raise ValueError(
f"Backup provider for {component_name} is set to {component_settings.BACKUP_PROVIDER}, "
+ "but this provider is not initialized. Please set the required API key/URL environment "
+ "variables or remove the backup configuration."
)
class HonchoLLMCallResponse(BaseModel, Generic[T]):
"""
@ -232,8 +253,8 @@ async def honcho_llm_call(
):
provider: SupportedProviders = llm_settings.BACKUP_PROVIDER
model: str = llm_settings.BACKUP_MODEL
logger.info(
f"Final retry attempt {attempt}: switching from "
logger.warning(
f"Final retry attempt {attempt}/{retry_attempts}: switching from "
+ f"{llm_settings.PROVIDER}/{llm_settings.MODEL} to "
+ f"backup {provider}/{model}"
)
@ -306,14 +327,20 @@ async def honcho_llm_call(
if enable_retry:
def before_retry_callback(retry_state: Any) -> None:
"""Update attempt counter before each retry."""
_current_attempt.set(retry_state.attempt_number)
"""Update attempt counter before each retry.
Note: before_sleep is called AFTER an attempt fails and BEFORE sleeping,
so we need to increment to the next attempt number.
"""
next_attempt = retry_state.attempt_number + 1
_current_attempt.set(next_attempt)
exc = retry_state.outcome.exception() if retry_state.outcome else None
if exc:
logger.warning(
f"Error on attempt {retry_state.attempt_number} with "
f"Error on attempt {retry_state.attempt_number}/{retry_attempts} with "
+ f"{llm_settings.PROVIDER}/{llm_settings.MODEL}: {exc}"
)
logger.info(f"Will retry with attempt {next_attempt}/{retry_attempts}")
decorated = retry(
stop=stop_after_attempt(retry_attempts),

View File

@ -0,0 +1,78 @@
"""Configuration resolution utilities for hierarchical settings."""
import logging
from typing import Any, cast
from src import models
from src.config import settings
from src.schemas import (
MessageConfiguration,
ResolvedConfiguration,
)
logger = logging.getLogger(__name__)
def deep_update(base: dict[str, Any], update: dict[str, Any]) -> None:
"""
Recursive update of a dictionary.
Skips None values in the update dictionary.
"""
for key, value in update.items():
if value is None:
continue
if isinstance(value, dict) and key in base and isinstance(base[key], dict):
deep_update(cast(dict[str, Any], base[key]), cast(dict[str, Any], value))
else:
base[key] = value
def get_configuration(
message_configuration: MessageConfiguration | None,
session: models.Session,
workspace: models.Workspace | None = None,
) -> ResolvedConfiguration:
"""
Resolve session configuration with hierarchical fallback.
Resolution hierarchy:
1. Message configuration
2. Session configuration
3. Workspace configuration
4. Global defaults from settings
Args:
session: The session model
workspace: Optional workspace model (if not provided, only session and global config are used)
Returns:
ResolvedConfiguration
"""
# Start with defaults
config_dict: dict[str, Any] = {
"deriver": {"enabled": True},
"peer_card": {
"use": settings.PEER_CARD.ENABLED,
"create": settings.PEER_CARD.ENABLED,
},
"summary": {
"enabled": settings.SUMMARY.ENABLED,
"messages_per_short_summary": settings.SUMMARY.MESSAGES_PER_SHORT_SUMMARY,
"messages_per_long_summary": settings.SUMMARY.MESSAGES_PER_LONG_SUMMARY,
},
"dream": {"enabled": settings.DREAM.ENABLED},
}
# Apply overrides in order (Workspace -> Session -> Message)
# Note: deep_update modifies config_dict in place
if workspace is not None:
deep_update(config_dict, workspace.configuration)
deep_update(config_dict, session.configuration)
if message_configuration is not None:
deep_update(config_dict, message_configuration.model_dump(exclude_none=True))
return ResolvedConfiguration(**config_dict)

View File

@ -1,3 +1,4 @@
import datetime
import logging
from io import BytesIO
from typing import Any, Protocol
@ -159,6 +160,9 @@ async def process_file_uploads_for_messages(
file: UploadFile,
peer_id: str,
max_chars: int = settings.MAX_MESSAGE_SIZE,
metadata: dict[str, Any] | None = None,
configuration: schemas.MessageConfiguration | None = None,
created_at: datetime.datetime | None = None,
) -> list[dict[str, Any]]:
"""
Process an uploaded file and prepare message creation data.
@ -170,6 +174,9 @@ async def process_file_uploads_for_messages(
file: Uploaded file to process
peer_id: ID of the peer creating the messages
max_chars: Maximum characters per message chunk
metadata: Optional metadata to associate with all messages created from this file
configuration: Optional configuration to associate with all messages created from this file
created_at: Optional created_at timestamp to use for all messages created from this file
Returns:
List of dictionaries containing message_create and file_metadata
@ -192,10 +199,13 @@ async def process_file_uploads_for_messages(
# Build message content properly handling empty files
message_content = chunk or ""
# Create message
# Create message with optional metadata, configuration, and created_at
message_create = schemas.MessageCreate(
content=message_content,
peer_id=peer_id,
metadata=metadata,
configuration=configuration,
created_at=created_at,
)
# Store file metadata separately to add to internal_metadata later

View File

@ -47,6 +47,14 @@ ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGES = {
"metadata": "h_metadata",
}
ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_DOCUMENTS = {
"session_id": "session_name",
"workspace_id": "workspace_name",
"observer_id": "observer",
"observed_id": "observed",
"metadata": "internal_metadata",
}
def apply_filter(
stmt: Select[tuple[T]], model_class: type[T], filters: dict[str, Any] | None = None
@ -193,8 +201,10 @@ def _build_field_condition(
if model_class.__name__ == "Message":
column_name = ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGES.get(key)
elif model_class.__name__ == "Document":
# documents are fully internal so we can use any column name directly
column_name = key
column_name = ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_DOCUMENTS.get(
key,
key, # fallback to the key itself if not found in the mapping for internal use here
)
else:
column_name = ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING.get(key)

View File

@ -3,6 +3,8 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
from src.schemas import DreamType, ResolvedConfiguration
class BasePayload(BaseModel):
"""Base payload with common fields."""
@ -19,6 +21,7 @@ class RepresentationPayload(BasePayload):
observer: str
observed: str
created_at: datetime
configuration: ResolvedConfiguration
class RepresentationPayloads(BasePayload):
@ -33,6 +36,7 @@ class SummaryPayload(BasePayload):
task_type: Literal["summary"] = "summary"
session_name: str
message_seq_in_session: int
configuration: ResolvedConfiguration
# Optional for backward compatibility with older queue items
message_public_id: str | None = None
@ -49,11 +53,19 @@ class DreamPayload(BasePayload):
"""Payload for dream tasks."""
task_type: Literal["dream"] = "dream"
dream_type: Literal["consolidate"] = "consolidate"
dream_type: DreamType
observer: str
observed: str
class DeletionPayload(BasePayload):
"""Payload for deletion tasks."""
task_type: Literal["deletion"] = "deletion"
deletion_type: Literal["session", "observation"]
resource_id: str
def create_webhook_payload(
event_type: str,
data: dict[str, Any],
@ -65,7 +77,7 @@ def create_webhook_payload(
def create_dream_payload(
dream_type: Literal["consolidate"] = "consolidate",
dream_type: DreamType,
*,
observer: str,
observed: str,
@ -78,8 +90,20 @@ def create_dream_payload(
).model_dump(mode="json", exclude_none=True)
def create_deletion_payload(
deletion_type: Literal["session", "observation"],
resource_id: str,
) -> dict[str, Any]:
"""Create a deletion payload."""
return DeletionPayload(
deletion_type=deletion_type,
resource_id=resource_id,
).model_dump(mode="json", exclude_none=True)
def create_payload(
message: dict[str, Any],
configuration: ResolvedConfiguration,
task_type: Literal["representation", "summary"],
message_seq_in_session: int | None = None,
*,
@ -96,9 +120,10 @@ def create_payload(
Args:
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)
observed: Name of the observed peer (*always* the peer who sent the message) (required for representation tasks)
message_seq_in_session: Required for summary tasks, must be None for representation
Returns:
Processed payload dictionary ready for queue processing (without workspace_name and message_id)
@ -143,6 +168,7 @@ def create_payload(
created_at=created_at,
observer=observer,
observed=observed,
configuration=configuration,
)
elif task_type == "summary":
if message_seq_in_session is None:
@ -158,6 +184,7 @@ def create_payload(
validated_payload = SummaryPayload(
session_name=session_name,
message_seq_in_session=message_seq_in_session,
configuration=configuration,
message_public_id=message_public_id,
)

View File

@ -10,7 +10,7 @@ from src.utils.formatting import parse_datetime_iso
class ObservationMetadata(BaseModel):
created_at: datetime
message_ids: list[tuple[int, int]]
message_ids: list[int]
session_name: str
@ -267,7 +267,7 @@ class Representation(BaseModel):
doc.internal_metadata, doc.created_at
),
content=doc.content,
message_ids=doc.internal_metadata.get("message_ids", [(0, 0)]),
message_ids=doc.internal_metadata.get("message_ids", []),
session_name=doc.session_name,
)
for doc in documents
@ -279,7 +279,7 @@ class Representation(BaseModel):
doc.internal_metadata, doc.created_at
),
conclusion=doc.content,
message_ids=doc.internal_metadata.get("message_ids", [(0, 0)]),
message_ids=doc.internal_metadata.get("message_ids", []),
session_name=doc.session_name,
premises=doc.internal_metadata.get("premises", []),
)
@ -292,7 +292,7 @@ class Representation(BaseModel):
def from_prompt_representation(
cls,
prompt_representation: "PromptRepresentation",
message_ids: tuple[int, int],
message_ids: list[int],
session_name: str,
created_at: datetime,
) -> "Representation":
@ -301,7 +301,7 @@ class Representation(BaseModel):
ExplicitObservation(
content=e.content,
created_at=created_at,
message_ids=[message_ids],
message_ids=message_ids,
session_name=session_name,
)
for e in prompt_representation.explicit
@ -310,7 +310,7 @@ class Representation(BaseModel):
DeductiveObservation(
conclusion=d.conclusion,
created_at=created_at,
message_ids=[message_ids],
message_ids=message_ids,
session_name=session_name,
premises=d.premises,
)

View File

@ -8,13 +8,14 @@ of each item's rank in each list, then summing these reciprocal ranks.
import re
from typing import Any, TypeVar
from sqlalchemy import Select, func, or_, select
from sqlalchemy import Select, and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.config import settings
from src.embedding_client import embedding_client
from src.exceptions import ValidationException
from src.models import session_peers_table
from src.utils.filter import apply_filter
T = TypeVar("T")
@ -173,7 +174,9 @@ async def search(
Args:
db: Database session
query: Search query to match against message content
filters: Optional filters to scope search
filters: Optional filters to scope search. Special filter 'peer_perspective' will search
across all messages from sessions that the peer is/was a member of, filtered
by the time window when they were actually in the session.
limit: Maximum number of results to return
Returns:
@ -184,6 +187,35 @@ async def search(
"""
# Base query conditions
stmt = select(models.Message)
# Handle special peer_perspective filter
if filters and "peer_perspective" in filters:
peer_name = filters["peer_perspective"]
# Remove from filters dict so apply_filter doesn't try to handle it
filters = {k: v for k, v in filters.items() if k != "peer_perspective"}
# Safety: peer_perspective must be scoped to a workspace
if not filters or (
"workspace_id" not in filters and "workspace_name" not in filters
):
raise ValidationException(
"peer_perspective requires a workspace scope (workspace_id or workspace_name)."
)
# Join with session_peers_table to get messages from sessions the peer was in
# Only include messages created during the time window the peer was active
stmt = stmt.join(
session_peers_table,
and_(
models.Message.session_name == session_peers_table.c.session_name,
models.Message.workspace_name == session_peers_table.c.workspace_name,
models.Message.created_at >= session_peers_table.c.joined_at,
or_(
session_peers_table.c.left_at.is_(None),
models.Message.created_at <= session_peers_table.c.left_at,
),
),
).where(session_peers_table.c.peer_name == peer_name)
stmt = apply_filter(stmt, models.Message, filters)
search_results: list[list[models.Message]] = []

View File

@ -237,6 +237,7 @@ async def summarize_if_needed(
message_id: int,
message_seq_in_session: int,
message_public_id: str,
configuration: schemas.ResolvedConfiguration,
) -> None:
"""
Create short/long summaries if thresholds met.
@ -248,9 +249,22 @@ async def summarize_if_needed(
workspace_name: The workspace name
session_name: The session name
message_id: The message ID
message_seq_in_session: The sequence number of the message in the session
message_public_id: The public ID of the message
configuration: The resolved configuration for the message
"""
should_create_long: bool = message_seq_in_session % MESSAGES_PER_LONG_SUMMARY == 0
should_create_short: bool = message_seq_in_session % MESSAGES_PER_SHORT_SUMMARY == 0
if configuration.summary.enabled is False:
return
should_create_long: bool = (
message_seq_in_session % configuration.summary.messages_per_long_summary == 0
)
should_create_short: bool = (
message_seq_in_session % configuration.summary.messages_per_short_summary == 0
)
if should_create_long is False and should_create_short is False:
return
# If both summaries need to be created, run them in parallel with separate database sessions
if should_create_long and should_create_short:

View File

@ -1,5 +1,5 @@
from typing import Literal
SupportedProviders = Literal["anthropic", "openai", "google", "groq", "custom", "vllm"]
TaskType = Literal["webhook", "summary", "representation", "dream"]
TaskType = Literal["webhook", "summary", "representation", "dream", "deletion"]
DocumentLevel = Literal["explicit", "deductive"]

View File

@ -13,6 +13,7 @@ class ParsedWorkUnit(BaseModel):
session_name: str | None
observer: str | None
observed: str | None
dream_type: str | None = None
def construct_work_unit_key(
@ -45,12 +46,24 @@ def construct_work_unit_key(
observed = payload.get("observed", "None")
session_name = payload.get("session_name", "None")
if task_type == "dream":
return f"{task_type}:{workspace_name}:{observer}:{observed}"
dream_type = payload.get("dream_type")
if not dream_type:
raise ValueError("dream_type is required for dream tasks")
return f"{task_type}:{dream_type}:{workspace_name}:{observer}:{observed}"
return f"{task_type}:{workspace_name}:{session_name}:{observer}:{observed}"
if task_type == "webhook":
return f"webhook:{workspace_name}"
if task_type == "deletion":
deletion_type = payload.get("deletion_type")
resource_id = payload.get("resource_id")
if not deletion_type or not resource_id:
raise ValueError(
"deletion_type and resource_id are required for deletion tasks"
)
return f"deletion:{workspace_name}:{deletion_type}:{resource_id}"
raise ValueError(f"Invalid task type: {task_type}")
@ -84,16 +97,17 @@ def parse_work_unit_key(work_unit_key: str) -> ParsedWorkUnit:
)
if task_type == "dream":
if len(parts) != 4:
if len(parts) != 5:
raise ValueError(
f"Invalid work_unit_key format for task_type {task_type}: {work_unit_key}"
)
return ParsedWorkUnit(
task_type=task_type,
workspace_name=parts[1],
workspace_name=parts[2],
session_name=None,
observer=parts[2],
observed=parts[3],
observer=parts[3],
observed=parts[4],
dream_type=parts[1],
)
if task_type == "webhook":
@ -109,4 +123,17 @@ def parse_work_unit_key(work_unit_key: str) -> ParsedWorkUnit:
observed=None,
)
if task_type == "deletion":
if len(parts) != 4:
raise ValueError(
f"Invalid work_unit_key format for task_type {task_type}: {work_unit_key}"
)
return ParsedWorkUnit(
task_type=task_type,
workspace_name=parts[1],
session_name=None,
observer=None,
observed=None,
)
raise ValueError(f"Invalid task type in work_unit_key: {task_type}")

View File

@ -9,6 +9,7 @@ from . import (
test_66e63cf2cf77_add_indexes_to_documents_table,
test_76ffba56fe8c_add_error_field_to_queueitem,
test_88b0fb10906f_add_webhooks_table,
test_110bdf470272_rename_deriver_disabled_to_deriver_,
test_556a16564f50_add_user_id_and_app_id_to_tables,
test_564ba40505c5_add_session_name_column_to_documents,
test_917195d9b5e9_add_messageembedding_table,
@ -27,6 +28,7 @@ __all__ = [
"test_05486ce795d5_make_session_name_required_on_messages",
"test_066e87ca5b07_align_schema_with_declarative_models",
"test_08894082221a_replace_collection_name_with_observer_",
"test_110bdf470272_rename_deriver_disabled_to_deriver_",
"test_20f89a421aff_rename_metamessage_type_to_label",
"test_29ade7350c19_remove_document_level_valid_constraint",
"test_556a16564f50_add_user_id_and_app_id_to_tables",

View File

@ -0,0 +1,159 @@
"""Hooks for revision 110bdf470272 (rename_deriver_disabled_to_deriver_)."""
from __future__ import annotations
import json
from nanoid import generate as generate_nanoid
from sqlalchemy import text
from tests.alembic.registry import register_after_upgrade, register_before_upgrade
from tests.alembic.verifier import MigrationVerifier
WORKSPACE_ID = generate_nanoid()
WORKSPACE_NAME = "workspace-name"
PEER_ID = generate_nanoid()
PEER_NAME = "peer-name"
SESSION_ID_DISABLED_TRUE = generate_nanoid()
SESSION_ID_DISABLED_FALSE = generate_nanoid()
SESSION_ID_NO_KEY = generate_nanoid()
@register_before_upgrade("110bdf470272")
def prepare_rename_deriver_disabled_to_deriver(verifier: MigrationVerifier) -> None:
"""Seed sessions with deriver_disabled configuration before upgrading to 110bdf470272."""
schema = verifier.schema
connection = verifier.conn
connection.execute(
text(
f"""
INSERT INTO "{schema}"."workspaces" ("id", "name")
VALUES (:workspace_id, :workspace_name)
"""
),
{"workspace_id": WORKSPACE_ID, "workspace_name": WORKSPACE_NAME},
)
connection.execute(
text(
f"""
INSERT INTO "{schema}"."peers" ("id", "name", "workspace_name")
VALUES (:peer_id, :peer_name, :workspace_name)
"""
),
{
"peer_id": PEER_ID,
"peer_name": PEER_NAME,
"workspace_name": WORKSPACE_NAME,
},
)
configuration_disabled_true = json.dumps({"deriver_disabled": True})
configuration_disabled_false = json.dumps({"deriver_disabled": False})
configuration_no_key = json.dumps({"other_setting": "value"})
connection.execute(
text(
f"""
INSERT INTO "{schema}"."sessions"
("id", "name", "workspace_name", "configuration")
VALUES (:session_id, :session_name, :workspace_name, :configuration)
"""
),
{
"session_id": SESSION_ID_DISABLED_TRUE,
"session_name": "session-disabled-true",
"workspace_name": WORKSPACE_NAME,
"configuration": configuration_disabled_true,
},
)
connection.execute(
text(
f"""
INSERT INTO "{schema}"."sessions"
("id", "name", "workspace_name", "configuration")
VALUES (:session_id, :session_name, :workspace_name, :configuration)
"""
),
{
"session_id": SESSION_ID_DISABLED_FALSE,
"session_name": "session-disabled-false",
"workspace_name": WORKSPACE_NAME,
"configuration": configuration_disabled_false,
},
)
connection.execute(
text(
f"""
INSERT INTO "{schema}"."sessions"
("id", "name", "workspace_name", "configuration")
VALUES (:session_id, :session_name, :workspace_name, :configuration)
"""
),
{
"session_id": SESSION_ID_NO_KEY,
"session_name": "session-no-key",
"workspace_name": WORKSPACE_NAME,
"configuration": configuration_no_key,
},
)
@register_after_upgrade("110bdf470272")
def verify_rename_deriver_disabled_to_deriver(verifier: MigrationVerifier) -> None:
"""Verify deriver_disabled was converted to deriver_enabled correctly."""
schema = verifier.schema
conn = verifier.conn
session_disabled_true = conn.execute(
text(
f"""
SELECT configuration FROM "{schema}"."sessions"
WHERE "id" = :session_id
"""
),
{"session_id": SESSION_ID_DISABLED_TRUE},
).one()
config = session_disabled_true.configuration
assert "deriver_disabled" not in config, "deriver_disabled should be removed"
assert (
config.get("deriver_enabled") is False
), "deriver_disabled: true should become deriver_enabled: false"
session_disabled_false = conn.execute(
text(
f"""
SELECT configuration FROM "{schema}"."sessions"
WHERE "id" = :session_id
"""
),
{"session_id": SESSION_ID_DISABLED_FALSE},
).one()
config = session_disabled_false.configuration
assert "deriver_disabled" not in config, "deriver_disabled should be removed"
assert (
config.get("deriver_enabled") is True
), "deriver_disabled: false should become deriver_enabled: true"
session_no_key = conn.execute(
text(
f"""
SELECT configuration FROM "{schema}"."sessions"
WHERE "id" = :session_id
"""
),
{"session_id": SESSION_ID_NO_KEY},
).one()
config = session_no_key.configuration
assert "deriver_disabled" not in config, "deriver_disabled should not exist"
assert (
"deriver_enabled" not in config
), "deriver_enabled should not be added when deriver_disabled was absent"
assert (
config.get("other_setting") == "value"
), "Other configuration should be preserved"

View File

@ -32,19 +32,26 @@ class HonchoHarness:
"""
def __init__(
self, db_port: int, api_port: int, project_root: Path, instance_id: int = 0
self,
db_port: int,
api_port: int,
redis_port: int,
project_root: Path,
instance_id: int = 0,
) -> None:
"""
Initialize the harness with database port, API port, and project root.
Initialize the harness with database port, API port, Redis port, and project root.
Args:
db_port: Port for the PostgreSQL database
api_port: Port for the FastAPI server
redis_port: Port for the Redis server
project_root: Path to the Honcho project root
instance_id: Instance identifier for pool management
"""
self.db_port: int = db_port
self.api_port: int = api_port
self.redis_port: int = redis_port
self.project_root: Path = project_root
self.instance_id: int = instance_id
self.temp_dir: Path | None = None
@ -86,8 +93,7 @@ class HonchoHarness:
compose_data["services"]["database"]["command"] = cmd
# Update the Redis port
# TODO: Make this configurable if running multiple instances
compose_data["services"]["redis"]["ports"] = ["6379:6379"]
compose_data["services"]["redis"]["ports"] = [f"{self.redis_port}:6379"]
# Add a unique project name to avoid conflicts
compose_data["name"] = f"honcho_harness_{self.db_port}"
@ -161,7 +167,7 @@ class HonchoHarness:
return {
"DB_CONNECTION_URI": f"postgresql+psycopg://testuser:testpwd@localhost:{self.db_port}/honcho",
"CACHE_ENABLED": "true",
"CACHE_URL": "redis://localhost:6379/0",
"CACHE_URL": f"redis://localhost:{self.redis_port}/0",
}
def start_database(self) -> None:
@ -216,24 +222,7 @@ class HonchoHarness:
"""
Start the Redis cache server using Docker Compose.
"""
print("Starting Redis cache server on port 6379...")
# Ensure clean state by removing any existing containers/volumes
subprocess.run(
[
"docker",
"compose",
"-f",
str(self.docker_compose_file),
"-p",
f"honcho_harness_{self.db_port}",
"down",
"--volumes",
"--remove-orphans",
],
cwd=self.temp_dir,
capture_output=True,
)
print(f"Starting Redis cache server on port {self.redis_port}...")
# Change to the temp directory and start the redis service
result = subprocess.run(
@ -271,7 +260,7 @@ class HonchoHarness:
"""
print("Waiting for Redis to be ready...")
start_time = time.time()
redis_port = 6379
redis_port = self.redis_port
while time.time() - start_time < timeout:
try:
@ -440,7 +429,7 @@ class HonchoHarness:
sys.exit(1)
print(
f"[Instance {self.instance_id}] Database verification passed: Database is empty"
f"[Instance {self.instance_id}] Database verification passed: Database is empty"
)
except Exception as e:
@ -686,7 +675,7 @@ except Exception as e:
print("=" * 60)
def cleanup(self) -> None:
async def cleanup(self) -> None:
"""
Clean up resources and stop all processes.
"""
@ -752,7 +741,7 @@ except Exception as e:
# Close cache
try:
asyncio.run(self.close_cache())
await self.close_cache()
except Exception as e:
print(f"Error closing cache: {e}")
@ -855,7 +844,7 @@ except Exception as e:
except Exception as e:
print(f"❌ Error: {e}")
finally:
self.cleanup()
await self.cleanup()
class HonchoHarnessPool:
@ -864,7 +853,12 @@ class HonchoHarnessPool:
"""
def __init__(
self, pool_size: int, base_db_port: int, base_api_port: int, project_root: Path
self,
pool_size: int,
base_db_port: int,
base_api_port: int,
base_redis_port: int,
project_root: Path,
) -> None:
"""
Initialize a pool of Honcho harnesses.
@ -873,11 +867,13 @@ class HonchoHarnessPool:
pool_size: Number of Honcho instances to create
base_db_port: Base port for PostgreSQL databases (each instance gets base + instance_id)
base_api_port: Base port for FastAPI servers (each instance gets base + instance_id)
base_redis_port: Base port for Redis servers (each instance gets base + instance_id)
project_root: Path to the Honcho project root
"""
self.pool_size: int = pool_size
self.base_db_port: int = base_db_port
self.base_api_port: int = base_api_port
self.base_redis_port: int = base_redis_port
self.project_root: Path = project_root
self.harnesses: list[HonchoHarness] = []
@ -886,6 +882,7 @@ class HonchoHarnessPool:
harness = HonchoHarness(
db_port=base_db_port + i,
api_port=base_api_port + i,
redis_port=base_redis_port + i,
project_root=project_root,
instance_id=i,
)
@ -1017,16 +1014,16 @@ class HonchoHarnessPool:
except Exception as e:
print(f"❌ Error: {e}")
finally:
self.cleanup()
await self.cleanup()
def cleanup(self) -> None:
async def cleanup(self) -> None:
"""
Clean up all harnesses in the pool.
"""
print("\nCleaning up pool...")
for harness in self.harnesses:
print(f"\n--- Cleaning up Instance {harness.instance_id} ---")
harness.cleanup()
await harness.cleanup()
def main():
@ -1058,6 +1055,13 @@ Examples:
help="Base port for the FastAPI server (default: 8000)",
)
parser.add_argument(
"--redis-port",
type=int,
default=6379,
help="Base port for the Redis server (default: 6379)",
)
parser.add_argument(
"--pool-size",
type=int,
@ -1105,6 +1109,7 @@ Examples:
pool_size=args.pool_size,
base_db_port=args.port,
base_api_port=args.api_port,
base_redis_port=args.redis_port,
project_root=args.project_root,
)
asyncio.run(pool.run())
@ -1112,6 +1117,7 @@ Examples:
harness = HonchoHarness(
db_port=args.port,
api_port=args.api_port,
redis_port=args.redis_port,
project_root=args.project_root,
instance_id=0,
)

View File

@ -334,7 +334,7 @@ async def run_benchmark(candidates: list[Candidate], cases: list[Case]) -> int:
ExplicitObservation(
content=o,
created_at=datetime.now(timezone.utc),
message_ids=[(0, 0)],
message_ids=[0],
session_name=case.name,
)
for o in case.new_observations

View File

@ -564,12 +564,12 @@ def mock_tracked_db(db_session: AsyncSession):
with (
patch("src.dependencies.tracked_db", mock_tracked_db_context),
patch("src.deriver.deriver.tracked_db", mock_tracked_db_context),
patch("src.deriver.queue_manager.tracked_db", mock_tracked_db_context),
patch("src.routers.sessions.tracked_db", mock_tracked_db_context),
patch("src.routers.peers.tracked_db", mock_tracked_db_context),
patch("src.crud.representation.tracked_db", mock_tracked_db_context),
patch("src.routers.peers.tracked_db", mock_tracked_db_context),
patch("src.dreamer.dreamer.tracked_db", mock_tracked_db_context),
patch("src.dreamer.consolidate.tracked_db", mock_tracked_db_context),
patch("src.dreamer.dream_scheduler.tracked_db", mock_tracked_db_context),
patch("src.dialectic.chat.tracked_db", mock_tracked_db_context),
patch("src.utils.summarizer.tracked_db", mock_tracked_db_context),

260
tests/crud/test_document.py Normal file
View File

@ -0,0 +1,260 @@
import pytest
from nanoid import generate as generate_nanoid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models, schemas
from src.exceptions import ResourceNotFoundException
class TestDocumentCRUD:
"""Test suite for document CRUD operations"""
async def _setup_test_data(
self,
db_session: AsyncSession,
test_workspace: models.Workspace,
test_peer: models.Peer,
) -> tuple[models.Peer, models.Session, models.Collection]:
"""Helper to set up test data with collection"""
# Create another peer to observe
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_session)
await db_session.flush()
# Create collection (required for documents foreign key)
collection = models.Collection(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
)
db_session.add(collection)
await db_session.flush()
return test_peer2, test_session, collection
@pytest.mark.asyncio
async def test_get_all_documents_returns_query(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test get_all_documents returns a Select query for pagination"""
test_workspace, test_peer = sample_data
test_peer2, test_session, _ = await self._setup_test_data(
db_session, test_workspace, test_peer
)
# Create test documents
doc1 = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="Test observation 1",
embedding=[0.1] * 1536,
session_name=test_session.name,
)
doc2 = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="Test observation 2",
embedding=[0.2] * 1536,
session_name=test_session.name,
)
db_session.add_all([doc1, doc2])
await db_session.flush()
# Get documents query
stmt = crud.get_all_documents(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
)
# Execute query
result = await db_session.execute(stmt)
documents = result.scalars().all()
assert len(documents) == 2
assert documents[0].content in ["Test observation 1", "Test observation 2"]
@pytest.mark.asyncio
async def test_query_documents(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test query_documents with semantic search"""
test_workspace, test_peer = sample_data
test_peer2, test_session, _ = await self._setup_test_data(
db_session, test_workspace, test_peer
)
# Create test documents with different embeddings
doc1 = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="User likes pizza",
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.1] * 1536,
session_name=test_session.name,
)
db_session.add_all([doc1, doc2])
await db_session.flush()
# Query documents
results = await crud.query_documents(
db_session,
workspace_name=test_workspace.name,
query="food preferences",
observer=test_peer.name,
observed=test_peer2.name,
top_k=10,
)
assert len(results) == 2
@pytest.mark.asyncio
async def test_delete_document_success(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test delete_document successfully deletes a document"""
test_workspace, test_peer = sample_data
test_peer2, test_session, _ = await self._setup_test_data(
db_session, test_workspace, test_peer
)
# Create a document
doc = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="Test observation",
embedding=[0.1] * 1536,
session_name=test_session.name,
)
db_session.add(doc)
await db_session.flush()
doc_id = doc.id
# Verify document exists
stmt = select(models.Document).where(models.Document.id == doc_id)
result = await db_session.execute(stmt)
assert result.scalar_one_or_none() is not None
# Delete document
await crud.delete_document(
db_session,
workspace_name=test_workspace.name,
document_id=doc_id,
observer=test_peer.name,
observed=test_peer2.name,
)
# Verify document is deleted
result = await db_session.execute(stmt)
assert result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_delete_document_not_found(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test delete_document raises exception for non-existent document"""
test_workspace, test_peer = sample_data
test_peer2, _, _ = await self._setup_test_data(
db_session, test_workspace, test_peer
)
# Try to delete non-existent document
with pytest.raises(ResourceNotFoundException):
await crud.delete_document(
db_session,
workspace_name=test_workspace.name,
document_id="nonexistent_id",
observer=test_peer.name,
observed=test_peer2.name,
)
@pytest.mark.asyncio
async def test_create_documents(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test create_documents creates multiple documents"""
test_workspace, test_peer = sample_data
test_peer2, test_session, _ = await self._setup_test_data(
db_session, test_workspace, test_peer
)
# Prepare document creation schemas
doc_schemas = [
schemas.DocumentCreate(
content="Observation 1",
session_name=test_session.name,
embedding=[0.1] * 1536,
level="explicit",
metadata=schemas.DocumentMetadata(
message_ids=[1, 2, 3, 4, 5],
message_created_at="2024-01-01T00:00:00Z",
),
),
schemas.DocumentCreate(
content="Observation 2",
session_name=test_session.name,
embedding=[0.2] * 1536,
level="deductive",
metadata=schemas.DocumentMetadata(
message_ids=[6, 7, 8, 9, 10],
message_created_at="2024-01-01T00:01:00Z",
premises=["Premise 1", "Premise 2"],
),
),
]
# Create documents
count = await crud.create_documents(
db_session,
documents=doc_schemas,
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
)
assert count == 2
# Verify documents were created
stmt = select(models.Document).where(
models.Document.workspace_name == test_workspace.name,
models.Document.observer == test_peer.name,
models.Document.observed == test_peer2.name,
)
result = await db_session.execute(stmt)
documents = result.scalars().all()
assert len(documents) == 2
assert documents[0].content in ["Observation 1", "Observation 2"]
assert documents[1].content in ["Observation 1", "Observation 2"]

View File

@ -144,8 +144,20 @@ def create_queue_payload() -> Callable[..., Any]:
"message_public_id": message.public_id,
}
configuration = schemas.ResolvedConfiguration(
deriver=schemas.ResolvedDeriverConfiguration(enabled=True),
peer_card=schemas.ResolvedPeerCardConfiguration(use=True, create=True),
summary=schemas.ResolvedSummaryConfiguration(
enabled=True,
messages_per_short_summary=10,
messages_per_long_summary=20,
),
dream=schemas.ResolvedDreamConfiguration(enabled=True),
)
return create_payload(
message=message_dict,
configuration=configuration,
task_type=task_type,
message_seq_in_session=message_seq_in_session,
observer=observer,

View File

@ -5,6 +5,7 @@ from typing import Any
from unittest.mock import AsyncMock
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.deriver.deriver import process_representation_tasks_batch
@ -108,6 +109,8 @@ class TestDeriverProcessing:
async def test_representation_batch_uses_earliest_cutoff(
self,
db_session: AsyncSession,
sample_session_with_peers: tuple[models.Session, list[models.Peer]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Ensure batching history cutoff uses the earliest payload in the batch."""
@ -134,50 +137,43 @@ class TestDeriverProcessing:
),
)
# Avoid DB access for collection and peer card
monkeypatch.setattr(
"src.crud.get_or_create_collection",
AsyncMock(return_value=type("Collection", (), {"name": "dummy"})()),
)
monkeypatch.setattr(
"src.crud.get_peer_card",
AsyncMock(return_value=[]),
)
# Short-circuit tracked_db context manager
from contextlib import asynccontextmanager
@asynccontextmanager
async def _no_db(_label: str):
yield object()
monkeypatch.setattr("src.deriver.deriver.tracked_db", _no_db)
# Avoid executing the full reasoning pipeline; we only care about cutoff behavior.
monkeypatch.setattr(
"src.deriver.deriver.CertaintyReasoner.reason",
AsyncMock(return_value=Representation(explicit=[], deductive=[])),
)
# Create test messages with different IDs (earlier message has lower ID)
# Use the real session and workspace from fixtures
session, peers = sample_session_with_peers
alice = peers[0]
# Create test messages with different IDs in the database
now = datetime.now(timezone.utc)
messages: list[models.Message] = []
for i in range(8):
message_id = 100 + i # 100, 101, 102, ..., 107
messages.append(
models.Message(
id=message_id,
workspace_name="test_workspace",
session_name="test_session",
peer_name="alice",
content=f"message {message_id}",
seq_in_session=i + 1,
token_count=0,
created_at=now - timedelta(minutes=7 - i),
)
message = models.Message(
workspace_name=session.workspace_name,
session_name=session.name,
peer_name=alice.name,
content=f"message {i}",
seq_in_session=i + 1,
token_count=10,
created_at=now - timedelta(minutes=7 - i),
)
db_session.add(message)
messages.append(message)
await db_session.commit()
# Refresh messages to get their IDs
for message in messages:
await db_session.refresh(message)
await process_representation_tasks_batch(
observer="alice", observed="alice", messages=messages
observer=alice.name,
message_level_configuration=None,
observed=alice.name,
messages=messages,
)
# Verify that the earliest message ID was used as the cutoff

View File

@ -173,7 +173,7 @@ class TestQueueProcessing:
await db_session.commit()
await db_session.refresh(aqs)
_, items_to_process = await qm.get_queue_item_batch(
_, items_to_process, _ = await qm.get_queue_item_batch(
task_type="representation",
work_unit_key=first.work_unit_key,
aqs_id=aqs.id,
@ -184,7 +184,7 @@ class TestQueueProcessing:
# Mark first processed, next should be the second
first.processed = True
await db_session.commit()
_, items_to_process2 = await qm.get_queue_item_batch(
_, items_to_process2, _ = await qm.get_queue_item_batch(
task_type="representation",
work_unit_key=first.work_unit_key,
aqs_id=aqs.id,
@ -343,6 +343,8 @@ class TestQueueProcessing:
async def mock_process_representation_batch(
messages: list[models.Message],
_message_level_configuration: Any,
*,
observed: str | None = None, # pyright: ignore[reportUnusedParameter]
observer: str | None = None, # pyright: ignore[reportUnusedParameter]
) -> None:
@ -471,7 +473,7 @@ class TestQueueProcessing:
await db_session.commit()
await db_session.refresh(alice_aqs)
alice_messages, alice_items = await qm.get_queue_item_batch(
alice_messages, alice_items, _ = await qm.get_queue_item_batch(
task_type="representation",
work_unit_key=alice_work_unit_key,
aqs_id=alice_aqs.id,
@ -499,7 +501,7 @@ class TestQueueProcessing:
await db_session.commit()
await db_session.refresh(bob_aqs)
bob_messages, bob_items = await qm.get_queue_item_batch(
bob_messages, bob_items, _ = await qm.get_queue_item_batch(
task_type="representation",
work_unit_key=bob_work_unit_key,
aqs_id=bob_aqs.id,
@ -525,7 +527,7 @@ class TestQueueProcessing:
await db_session.commit()
await db_session.refresh(steve_aqs)
steve_messages, steve_items = await qm.get_queue_item_batch(
steve_messages, steve_items, _ = await qm.get_queue_item_batch(
task_type="representation",
work_unit_key=steve_work_unit_key,
aqs_id=steve_aqs.id,
@ -641,7 +643,7 @@ class TestQueueProcessing:
await db_session.commit()
await db_session.refresh(alice_aqs)
alice_messages2, _ = await qm.get_queue_item_batch(
alice_messages2, _, _ = await qm.get_queue_item_batch(
task_type="representation",
work_unit_key=alice_work_unit_key,
aqs_id=alice_aqs.id,
@ -664,7 +666,7 @@ class TestQueueProcessing:
await db_session.commit()
await db_session.refresh(bob_aqs)
bob_messages2, _ = await qm.get_queue_item_batch(
bob_messages2, _, _ = await qm.get_queue_item_batch(
task_type="representation",
work_unit_key=bob_work_unit_key,
aqs_id=bob_aqs.id,
@ -683,7 +685,7 @@ class TestQueueProcessing:
await db_session.commit()
await db_session.refresh(steve_aqs)
steve_messages2, _ = await qm.get_queue_item_batch(
steve_messages2, _, _ = await qm.get_queue_item_batch(
task_type="representation",
work_unit_key=steve_work_unit_key,
aqs_id=steve_aqs.id,
@ -897,6 +899,8 @@ class TestQueueProcessing:
async def mock_process_representation_batch(
messages: list[models.Message],
_message_level_configuration: Any,
*,
observed: str | None = None, # pyright: ignore[reportUnusedParameter]
observer: str | None = None, # pyright: ignore[reportUnusedParameter]
) -> None:
@ -1013,6 +1017,8 @@ class TestQueueProcessing:
async def mock_process_representation_batch(
messages: list[models.Message],
_message_level_configuration: Any,
*,
observed: str | None = None, # pyright: ignore[reportUnusedParameter]
observer: str | None = None, # pyright: ignore[reportUnusedParameter]
) -> None:

View File

@ -17,13 +17,13 @@ def test_representation_is_empty_and_diff():
exp_shared_1 = ExplicitObservation(
content="A",
created_at=shared_time,
message_ids=[(1, 1)],
message_ids=[1],
session_name="s",
)
exp_shared_2 = ExplicitObservation(
content="B",
created_at=shared_time,
message_ids=[(1, 1)],
message_ids=[1],
session_name="s",
)
rep1 = Representation(explicit=[exp_shared_1], deductive=[])
@ -32,7 +32,7 @@ def test_representation_is_empty_and_diff():
ExplicitObservation(
content="A",
created_at=shared_time,
message_ids=[(1, 1)],
message_ids=[1],
session_name="s",
),
exp_shared_2,
@ -53,12 +53,12 @@ def test_representation_formatting_methods():
e = ExplicitObservation(
content="has a dog",
created_at=now,
message_ids=[(1, 1)],
message_ids=[1],
session_name="s",
)
d = DeductiveObservation(
created_at=now,
message_ids=[(1, 1)],
message_ids=[1],
session_name="s",
conclusion="owns a pet",
premises=[e.content],
@ -85,7 +85,7 @@ def test_prompt_representation_conversion():
timestamp = datetime.datetime(2025, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
rep = Representation.from_prompt_representation(
pr,
message_ids=(1, 1),
message_ids=[1],
session_name="s",
created_at=timestamp,
)

View File

@ -105,7 +105,7 @@ class TestEnqueueFunction:
test_session = models.Session(
workspace_name=test_workspace.name,
name=str(generate_nanoid()),
configuration={"deriver_disabled": True},
configuration={"deriver": {"enabled": False}},
)
db_session.add(test_session)
await db_session.commit()
@ -1431,12 +1431,22 @@ class TestGenerateQueueRecordsSeqInSession:
{"observe_others": True},
]
}
resolved_configuration = schemas.ResolvedConfiguration(
deriver=schemas.ResolvedDeriverConfiguration(enabled=True),
summary=schemas.ResolvedSummaryConfiguration(
enabled=True,
messages_per_short_summary=20,
messages_per_long_summary=60,
),
peer_card=schemas.ResolvedPeerCardConfiguration(use=True, create=True),
dream=schemas.ResolvedDreamConfiguration(enabled=True),
)
records = await generate_queue_records(
db_session=mock_db_session,
message=message_payload,
peers_with_configuration=peers_config,
session_id=test_session.id,
deriver_disabled=False,
conf=resolved_configuration,
)
mock_crud.assert_not_called()
@ -1501,12 +1511,22 @@ class TestGenerateQueueRecordsSeqInSession:
{"observe_others": True},
]
}
resolved_configuration = schemas.ResolvedConfiguration(
deriver=schemas.ResolvedDeriverConfiguration(enabled=True),
summary=schemas.ResolvedSummaryConfiguration(
enabled=True,
messages_per_short_summary=20,
messages_per_long_summary=60,
),
peer_card=schemas.ResolvedPeerCardConfiguration(use=True, create=True),
dream=schemas.ResolvedDreamConfiguration(enabled=True),
)
records = await generate_queue_records(
db_session=mock_db_session,
message=message_payload,
peers_with_configuration=peers_config,
session_id=test_session.id,
deriver_disabled=False,
conf=resolved_configuration,
)
# The CRUD function SHOULD have been called as fallback

View File

@ -88,13 +88,13 @@ class TestRepresentationWorkflow:
explicit_obs1 = ExplicitObservation(
content="User likes dogs",
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
message_ids=[(1, 1)],
message_ids=[1],
session_name="test_session",
)
explicit_obs2 = ExplicitObservation(
content="User has a pet named Rover",
created_at=datetime(2025, 1, 1, 12, 1, 0, tzinfo=timezone.utc),
message_ids=[(2, 2)],
message_ids=[2],
session_name="test_session",
)
@ -103,7 +103,7 @@ class TestRepresentationWorkflow:
conclusion="User probably has a dog named Rover",
premises=["User likes dogs", "User has a pet named Rover"],
created_at=datetime(2025, 1, 1, 12, 2, 0, tzinfo=timezone.utc),
message_ids=[(3, 3)],
message_ids=[3],
session_name="test_session",
)
@ -144,7 +144,7 @@ class TestRepresentationWorkflow:
ExplicitObservation(
content="User likes cats",
created_at=datetime(2025, 1, 1, 10, 0, 0, tzinfo=timezone.utc),
message_ids=[(1, 1)],
message_ids=[1],
session_name="session1",
)
]
@ -156,13 +156,13 @@ class TestRepresentationWorkflow:
ExplicitObservation(
content="User likes cats", # Duplicate
created_at=datetime(2025, 1, 1, 10, 0, 0, tzinfo=timezone.utc),
message_ids=[(1, 1)],
message_ids=[1],
session_name="session1",
),
ExplicitObservation(
content="User likes dogs", # New
created_at=datetime(2025, 1, 1, 11, 0, 0, tzinfo=timezone.utc),
message_ids=[(2, 2)],
message_ids=[2],
session_name="session1",
),
]
@ -186,7 +186,7 @@ class TestRepresentationWorkflow:
ExplicitObservation(
content="User likes birds",
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
message_ids=[(3, 3)],
message_ids=[3],
session_name="session1",
)
]
@ -224,7 +224,7 @@ class TestDocumentCreationWorkflow:
session_name="test_session",
level="explicit",
internal_metadata={
"message_ids": [(1, 1)],
"message_ids": [1],
"session_name": "test_session",
},
created_at=datetime.now(timezone.utc),
@ -321,7 +321,7 @@ class TestDocumentCreationWorkflow:
content="User said they like programming",
level="explicit",
internal_metadata={
"message_ids": [(1, 1)],
"message_ids": [1],
},
session_name="test_session",
embedding=[0.1] * 1536,
@ -335,7 +335,7 @@ class TestDocumentCreationWorkflow:
content="User is likely a software developer",
level="deductive",
internal_metadata={
"message_ids": [(1, 1)],
"message_ids": [1],
"premises": ["User said they like programming"],
},
session_name="test_session",
@ -351,13 +351,13 @@ class TestDocumentCreationWorkflow:
explicit_obs = representation.explicit[0]
assert explicit_obs.content == "User said they like programming"
assert explicit_obs.message_ids == [(1, 1)]
assert explicit_obs.message_ids == [1]
assert explicit_obs.session_name == "test_session"
deductive_obs = representation.deductive[0]
assert deductive_obs.conclusion == "User is likely a software developer"
assert deductive_obs.premises == ["User said they like programming"]
assert deductive_obs.message_ids == [(1, 1)]
assert deductive_obs.message_ids == [1]
assert deductive_obs.session_name == "test_session"
async def create_test_workspace_and_peer(
@ -424,7 +424,7 @@ class TestPromptRepresentationConversion:
representation = Representation.from_prompt_representation(
prompt_rep,
message_ids=(123, 123),
message_ids=[123],
session_name="test_session",
created_at=timestamp,
)
@ -434,7 +434,7 @@ class TestPromptRepresentationConversion:
# Check explicit observations
assert representation.explicit[0].content == "User likes coffee"
assert representation.explicit[0].message_ids == [(123, 123)]
assert representation.explicit[0].message_ids == [123]
assert representation.explicit[0].session_name == "test_session"
assert representation.explicit[1].content == "User works remotely"
assert representation.explicit[0].created_at == timestamp
@ -446,7 +446,7 @@ class TestPromptRepresentationConversion:
== "User probably works from a coffee shop sometimes"
)
assert deductive_obs.premises == ["User likes coffee", "User works remotely"]
assert deductive_obs.message_ids == [(123, 123)]
assert deductive_obs.message_ids == [123]
assert deductive_obs.session_name == "test_session"
assert deductive_obs.created_at == timestamp
@ -455,7 +455,7 @@ class TestPromptRepresentationConversion:
empty_prompt_rep = PromptRepresentation()
representation = Representation.from_prompt_representation(
empty_prompt_rep,
message_ids=(1, 1),
message_ids=[1],
session_name="test",
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
)
@ -474,21 +474,21 @@ class TestRepresentationHashingAndEquality:
obs1 = ExplicitObservation(
content="Test content",
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
message_ids=[(1, 1)],
message_ids=[1],
session_name="session1",
)
obs2 = ExplicitObservation(
content="Test content",
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
message_ids=[(1, 1)],
message_ids=[1],
session_name="session1",
)
obs3 = ExplicitObservation(
content="Different content",
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
message_ids=[(1, 1)],
message_ids=[1],
session_name="session1",
)
@ -507,7 +507,7 @@ class TestRepresentationHashingAndEquality:
conclusion="Test conclusion",
premises=["premise1", "premise2"],
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
message_ids=[(1, 1)],
message_ids=[1],
session_name="session1",
)
@ -515,7 +515,7 @@ class TestRepresentationHashingAndEquality:
conclusion="Test conclusion",
premises=["premise1", "premise2"],
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
message_ids=[(1, 1)],
message_ids=[1],
session_name="session1",
)
@ -523,7 +523,7 @@ class TestRepresentationHashingAndEquality:
conclusion="Different conclusion",
premises=["premise1", "premise2"],
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
message_ids=[(1, 1)],
message_ids=[1],
session_name="session1",
)

View File

@ -333,3 +333,256 @@ async def test_file_too_large_rejected(
# Should reject the file with 413 (Request Entity Too Large)
assert response.status_code == 413
@pytest.mark.asyncio
async def test_file_upload_with_metadata(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test file upload with metadata parameter"""
test_workspace, test_peer = sample_data
# Create session for session endpoint
test_session = await _create_test_session(db_session, test_workspace)
session_name = test_session.name
# Create a mock text file
file_content = "Test file with metadata"
file_data = io.BytesIO(file_content.encode("utf-8"))
# Prepare metadata
metadata = {"source": "test", "category": "upload", "priority": 1}
files = {"file": ("test_metadata.txt", file_data, "text/plain")}
form_data = {
"peer_id": test_peer.name,
"metadata": json.dumps(metadata),
}
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
message = data[0]
assert file_content in message["content"]
assert message["peer_id"] == test_peer.name
assert message["session_id"] == session_name
# Check that metadata was applied
assert message["metadata"] == metadata
@pytest.mark.asyncio
async def test_file_upload_with_configuration(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test file upload with configuration parameter"""
test_workspace, test_peer = sample_data
# Create session for session endpoint
test_session = await _create_test_session(db_session, test_workspace)
session_name = test_session.name
# Create a mock text file
file_content = "Test file with configuration"
file_data = io.BytesIO(file_content.encode("utf-8"))
# Prepare configuration
configuration = {"skip_deriver": True, "custom_flag": "test"}
files = {"file": ("test_config.txt", file_data, "text/plain")}
form_data = {
"peer_id": test_peer.name,
"configuration": json.dumps(configuration),
}
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
message = data[0]
assert file_content in message["content"]
assert message["peer_id"] == test_peer.name
assert message["session_id"] == session_name
# Note: Configuration is used during processing, may not be directly stored
# This test confirms the endpoint accepts it without error
@pytest.mark.asyncio
async def test_file_upload_with_created_at(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test file upload with created_at parameter"""
test_workspace, test_peer = sample_data
# Create session for session endpoint
test_session = await _create_test_session(db_session, test_workspace)
session_name = test_session.name
# Create a mock text file
file_content = "Test file with created_at"
file_data = io.BytesIO(file_content.encode("utf-8"))
# Prepare created_at timestamp (ISO 8601 format)
from datetime import datetime, timezone
test_timestamp = datetime(2023, 1, 15, 10, 30, 45, tzinfo=timezone.utc)
created_at_str = test_timestamp.isoformat()
files = {"file": ("test_timestamp.txt", file_data, "text/plain")}
form_data = {
"peer_id": test_peer.name,
"created_at": created_at_str,
}
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
message = data[0]
assert file_content in message["content"]
assert message["peer_id"] == test_peer.name
assert message["session_id"] == session_name
# Check that created_at was applied (compare timestamps, allowing for small differences)
message_timestamp = datetime.fromisoformat(
message["created_at"].replace("Z", "+00:00")
)
assert abs((message_timestamp - test_timestamp).total_seconds()) < 1
@pytest.mark.asyncio
async def test_file_upload_with_all_parameters(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test file upload with metadata, configuration, and created_at all together"""
test_workspace, test_peer = sample_data
# Create session for session endpoint
test_session = await _create_test_session(db_session, test_workspace)
session_name = test_session.name
# Create a mock text file
file_content = "Test file with all parameters"
file_data = io.BytesIO(file_content.encode("utf-8"))
# Prepare all parameters
metadata = {"source": "comprehensive_test", "version": "1.0"}
configuration = {"skip_deriver": False, "test_mode": True}
from datetime import datetime, timezone
test_timestamp = datetime(2023, 6, 20, 14, 15, 30, tzinfo=timezone.utc)
created_at_str = test_timestamp.isoformat()
files = {"file": ("test_all_params.txt", file_data, "text/plain")}
form_data = {
"peer_id": test_peer.name,
"metadata": json.dumps(metadata),
"configuration": json.dumps(configuration),
"created_at": created_at_str,
}
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
message = data[0]
assert file_content in message["content"]
assert message["peer_id"] == test_peer.name
assert message["session_id"] == session_name
# Check metadata
assert message["metadata"] == metadata
# Check created_at
message_timestamp = datetime.fromisoformat(
message["created_at"].replace("Z", "+00:00")
)
assert abs((message_timestamp - test_timestamp).total_seconds()) < 1
@pytest.mark.asyncio
async def test_file_upload_with_invalid_metadata_json(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test file upload with invalid JSON in metadata parameter"""
test_workspace, test_peer = sample_data
# Create session for session endpoint
test_session = await _create_test_session(db_session, test_workspace)
session_name = test_session.name
file_content = "Test file"
file_data = io.BytesIO(file_content.encode("utf-8"))
files = {"file": ("test.txt", file_data, "text/plain")}
form_data = {
"peer_id": test_peer.name,
"metadata": "invalid json {", # Invalid JSON
}
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
# Should still succeed but metadata will be None (backend handles gracefully)
assert response.status_code == 200
data = response.json()
# Metadata parsing failure is logged but doesn't fail the request
assert len(data) == 1
@pytest.mark.asyncio
async def test_large_file_upload_with_metadata(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test that large files with metadata get split correctly and metadata is applied to all chunks"""
test_workspace, test_peer = sample_data
# Create session for session endpoint
test_session = await _create_test_session(db_session, test_workspace)
session_name = test_session.name
# Create a large text file that will require chunking
large_content = "This is a test line.\n" * 3000 # Should exceed 49500 chars
file_data = io.BytesIO(large_content.encode("utf-8"))
metadata = {"source": "chunked_test", "chunked": True}
files = {"file": ("large_metadata.txt", file_data, "text/plain")}
form_data = {
"peer_id": test_peer.name,
"metadata": json.dumps(metadata),
}
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
assert response.status_code == 200
data = response.json()
assert len(data) > 1 # Should be multiple messages due to chunking
# All messages should have the same metadata, peer_id and session_id
for message in data:
assert message["peer_id"] == test_peer.name
assert message["session_id"] == session_name
assert message["metadata"] == metadata

View File

@ -0,0 +1,755 @@
import pytest
from fastapi.testclient import TestClient
from nanoid import generate as generate_nanoid
from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.models import Peer, Workspace
class TestObservationRoutes:
"""Test suite for observation API endpoints"""
async def _create_collection(
self,
db_session: AsyncSession,
workspace_name: str,
observer: str,
observed: str,
) -> models.Collection:
"""Helper to create collection for tests"""
collection = models.Collection(
workspace_name=workspace_name,
observer=observer,
observed=observed,
)
db_session.add(collection)
await db_session.flush()
return collection
@pytest.mark.asyncio
async def test_list_observations_success(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test listing observations for a session"""
test_workspace, test_peer = sample_data
# Create another peer
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
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 observations (documents)
doc1 = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="User prefers dark mode",
embedding=[0.1] * 1536,
session_name=test_session.name,
)
doc2 = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="User works late at night",
embedding=[0.2] * 1536,
session_name=test_session.name,
)
db_session.add_all([doc1, doc2])
await db_session.commit()
# List observations
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list",
json={"filters": {"session_id": test_session.name}},
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert len(data["items"]) == 2
# Check observation structure
observation = data["items"][0]
assert "id" in observation
assert "content" in observation
assert "observer_id" in observation
assert "observed_id" in observation
assert "session_id" in observation
assert "created_at" in observation
# Verify content
contents = [item["content"] for item in data["items"]]
assert "User prefers dark mode" in contents
assert "User works late at night" in contents
@pytest.mark.asyncio
async def test_list_observations_empty_session(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test listing observations for a session with no observations"""
test_workspace, _test_peer = sample_data
# Create a session without any observations
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_session)
await db_session.commit()
# List observations
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list",
json={"filters": {"session_id": test_session.name}},
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert len(data["items"]) == 0
@pytest.mark.asyncio
async def test_list_observations_with_filters(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test listing observations with observer/observed filters"""
test_workspace, test_peer = sample_data
# Create two more peers
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
test_peer3 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add_all([test_peer2, test_peer3])
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_session)
await db_session.commit()
# Create collections for both observer/observed pairs
await self._create_collection(
db_session, test_workspace.name, test_peer.name, test_peer2.name
)
await self._create_collection(
db_session, test_workspace.name, test_peer2.name, test_peer3.name
)
# Create observations with different observer/observed pairs
doc1 = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="Peer1 observes Peer2",
embedding=[0.1] * 1536,
session_name=test_session.name,
)
doc2 = models.Document(
workspace_name=test_workspace.name,
observer=test_peer2.name,
observed=test_peer3.name,
content="Peer2 observes Peer3",
embedding=[0.2] * 1536,
session_name=test_session.name,
)
db_session.add_all([doc1, doc2])
await db_session.commit()
# List observations filtered by observer
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list",
json={
"filters": {"observer": test_peer.name, "session_id": test_session.name}
},
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["items"][0]["content"] == "Peer1 observes Peer2"
assert data["items"][0]["observer_id"] == test_peer.name
@pytest.mark.asyncio
async def test_list_observations_reverse_order(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test listing observations in reverse chronological order"""
test_workspace, test_peer = sample_data
# Create another peer
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
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 observations
doc1 = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="First observation",
embedding=[0.1] * 1536,
session_name=test_session.name,
)
db_session.add(doc1)
await db_session.flush()
doc2 = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="Second observation",
embedding=[0.2] * 1536,
session_name=test_session.name,
)
db_session.add(doc2)
await db_session.commit()
# List observations in reverse (oldest first)
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list?reverse=true",
json={"filters": {"session_id": test_session.name}},
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 2
assert data["items"][0]["content"] == "First observation"
assert data["items"][1]["content"] == "Second observation"
@pytest.mark.asyncio
async def test_list_observations_pagination(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test pagination of observations list"""
test_workspace, test_peer = sample_data
# Create another peer
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
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 observations
for i in range(15):
doc = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content=f"Observation {i}",
embedding=[0.1 * i] * 1536,
session_name=test_session.name,
)
db_session.add(doc)
await db_session.commit()
# Get first page (default size)
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list?page=1&size=10",
json={"filters": {"session_id": test_session.name}},
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 10
assert data["total"] == 15
# Get second page
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list?page=2&size=10",
json={"filters": {"session_id": test_session.name}},
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 5
assert data["total"] == 15
@pytest.mark.asyncio
async def test_query_observations_success(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test querying observations with semantic search"""
test_workspace, test_peer = sample_data
# Create another peer
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
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 observations
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()
# Query observations
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/query",
json={
"query": "food preferences",
"filters": {
"observer": test_peer.name,
"observed": test_peer2.name,
"session_id": test_session.name,
},
},
)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) >= 1 # pyright: ignore
# Check observation structure
observation = data[0] # pyright: ignore
assert "id" in observation
assert "content" in observation
assert "observer_id" in observation
assert "observed_id" in observation
@pytest.mark.asyncio
async def test_query_observations_with_top_k(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test querying observations with top_k limit"""
test_workspace, test_peer = sample_data
# Create another peer
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
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 observations
for i in range(5):
doc = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content=f"Observation about topic {i}",
embedding=[0.1 * i] * 1536,
session_name=test_session.name,
)
db_session.add(doc)
await db_session.commit()
# Query with top_k=2
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/query",
json={
"query": "relevant topic",
"top_k": 2,
"filters": {
"observer": test_peer.name,
"observed": test_peer2.name,
"session_id": test_session.name,
},
},
)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) <= 2 # pyright: ignore
@pytest.mark.asyncio
async def test_query_observations_with_distance_threshold(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test querying observations with distance threshold"""
test_workspace, test_peer = sample_data
# Create another peer
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
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 observation
doc = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="Test observation",
embedding=[0.5] * 1536,
session_name=test_session.name,
)
db_session.add(doc)
await db_session.commit()
# Query with distance threshold
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/query",
json={
"query": "test",
"distance": 0.8,
"filters": {
"observer": test_peer.name,
"observed": test_peer2.name,
"session_id": test_session.name,
},
},
)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
@pytest.mark.asyncio
async def test_query_observations_requires_observer_observed(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test query observations requires observer and observed in filters"""
test_workspace, _test_peer = sample_data
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_session)
await db_session.commit()
# Query without observer/observed filters should fail
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/query",
json={"query": "test"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_query_observations_invalid_top_k(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test query observations validates top_k range"""
test_workspace, test_peer = sample_data
# Create another peer
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_session)
await db_session.commit()
# Query with invalid top_k (too high)
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/query",
json={
"query": "test",
"top_k": 101, # Max is 100
"filters": {
"observer": test_peer.name,
"observed": test_peer2.name,
"session_id": test_session.name,
},
},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_delete_observation_success(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test deleting an observation"""
test_workspace, test_peer = sample_data
# Create another peer
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
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 a test observation
doc = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="Test observation to delete",
embedding=[0.1] * 1536,
session_name=test_session.name,
)
db_session.add(doc)
await db_session.commit()
observation_id = doc.id
# Delete observation
response = client.delete(
f"/v2/workspaces/{test_workspace.name}/observations/{observation_id}"
)
assert response.status_code == 200
data = response.json()
assert data["message"] == "Observation deleted successfully"
# Verify observation is deleted
from sqlalchemy import select
stmt = select(models.Document).where(models.Document.id == observation_id)
result = await db_session.execute(stmt)
assert result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_delete_observation_not_found(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test deleting a non-existent observation"""
test_workspace, _test_peer = sample_data
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_session)
await db_session.commit()
# Try to delete non-existent observation
response = client.delete(
f"/v2/workspaces/{test_workspace.name}/observations/nonexistent_id"
)
assert response.status_code == 404
data = response.json()
assert "not found" in data["detail"].lower()
@pytest.mark.asyncio
async def test_list_observations_nonexistent_session(
self,
client: TestClient,
sample_data: tuple[Workspace, Peer],
):
"""Test listing observations for non-existent session"""
test_workspace, _test_peer = sample_data
# Try to list observations for non-existent session
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list",
json={"filters": {"session_id": "nonexistent_session"}},
)
# Should return empty result, not error (session might exist but no observations)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 0
@pytest.mark.asyncio
async def test_observations_field_mapping(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test that observation fields are properly mapped from document model"""
test_workspace, test_peer = sample_data
# Create another peer
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
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 observation
doc = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="Test observation content",
embedding=[0.1] * 1536,
session_name=test_session.name,
)
db_session.add(doc)
await db_session.commit()
# List observations
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list",
json={"filters": {"session_id": test_session.name}},
)
assert response.status_code == 200
data = response.json()
observation = data["items"][0]
# Verify field mappings
assert observation["id"] == doc.id
assert observation["content"] == doc.content
assert observation["observer_id"] == doc.observer
assert observation["observed_id"] == doc.observed
assert observation["session_id"] == doc.session_name
assert "created_at" in observation
# Verify internal fields are NOT exposed
assert "embedding" not in observation
assert "internal_metadata" not in observation
assert "collection" not in observation

View File

@ -376,10 +376,278 @@ def test_get_peer_representation_with_session(
f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation",
json={
"session_id": session_id,
"queries": "Hello, how are you?",
},
)
assert response.status_code == 200
data = response.json()
assert "representation" in data
assert isinstance(data["representation"], dict)
def test_get_peer_representation_global(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer representation without session_id (global representation)"""
test_workspace, test_peer = sample_data
# Test global representation (no session_id)
response = client.post(
f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation",
json={},
)
assert response.status_code == 200
data = response.json()
assert "representation" in data
assert isinstance(data["representation"], dict)
def test_get_peer_representation_with_target(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer representation with target parameter"""
test_workspace, test_peer = sample_data
# Create a second peer to be the target
target_peer_name = str(generate_nanoid())
client.post(
f"/v2/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",
json={
"target": target_peer_name,
},
)
assert response.status_code == 200
data = response.json()
assert "representation" in data
assert isinstance(data["representation"], dict)
def test_get_peer_representation_with_search_query(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer representation with search_query parameter"""
test_workspace, test_peer = sample_data
# Test representation with semantic search query
response = client.post(
f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation",
json={
"search_query": "What are my interests and hobbies?",
},
)
assert response.status_code == 200
data = response.json()
assert "representation" in data
def test_get_peer_representation_with_search_top_k(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer representation with search_top_k parameter"""
test_workspace, test_peer = sample_data
# 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",
json={
"search_query": "test query",
"search_top_k": top_k,
},
)
assert response.status_code == 200
data = response.json()
assert "representation" in data
def test_get_peer_representation_with_search_max_distance(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer representation with search_max_distance parameter"""
test_workspace, test_peer = sample_data
# 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",
json={
"search_query": "test query",
"search_max_distance": max_distance,
},
)
assert response.status_code == 200
data = response.json()
assert "representation" in data
def test_get_peer_representation_with_include_most_derived(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer representation with include_most_derived parameter"""
test_workspace, test_peer = sample_data
# Test with include_most_derived=True
response = client.post(
f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation",
json={
"search_query": "test query",
"include_most_derived": True,
},
)
assert response.status_code == 200
data = response.json()
assert "representation" in data
# Test with include_most_derived=False
response = client.post(
f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation",
json={
"search_query": "test query",
"include_most_derived": False,
},
)
assert response.status_code == 200
data = response.json()
assert "representation" in data
def test_get_peer_representation_with_max_observations(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer representation with max_observations parameter"""
test_workspace, test_peer = sample_data
# 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",
json={
"search_query": "test query",
"max_observations": max_obs,
},
)
assert response.status_code == 200
data = response.json()
assert "representation" in data
def test_get_peer_representation_with_all_parameters(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer representation with all optional parameters"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
# Create a session and target peer
target_peer_name = str(generate_nanoid())
client.post(
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": target_peer_name, "metadata": {}},
)
client.post(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={
"id": session_id,
"peer_names": {test_peer.name: {}, target_peer_name: {}},
},
)
# Test with all parameters
response = client.post(
f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation",
json={
"session_id": session_id,
"target": target_peer_name,
"search_query": "What do I know about this peer?",
"search_top_k": 15,
"search_max_distance": 0.75,
"include_most_derived": True,
"max_observations": 30,
},
)
assert response.status_code == 200
data = response.json()
assert "representation" in data
assert isinstance(data["representation"], dict)
def test_get_peer_representation_structure(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test that peer representation response has correct structure"""
test_workspace, test_peer = sample_data
# Get representation and validate structure
response = client.post(
f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation",
json={},
)
assert response.status_code == 200
data = response.json()
# Validate response structure
assert "representation" in data
assert isinstance(data["representation"], dict)
# Representation should have expected keys based on Representation type
representation = data["representation"]
# The exact keys depend on the Representation implementation,
# but we can verify it's a dict
assert isinstance(representation, dict)
def test_get_peer_representation_boundary_values(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer representation with boundary values for numeric parameters"""
test_workspace, test_peer = sample_data
# Test minimum values
response = client.post(
f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation",
json={
"search_query": "test",
"search_top_k": 1,
"search_max_distance": 0.0,
"max_observations": 1,
},
)
assert response.status_code == 200
# Test maximum values
response = client.post(
f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation",
json={
"search_query": "test",
"search_top_k": 100,
"search_max_distance": 1.0,
"max_observations": 100,
},
)
assert response.status_code == 200
def test_get_peer_representation_default_max_observations(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test that max_observations defaults to 25 when not provided"""
test_workspace, test_peer = sample_data
# Test without max_observations - should use default of 25
response = client.post(
f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation",
json={
"search_query": "test query",
},
)
assert response.status_code == 200
data = response.json()
assert "representation" in data
def test_search_peer(client: TestClient, sample_data: tuple[Workspace, Peer]):

View File

@ -270,6 +270,39 @@ def test_update_session(client: TestClient, sample_data: tuple[Workspace, Peer])
assert data["metadata"] == {"new_key": "new_value"}
def test_delete_session(client: TestClient, sample_data: tuple[Workspace, Peer]):
"""Test deleting a session"""
test_workspace, test_peer = sample_data
# Create a test session
session_id = str(generate_nanoid())
response = client.post(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={
"id": session_id,
"peer_names": {test_peer.name: {}},
"metadata": {"test_key": "test_value"},
},
)
assert response.status_code == 200
# Delete the session
response = client.delete(
f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}",
)
assert response.status_code == 202
data = response.json()
assert data["message"] == "Session deleted successfully"
# Verify the session is deleted by trying to list it
response = client.post(
f"/v2/workspaces/{test_workspace.name}/sessions/list",
json={"filters": {"id": session_id}},
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 0
def test_update_session_with_configuration(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
@ -347,36 +380,6 @@ def test_update_session_with_null_configuration(
assert "configuration" in data
def test_delete_session(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, test_peer = sample_data
# Create a test session
session_id = str(generate_nanoid())
response = client.post(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={
"id": session_id,
"peer_names": {test_peer.name: {}},
},
)
assert response.status_code == 200
response = client.delete(
f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}"
)
assert response.status_code == 200
# Check that session is marked as inactive
response = client.post(
f"/v2/workspaces/{test_workspace.name}/sessions/list",
json={"filters": {"is_active": False}},
)
data = response.json()
# Find our session in the inactive sessions
inactive_session = next((s for s in data["items"] if s["id"] == session_id), None)
assert inactive_session is not None
assert inactive_session["is_active"] is False
def test_clone_session(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, test_peer = sample_data
# Create a test session
@ -1052,3 +1055,332 @@ def test_search_session_with_limit(
assert isinstance(data, list)
# Should not exceed the limit
assert len(data) <= 2
def test_get_session_context_with_peer_target(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session context with peer_target parameter"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
# Create session
client.post(
f"/v2/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",
json={
"messages": [
{"content": "Test message 1", "peer_id": test_peer.name},
]
},
)
# Get context with peer_target
response = client.get(
f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context?peer_target={test_peer.name}",
)
assert response.status_code == 200
data = response.json()
assert "id" in data
assert "messages" in data
assert "peer_representation" in data
assert "peer_card" in data
# Representation should be present
assert data["peer_representation"] is not None
assert isinstance(data["peer_representation"], dict)
def test_get_session_context_with_peer_perspective(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session context with both peer_target and peer_perspective"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
# Create another peer
peer2_name = str(generate_nanoid())
client.post(
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": peer2_name, "metadata": {}},
)
# Create session with both peers
client.post(
f"/v2/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}",
)
assert response.status_code == 200
data = response.json()
assert "peer_representation" in data
assert "peer_card" in data
def test_get_session_context_peer_perspective_without_target_fails(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test that peer_perspective without peer_target raises validation error"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
# Create session
client.post(
f"/v2/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}",
)
# FastAPI returns 422 for validation errors, or 400 if it's a custom ValidationException
assert response.status_code in [400, 422]
error_detail = response.json()["detail"]
assert "peer_target" in error_detail.lower()
def test_get_session_context_with_last_message(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session context with last_message parameter for semantic search"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
# Create session
client.post(
f"/v2/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",
params={
"peer_target": test_peer.name,
"last_message": "What is my favorite color?",
},
)
assert response.status_code == 200
data = response.json()
assert "peer_representation" in data
def test_get_session_context_with_limit_to_session(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session context with limit_to_session parameter"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
# Create session
client.post(
f"/v2/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",
params={
"peer_target": test_peer.name,
"last_message": "Test query",
"limit_to_session": True,
},
)
assert response.status_code == 200
data = response.json()
assert "peer_representation" in data
def test_get_session_context_with_search_parameters(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session context with search_top_k and search_max_distance parameters"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
# Create session
client.post(
f"/v2/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",
params={
"peer_target": test_peer.name,
"last_message": "Test query",
"search_top_k": 5,
"search_max_distance": 0.8, # float value (semantic distance 0.0-1.0)
},
)
assert response.status_code == 200
data = response.json()
assert "peer_representation" in data
def test_get_session_context_with_include_most_derived(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session context with include_most_derived parameter"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
# Create session
client.post(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"id": session_id, "peers": {test_peer.name: {}}},
)
# Get context with include_most_derived
response = client.get(
f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context",
params={
"peer_target": test_peer.name,
"last_message": "Test query",
"include_most_derived": True,
},
)
assert response.status_code == 200
data = response.json()
assert "peer_representation" in data
def test_get_session_context_with_max_observations(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session context with max_observations parameter"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
# Create session
client.post(
f"/v2/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",
params={
"peer_target": test_peer.name,
"last_message": "Test query",
"max_observations": 10,
},
)
assert response.status_code == 200
data = response.json()
assert "peer_representation" in data
def test_get_session_context_with_all_representation_params(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session context with all representation-related parameters"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
# Create another peer
peer2_name = str(generate_nanoid())
client.post(
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": peer2_name, "metadata": {}},
)
# Create session
client.post(
f"/v2/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",
params={
"tokens": 500,
"peer_target": test_peer.name,
"peer_perspective": peer2_name,
"last_message": "What do you know about me?",
"limit_to_session": True,
"search_top_k": 10,
"search_max_distance": 0.9, # float value (semantic distance 0.0-1.0)
"include_most_derived": True,
"max_observations": 15,
"summary": True,
},
)
assert response.status_code == 200
data = response.json()
assert "id" in data
assert data["id"] == session_id
assert "messages" in data
assert isinstance(data["messages"], list)
assert "summary" in data
assert "peer_representation" in data
assert "peer_card" in data
# Validate representation structure
assert isinstance(data["peer_representation"], dict)
def test_get_session_context_response_structure(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test that session context response has correct structure"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
# Create session
client.post(
f"/v2/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",
json={
"messages": [
{"content": "Message 1", "peer_id": test_peer.name},
{"content": "Message 2", "peer_id": test_peer.name},
]
},
)
assert response.status_code == 200
# Get context and validate response structure
response = client.get(
f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/context",
)
assert response.status_code == 200
data = response.json()
# Validate SessionContext schema
assert "id" in data
assert data["id"] == session_id
assert "messages" in data
assert isinstance(data["messages"], list)
assert len(data["messages"]) >= 2
# Validate Message schema
for message in data["messages"]:
assert "id" in message
assert "content" in message
assert "peer_id" in message
assert "session_id" in message
assert "workspace_id" in message
assert "created_at" in message
assert "token_count" in message
# When no peer_target, these should not be present or be None
assert data.get("peer_representation") is None
assert data.get("peer_card") is None

View File

@ -252,3 +252,244 @@ async def test_file_upload_with_tuple_input(
assert content in messages[0].content
assert messages[0].peer_id == user.id
assert messages[0].session_id == session.id
@pytest.mark.asyncio
async def test_file_upload_with_metadata(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests uploading a file with metadata parameter.
"""
honcho_client, _client_type = client_fixture
text_content = "Test file with metadata"
from io import BytesIO
text_file = BytesIO(text_content.encode("utf-8"))
text_file.name = "test_metadata.txt"
metadata: dict[str, object] = {
"source": "sdk_test",
"category": "upload",
"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(
file=text_file,
peer_id=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(
file=text_file,
peer_id=user.id,
metadata=metadata,
)
assert len(messages) >= 1
assert text_content in messages[0].content
assert messages[0].peer_id == user.id
assert messages[0].session_id == session.id
# Check that metadata was applied
assert messages[0].metadata == metadata
@pytest.mark.asyncio
async def test_file_upload_with_configuration(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests uploading a file with configuration parameter.
"""
honcho_client, _client_type = client_fixture
text_content = "Test file with configuration"
from io import BytesIO
text_file = BytesIO(text_content.encode("utf-8"))
text_file.name = "test_config.txt"
from typing import cast
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(
file=text_file,
peer_id=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(
file=text_file,
peer_id=user.id,
configuration=configuration,
)
assert len(messages) >= 1
assert text_content in messages[0].content
assert messages[0].peer_id == user.id
assert messages[0].session_id == session.id
# Configuration is used during processing, not directly stored in message
# This test confirms the endpoint accepts it without error
@pytest.mark.asyncio
async def test_file_upload_with_created_at(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests uploading a file with created_at parameter.
"""
honcho_client, _client_type = client_fixture
text_content = "Test file with created_at"
from datetime import datetime, timezone
from io import BytesIO
text_file = BytesIO(text_content.encode("utf-8"))
text_file.name = "test_timestamp.txt"
test_timestamp = datetime(2023, 1, 15, 10, 30, 45, tzinfo=timezone.utc)
created_at_str = test_timestamp.isoformat()
if isinstance(honcho_client, Honcho):
session = honcho_client.session(id="test-session-timestamp")
user = honcho_client.peer(id="user-timestamp")
messages = session.upload_file(
file=text_file,
peer_id=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_id=user.id,
created_at=created_at_str,
)
assert len(messages) >= 1
assert text_content in messages[0].content
assert messages[0].peer_id == user.id
assert messages[0].session_id == session.id
# Check that created_at was applied (compare timestamps, allowing for small differences)
# Message.created_at from honcho_core is a datetime object
message_timestamp = messages[0].created_at
assert abs((message_timestamp - test_timestamp).total_seconds()) < 1
@pytest.mark.asyncio
async def test_file_upload_with_all_parameters(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests uploading a file with metadata, configuration, and created_at all together.
"""
honcho_client, _client_type = client_fixture
text_content = "Test file with all parameters"
from datetime import datetime, timezone
from io import BytesIO
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})
)
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(
file=text_file,
peer_id=user.id,
metadata=metadata,
configuration=configuration,
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(
file=text_file,
peer_id=user.id,
metadata=metadata,
configuration=configuration,
created_at=created_at_str,
)
assert len(messages) >= 1
assert text_content in messages[0].content
assert messages[0].peer_id == user.id
assert messages[0].session_id == session.id
# Check metadata
assert messages[0].metadata == metadata
# Check created_at
# Message.created_at from honcho_core is a datetime object
message_timestamp = messages[0].created_at
assert abs((message_timestamp - test_timestamp).total_seconds()) < 1
@pytest.mark.asyncio
async def test_file_upload_with_datetime_object(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests uploading a file with created_at as a datetime object (Python only).
"""
honcho_client, _client_type = client_fixture
text_content = "Test file with datetime object"
from datetime import datetime, timezone
from io import BytesIO
text_file = BytesIO(text_content.encode("utf-8"))
text_file.name = "test_datetime.txt"
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(
file=text_file, peer_id=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(
file=text_file, peer_id=user.id, created_at=test_timestamp
)
assert len(messages) >= 1
assert text_content in messages[0].content
assert messages[0].peer_id == user.id
assert messages[0].session_id == session.id
# Check that created_at was applied
# Message.created_at from honcho_core is a datetime object
message_timestamp = messages[0].created_at
assert abs((message_timestamp - test_timestamp).total_seconds()) < 1

View File

@ -0,0 +1,592 @@
"""Tests for metadata and configuration caching in Honcho SDK."""
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.client import Honcho
from sdks.python.src.honcho.pagination import 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],
) -> None:
"""
Tests that workspace metadata is properly cached after get/set operations.
"""
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()
assert isinstance(metadata, dict)
assert honcho_client.metadata == metadata
# Set metadata should update cache
await honcho_client.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()
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
# Get metadata should cache it
metadata = honcho_client.get_metadata()
assert isinstance(metadata, dict)
assert honcho_client.metadata == metadata
# Set metadata should update cache
honcho_client.set_metadata({"theme": "dark", "version": "1.0"})
assert honcho_client.metadata == {"theme": "dark", "version": "1.0"}
# Get should return cached value
retrieved = honcho_client.get_metadata()
assert retrieved == {"theme": "dark", "version": "1.0"}
assert honcho_client.metadata == {"theme": "dark", "version": "1.0"}
@pytest.mark.asyncio
async def test_peer_metadata_caching(
client_fixture: tuple[Honcho | AsyncHoncho, str],
) -> None:
"""
Tests that peer metadata is properly cached after get/set operations.
"""
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)
# Get metadata should cache it
metadata = await peer.get_metadata()
assert isinstance(metadata, dict)
assert peer.metadata == metadata
# Set metadata should update cache
await peer.set_metadata({"name": "Alice", "role": "user"})
assert peer.metadata == {"name": "Alice", "role": "user"}
# Get should return cached value
retrieved = await peer.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)
# Get metadata should cache it
metadata = peer.get_metadata()
assert isinstance(metadata, dict)
assert peer.metadata == metadata
# Set metadata should update cache
peer.set_metadata({"name": "Alice", "role": "user"})
assert peer.metadata == {"name": "Alice", "role": "user"}
# Get should return cached value
retrieved = peer.get_metadata()
assert retrieved == {"name": "Alice", "role": "user"}
@pytest.mark.asyncio
async def test_peer_config_caching(
client_fixture: tuple[Honcho | AsyncHoncho, str],
) -> None:
"""
Tests that peer configuration is properly cached after get/set operations.
"""
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)
# Get config should cache it
config = await peer.get_config()
assert isinstance(config, dict)
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}
# Get should return cached value
retrieved = await peer.get_config()
assert retrieved == {"observe_me": True, "observe_others": False}
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)
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}
# 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}
@pytest.mark.asyncio
async def test_peer_metadata_and_config_independence(
client_fixture: tuple[Honcho | AsyncHoncho, str],
) -> None:
"""
Tests that peer metadata and config are cached independently.
"""
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)
# Set both metadata and config
await peer.set_metadata({"name": "Test"})
await peer.set_config({"observe_me": True})
assert peer.metadata == {"name": "Test"}
assert peer.configuration == {"observe_me": True}
# Update metadata only
await peer.set_metadata({"name": "Updated"})
assert peer.metadata == {"name": "Updated"}
assert peer.configuration == {"observe_me": True} # Should remain unchanged
# Update config only
await peer.set_config({"observe_me": False})
assert peer.metadata == {"name": "Updated"} # Should remain unchanged
assert peer.configuration == {"observe_me": False}
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})
assert peer.metadata == {"name": "Test"}
assert peer.configuration == {"observe_me": True}
# Update metadata only
peer.set_metadata({"name": "Updated"})
assert peer.metadata == {"name": "Updated"}
assert peer.configuration == {"observe_me": True} # Should remain unchanged
# Update config only
peer.set_config({"observe_me": False})
assert peer.metadata == {"name": "Updated"} # Should remain unchanged
assert peer.configuration == {"observe_me": False}
@pytest.mark.asyncio
async def test_peer_list_with_metadata_and_config(
client_fixture: tuple[Honcho | AsyncHoncho, str],
) -> None:
"""
Tests that listed peers have metadata and config populated from API response.
"""
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})
peer2 = await honcho_client.peer(id="test-list-peer2")
await peer2.set_metadata({"name": "Bob"})
await peer2.set_config({"observe_me": False})
# List peers and check cached data
peers_page = await honcho_client.get_peers()
assert isinstance(peers_page, AsyncPage)
peers = 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}
if "test-list-peer2" in peer_map:
p2 = peer_map["test-list-peer2"]
assert p2.metadata == {"name": "Bob"}
assert p2.configuration == {"observe_me": 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})
peer2 = honcho_client.peer(id="test-list-peer2")
peer2.set_metadata({"name": "Bob"})
peer2.set_config({"observe_me": False})
# List peers and check cached data
peers_page = honcho_client.get_peers()
assert isinstance(peers_page, SyncPage)
peers = 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}
if "test-list-peer2" in peer_map:
p2 = peer_map["test-list-peer2"]
assert p2.metadata == {"name": "Bob"}
assert p2.configuration == {"observe_me": False}
@pytest.mark.asyncio
async def test_session_metadata_caching(
client_fixture: tuple[Honcho | AsyncHoncho, str],
) -> None:
"""
Tests that session metadata is properly cached after get/set operations.
"""
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)
# Get metadata should cache it
metadata = await session.get_metadata()
assert isinstance(metadata, dict)
assert session.metadata == metadata
# Set metadata should update cache
await session.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()
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)
# Get metadata should cache it
metadata = session.get_metadata()
assert isinstance(metadata, dict)
assert session.metadata == metadata
# Set metadata should update cache
session.set_metadata({"title": "Chat Session", "active": True})
assert session.metadata == {"title": "Chat Session", "active": True}
# Get should return cached value
retrieved = session.get_metadata()
assert retrieved == {"title": "Chat Session", "active": True}
@pytest.mark.asyncio
async def test_session_config_caching(
client_fixture: tuple[Honcho | AsyncHoncho, str],
) -> None:
"""
Tests that session configuration is properly cached after get/set operations.
"""
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)
# Get config should cache it
config = await session.get_config()
assert isinstance(config, dict)
assert session.configuration == config
# Set config should update cache
await session.set_config({"anonymous": True, "summarize": False})
assert session.configuration == {"anonymous": True, "summarize": False}
# Get should return cached value
retrieved = await session.get_config()
assert retrieved == {"anonymous": True, "summarize": False}
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)
assert session.configuration == config
# Set config should update cache
session.set_config({"anonymous": True, "summarize": False})
assert session.configuration == {"anonymous": True, "summarize": False}
# Get should return cached value
retrieved = session.get_config()
assert retrieved == {"anonymous": True, "summarize": False}
@pytest.mark.asyncio
async def test_session_metadata_and_config_independence(
client_fixture: tuple[Honcho | AsyncHoncho, str],
) -> None:
"""
Tests that session metadata and config are cached independently.
"""
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)
# Set both metadata and config
await session.set_metadata({"title": "Test"})
await session.set_config({"anonymous": True})
assert session.metadata == {"title": "Test"}
assert session.configuration == {"anonymous": True}
# Update metadata only
await session.set_metadata({"title": "Updated"})
assert session.metadata == {"title": "Updated"}
assert session.configuration == {"anonymous": True} # Should remain unchanged
# Update config only
await session.set_config({"anonymous": False})
assert session.metadata == {"title": "Updated"} # Should remain unchanged
assert session.configuration == {"anonymous": False}
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})
assert session.metadata == {"title": "Test"}
assert session.configuration == {"anonymous": True}
# Update metadata only
session.set_metadata({"title": "Updated"})
assert session.metadata == {"title": "Updated"}
assert session.configuration == {"anonymous": True} # Should remain unchanged
# Update config only
session.set_config({"anonymous": False})
assert session.metadata == {"title": "Updated"} # Should remain unchanged
assert session.configuration == {"anonymous": False}
@pytest.mark.asyncio
async def test_session_list_with_metadata_and_config(
client_fixture: tuple[Honcho | AsyncHoncho, str],
) -> None:
"""
Tests that listed sessions have metadata and config populated from API response.
"""
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})
session2 = await honcho_client.session(id="test-list-session2")
await session2.set_metadata({"title": "Session 2"})
await session2.set_config({"anonymous": False})
# List sessions and check cached data
sessions_page = await honcho_client.get_sessions()
assert isinstance(sessions_page, AsyncPage)
sessions = 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}
if "test-list-session2" in session_map:
s2 = session_map["test-list-session2"]
assert s2.metadata == {"title": "Session 2"}
assert s2.configuration == {"anonymous": False}
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})
session2 = honcho_client.session(id="test-list-session2")
session2.set_metadata({"title": "Session 2"})
session2.set_config({"anonymous": False})
# List sessions and check cached data
sessions_page = honcho_client.get_sessions()
assert isinstance(sessions_page, SyncPage)
sessions = 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}
if "test-list-session2" in session_map:
s2 = session_map["test-list-session2"]
assert s2.metadata == {"title": "Session 2"}
assert s2.configuration == {"anonymous": False}
@pytest.mark.asyncio
async def test_peer_initialization_with_metadata_and_config(
client_fixture: tuple[Honcho | AsyncHoncho, str],
) -> None:
"""
Tests that peers can be initialized with metadata and config.
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
peer = await honcho_client.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},
)
assert isinstance(peer, Peer)
assert peer.metadata == {"name": "Test Peer", "role": "assistant"}
assert peer.configuration == {"observe_me": False}
@pytest.mark.asyncio
async def test_session_initialization_with_metadata_and_config(
client_fixture: tuple[Honcho | AsyncHoncho, str],
) -> None:
"""
Tests that sessions can be initialized with metadata and config.
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
session = await honcho_client.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},
)
assert isinstance(session, Session)
assert session.metadata == {"title": "Test Session", "tags": ["important"]}
assert session.configuration == {"anonymous": False}

View File

@ -6,7 +6,7 @@ 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.types import DialecticStreamResponse, Representation
@pytest.mark.asyncio
@ -197,8 +197,12 @@ async def test_peer_chat_streaming(client_fixture: tuple[Honcho | AsyncHoncho, s
yield 'data: {"delta": {"content": " async"}}'
yield 'data: {"done": true}'
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)
@ -285,3 +289,411 @@ async def test_peer_chat_non_streaming(
response = peer.chat("What do I like?", stream=False)
# Response can be None or a string
assert response is None or isinstance(response, str)
@pytest.mark.asyncio
async def test_peer_working_rep_no_params(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests peer.working_rep() 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")
# Add some messages to create context
await session.add_messages([peer.message("I enjoy hiking and nature")])
# Get working representation with no parameters
result = await peer.working_rep()
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
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")
# Add some messages to create context
session.add_messages([peer.message("I enjoy hiking and nature")])
# Get working representation with no parameters
result = peer.working_rep()
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
@pytest.mark.asyncio
async def test_peer_working_rep_with_session_string(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests peer.working_rep() 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")
# Add some messages to the session
await session.add_messages([peer.message("I like reading books")])
# Get working representation scoped to session (as string)
result = await peer.working_rep(session=session.id)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
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")
# Add some messages to the session
session.add_messages([peer.message("I like reading books")])
# Get working representation scoped to session (as string)
result = peer.working_rep(session=session.id)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
@pytest.mark.asyncio
async def test_peer_working_rep_with_session_object(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests peer.working_rep() 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)
# Add some messages to the session
await session.add_messages([peer.message("I prefer tea over coffee")])
# Get working representation scoped to session (as Session object)
result = await peer.working_rep(session=session)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
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.working_rep(session=session)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
@pytest.mark.asyncio
async def test_peer_working_rep_with_target_string(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests peer.working_rep() 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")
# Add messages from both peers
await session.add_messages(
[
observer.message("Hello there"),
target.message("Hi, how are you?"),
]
)
# Get working representation of target from observer's perspective (as string)
result = await observer.working_rep(target=target.id)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
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")
# Add messages from both peers
session.add_messages(
[
observer.message("Hello there"),
target.message("Hi, how are you?"),
]
)
# Get working representation of target from observer's perspective (as string)
result = observer.working_rep(target=target.id)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
@pytest.mark.asyncio
async def test_peer_working_rep_with_target_object(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests peer.working_rep() 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)
# Add messages from both peers
await session.add_messages(
[
observer.message("What do you think?"),
target.message("I think it's great!"),
]
)
# Get working representation of target from observer's perspective (as Peer object)
result = await observer.working_rep(target=target)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
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
session.add_messages(
[
observer.message("What do you think?"),
target.message("I think it's great!"),
]
)
# Get working representation of target from observer's perspective (as Peer object)
result = observer.working_rep(target=target)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
@pytest.mark.asyncio
async def test_peer_working_rep_with_search_query(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests peer.working_rep() 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")
# Add some messages with different topics
await session.add_messages(
[
peer.message("I love programming in Python"),
peer.message("I also enjoy playing basketball"),
]
)
# Get working representation with search query
result = await peer.working_rep(search_query="programming")
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
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")
# Add some messages with different topics
session.add_messages(
[
peer.message("I love programming in Python"),
peer.message("I also enjoy playing basketball"),
]
)
# Get working representation with search query
result = peer.working_rep(search_query="programming")
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
@pytest.mark.asyncio
async def test_peer_working_rep_with_size(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests peer.working_rep() 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")
# Add multiple messages
await session.add_messages(
[peer.message(f"Message number {i}") for i in range(10)]
)
# Get working representation with custom max_observations
result = await peer.working_rep(max_observations=5)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
# Test with different max_observations values
result = await peer.working_rep(max_observations=1)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
result = await peer.working_rep(max_observations=100)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
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")
# Add multiple messages
session.add_messages([peer.message(f"Message number {i}") for i in range(10)])
# Get working representation with custom size
result = peer.working_rep(max_observations=5)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
# Test with different max_observations values
result = peer.working_rep(max_observations=1)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
result = peer.working_rep(max_observations=100)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
@pytest.mark.asyncio
async def test_peer_working_rep_with_all_params(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests peer.working_rep() 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")
# Add messages from both peers
await session.add_messages(
[
observer.message("I think Python is great for data science"),
target.message("I agree, especially with libraries like pandas"),
observer.message("What about machine learning?"),
target.message("TensorFlow and PyTorch are excellent choices"),
]
)
# Get working representation with all parameters
result = await observer.working_rep(
session=session, target=target, search_query="Python", max_observations=10
)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
# Test with session as string and target as string
result = await observer.working_rep(
session=session.id,
target=target.id,
search_query="machine learning",
max_observations=5,
)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
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")
# Add messages from both peers
session.add_messages(
[
observer.message(content="I think Python is great for data science"),
target.message("I agree, especially with libraries like pandas"),
observer.message("What about machine learning?"),
target.message("TensorFlow and PyTorch are excellent choices"),
]
)
# Get working representation with all parameters
result = observer.working_rep(
session=session, target=target, search_query="Python", max_observations=10
)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
# Test with session as string and target as string
result = observer.working_rep(
session=session.id,
target=target.id,
search_query="machine learning",
max_observations=5,
)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")

View File

@ -277,6 +277,83 @@ async def test_session_search(client_fixture: tuple[Honcho | AsyncHoncho, str]):
assert search_query in search_results[0].content
@pytest.mark.asyncio
async def test_session_add_messages_return_value(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests that add_messages returns a list of Message objects.
"""
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)
# Test single message return value
from honcho_core.types.workspaces.sessions.message import Message
result = await session.add_messages(user.message("Hello assistant"))
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], Message)
assert result[0].content == "Hello assistant"
assert result[0].peer_id == user.id
# Test multiple messages return value
result = await session.add_messages(
[
user.message("How are you?"),
assistant.message("I'm doing well, thank you!"),
]
)
assert isinstance(result, list)
assert len(result) == 2
assert all(isinstance(msg, Message) for msg in result)
assert result[0].content == "How are you?"
assert result[0].peer_id == user.id
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")
assert isinstance(user, Peer)
assistant = honcho_client.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 = session.add_messages(user.message("Hello assistant"))
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], Message)
assert result[0].content == "Hello assistant"
assert result[0].peer_id == user.id
# Test multiple messages return value
result = session.add_messages(
[
user.message("How are you?"),
assistant.message("I'm doing well, thank you!"),
]
)
assert isinstance(result, list)
assert len(result) == 2
assert all(isinstance(msg, Message) for msg in result)
assert result[0].content == "How are you?"
assert result[0].peer_id == user.id
assert result[1].content == "I'm doing well, thank you!"
assert result[1].peer_id == assistant.id
@pytest.mark.asyncio
async def test_session_working_rep(client_fixture: tuple[Honcho | AsyncHoncho, str]):
"""
@ -305,7 +382,7 @@ async def test_session_working_rep(client_fixture: tuple[Honcho | AsyncHoncho, s
@pytest.mark.asyncio
async def test_session_delete(client_fixture: tuple[Honcho | AsyncHoncho, str]) -> None:
"""
Tests deleting a session.
Tests deleting a session and verifying all associated data is removed.
"""
honcho_client, client_type = client_fixture
@ -314,17 +391,60 @@ async def test_session_delete(client_fixture: tuple[Honcho | AsyncHoncho, str])
session = await honcho_client.session(id="test-session-delete")
assert isinstance(session, AsyncSession)
# Add a peer to make the session exist
# 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.message("Test message that should be deleted")]
)
# Verify messages exist before deletion
messages_page = await session.get_messages()
messages = messages_page.items
assert len(messages) == 1
# Delete should not raise an exception
await session.delete()
# Verify session is removed from active sessions list
all_sessions_page = await honcho_client.get_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 = 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)
# Add a peer and messages to make the session have data
user = honcho_client.peer(id="user-delete")
session.add_peers([user])
session.add_messages([user.message("Test message that should be deleted")])
# Verify messages exist before deletion
messages_page = session.get_messages()
messages = list(messages_page)
assert len(messages) == 1
# Delete should not raise an exception
session.delete()
# Verify session is removed from active sessions list
all_sessions_page = honcho_client.get_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 = list(all_sessions_page)
all_session_ids = [s.id for s in all_sessions]
assert "test-session-delete" not in all_session_ids

Some files were not shown because too many files have changed in this diff Show More