Merge branch 'main' into vineeth/dev-1295

This commit is contained in:
Vineeth Voruganti 2026-01-13 10:20:01 -05:00
commit e7e4d47e42
85 changed files with 2956 additions and 3783 deletions

View File

@ -15,21 +15,14 @@
},
"favicon": "/favicon.svg",
"contextual": {
"options": [
"copy",
"view",
"chatgpt",
"claude"
]
"options": ["copy", "view", "chatgpt", "claude"]
},
"navigation": {
"versions": [
{
"version": "v2.5.1",
"api": {
"openapi": [
"openapi.json"
]
"openapi": ["openapi.json"]
},
"tabs": [
{
@ -76,15 +69,11 @@
"groups": [
{
"group": "Getting Started",
"pages": [
"v2/guides/overview"
]
"pages": ["v2/guides/overview"]
},
{
"group": "Migrations",
"pages": [
"v2/migrations/from-mem0"
]
"pages": ["v2/migrations/from-mem0"]
},
{
"group": "Integrations",
@ -97,10 +86,7 @@
},
{
"group": "Application Interfaces",
"pages": [
"v2/guides/discord",
"v2/guides/telegram"
]
"pages": ["v2/guides/discord", "v2/guides/telegram"]
}
]
},
@ -109,9 +95,7 @@
"groups": [
{
"group": "API Documentation",
"pages": [
"v2/api-reference/introduction"
]
"pages": ["v2/api-reference/introduction"]
},
{
"group": "workspaces",
@ -227,9 +211,7 @@
{
"version": "v2.6.0-alpha",
"api": {
"openapi": [
"openapi.json"
]
"openapi": ["openapi.json"]
},
"tabs": [
{
@ -301,9 +283,7 @@
},
{
"group": "Migrations",
"pages": [
"v2.6.0-alpha/guides/migrations/mem0"
]
"pages": ["v2.6.0-alpha/guides/migrations/mem0"]
},
{
"group": "Chatbots",
@ -338,9 +318,7 @@
"groups": [
{
"group": "API Documentation",
"pages": [
"v2.6.0-alpha/api-reference/introduction"
]
"pages": ["v2.6.0-alpha/api-reference/introduction"]
},
{
"group": "workspaces",
@ -442,9 +420,7 @@
{
"version": "v1.1.0",
"api": {
"openapi": [
"openapi.json"
]
"openapi": ["openapi.json"]
},
"tabs": [
{
@ -474,23 +450,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"]
}
]
},
@ -499,9 +467,7 @@
"groups": [
{
"group": "API Documentation",
"pages": [
"v1/api-reference/introduction"
]
"pages": ["v1/api-reference/introduction"]
},
{
"group": "apps",
@ -549,9 +515,7 @@
},
{
"group": "keys",
"pages": [
"v1/api-reference/endpoint/keys/create-key"
]
"pages": ["v1/api-reference/endpoint/keys/create-key"]
},
{
"group": "metamessages",

View File

@ -15,11 +15,11 @@ Assuming reasoning is enabled, you can control the perspectives representations
When `observe_me=true` (the default), Honcho forms one representation per peer, reasoning over every message written to that peer across all sessions.
You can retrieve a subset of conclusions from a peer's representation using `working_rep()`:
You can retrieve a subset of conclusions from a peer's representation using `get_representation()`:
```python
# Retrieve conclusions from Honcho's representation of Alice (across all sessions)
alice_rep = session.working_rep("alice")
alice_rep = session.get_representation("alice")
# Or via chat
response = alice.chat("What are Alice's main interests?", session_id=session.id)
@ -72,9 +72,9 @@ The `target` parameter controls which representation you retrieve:
| Query | Returns |
|-------|---------|
| `working_rep("alice")` | Conclusions from Honcho's representation of Alice (across all sessions) |
| `working_rep("alice", target="bob")` | Conclusions from Alice's representation of Bob (from sessions Alice participated in) |
| `working_rep("alice", target="charlie")` | Conclusions from Alice's representation of Charlie (from sessions Alice participated in) |
| `get_representation("alice")` | Conclusions from Honcho's representation of Alice (across all sessions) |
| `get_representation("alice", target="bob")` | Conclusions from Alice's representation of Bob (from sessions Alice participated in) |
| `get_representation("alice", target="charlie")` | Conclusions from Alice's representation of Charlie (from sessions Alice participated in) |
### Code Examples
@ -111,9 +111,9 @@ session2.add_messages([
])
# Retrieve conclusions from different perspectives
honcho_view = session.working_rep("alice") # Across all sessions
bob_view = session.working_rep("alice", target="bob") # Alice's view of Bob
charlie_view = session2.working_rep("alice", target="charlie") # Alice's view of Charlie
honcho_view = session.get_representation("alice") # Across all sessions
bob_view = session.get_representation("alice", target="bob") # Alice's view of Bob
charlie_view = session2.get_representation("alice", target="charlie") # Alice's view of Charlie
```
```typescript TypeScript
@ -144,9 +144,9 @@ await session2.addMessages([
]);
// Retrieve conclusions from different perspectives
const honchoView = await session.workingRep("alice"); // Across all sessions
const bobView = await session.workingRep("alice", { target: "bob" }); // Alice's view of Bob
const charlieView = await session2.workingRep("alice", { target: "charlie" }); // Alice's view of Charlie
const honchoView = await session.getRepresentation("alice"); // Across all sessions
const bobView = await session.getRepresentation("alice", { target: "bob" }); // Alice's view of Bob
const charlieView = await session2.getRepresentation("alice", { target: "charlie" }); // Alice's view of Charlie
```
</CodeGroup>
@ -224,34 +224,34 @@ This architecture enables:
## Semantic Search Parameters
Both `working_rep()` and `chat()` support semantic filtering to retrieve a subset of relevant conclusions. You can optionally filter by session to retrieve only conclusions from specific session context:
Both `get_representation()` and `chat()` support semantic filtering to retrieve a subset of relevant conclusions. You can optionally filter by session to retrieve only conclusions from specific session context:
| Parameter | Type | Description |
|-----------|------|-------------|
| `search_query` | `str` | Semantic query to filter conclusions |
| `search_top_k` | `int` | Number of results to include (1100) |
| `search_max_distance` | `float` | Maximum semantic distance (0.01.0) |
| `include_most_derived` | `bool` | Include most recently derived conclusions |
| `max_observations` | `int` | Cap on total conclusions returned (1100) |
| `include_most_frequent` | `bool` | Include most frequent conclusions |
| `max_conclusions` | `int` | Cap on total conclusions returned (1100) |
<CodeGroup>
```python Python
# Retrieve conclusions about billing from Alice's representation of Bob
alice_view_billing = session.working_rep(
alice_view_billing = session.get_representation(
"alice",
target="bob",
search_query="billing issues",
search_top_k=10,
include_most_derived=True
include_most_frequent=True
)
```
```typescript TypeScript
const aliceViewBilling = await session.workingRep("alice", {
const aliceViewBilling = await session.getRepresentation("alice", {
target: "bob",
searchQuery: "billing issues",
searchTopK: 10,
includeMostDerived: true
includeMostFrequent: true
});
```
</CodeGroup>
@ -267,5 +267,5 @@ Directional representations update automatically through the reasoning pipeline
The pipeline respects scoping—Honcho's representations reason over messages across all sessions, while directional representations only reason over messages from sessions where the observer was an active participant.
<Note>
Conclusions are cached for fast retrieval. Use `working_rep()` to retrieve stored conclusions for dashboards and analytics. Use `peer.chat()` when you need query-specific reasoning with natural language.
Conclusions are cached for fast retrieval. Use `get_representation()` to retrieve stored conclusions for dashboards and analytics. Use `peer.chat()` when you need query-specific reasoning with natural language.
</Note>

View File

@ -153,10 +153,10 @@ context = session.get_context(
tokens=2000,
peer_target="user-123",
last_user_message="What are my coding preferences?",
search_top_k=10, # Number of relevant observations
search_top_k=10, # Number of relevant conclusions to fetch
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
include_most_frequent=True, # Include most frequent conclusions
max_conclusions=25 # Cap total conclusions
)
```
@ -167,10 +167,10 @@ context = session.get_context(
peerTarget: "user-123",
lastUserMessage: "What are my coding preferences?",
representationOptions: {
searchTopK: 10, // Number of relevant observations
searchTopK: 10, // Number of relevant conclusions to fetch
searchMaxDistance: 0.8, // Max semantic distance (0.0-1.0)
includeMostDerived: true, // Include most recent observations
maxObservations: 25 // Cap total observations
includeMostFrequent: true, // Include most frequent conclusions
maxConclusions: 25 // Cap total conclusions
}
});
})();
@ -212,11 +212,11 @@ context = session.get_context(
| `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 |
| `limit_to_session` | `bool` | Limit to session conclusions 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) |
| `include_most_frequent` | `bool` | Include most frequent conclusions |
| `max_conclusions` | `int` | Maximum conclusions to include (1-100) |
## Converting to LLM Formats

View File

@ -284,7 +284,7 @@ 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)
rep = alice.get_representation(search_query="preferences", search_top_k=10)
# Access observations
self_observations = alice.observations.list() # Self-observations
@ -335,7 +335,7 @@ 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, {
const rep = await alice.getRepresentation(undefined, undefined, {
searchQuery: "preferences",
searchTopK: 10
});
@ -366,8 +366,8 @@ context = alice.get_context(
search_query="work preferences",
search_top_k=10,
search_max_distance=0.8,
include_most_derived=True,
max_observations=50
include_most_frequent=True,
max_conclusions=50
)
```
@ -385,8 +385,8 @@ const searchedContext = await alice.getContext("bob", {
searchQuery: "work preferences",
searchTopK: 10,
searchMaxDistance: 0.8,
includeMostDerived: true,
maxObservations: 50
includeMostFrequent: true,
maxConclusions: 50
});
```
</CodeGroup>
@ -455,8 +455,8 @@ context = alice.get_context(
search_query="work preferences",
search_top_k=10,
search_max_distance=0.8,
include_most_derived=True,
max_observations=50
include_most_frequent=True,
max_conclusions=50
)
```
@ -474,8 +474,8 @@ const searchedContext = await alice.getContext("bob", {
searchQuery: "work preferences",
searchTopK: 10,
searchMaxDistance: 0.8,
includeMostDerived: true,
maxObservations: 50
includeMostFrequent: true,
maxConclusions: 50
});
```
</CodeGroup>
@ -626,7 +626,7 @@ context = session.get_context(
limit_to_session=True,
search_top_k=10,
search_max_distance=0.8,
include_most_derived=True,
include_most_frequent=True,
max_observations=25
)
@ -634,13 +634,13 @@ context = session.get_context(
results = session.search("help")
# Working representation queries with semantic search
global_rep = session.working_rep("alice")
targeted_rep = session.working_rep(alice, target=bob)
searched_rep = session.working_rep(
global_rep = session.get_representation("alice")
targeted_rep = session.get_representation(alice, target=bob)
searched_rep = session.get_representation(
"alice",
search_query="preferences",
search_top_k=10,
include_most_derived=True
include_most_frequent=True
)
# Upload a file to create messages
@ -713,9 +713,9 @@ const richContext = await session.getContext({
const results = await session.search("help");
// Working representation queries with semantic search
const globalRep = await session.workingRep("alice");
const targetedRep = await session.workingRep(alice, { target: bob });
const searchedRep = await session.workingRep("alice", undefined, {
const globalRep = await session.getRepresentation("alice");
const targetedRep = await session.getRepresentation(alice, { target: bob });
const searchedRep = await session.getRepresentation("alice", undefined, {
searchQuery: "preferences",
searchTopK: 10,
includeMostDerived: true
@ -845,8 +845,8 @@ The SessionContext object has the following structure:
| `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) |
| `include_most_frequent` | `bool` | Include most frequent conclusions |
| `max_conclusions` | `int` | Max conclusions to include (1-100) |
## Advanced Usage

View File

@ -275,7 +275,7 @@ Additional features with **no Mem0 equivalent**:
| Honcho Method | Description | Use Case |
|---------------|-------------|----------|
| `peer.card()` | Stable biographical facts (name, preferences, background) | User profiles, personalization |
| `session.working_rep(peer)` | Cached psychological analysis (mental state, intentions) | Real-time adaptation |
| `session.get_representation(peer)` | Cached psychological analysis (mental state, intentions) | Real-time adaptation |
| `session.get_summaries()` | Auto-generated short/long session summaries | Conversation continuity |
| `SessionPeerConfig` | Configure observation settings (who learns about whom) | Privacy controls, role-based learning |

View File

@ -275,7 +275,7 @@ Additional features with **no Mem0 equivalent**:
| Honcho Method | Description | Use Case |
|---------------|-------------|----------|
| `peer.card()` | Stable biographical facts (name, preferences, background) | User profiles, personalization |
| `session.working_rep(peer)` | Cached psychological analysis (mental state, intentions) | Real-time adaptation |
| `session.get_representation(peer)` | Cached psychological analysis (mental state, intentions) | Real-time adaptation |
| `session.get_summaries()` | Auto-generated short/long session summaries | Conversation continuity |
| `SessionPeerConfig` | Configure observation settings (who learns about whom) | Privacy controls, role-based learning |

View File

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

View File

@ -42,17 +42,13 @@ from .async_client import (
)
from .base import PeerBase, SessionBase
from .client import Honcho
from .observations import AsyncObservationScope, Observation, ObservationScope
from .conclusions import AsyncConclusionScope, ConclusionScope
from .pagination import SyncPage
from .peer import Peer
from .session import Session
from .session_context import SessionContext, SessionSummaries, Summary
from .types import (
DeductiveObservation,
DialecticStreamResponse,
ExplicitObservation,
PeerContext,
Representation,
)
__version__ = "1.6.0"
@ -61,16 +57,14 @@ __email__ = "hello@plasticlabs.ai"
__all__ = [
"AsyncHoncho",
"AsyncObservationScope",
"AsyncConclusionScope",
"AsyncPeer",
"AsyncSession",
"AsyncPage",
"Honcho",
"Observation",
"ObservationScope",
"ConclusionScope",
"Peer",
"PeerBase",
"PeerContext",
"Session",
"SessionBase",
"SessionContext",
@ -78,7 +72,4 @@ __all__ = [
"Summary",
"SyncPage",
"DialecticStreamResponse",
"Representation",
"ExplicitObservation",
"DeductiveObservation",
]

View File

@ -8,7 +8,7 @@ from typing import Any, Literal
import httpx
from honcho_core import AsyncHoncho as AsyncHonchoCore
from honcho_core import Honcho as HonchoCore
from honcho_core.types import DeriverStatus, Workspace
from honcho_core.types.workspaces import QueueStatusResponse
from honcho_core.types.workspaces.peer import Peer as PeerCore
from honcho_core.types.workspaces.session import Session as SessionCore
from honcho_core.types.workspaces.sessions.message import Message
@ -425,19 +425,16 @@ class AsyncHoncho(BaseModel):
workspace_id: str = Field(
..., min_length=1, description="ID of the workspace to delete"
),
) -> Workspace:
) -> None:
"""
Delete a workspace.
Makes an async API call to delete the specified workspace.
Makes an async API call to delete the specified workspace. This action cannot be undone.
Args:
workspace_id: The ID of the workspace to delete
Returns:
The deleted Workspace object
"""
return await self._client.workspaces.delete(workspace_id)
await self._client.workspaces.delete(workspace_id)
@validate_call
async def search(
@ -472,14 +469,14 @@ class AsyncHoncho(BaseModel):
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def get_deriver_status(
async def get_queue_status(
self,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
session: str | SessionBase | None = None,
) -> DeriverStatus:
) -> QueueStatusResponse:
"""
Get the deriver processing status, optionally scoped to an observer, sender, and/or session.
Get the queue processing status, optionally scoped to an observer, sender, and/or session.
Args:
observer: Optional observer (ID string or Peer object) to scope the status check
@ -502,7 +499,7 @@ class AsyncHoncho(BaseModel):
else (session if isinstance(session, str) else session.id)
)
return await self._client.workspaces.deriver_status(
return await self._client.workspaces.queue.status(
workspace_id=self.workspace_id,
observer_id=resolved_observer_id,
sender_id=resolved_sender_id,
@ -510,7 +507,7 @@ class AsyncHoncho(BaseModel):
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def poll_deriver_status(
async def poll_queue_status(
self,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
@ -520,11 +517,11 @@ class AsyncHoncho(BaseModel):
gt=0,
description="Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).",
),
) -> DeriverStatus:
) -> QueueStatusResponse:
"""
Poll get_deriver_status until pending_work_units and in_progress_work_units are both 0.
This allows you to guarantee that all messages have been processed by the deriver for
use with the dialectic endpoint.
Poll get_queue_status until pending_work_units and in_progress_work_units are both 0.
This allows you to guarantee that all messages have been processed by the queue for
use with the chat endpoint.
The polling estimates sleep time by assuming each work unit takes 1 second.
@ -535,19 +532,19 @@ class AsyncHoncho(BaseModel):
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
Returns:
DeriverStatus when all work units are complete
QueueStatusResponse when all work units are complete
Raises:
TimeoutError: If timeout is exceeded before work units complete
Exception: If get_deriver_status fails repeatedly
Exception: If get_queue_status fails repeatedly
"""
start_time = time.time()
while True:
try:
status = await self.get_deriver_status(observer, sender, session)
status = await self.get_queue_status(observer, sender, session)
except Exception as e:
logger.warning(f"Failed to get deriver status: {e}")
logger.warning(f"Failed to get queue status: {e}")
# Sleep briefly before retrying
await asyncio.sleep(1)
@ -590,44 +587,44 @@ class AsyncHoncho(BaseModel):
await asyncio.sleep(sleep_time)
@validate_call
async def list_observations(
async def list_conclusions(
self,
filters: dict[str, object] | None = Field(
None, description="Filters to scope the observations"
None, description="Filters to scope the conclusions"
),
reverse: bool = Field(
False, description="Whether to reverse the order of results"
),
):
"""
List all observations in the current workspace with optional filtering.
List all conclusions in the current workspace with optional filtering.
Makes an async API call to retrieve observations that match the specified filters.
Observations can be filtered by session_id, observer_id, and observed_id.
Makes an async API call to retrieve conclusions that match the specified filters.
Conclusions can be filtered by session_id, observer_id, and observed_id.
Args:
filters: Optional filter criteria for observations. Supported filters include:
- session_id: Filter observations by session
- observer_id: Filter observations by observer peer
- observed_id: Filter observations by observed peer
filters: Optional filter criteria for conclusions. Supported filters include:
- session_id: Filter conclusions by session
- observer_id: Filter conclusions by observer peer
- observed_id: Filter conclusions by observed peer
reverse: Whether to reverse the order of results (default: False)
Returns:
A paginated list of Observation objects matching the specified criteria
A paginated list of Conclusion objects matching the specified criteria
Example:
>>> observations = await client.list_observations(
>>> conclusions = await client.list_conclusions(
... filters={"observer_id": "user123", "observed_id": "assistant"}
... )
"""
return await self._client.workspaces.observations.list(
return await self._client.workspaces.conclusions.list(
workspace_id=self.workspace_id,
filters=filters,
reverse=reverse,
)
@validate_call
async def query_observations(
async def query_conclusions(
self,
query: str = Field(..., min_length=1, description="Semantic search query"),
observer: str = Field(
@ -650,9 +647,9 @@ class AsyncHoncho(BaseModel):
),
):
"""
Query observations using semantic search.
Query conclusions using semantic search.
Performs vector similarity search on observations to find semantically relevant results.
Performs vector similarity search on conclusions to find semantically relevant results.
Observer and observed peer IDs are required for semantic search.
Args:
@ -664,10 +661,10 @@ class AsyncHoncho(BaseModel):
filters: Optional filters to scope the query
Returns:
A list of Observation objects matching the query
A list of Conclusion objects matching the query
Example:
>>> observations = await client.query_observations(
>>> conclusions = await client.query_conclusions(
... query="user preferences about music",
... observer="user123",
... observed="assistant",
@ -682,7 +679,7 @@ class AsyncHoncho(BaseModel):
"observed": observed,
}
return await self._client.workspaces.observations.query(
return await self._client.workspaces.conclusions.query(
workspace_id=self.workspace_id,
query=query,
top_k=top_k,
@ -691,27 +688,27 @@ class AsyncHoncho(BaseModel):
)
@validate_call
async def delete_observation(
async def delete_conclusion(
self,
observation_id: str = Field(
..., min_length=1, description="ID of the observation to delete"
conclusion_id: str = Field(
..., min_length=1, description="ID of the conclusion to delete"
),
) -> None:
"""
Delete a specific observation by ID.
Delete a specific conclusion by ID.
This permanently deletes the observation (document) from the theory-of-mind system.
This permanently deletes the conclusion (document) from the theory-of-mind system.
This action cannot be undone.
Args:
observation_id: The ID of the observation to delete
conclusion_id: The ID of the conclusion to delete
Example:
>>> await client.delete_observation('obs_123abc')
>>> await client.delete_conclusion('con_123abc')
"""
await self._client.workspaces.observations.delete(
await self._client.workspaces.conclusions.delete(
workspace_id=self.workspace_id,
observation_id=observation_id,
conclusion_id=conclusion_id,
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))

View File

@ -2,13 +2,16 @@ from __future__ import annotations
import datetime
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, cast
from typing import Literal
from honcho_core import AsyncHoncho as AsyncHonchoCore
from honcho_core._types import omit
from honcho_core.types.workspaces import PeerCardResponse
from honcho_core.types.workspaces.peer_working_representation_response import (
PeerWorkingRepresentationResponse,
from honcho_core.types.workspaces.peer_context_response import (
PeerContextResponse,
)
from honcho_core.types.workspaces.peer_representation_response import (
PeerRepresentationResponse,
)
from honcho_core.types.workspaces.session import Session as SessionCore
from honcho_core.types.workspaces.sessions import MessageCreateParam
@ -17,13 +20,9 @@ from honcho_core.types.workspaces.sessions.message_create_param import Configura
from pydantic import ConfigDict, Field, PrivateAttr, validate_call
from ..base import PeerBase, SessionBase
from ..conclusions import AsyncConclusionScope
from ..types import DialecticStreamResponse
from .pagination import AsyncPage
if TYPE_CHECKING:
from ..observations import AsyncObservationScope
from ..types import PeerContext, Representation
from .session import AsyncSession
@ -146,6 +145,8 @@ class AsyncPeer(PeerBase):
stream: bool = False,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "extra-high"]
| None = None,
) -> str | DialecticStreamResponse | None:
"""
Query the peer's representation with a natural language question.
@ -164,6 +165,8 @@ class AsyncPeer(PeerBase):
session: Optional session to scope the query to. If provided, only
information from that session is considered. Can be a session
ID string or an AsyncSession object.
reasoning_level: Optional reasoning level for the query: "minimal", "low", "medium",
"high", or "extra-high". Defaults to "low" if not provided.
Returns:
For non-streaming: Response string containing the answer, or None if no relevant information
@ -194,6 +197,9 @@ class AsyncPeer(PeerBase):
stream=True,
target=target_id,
session_id=resolved_session_id,
reasoning_level=reasoning_level
if reasoning_level is not None
else omit,
) as response:
response.http_response.raise_for_status()
async for line in response.iter_lines():
@ -219,6 +225,7 @@ class AsyncPeer(PeerBase):
stream=stream,
target=target_id,
session_id=resolved_session_id,
reasoning_level=reasoning_level if reasoning_level is not None else omit,
)
# "If the context provided doesn't help address the query, write absolutely NOTHING but "None""
if response.content in ("", None, "None"):
@ -501,50 +508,49 @@ class AsyncPeer(PeerBase):
items: list[str] = response.peer_card
return "\n".join(items)
async def working_rep(
async def get_representation(
self,
session: str | SessionBase | None = None,
target: str | PeerBase | None = None,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "Representation":
include_most_frequent: bool | None = None,
max_conclusions: int | None = None,
) -> str:
"""
Get a working representation for this peer.
Get a subset of the representation of the peer.
Args:
session: Optional session to scope the representation to.
target: Optional target peer to get the representation of. If provided,
returns the representation of the target from the perspective of this peer.
search_query: Semantic search query to filter relevant observations
search_query: Semantic search query to filter relevant conclusions
search_top_k: Number of semantically relevant facts to return
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
include_most_frequent: Whether to include the most frequent conclusions
max_conclusions: Maximum number of conclusions to include
Returns:
A Representation object containing explicit and deductive observations
A Representation string
Example:
```python
# Get global representation
rep = await peer.working_rep()
rep = await peer.get_representation()
print(rep)
# Get representation scoped to a session
session_rep = await peer.working_rep(session='session-123')
session_rep = await peer.get_representation(session='session-123')
# Get representation with semantic search
searched_rep = await peer.working_rep(
searched_rep = await peer.get_representation(
search_query='preferences',
search_top_k=10,
max_observations=50
max_conclusions=50
)
```
"""
from ..types import Representation as _Representation
session_id = (
None
@ -559,8 +565,8 @@ class AsyncPeer(PeerBase):
if target is None
else (target if isinstance(target, str) else target.id)
)
data: PeerWorkingRepresentationResponse = (
await self._client.workspaces.peers.working_representation(
data: PeerRepresentationResponse = (
await self._client.workspaces.peers.representation(
peer_id=self.id,
workspace_id=self.workspace_id,
session_id=session_id,
@ -570,19 +576,15 @@ class AsyncPeer(PeerBase):
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
include_most_frequent=include_most_frequent
if include_most_frequent is not None
else omit,
max_observations=max_observations
if max_observations is not None
max_conclusions=max_conclusions
if max_conclusions 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)
return data.representation
async def get_context(
self,
@ -590,9 +592,9 @@ class AsyncPeer(PeerBase):
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":
include_most_frequent: bool | None = None,
max_conclusions: int | None = None,
) -> PeerContextResponse:
"""
Get context for this peer, including representation and peer card.
@ -603,11 +605,11 @@ class AsyncPeer(PeerBase):
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_query: Semantic search query to filter relevant conclusions
search_top_k: Number of semantically relevant facts to return
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
include_most_frequent: Whether to include the most frequent conclusions
max_conclusions: Maximum number of conclusions to include
Returns:
A PeerContext object containing the representation and peer card
@ -629,15 +631,13 @@ class AsyncPeer(PeerBase):
)
```
"""
from ..types import PeerContext as _PeerContext
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
response = await self._client.workspaces.peers.get_context(
return await self._client.workspaces.peers.context(
peer_id=self.id,
workspace_id=self.workspace_id,
target=target_id,
@ -646,73 +646,69 @@ class AsyncPeer(PeerBase):
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
include_most_frequent=include_most_frequent
if include_most_frequent is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
max_conclusions=max_conclusions if max_conclusions is not None else omit,
)
return _PeerContext.from_api_response(response)
@property
def observations(self) -> "AsyncObservationScope":
def conclusions(self) -> "AsyncConclusionScope":
"""
Access this peer's self-observations (where observer == observed == self).
Access this peer's self-conclusions (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.
This property provides a convenient way to access conclusions that this peer
has made about themselves. Use this for self-conclusion scenarios.
Returns:
An AsyncObservationScope scoped to this peer's self-observations
An AsyncConclusionScope scoped to this peer's self-conclusions
Example:
```python
# List self-observations
obs_list = await peer.observations.list()
# List self-conclusions
obs_list = await peer.conclusions.list()
# Search self-observations
results = await peer.observations.query("preferences")
# Search self-conclusions
results = await peer.conclusions.query("preferences")
# Delete a self-observation
await peer.observations.delete("obs-123")
# Delete a self-conclusion
await peer.conclusions.delete("obs-123")
```
"""
from ..observations import AsyncObservationScope as _AsyncObservationScope
return AsyncConclusionScope(self._client, self.workspace_id, self.id, self.id)
return _AsyncObservationScope(self._client, self.workspace_id, self.id, self.id)
def observations_of(self, target: str | PeerBase) -> "AsyncObservationScope":
def conclusions_of(self, target: str | PeerBase) -> "AsyncConclusionScope":
"""
Access observations this peer has made about another peer.
Access conclusions this peer has made about another peer.
This method provides scoped access to observations where this peer is the
This method provides scoped access to conclusions where this peer is the
observer and the target is the observed peer.
Args:
target: The target peer (either an AsyncPeer object or peer ID string)
Returns:
An AsyncObservationScope scoped to this peer's observations of the target
An AsyncConclusionScope scoped to this peer's conclusions of the target
Example:
```python
# Get observations about another peer
bob_observations = peer.observations_of("bob")
# Get conclusions about another peer
bob_conclusions = peer.conclusions_of("bob")
# List observations
obs_list = await bob_observations.list()
# List conclusions
obs_list = await bob_conclusions.list()
# Search observations
results = await bob_observations.query("work history")
# Search conclusions
results = await bob_conclusions.query("work history")
# Get the representation from these observations
rep = await bob_observations.get_representation()
# Get the representation from these conclusions
rep = await bob_conclusions.get_representation()
```
"""
from ..observations import AsyncObservationScope as _AsyncObservationScope
from ..conclusions import AsyncConclusionScope as _AsyncConclusionScope
target_id = target.id if isinstance(target, PeerBase) else target
return _AsyncObservationScope(
return _AsyncConclusionScope(
self._client, self.workspace_id, self.id, target_id
)

View File

@ -9,7 +9,10 @@ from typing import TYPE_CHECKING, Any
from honcho_core import AsyncHoncho as AsyncHonchoCore
from honcho_core._types import omit
from honcho_core.types import DeriverStatus
from honcho_core.types.workspaces import QueueStatusResponse
from honcho_core.types.workspaces.peer_representation_response import (
PeerRepresentationResponse,
)
from honcho_core.types.workspaces.sessions import MessageCreateParam
from honcho_core.types.workspaces.sessions.message import Message
from honcho_core.types.workspaces.sessions.message_create_param import Configuration
@ -21,7 +24,6 @@ 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__)
@ -304,16 +306,14 @@ class AsyncSession(SessionBase):
Get the configuration for a peer in this session.
"""
peer_id = peer if isinstance(peer, str) else peer.id
peer_get_config_response = (
await self._client.workspaces.sessions.peers.get_config(
peer_id=peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
)
peer_config_response = await self._client.workspaces.sessions.peers.config(
peer_id=peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
)
return SessionPeerConfig(
observe_others=peer_get_config_response.observe_others,
observe_me=peer_get_config_response.observe_me,
observe_others=peer_config_response.observe_others,
observe_me=peer_config_response.observe_me,
)
async def set_peer_config(
@ -400,7 +400,7 @@ class AsyncSession(SessionBase):
Makes an async API call to permanently delete this session and all related data including:
- Messages
- Message embeddings
- Observations
- Conclusions
- Session-Peer associations
- Background processing queue items
@ -573,7 +573,7 @@ class AsyncSession(SessionBase):
),
last_user_message: str | Message | None = Field(
None,
description="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.",
description="The most recent message (string or Message object), used to fetch semantically relevant conclusions and returned as part of the context object. Use this alongside `peer_target` to get a more focused context -- does nothing if `peer_target` is not provided.",
),
peer_perspective: str | None = Field(
None,
@ -581,7 +581,7 @@ class AsyncSession(SessionBase):
),
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.",
description="Whether to limit the representation to this session only. If True, only conclusions from this session will be included.",
),
search_top_k: int | None = Field(
None,
@ -595,15 +595,15 @@ class AsyncSession(SessionBase):
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(
include_most_frequent: bool | None = Field(
None,
description="Whether to include the most derived observations in the representation.",
description="Whether to include the most frequent conclusions in the representation.",
),
max_observations: int | None = Field(
max_conclusions: int | None = Field(
None,
ge=1,
le=100,
description="Maximum number of observations to include in the representation.",
description="Maximum number of conclusions to include in the representation.",
),
) -> SessionContext:
"""
@ -619,13 +619,13 @@ class AsyncSession(SessionBase):
tokens: Maximum number of tokens to include in the context. Will default
to Honcho server configuration if not provided.
peer_target: A peer ID to get context for. If given *without* `peer_perspective`, a representation and peer card will be included from the omniscient Honcho-level view of `peer_target`. If given *with* `peer_perspective`, will get the representation and card for `peer_target` *from the perspective of `peer_perspective`*.
last_user_message: The most recent message (string or Message object), used to fetch semantically relevant 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.
last_user_message: The most recent message (string or Message object), used to fetch semantically relevant conclusions and returned as part of the context object. Use this alongside `peer_target` to get a more focused context -- does nothing if `peer_target` is not provided.
peer_perspective: A peer ID to get context *from the perspective of*. If given, response will attempt to include representation and card from the perspective of `peer_perspective`. Must be provided with `peer_target`.
limit_to_session: Whether to limit the representation to this session only. If True, only observations from this session will be included.
limit_to_session: Whether to limit the representation to this session only. If True, only conclusions from this session will be included.
search_top_k: Number of semantically relevant facts to return when searching with `last_user_message`.
search_max_distance: Maximum semantic distance for search results (0.0-1.0) when searching with `last_user_message`.
include_most_derived: Whether to include the most derived observations in the representation.
max_observations: Maximum number of observations to include in the representation.
include_most_frequent: Whether to include the most frequent conclusions in the representation.
max_conclusions: Maximum number of conclusions to include in the representation.
Returns:
A SessionContext object containing the optimized message history and
@ -652,7 +652,7 @@ class AsyncSession(SessionBase):
if isinstance(last_user_message, Message)
else last_user_message
)
context = await self._client.workspaces.sessions.get_context(
context = await self._client.workspaces.sessions.context(
session_id=self.id,
workspace_id=self.workspace_id,
tokens=tokens if tokens is not None else omit,
@ -667,10 +667,10 @@ class AsyncSession(SessionBase):
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
include_most_frequent=include_most_frequent
if include_most_frequent is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
max_conclusions=max_conclusions if max_conclusions is not None else omit,
)
# Convert the honcho_core summary to our Summary if it exists
@ -863,7 +863,7 @@ class AsyncSession(SessionBase):
return [Message.model_validate(msg) for msg in response]
async def working_rep(
async def get_representation(
self,
peer: str | PeerBase,
*,
@ -871,43 +871,42 @@ class AsyncSession(SessionBase):
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":
include_most_frequent: bool | None = None,
max_conclusions: int | None = None,
) -> str:
"""
Get the current working representation of the peer in this session.
Get a subset of the representation of the peer in this session.
Args:
peer: Peer to get the working representation of.
peer: Peer to get the representation of.
target: Optional target peer to get the representation of. If provided,
queries what `peer` knows about the `target`.
search_query: Semantic search query to filter relevant observations
search_query: Semantic search query to filter relevant conclusions
search_top_k: Number of semantically relevant facts to return
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
include_most_frequent: Whether to include the most frequent conclusions
max_conclusions: Maximum number of conclusions to include
Returns:
A Representation object containing explicit and deductive observations
A Representation string
Example:
```python
# Get peer's representation in this session
rep = await session.working_rep('user123')
rep = await session.get_representation('user123')
print(rep)
# Get what user123 knows about assistant in this session
local_rep = await session.working_rep('user123', target='assistant')
local_rep = await session.get_representation('user123', target='assistant')
# Get representation with semantic search
searched_rep = await session.working_rep(
searched_rep = await session.get_representation(
'user123',
search_query='preferences',
search_top_k=10
)
```
"""
from ..types import Representation as _Representation
peer_id = peer if isinstance(peer, str) else peer.id
target_id = (
@ -915,31 +914,35 @@ class AsyncSession(SessionBase):
if target is None
else (target if isinstance(target, str) else target.id)
)
data = await self._client.workspaces.peers.working_representation(
peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
target=target_id,
search_query=search_query if search_query is not None else omit,
search_top_k=search_top_k if search_top_k is not None else omit,
search_max_distance=search_max_distance
if search_max_distance is not None
else omit,
include_most_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,
data: PeerRepresentationResponse = (
await self._client.workspaces.peers.representation(
peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
target=target_id,
search_query=search_query if search_query is not None else omit,
search_top_k=search_top_k if search_top_k is not None else omit,
search_max_distance=search_max_distance
if search_max_distance is not None
else omit,
include_most_frequent=include_most_frequent
if include_most_frequent is not None
else omit,
max_conclusions=max_conclusions
if max_conclusions is not None
else omit,
)
)
return _Representation.from_dict(data) # type: ignore
return data.representation
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def get_deriver_status(
async def get_queue_status(
self,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
) -> DeriverStatus:
) -> QueueStatusResponse:
"""
Get the deriver processing status, optionally scoped to an observer, sender, and/or session.
Get the queue processing status, optionally scoped to an observer, sender, and/or session.
Args:
observer: Optional observer (ID string or AsyncPeer object) to scope the status check
@ -956,7 +959,7 @@ class AsyncSession(SessionBase):
else (sender if isinstance(sender, str) else sender.id)
)
return await self._client.workspaces.deriver_status(
return await self._client.workspaces.queue.status(
workspace_id=self.workspace_id,
observer_id=resolved_observer_id,
sender_id=resolved_sender_id,
@ -964,7 +967,7 @@ class AsyncSession(SessionBase):
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def poll_deriver_status(
async def poll_queue_status(
self,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
@ -973,10 +976,10 @@ class AsyncSession(SessionBase):
gt=0,
description="Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).",
),
) -> DeriverStatus:
) -> QueueStatusResponse:
"""
Poll get_deriver_status until pending_work_units and in_progress_work_units are both 0.
This allows you to guarantee that all messages have been processed by the deriver for
Poll get_queue_status until pending_work_units and in_progress_work_units are both 0.
This allows you to guarantee that all messages have been processed by the queue for
use with the dialectic endpoint.
The polling estimates sleep time by assuming each work unit takes 1 second.
@ -987,19 +990,19 @@ class AsyncSession(SessionBase):
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
Returns:
DeriverStatus when all work units are complete
QueueStatusResponse when all work units are complete
Raises:
TimeoutError: If timeout is exceeded before work units complete
Exception: If get_deriver_status fails repeatedly
Exception: If get_queue_status fails repeatedly
"""
start_time = time.time()
while True:
try:
status = await self.get_deriver_status(observer, sender)
status = await self.get_queue_status(observer, sender)
except Exception as e:
logger.warning(f"Failed to get deriver status: {e}")
logger.warning(f"Failed to get queue status: {e}")
# Sleep briefly before retrying
await asyncio.sleep(1)

View File

@ -6,7 +6,7 @@ from typing import Any, Literal
import httpx
from honcho_core import Honcho as HonchoCore
from honcho_core.types import DeriverStatus, Workspace
from honcho_core.types.workspaces import QueueStatusResponse
from honcho_core.types.workspaces.peer import Peer as PeerCore
from honcho_core.types.workspaces.session import Session as SessionCore
from honcho_core.types.workspaces.sessions.message import Message
@ -403,19 +403,16 @@ class Honcho(BaseModel):
workspace_id: str = Field(
..., min_length=1, description="ID of the workspace to delete"
),
) -> Workspace:
) -> None:
"""
Delete a workspace.
Makes an API call to delete the specified workspace.
Makes an API call to delete the specified workspace. This action cannot be undone.
Args:
workspace_id: The ID of the workspace to delete
Returns:
The deleted Workspace object
"""
return self._client.workspaces.delete(workspace_id)
self._client.workspaces.delete(workspace_id)
@validate_call
def search(
@ -447,14 +444,14 @@ class Honcho(BaseModel):
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def get_deriver_status(
def get_queue_status(
self,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
session: str | SessionBase | None = None,
) -> DeriverStatus:
) -> QueueStatusResponse:
"""
Get the deriver processing status, optionally scoped to an observer, sender, and/or session.
Get the queue processing status, optionally scoped to an observer, sender, and/or session.
Args:
observer: Optional observer (ID string or Peer object) to scope the status check
@ -477,7 +474,7 @@ class Honcho(BaseModel):
else (session if isinstance(session, str) else session.id)
)
return self._client.workspaces.deriver_status(
return self._client.workspaces.queue.status(
workspace_id=self.workspace_id,
observer_id=resolved_observer_id,
sender_id=resolved_sender_id,
@ -485,7 +482,7 @@ class Honcho(BaseModel):
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def poll_deriver_status(
def poll_queue_status(
self,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
@ -495,10 +492,10 @@ class Honcho(BaseModel):
gt=0,
description="Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).",
),
) -> DeriverStatus:
) -> QueueStatusResponse:
"""
Poll get_deriver_status until pending_work_units and in_progress_work_units are both 0.
This allows you to guarantee that all messages have been processed by the deriver for
Poll get_queue_status until pending_work_units and in_progress_work_units are both 0.
This allows you to guarantee that all messages have been processed by the queue for
use with the dialectic endpoint.
The polling estimates sleep time by assuming each work unit takes 1 second.
@ -510,19 +507,19 @@ class Honcho(BaseModel):
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
Returns:
DeriverStatus when all work units are complete
QueueStatusResponse when all work units are complete
Raises:
TimeoutError: If timeout is exceeded before work units complete
Exception: If get_deriver_status fails repeatedly
Exception: If get_queue_status fails repeatedly
"""
start_time = time.time()
while True:
try:
status = self.get_deriver_status(observer, sender, session)
status = self.get_queue_status(observer, sender, session)
except Exception as e:
logger.warning(f"Failed to get deriver status: {e}")
logger.warning(f"Failed to get queue status: {e}")
# Sleep briefly before retrying
time.sleep(1)
@ -565,44 +562,44 @@ class Honcho(BaseModel):
time.sleep(sleep_time)
@validate_call
def list_observations(
def list_conclusions(
self,
filters: dict[str, object] | None = Field(
None, description="Filters to scope the observations"
None, description="Filters to scope the conclusions"
),
reverse: bool = Field(
False, description="Whether to reverse the order of results"
),
):
"""
List all observations in the current workspace with optional filtering.
List all conclusions in the current workspace with optional filtering.
Makes an API call to retrieve observations that match the specified filters.
Observations can be filtered by session_id, observer_id, and observed_id.
Makes an API call to retrieve conclusions that match the specified filters.
conclusions can be filtered by session_id, observer_id, and observed_id.
Args:
filters: Optional filter criteria for observations. Supported filters include:
- session_id: Filter observations by session
- observer_id: Filter observations by observer peer
- observed_id: Filter observations by observed peer
filters: Optional filter criteria for conclusions. Supported filters include:
- session_id: Filter conclusions by session
- observer_id: Filter conclusions by observer peer
- observed_id: Filter conclusions by observed peer
reverse: Whether to reverse the order of results (default: False)
Returns:
A paginated list of Observation objects matching the specified criteria
A paginated list of conclusion objects matching the specified criteria
Example:
>>> observations = client.list_observations(
>>> conclusions = client.list_conclusions(
... filters={"observer_id": "user123", "observed_id": "assistant"}
... )
"""
return self._client.workspaces.observations.list(
return self._client.workspaces.conclusions.list(
workspace_id=self.workspace_id,
filters=filters,
reverse=reverse,
)
@validate_call
def query_observations(
def query_conclusions(
self,
query: str = Field(..., min_length=1, description="Semantic search query"),
observer: str = Field(
@ -625,9 +622,9 @@ class Honcho(BaseModel):
),
):
"""
Query observations using semantic search.
Query conclusions using semantic search.
Performs vector similarity search on observations to find semantically relevant results.
Performs vector similarity search on conclusions to find semantically relevant results.
Observer and observed peer IDs are required for semantic search.
Args:
@ -639,10 +636,10 @@ class Honcho(BaseModel):
filters: Optional filters to scope the query
Returns:
A list of Observation objects matching the query
A list of conclusion objects matching the query
Example:
>>> observations = client.query_observations(
>>> conclusions = client.query_conclusions(
... query="user preferences about music",
... observer="user123",
... observed="assistant",
@ -657,7 +654,7 @@ class Honcho(BaseModel):
"observed": observed,
}
return self._client.workspaces.observations.query(
return self._client.workspaces.conclusions.query(
workspace_id=self.workspace_id,
query=query,
top_k=top_k,
@ -666,27 +663,27 @@ class Honcho(BaseModel):
)
@validate_call
def delete_observation(
def delete_conclusion(
self,
observation_id: str = Field(
..., min_length=1, description="ID of the observation to delete"
conclusion_id: str = Field(
..., min_length=1, description="ID of the conclusion to delete"
),
) -> None:
"""
Delete a specific observation by ID.
Delete a specific conclusion by ID.
This permanently deletes the observation (document) from the theory-of-mind system.
This permanently deletes the conclusion (document) from the theory-of-mind system.
This action cannot be undone.
Args:
observation_id: The ID of the observation to delete
conclusion_id: The ID of the conclusion to delete
Example:
>>> client.delete_observation('obs_123abc')
>>> client.delete_conclusion('obs_123abc')
"""
self._client.workspaces.observations.delete(
self._client.workspaces.conclusions.delete(
workspace_id=self.workspace_id,
observation_id=observation_id,
conclusion_id=conclusion_id,
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))

View File

@ -0,0 +1,463 @@
"""Conclusion types and scoped access for the Honcho SDK."""
from __future__ import annotations
from typing import Any
from honcho_core import AsyncHoncho as AsyncHonchoCore
from honcho_core import Honcho as HonchoCore
from honcho_core.pagination import AsyncPage, SyncPage
from honcho_core.types.workspaces import conclusion_create_params
from honcho_core.types.workspaces.conclusion import Conclusion
from pydantic import BaseModel, PrivateAttr
from typing_extensions import TypeAlias
from .base import SessionBase
__all__ = [
"Conclusion",
"ConclusionCreateResponse",
"ConclusionScope",
"ConclusionCreateParams",
"AsyncConclusionScope",
]
ConclusionCreateResponse: TypeAlias = list[Conclusion]
class ConclusionCreateParams(BaseModel):
content: str
session_id: str
class ConclusionScope:
"""
Scoped access to conclusions for a specific observer/observed relationship.
This class provides convenient methods to list, query, create, and delete conclusions
that are automatically scoped to a specific observer/observed pair.
Typically accessed via `peer.conclusions` (for self-conclusions) or
`peer.conclusions_of(target)` (for conclusions about another peer).
Example:
```python
# Get self-conclusions
conclusions = peer.conclusions
obs_list = conclusions.list()
search_results = conclusions.query("preferences")
# Get conclusions about another peer
bob_conclusions = peer.conclusions_of("bob")
bob_list = bob_conclusions.list()
```
"""
_client: HonchoCore = PrivateAttr()
workspace_id: str
observer: str
observed: str
def __init__(
self,
client: HonchoCore,
workspace_id: str,
observer: str,
observed: str,
):
"""
Initialize a ConclusionScope.
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: str | SessionBase | None = None,
) -> SyncPage[Conclusion]:
"""
List conclusions in this scope.
Args:
page: Page number (1-indexed)
size: Number of results per page
session: Optional session (ID string or Session object) to filter by
Returns:
Paginated response containing Conclusion objects
"""
resolved_session_id = (
None
if session is None
else (session if isinstance(session, str) else session.id)
)
filters: dict[str, Any] = {
"observer": self.observer,
"observed": self.observed,
}
if resolved_session_id:
filters["session_id"] = resolved_session_id
return self._client.workspaces.conclusions.list(
workspace_id=self.workspace_id,
filters=filters,
page=page,
size=size,
)
def query(
self,
query: str,
top_k: int = 10,
distance: float | None = None,
) -> list[Conclusion]:
"""
Semantic search for conclusions in this scope.
Args:
query: The search query string
top_k: Maximum number of results to return
distance: Maximum cosine distance threshold (0.0-1.0)
Returns:
List of matching Conclusion objects
"""
filters: dict[str, Any] = {
"observer": self.observer,
"observed": self.observed,
}
return self._client.workspaces.conclusions.query(
workspace_id=self.workspace_id,
query=query,
top_k=top_k,
distance=distance,
filters=filters,
)
def delete(self, conclusion_id: str) -> None:
"""
Delete a conclusion by ID.
Args:
conclusion_id: The ID of the conclusion to delete
"""
self._client.workspaces.conclusions.delete(
workspace_id=self.workspace_id,
conclusion_id=conclusion_id,
)
def create(
self,
conclusions: list[ConclusionCreateParams | dict[str, Any]],
) -> list[Conclusion]:
"""
Create conclusions in this scope.
Args:
conclusions: List of conclusions to create.
Each conclusion can be a ConclusionCreateParams object or a dictionary with 'content' and 'session_id' keys.
Returns:
List of created Conclusion objects
Example:
```python
conclusions = peer.conclusions.create([
{"content": "User prefers dark mode", "session_id": "session1"},
{"content": "User is interested in AI", "session_id": "session1"},
])
```
"""
return self._client.workspaces.conclusions.create(
workspace_id=self.workspace_id,
conclusions=[
conclusion_create_params.Conclusion(
content=conclusion.content
if isinstance(conclusion, ConclusionCreateParams)
else conclusion["content"],
session_id=conclusion.session_id
if isinstance(conclusion, ConclusionCreateParams)
else conclusion["session_id"],
observer_id=self.observer,
observed_id=self.observed,
)
for conclusion in conclusions
],
)
def get_representation(
self,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_frequent: bool | None = None,
max_conclusions: int | None = None,
) -> str:
"""
Get the computed representation for this scope.
This returns the working representation (narrative) built from the
conclusions in this scope.
Args:
search_query: Optional semantic search query to curate the representation
search_top_k: Number of semantically relevant facts to return
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_frequent: Whether to include the most frequent conclusions
max_conclusions: Maximum number of conclusions to include
Returns:
A Representation string
"""
from honcho_core._types import omit
response = self._client.workspaces.peers.representation(
peer_id=self.observer,
workspace_id=self.workspace_id,
target=self.observed,
search_query=search_query if search_query is not None else omit,
search_top_k=search_top_k if search_top_k is not None else omit,
search_max_distance=search_max_distance
if search_max_distance is not None
else omit,
include_most_frequent=include_most_frequent
if include_most_frequent is not None
else omit,
max_conclusions=max_conclusions if max_conclusions is not None else omit,
)
return response.representation
def __repr__(self) -> str:
return (
f"ConclusionScope(workspace_id={self.workspace_id!r}, "
f"observer={self.observer!r}, observed={self.observed!r})"
)
class AsyncConclusionScope:
"""
Async scoped access to conclusions for a specific observer/observed relationship.
This class provides convenient async methods to list, query, create, and delete conclusions
that are automatically scoped to a specific observer/observed pair.
Typically accessed via `peer.conclusions` (for self-conclusions) or
`peer.conclusions_of(target)` (for conclusions about another peer).
Example:
```python
# Get self-conclusions
conclusions = peer.conclusions
obs_list = await conclusions.list()
search_results = await conclusions.query("preferences")
# Get conclusions about another peer
bob_conclusions = peer.conclusions_of("bob")
bob_list = await bob_conclusions.list()
```
"""
_client: AsyncHonchoCore = PrivateAttr()
workspace_id: str
observer: str
observed: str
def __init__(
self,
client: AsyncHonchoCore,
workspace_id: str,
observer: str,
observed: str,
):
"""
Initialize an AsyncConclusionScope.
Args:
client: The AsyncHoncho client instance
workspace_id: The workspace ID
observer: The observer peer ID
observed: The observed peer ID
"""
self._client = client
self.workspace_id = workspace_id
self.observer = observer
self.observed = observed
async def list(
self,
page: int = 1,
size: int = 50,
session: str | SessionBase | None = None,
) -> AsyncPage[Conclusion]:
"""
List conclusions in this scope.
Args:
page: Page number (1-indexed)
size: Number of results per page
session: Optional session (ID string or AsyncSession object) to filter by
Returns:
Paginated response containing Conclusion objects
"""
resolved_session_id = (
None
if session is None
else (session if isinstance(session, str) else session.id)
)
filters: dict[str, Any] = {
"observer": self.observer,
"observed": self.observed,
}
if resolved_session_id:
filters["session_id"] = resolved_session_id
return await self._client.workspaces.conclusions.list(
workspace_id=self.workspace_id,
filters=filters,
page=page,
size=size,
)
async def query(
self,
query: str,
top_k: int = 10,
distance: float | None = None,
) -> list[Conclusion]:
"""
Semantic search for conclusions in this scope.
Args:
query: The search query string
top_k: Maximum number of results to return
distance: Maximum cosine distance threshold (0.0-1.0)
Returns:
List of matching Conclusion objects
"""
filters: dict[str, Any] = {
"observer": self.observer,
"observed": self.observed,
}
return await self._client.workspaces.conclusions.query(
workspace_id=self.workspace_id,
query=query,
top_k=top_k,
distance=distance,
filters=filters,
)
async def delete(self, conclusion_id: str) -> None:
"""
Delete a conclusion by ID.
Args:
conclusion_id: The ID of the conclusion to delete
"""
await self._client.workspaces.conclusions.delete(
workspace_id=self.workspace_id,
conclusion_id=conclusion_id,
)
async def create(
self,
conclusions: list[ConclusionCreateParams | dict[str, Any]],
) -> list[Conclusion]:
"""
Create conclusions in this scope.
Args:
conclusions: List of conclusions to create.
Each conclusion can be a ConclusionCreateParams object or a dictionary with 'content' and 'session_id' keys.
Returns:
List of created Conclusion objects
Example:
```python
conclusions = await peer.conclusions.create([
{"content": "User prefers dark mode", "session_id": "session1"},
{"content": "User is interested in AI", "session_id": "session1"},
])
```
"""
return await self._client.workspaces.conclusions.create(
workspace_id=self.workspace_id,
conclusions=[
conclusion_create_params.Conclusion(
content=conclusion.content
if isinstance(conclusion, ConclusionCreateParams)
else conclusion["content"],
session_id=conclusion.session_id
if isinstance(conclusion, ConclusionCreateParams)
else conclusion["session_id"],
observer_id=self.observer,
observed_id=self.observed,
)
for conclusion in conclusions
],
)
async def get_representation(
self,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_frequent: bool | None = None,
max_conclusions: int | None = None,
) -> str:
"""
Get the computed representation for this scope.
This returns the working representation (narrative) built from the
conclusions in this scope.
Args:
search_query: Optional semantic search query to curate the representation
search_top_k: Number of semantically relevant facts to return
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_frequent: Whether to include the most frequent conclusions
max_conclusions: Maximum number of conclusions to include
Returns:
A Representation string
"""
from honcho_core._types import omit
response = await self._client.workspaces.peers.representation(
peer_id=self.observer,
workspace_id=self.workspace_id,
target=self.observed,
search_query=search_query if search_query is not None else omit,
search_top_k=search_top_k if search_top_k is not None else omit,
search_max_distance=search_max_distance
if search_max_distance is not None
else omit,
include_most_frequent=include_most_frequent
if include_most_frequent is not None
else omit,
max_conclusions=max_conclusions if max_conclusions is not None else omit,
)
return response.representation
def __repr__(self) -> str:
return (
f"AsyncConclusionScope(workspace_id={self.workspace_id!r}, "
f"observer={self.observer!r}, observed={self.observed!r})"
)

View File

@ -1,589 +0,0 @@
"""Observation types and scoped access for the Honcho SDK."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from .base import SessionBase
if TYPE_CHECKING:
from .types import ObservationCreateParam, Representation
def _convert_observation(item: Any) -> dict[str, Any]:
"""Convert a core SDK Observations model to a dict for our Observation class."""
if hasattr(item, "model_dump"):
# Pydantic model - use model_dump()
return item.model_dump() # type: ignore[no-any-return]
elif isinstance(item, dict):
return cast(dict[str, Any], item)
else:
# Fallback: access as object attributes
return {
"id": getattr(item, "id", ""),
"content": getattr(item, "content", ""),
"observer_id": getattr(item, "observer_id", ""),
"observed_id": getattr(item, "observed_id", ""),
"session_id": getattr(item, "session_id", ""),
"created_at": str(getattr(item, "created_at", "")),
}
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=str(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, create, 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()
```
"""
_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: str | SessionBase | None = None,
) -> list[Observation]:
"""
List observations in this scope.
Args:
page: Page number (1-indexed)
size: Number of results per page
session: Optional session (ID string or Session object) to filter by
Returns:
List of Observation objects
"""
resolved_session_id = (
None
if session is None
else (session if isinstance(session, str) else session.id)
)
filters: dict[str, Any] = {
"observer": self.observer,
"observed": self.observed,
}
if resolved_session_id:
filters["session_id"] = resolved_session_id
response = self._client.workspaces.observations.list(
workspace_id=self.workspace_id,
filters=filters,
page=page,
size=size,
)
# response.items is List[Observations] (Pydantic models)
return [
Observation.from_api_response(_convert_observation(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,
}
response = self._client.workspaces.observations.query(
workspace_id=self.workspace_id,
query=query,
top_k=top_k,
distance=distance,
filters=filters,
)
# response is List[Observations] (Pydantic models)
return [
Observation.from_api_response(_convert_observation(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
"""
self._client.workspaces.observations.delete(
workspace_id=self.workspace_id,
observation_id=observation_id,
)
def create(
self,
observations: "ObservationCreateParam | list[ObservationCreateParam]",
) -> list[Observation]:
"""
Create observations in this scope.
Args:
observations: Single observation or list of observations to create.
Each observation must have 'content' and 'session_id' keys.
Returns:
List of created Observation objects
Example:
```python
# Create a single observation
observations = peer.observations.create(
{"content": "User prefers dark mode", "session_id": "session1"}
)
# Create multiple observations
observations = peer.observations.create([
{"content": "User prefers dark mode", "session_id": "session1"},
{"content": "User is interested in AI", "session_id": "session1"},
])
```
"""
# Normalize to list
if not isinstance(observations, list):
observations = [observations]
# Build the request body with observer/observed from scope
request_observations = [
{
"content": obs["content"],
"session_id": obs["session_id"]
if isinstance(obs["session_id"], str)
else obs["session_id"].id,
"observer_id": self.observer,
"observed_id": self.observed,
}
for obs in observations
]
response = self._client.workspaces.observations.create( # type: ignore[attr-defined]
workspace_id=self.workspace_id,
observations=request_observations,
)
# response is List[Observations] (Pydantic models)
return [
Observation.from_api_response(_convert_observation(item))
for item in response
]
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, create, 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()
```
"""
_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: str | SessionBase | None = None,
) -> list[Observation]:
"""
List observations in this scope.
Args:
page: Page number (1-indexed)
size: Number of results per page
session: Optional session (ID string or AsyncSession object) to filter by
Returns:
List of Observation objects
"""
resolved_session_id = (
None
if session is None
else (session if isinstance(session, str) else session.id)
)
filters: dict[str, Any] = {
"observer": self.observer,
"observed": self.observed,
}
if resolved_session_id:
filters["session_id"] = resolved_session_id
response = await self._client.workspaces.observations.list(
workspace_id=self.workspace_id,
filters=filters,
page=page,
size=size,
)
# response.items is List[Observations] (Pydantic models)
return [
Observation.from_api_response(_convert_observation(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,
}
response = await self._client.workspaces.observations.query(
workspace_id=self.workspace_id,
query=query,
top_k=top_k,
distance=distance,
filters=filters,
)
# response is List[Observations] (Pydantic models)
return [
Observation.from_api_response(_convert_observation(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
"""
await self._client.workspaces.observations.delete(
workspace_id=self.workspace_id,
observation_id=observation_id,
)
async def create(
self,
observations: "ObservationCreateParam | list[ObservationCreateParam]",
) -> list[Observation]:
"""
Create observations in this scope.
Args:
observations: Single observation or list of observations to create.
Each observation must have 'content' and 'session_id' keys.
Returns:
List of created Observation objects
Example:
```python
# Create a single observation
observations = await peer.observations.create(
{"content": "User prefers dark mode", "session_id": "session1"}
)
# Create multiple observations
observations = await peer.observations.create([
{"content": "User prefers dark mode", "session_id": "session1"},
{"content": "User is interested in AI", "session_id": "session1"},
])
```
"""
# Normalize to list
if not isinstance(observations, list):
observations = [observations]
# Build the request body with observer/observed from scope
request_observations = [
{
"content": obs["content"],
"session_id": obs["session_id"]
if isinstance(obs["session_id"], str)
else obs["session_id"].id,
"observer_id": self.observer,
"observed_id": self.observed,
}
for obs in observations
]
response = await self._client.workspaces.observations.create( # type: ignore[attr-defined]
workspace_id=self.workspace_id,
observations=request_observations,
)
# response is List[Observations] (Pydantic models)
return [
Observation.from_api_response(_convert_observation(item))
for item in response
]
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

@ -2,11 +2,17 @@ from __future__ import annotations
import datetime
from collections.abc import Generator
from typing import TYPE_CHECKING, cast
from typing import Literal
from honcho_core import Honcho as HonchoCore
from honcho_core._types import omit
from honcho_core.types.workspaces import PeerCardResponse
from honcho_core.types.workspaces.peer_context_response import (
PeerContextResponse,
)
from honcho_core.types.workspaces.peer_representation_response import (
PeerRepresentationResponse,
)
from honcho_core.types.workspaces.session import Session as SessionCore
from honcho_core.types.workspaces.sessions import MessageCreateParam
from honcho_core.types.workspaces.sessions.message import Message
@ -14,14 +20,10 @@ from honcho_core.types.workspaces.sessions.message_create_param import Configura
from pydantic import ConfigDict, Field, PrivateAttr, validate_call
from .base import PeerBase, SessionBase
from .conclusions import ConclusionScope
from .pagination import SyncPage
from .types import DialecticStreamResponse
if TYPE_CHECKING:
from .observations import ObservationScope
from .types import PeerContext, Representation
from .session import Session
from .types import DialecticStreamResponse
class Peer(PeerBase):
@ -120,6 +122,8 @@ class Peer(PeerBase):
stream: bool = False,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "extra-high"]
| None = None,
) -> str | DialecticStreamResponse | None:
"""
Query the peer's representation with a natural language question.
@ -138,6 +142,8 @@ class Peer(PeerBase):
session: Optional session to scope the query to. If provided, only
information from that session is considered. Can be a session
ID string or a Session object.
reasoning_level: Optional reasoning level for the query: "minimal", "low", "medium",
"high", or "extra-high". Defaults to "low" if not provided.
Returns:
For non-streaming: Response string containing the answer, or None if no relevant information
@ -168,6 +174,9 @@ class Peer(PeerBase):
stream=True,
target=target_id,
session_id=resolved_session_id,
reasoning_level=reasoning_level
if reasoning_level is not None
else omit,
) as response:
response.http_response.raise_for_status()
for line in response.iter_lines():
@ -193,6 +202,7 @@ class Peer(PeerBase):
stream=stream,
target=target_id,
session_id=resolved_session_id,
reasoning_level=reasoning_level if reasoning_level is not None else omit,
)
if response.content in ("", None, "None"):
return None
@ -474,50 +484,49 @@ class Peer(PeerBase):
return "\n".join(items)
def working_rep(
def get_representation(
self,
session: str | SessionBase | None = None,
target: str | PeerBase | None = None,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "Representation":
include_most_frequent: bool | None = None,
max_conclusions: int | None = None,
) -> str:
"""
Get a working representation for this peer.
Get a subset of the representation of the peer.
Args:
session: Optional session to scope the representation to.
target: Optional target peer to get the representation of. If provided,
returns the representation of the target from the perspective of this peer.
search_query: Semantic search query to filter relevant observations
search_query: Semantic search query to filter relevant conclusions
search_top_k: Number of semantically relevant facts to return
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
include_most_frequent: Whether to include the most frequent conclusions
max_conclusions: Maximum number of conclusions to include
Returns:
A Representation object containing explicit and deductive observations
A Representation string
Example:
```python
# Get global representation
rep = peer.working_rep()
rep = peer.get_representation()
print(rep)
# Get representation scoped to a session
session_rep = peer.working_rep(session='session-123')
session_rep = peer.get_representation(session='session-123')
# Get representation with semantic search
searched_rep = peer.working_rep(
searched_rep = peer.get_representation(
search_query='preferences',
search_top_k=10,
max_observations=50
max_conclusions=50
)
```
"""
from .types import Representation as _Representation
session_id = (
None
@ -532,7 +541,7 @@ class Peer(PeerBase):
if target is None
else (target if isinstance(target, str) else target.id)
)
data = self._client.workspaces.peers.working_representation(
data: PeerRepresentationResponse = self._client.workspaces.peers.representation(
peer_id=self.id,
workspace_id=self.workspace_id,
session_id=session_id,
@ -542,16 +551,12 @@ class Peer(PeerBase):
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
include_most_frequent=include_most_frequent
if include_most_frequent is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
max_conclusions=max_conclusions if max_conclusions 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)
return data.representation
def get_context(
self,
@ -559,9 +564,9 @@ class Peer(PeerBase):
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":
include_most_frequent: bool | None = None,
max_conclusions: int | None = None,
) -> PeerContextResponse:
"""
Get context for this peer, including representation and peer card.
@ -572,11 +577,11 @@ class Peer(PeerBase):
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_query: Semantic search query to filter relevant conclusions
search_top_k: Number of semantically relevant facts to return
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
include_most_frequent: Whether to include the most frequent conclusions
max_conclusions: Maximum number of conclusions to include
Returns:
A PeerContext object containing the representation and peer card
@ -598,7 +603,6 @@ class Peer(PeerBase):
)
```
"""
from .types import PeerContext as _PeerContext
target_id = (
None
@ -606,7 +610,7 @@ class Peer(PeerBase):
else (target if isinstance(target, str) else target.id)
)
response = self._client.workspaces.peers.get_context(
return self._client.workspaces.peers.context(
peer_id=self.id,
workspace_id=self.workspace_id,
target=target_id,
@ -615,73 +619,71 @@ class Peer(PeerBase):
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
include_most_frequent=include_most_frequent
if include_most_frequent is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
max_conclusions=max_conclusions if max_conclusions is not None else omit,
)
return _PeerContext.from_api_response(response)
@property
def observations(self) -> "ObservationScope":
def conclusions(self) -> "ConclusionScope":
"""
Access this peer's self-observations (where observer == observed == self).
Access this peer's self-conclusions (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.
This property provides a convenient way to access conclusions that this peer
has made about themselves. Use this for self-conclusion scenarios.
Returns:
An ObservationScope scoped to this peer's self-observations
A ConclusionScope scoped to this peer's self-conclusions
Example:
```python
# List self-observations
obs_list = peer.observations.list()
# List self-conclusions
obs_list = peer.conclusions.list()
# Search self-observations
results = peer.observations.query("preferences")
# Search self-conclusions
results = peer.conclusions.query("preferences")
# Delete a self-observation
peer.observations.delete("obs-123")
# Delete a self-conclusion
peer.conclusions.delete("obs-123")
```
"""
from .observations import ObservationScope as _ObservationScope
from .conclusions import ConclusionScope as _ConclusionScope
return _ObservationScope(self._client, self.workspace_id, self.id, self.id)
return _ConclusionScope(self._client, self.workspace_id, self.id, self.id)
def observations_of(self, target: str | PeerBase) -> "ObservationScope":
def conclusions_of(self, target: str | PeerBase) -> "ConclusionScope":
"""
Access observations this peer has made about another peer.
Access conclusions this peer has made about another peer.
This method provides scoped access to observations where this peer is the
This method provides scoped access to conclusions where this peer is the
observer and the target is the observed peer.
Args:
target: The target peer (either a Peer object or peer ID string)
Returns:
An ObservationScope scoped to this peer's observations of the target
A ConclusionScope scoped to this peer's conclusions of the target
Example:
```python
# Get observations about another peer
bob_observations = peer.observations_of("bob")
# Get conclusions about another peer
bob_conclusions = peer.conclusions_of("bob")
# List observations
obs_list = bob_observations.list()
# List conclusions
obs_list = bob_conclusions.list()
# Search observations
results = bob_observations.query("work history")
# Search conclusions
results = bob_conclusions.query("work history")
# Get the representation from these observations
rep = bob_observations.get_representation()
# Get the representation from these conclusions
rep = bob_conclusions.get_representation()
```
"""
from .observations import ObservationScope as _ObservationScope
from .conclusions import ConclusionScope as _ConclusionScope
target_id = target.id if isinstance(target, PeerBase) else target
return _ObservationScope(self._client, self.workspace_id, self.id, target_id)
return _ConclusionScope(self._client, self.workspace_id, self.id, target_id)
def __repr__(self) -> str:
"""

View File

@ -8,7 +8,10 @@ from typing import TYPE_CHECKING, Any
from honcho_core import Honcho as HonchoCore
from honcho_core._types import omit
from honcho_core.types import DeriverStatus
from honcho_core.types.workspaces import QueueStatusResponse
from honcho_core.types.workspaces.peer_representation_response import (
PeerRepresentationResponse,
)
from honcho_core.types.workspaces.sessions import MessageCreateParam
from honcho_core.types.workspaces.sessions.message import Message
from honcho_core.types.workspaces.sessions.message_create_param import Configuration
@ -21,7 +24,6 @@ from .utils import prepare_file_for_upload
if TYPE_CHECKING:
from .peer import Peer
from .types import Representation
logger = logging.getLogger(__name__)
@ -279,14 +281,14 @@ class Session(SessionBase):
Get the configuration for a peer in this session.
"""
peer_id = peer if isinstance(peer, str) else peer.id
peer_get_config_response = self._client.workspaces.sessions.peers.get_config(
peer_config_response = self._client.workspaces.sessions.peers.config(
peer_id=peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
)
return SessionPeerConfig(
observe_others=peer_get_config_response.observe_others,
observe_me=peer_get_config_response.observe_me,
observe_others=peer_config_response.observe_others,
observe_me=peer_config_response.observe_me,
)
def set_peer_config(self, peer: str | PeerBase, config: SessionPeerConfig) -> None:
@ -390,7 +392,7 @@ class Session(SessionBase):
Makes an API call to permanently delete this session and all related data including:
- Messages
- Message embeddings
- Observations
- Conclusions
- Session-Peer associations
- Background processing queue items
@ -544,7 +546,7 @@ class Session(SessionBase):
),
last_user_message: str | Message | None = Field(
None,
description="The most recent message (string or Message object), used to fetch semantically relevant 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.",
description="The most recent message (string or Message object), used to fetch semantically relevant conclusions and returned as part of the context object. Use this alongside `peer_target` to get a more focused context -- does nothing if `peer_target` is not provided.",
),
peer_perspective: str | None = Field(
None,
@ -552,7 +554,7 @@ class Session(SessionBase):
),
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.",
description="Whether to limit the representation to this session only. If True, only conclusions from this session will be included.",
),
search_top_k: int | None = Field(
None,
@ -566,15 +568,15 @@ class Session(SessionBase):
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(
include_most_frequent: bool | None = Field(
None,
description="Whether to include the most derived observations in the representation.",
description="Whether to include the most frequent conclusions in the representation.",
),
max_observations: int | None = Field(
max_conclusions: int | None = Field(
None,
ge=1,
le=100,
description="Maximum number of observations to include in the representation.",
description="Maximum number of conclusions to include in the representation.",
),
) -> SessionContext:
"""
@ -590,13 +592,13 @@ class Session(SessionBase):
tokens: Maximum number of tokens to include in the context. Will default
to Honcho server configuration if not provided.
peer_target: A peer ID to get context for. If given *without* `peer_perspective`, a representation and peer card will be included from the omniscient Honcho-level view of `peer_target`. If given *with* `peer_perspective`, will get the representation and card for `peer_target` *from the perspective of `peer_perspective`*.
last_user_message: The most recent message (string or Message object), used to fetch semantically relevant 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.
last_user_message: The most recent message (string or Message object), used to fetch semantically relevant conclusions and returned as part of the context object. Use this alongside `peer_target` to get a more focused context -- does nothing if `peer_target` is not provided.
peer_perspective: A peer ID to get context *from the perspective of*. If given, response will attempt to include representation and card from the perspective of `peer_perspective`. Must be provided with `peer_target`.
limit_to_session: Whether to limit the representation to this session only. If True, only observations from this session will be included.
limit_to_session: Whether to limit the representation to this session only. If True, only conclusions from this session will be included.
search_top_k: Number of semantically relevant facts to return when searching with `last_user_message`.
search_max_distance: Maximum semantic distance for search results (0.0-1.0) when searching with `last_user_message`.
include_most_derived: Whether to include the most derived observations in the representation.
max_observations: Maximum number of observations to include in the representation.
include_most_frequent: Whether to include the most frequent conclusions in the representation.
max_conclusions: Maximum number of conclusions to include in the representation.
Returns:
A SessionContext object containing the optimized message history and
@ -623,7 +625,7 @@ class Session(SessionBase):
if isinstance(last_user_message, Message)
else last_user_message
)
context = self._client.workspaces.sessions.get_context(
context = self._client.workspaces.sessions.context(
session_id=self.id,
workspace_id=self.workspace_id,
tokens=tokens if tokens is not None else omit,
@ -638,10 +640,10 @@ class Session(SessionBase):
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
include_most_frequent=include_most_frequent
if include_most_frequent is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
max_conclusions=max_conclusions if max_conclusions is not None else omit,
)
# Convert the honcho_core summary to our Summary if it exists
@ -834,7 +836,7 @@ class Session(SessionBase):
return [Message.model_validate(msg) for msg in response]
def working_rep(
def get_representation(
self,
peer: str | PeerBase,
*,
@ -842,43 +844,42 @@ class Session(SessionBase):
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":
include_most_frequent: bool | None = None,
max_conclusions: int | None = None,
) -> str:
"""
Get the current working representation of the peer in this session.
Get a subset of the representation of the peer in this session.
Args:
peer: Peer to get the working representation of.
peer: Peer to get the representation of.
target: Optional target peer to get the representation of. If provided,
queries what `peer` knows about the `target`.
search_query: Semantic search query to filter relevant observations
search_query: Semantic search query to filter relevant conclusions
search_top_k: Number of semantically relevant facts to return
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
include_most_frequent: Whether to include the most frequent conclusions
max_conclusions: Maximum number of conclusions to include
Returns:
A Representation object containing explicit and deductive observations
A Representation string
Example:
```python
# Get peer's representation in this session
rep = session.working_rep('user123')
rep = session.get_representation('user123')
print(rep)
# Get what user123 knows about assistant in this session
local_rep = session.working_rep('user123', target='assistant')
local_rep = session.get_representation('user123', target='assistant')
# Get representation with semantic search
searched_rep = session.working_rep(
searched_rep = session.get_representation(
'user123',
search_query='preferences',
search_top_k=10
)
```
"""
from .types import Representation as _Representation
peer_id = peer if isinstance(peer, str) else peer.id
target_id = (
@ -886,7 +887,7 @@ class Session(SessionBase):
if target is None
else (target if isinstance(target, str) else target.id)
)
data = self._client.workspaces.peers.working_representation(
data: PeerRepresentationResponse = self._client.workspaces.peers.representation(
peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
@ -896,21 +897,21 @@ class Session(SessionBase):
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
include_most_frequent=include_most_frequent
if include_most_frequent is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
max_conclusions=max_conclusions if max_conclusions is not None else omit,
)
return _Representation.from_dict(data) # type: ignore
return data.representation
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def get_deriver_status(
def get_queue_status(
self,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
) -> DeriverStatus:
) -> QueueStatusResponse:
"""
Get the deriver processing status, optionally scoped to an observer, sender, and/or session.
Get the queue processing status, optionally scoped to an observer, sender, and/or session.
Args:
observer: Optional observer (ID string or Peer object) to scope the status check
@ -927,7 +928,7 @@ class Session(SessionBase):
else (sender if isinstance(sender, str) else sender.id)
)
return self._client.workspaces.deriver_status(
return self._client.workspaces.queue.status(
workspace_id=self.workspace_id,
observer_id=resolved_observer_id,
sender_id=resolved_sender_id,
@ -935,7 +936,7 @@ class Session(SessionBase):
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def poll_deriver_status(
def poll_queue_status(
self,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
@ -944,10 +945,10 @@ class Session(SessionBase):
gt=0,
description="Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).",
),
) -> DeriverStatus:
) -> QueueStatusResponse:
"""
Poll get_deriver_status until pending_work_units and in_progress_work_units are both 0.
This allows you to guarantee that all messages have been processed by the deriver for
Poll get_queue_status until pending_work_units and in_progress_work_units are both 0.
This allows you to guarantee that all messages have been processed by the queue for
use with the dialectic endpoint.
The polling estimates sleep time by assuming each work unit takes 1 second.
@ -958,19 +959,19 @@ class Session(SessionBase):
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
Returns:
DeriverStatus when all work units are complete
QueueStatusResponse when all work units are complete
Raises:
TimeoutError: If timeout is exceeded before work units complete
Exception: If get_deriver_status fails repeatedly
Exception: If get_queue_status fails repeatedly
"""
start_time = time.time()
while True:
try:
status = self.get_deriver_status(observer, sender)
status = self.get_queue_status(observer, sender)
except Exception as e:
logger.warning(f"Failed to get deriver status: {e}")
logger.warning(f"Failed to get queue status: {e}")
# Sleep briefly before retrying
time.sleep(1)

View File

@ -3,340 +3,12 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Iterator
from datetime import datetime
from typing import TYPE_CHECKING, Any, cast
from pydantic import BaseModel, Field
from typing_extensions import Required, TypedDict
# Re-export observation types from dedicated module
from .observations import AsyncObservationScope, Observation, ObservationScope
if TYPE_CHECKING:
from .base import SessionBase
__all__ = [
"AsyncObservationScope",
"DeductiveObservation",
"DeductiveObservationBase",
"DialecticStreamResponse",
"ExplicitObservation",
"ExplicitObservationBase",
"Observation",
"ObservationCreateParam",
"ObservationMetadata",
"ObservationScope",
"PeerContext",
"Representation",
]
class ObservationCreateParam(TypedDict, total=False):
"""Parameters for creating an observation.
Attributes:
content: The observation content/text (required)
session_id: The session this observation relates to (ID string or Session object) (required)
"""
content: Required[str]
session_id: "Required[str | SessionBase]"
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:
"""
Iterator for streaming dialectic responses with utilities for accessing the final response.
@ -436,81 +108,3 @@ 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

@ -14,7 +14,7 @@ export default class MockHonchoCore {
getOrCreate: jest.fn(),
update: jest.fn(),
search: jest.fn(),
workingRepresentation: jest.fn(),
getRepresentation: jest.fn(),
},
sessions: {
list: jest.fn(),

View File

@ -16,11 +16,13 @@ jest.mock('@honcho-ai/core', () => {
list: jest.fn(),
getOrCreate: jest.fn(),
},
queue: {
status: jest.fn(),
},
getOrCreate: jest.fn().mockResolvedValue({ id: 'test-workspace', metadata: {} }),
update: jest.fn(),
list: jest.fn(),
search: jest.fn(),
deriverStatus: jest.fn(),
},
}));
});
@ -408,8 +410,8 @@ describe('Honcho Client', () => {
});
});
describe('getDeriverStatus', () => {
it('should return deriver status without options', async () => {
describe('getQueueStatus', () => {
it('should return queue status without options', async () => {
const mockStatus = {
total_work_units: 10,
completed_work_units: 5,
@ -417,9 +419,9 @@ describe('Honcho Client', () => {
pending_work_units: 2,
sessions: { 'session1': { status: 'active' } },
};
mockClient.workspaces.deriverStatus.mockResolvedValue(mockStatus);
mockClient.workspaces.queue.status.mockResolvedValue(mockStatus);
const status = await honcho.getDeriverStatus();
const status = await honcho.getQueueStatus();
expect(status).toEqual({
totalWorkUnits: 10,
@ -428,19 +430,22 @@ describe('Honcho Client', () => {
pendingWorkUnits: 2,
sessions: { 'session1': { status: 'active' } },
});
expect(mockClient.workspaces.deriverStatus).toHaveBeenCalledWith('test-workspace', {});
expect(mockClient.workspaces.queue.status).toHaveBeenCalledWith(
'test-workspace',
{}
);
});
it('should return deriver status with options', async () => {
it('should return queue status with options', async () => {
const mockStatus = {
total_work_units: 5,
completed_work_units: 3,
in_progress_work_units: 1,
pending_work_units: 1,
};
mockClient.workspaces.deriverStatus.mockResolvedValue(mockStatus);
mockClient.workspaces.queue.status.mockResolvedValue(mockStatus);
const status = await honcho.getDeriverStatus({
const status = await honcho.getQueueStatus({
observer: 'observer1',
sender: 'sender1',
session: 'session1',
@ -453,15 +458,18 @@ describe('Honcho Client', () => {
pendingWorkUnits: 1,
sessions: undefined,
});
expect(mockClient.workspaces.deriverStatus).toHaveBeenCalledWith('test-workspace', {
observer_id: 'observer1',
sender_id: 'sender1',
session_id: 'session1',
});
expect(mockClient.workspaces.queue.status).toHaveBeenCalledWith(
'test-workspace',
{
observer_id: 'observer1',
sender_id: 'sender1',
session_id: 'session1',
}
);
});
});
describe('pollDeriverStatus', () => {
describe('pollQueueStatus', () => {
it('should poll until processing is complete', async () => {
const mockStatusComplete = {
total_work_units: 5,
@ -469,9 +477,9 @@ describe('Honcho Client', () => {
in_progress_work_units: 0,
pending_work_units: 0,
};
mockClient.workspaces.deriverStatus.mockResolvedValue(mockStatusComplete);
mockClient.workspaces.queue.status.mockResolvedValue(mockStatusComplete);
const status = await honcho.pollDeriverStatus();
const status = await honcho.pollQueueStatus();
expect(status).toEqual({
totalWorkUnits: 5,
@ -489,9 +497,9 @@ describe('Honcho Client', () => {
in_progress_work_units: 2,
pending_work_units: 1,
};
mockClient.workspaces.deriverStatus.mockResolvedValue(mockStatusPending);
mockClient.workspaces.queue.status.mockResolvedValue(mockStatusPending);
await expect(honcho.pollDeriverStatus({ timeoutMs: 100 })).rejects.toThrow();
await expect(honcho.pollQueueStatus({ timeoutMs: 0 })).rejects.toThrow();
});
});

View File

@ -3,7 +3,6 @@ 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
@ -26,7 +25,7 @@ describe('Honcho SDK Integration Tests', () => {
getOrCreate: jest.fn(),
update: jest.fn(),
search: jest.fn(),
workingRepresentation: jest.fn(),
representation: jest.fn(),
},
sessions: {
list: jest.fn(),
@ -39,7 +38,7 @@ describe('Honcho SDK Integration Tests', () => {
messages: { create: jest.fn(), list: jest.fn() },
getOrCreate: jest.fn(),
update: jest.fn(),
getContext: jest.fn(),
context: jest.fn(),
search: jest.fn(),
},
getOrCreate: jest.fn(),
@ -91,7 +90,7 @@ describe('Honcho SDK Integration Tests', () => {
mockWorkspacesApi.workspaces.sessions.messages.create.mockResolvedValue(
{}
)
mockWorkspacesApi.workspaces.sessions.getContext.mockResolvedValue(
mockWorkspacesApi.workspaces.sessions.context.mockResolvedValue(
mockContextData
)
mockWorkspacesApi.workspaces.peers.chat.mockResolvedValue({
@ -285,7 +284,7 @@ describe('Honcho SDK Integration Tests', () => {
mockWorkspacesApi.workspaces.peers.chat.mockRejectedValue(
new Error('Chat API failed')
)
mockWorkspacesApi.workspaces.sessions.getContext.mockRejectedValue(
mockWorkspacesApi.workspaces.sessions.context.mockRejectedValue(
new Error('Context API failed')
)
@ -370,67 +369,46 @@ describe('Honcho SDK Integration Tests', () => {
})
it('should handle working representation queries', async () => {
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',
},
],
}
const mockRepresentation =
'Alice likes coffee\nAlice works as a developer\nAlice is a coffee-drinking developer'
mockWorkspacesApi.workspaces.peers.workingRepresentation.mockResolvedValue(
{
representation: mockWorkingRepData,
}
)
mockWorkspacesApi.workspaces.peers.representation.mockResolvedValue({
representation: mockRepresentation,
})
const session = await honcho.session('working-rep-session')
const alice = await honcho.peer('alice')
const bob = await honcho.peer('bob')
// Test working representation without target
const globalRep = await session.workingRep('alice')
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'
)
const globalRep = await session.getRepresentation('alice')
expect(globalRep).toBe(mockRepresentation)
expect(
mockWorkspacesApi.workspaces.peers.workingRepresentation
mockWorkspacesApi.workspaces.peers.representation
).toHaveBeenCalledWith('integration-test-workspace', 'alice', {
session_id: 'working-rep-session',
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_frequent: undefined,
max_conclusions: undefined,
})
// Test working representation with target
const targetRep = await session.workingRep(alice, bob)
expect(targetRep).toBeInstanceOf(Representation)
const targetRep = await session.getRepresentation(alice, bob)
expect(targetRep).toBe(mockRepresentation)
expect(
mockWorkspacesApi.workspaces.peers.workingRepresentation
mockWorkspacesApi.workspaces.peers.representation
).toHaveBeenCalledWith('integration-test-workspace', 'alice', {
session_id: 'working-rep-session',
target: 'bob',
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_frequent: undefined,
max_conclusions: undefined,
})
})
})
@ -446,7 +424,7 @@ describe('Honcho SDK Integration Tests', () => {
total: 0,
hasNextPage: false,
})
mockWorkspacesApi.workspaces.sessions.getContext.mockResolvedValue({
mockWorkspacesApi.workspaces.sessions.context.mockResolvedValue({
messages: [],
})
@ -484,7 +462,7 @@ describe('Honcho SDK Integration Tests', () => {
expect(typeof message.metadata).toBe('object')
// Mock successful operations
mockWorkspacesApi.workspaces.sessions.getContext.mockResolvedValue({
mockWorkspacesApi.workspaces.sessions.context.mockResolvedValue({
messages: [{ id: 'msg1', content: 'Hello', peer_id: 'typed-peer' }],
summary: {
content: 'Test summary',

View File

@ -2,7 +2,6 @@ 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', () => {
@ -553,420 +552,268 @@ describe('Peer', () => {
});
});
describe('workingRep', () => {
describe('getRepresentation', () => {
beforeEach(() => {
mockClient.workspaces.peers.workingRepresentation = jest.fn();
mockClient.workspaces.peers.representation = 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 mockRepresentation = 'Observation 1\nObservation 2\nConclusion 1';
mockClient.workspaces.peers.representation.mockResolvedValue({
representation: mockRepresentation,
});
const result = await peer.workingRep();
const result = await peer.getRepresentation();
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(result).toBe(mockRepresentation);
expect(
mockClient.workspaces.peers.workingRepresentation
mockClient.workspaces.peers.representation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
include_most_frequent: undefined,
max_conclusions: 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 mockRepresentation = 'Session-scoped observation';
mockClient.workspaces.peers.representation.mockResolvedValue({
representation: mockRepresentation,
});
const result = await peer.workingRep('session-123');
const result = await peer.getRepresentation('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(result).toBe(mockRepresentation);
expect(
mockClient.workspaces.peers.workingRepresentation
mockClient.workspaces.peers.representation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: 'session-123',
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
include_most_frequent: undefined,
max_conclusions: undefined,
});
});
it('should get working representation with session as Session object', async () => {
const session = new Session('session-123', 'test-workspace', mockClient);
const 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 mockRepresentation = 'Session object observation';
mockClient.workspaces.peers.representation.mockResolvedValue({
representation: mockRepresentation,
});
const result = await peer.workingRep(session);
const result = await peer.getRepresentation(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(result).toBe(mockRepresentation);
expect(
mockClient.workspaces.peers.workingRepresentation
mockClient.workspaces.peers.representation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: 'session-123',
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
include_most_frequent: undefined,
max_conclusions: 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 mockRepresentation = "Observer's view of target";
mockClient.workspaces.peers.representation.mockResolvedValue({
representation: mockRepresentation,
});
const result = await peer.workingRep(undefined, 'target-peer');
const result = await peer.getRepresentation(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(result).toBe(mockRepresentation);
expect(
mockClient.workspaces.peers.workingRepresentation
mockClient.workspaces.peers.representation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: 'target-peer',
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
include_most_frequent: undefined,
max_conclusions: undefined,
});
});
it('should get working representation with target as Peer object', async () => {
const targetPeer = new Peer('target-peer', 'test-workspace', mockClient);
const 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 mockRepresentation = "Observer's view of target peer object";
mockClient.workspaces.peers.representation.mockResolvedValue({
representation: mockRepresentation,
});
const result = await peer.workingRep(undefined, targetPeer);
const result = await peer.getRepresentation(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(result).toBe(mockRepresentation);
expect(
mockClient.workspaces.peers.workingRepresentation
mockClient.workspaces.peers.representation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: 'target-peer',
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
include_most_frequent: undefined,
max_conclusions: 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 mockRepresentation = 'Query-curated observation';
mockClient.workspaces.peers.representation.mockResolvedValue({
representation: mockRepresentation,
});
const result = await peer.workingRep(
const result = await peer.getRepresentation(
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(result).toBe(mockRepresentation);
expect(
mockClient.workspaces.peers.workingRepresentation
mockClient.workspaces.peers.representation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: undefined,
search_query: 'programming',
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
include_most_frequent: undefined,
max_conclusions: 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 mockRepresentation = 'Limited observations';
mockClient.workspaces.peers.representation.mockResolvedValue({
representation: mockRepresentation,
});
const result = await peer.workingRep(undefined, undefined, { maxObservations: 10 });
const result = await peer.getRepresentation(undefined, undefined, { maxConclusions: 10 });
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe('Limited observations');
expect(result.deductive).toHaveLength(0);
expect(result).toBe(mockRepresentation);
expect(
mockClient.workspaces.peers.workingRepresentation
mockClient.workspaces.peers.representation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: 10,
include_most_frequent: undefined,
max_conclusions: 10,
});
});
it('should get working representation with all parameters', async () => {
const session = new Session('session-123', 'test-workspace', mockClient);
const targetPeer = new Peer('target-peer', 'test-workspace', mockClient);
const 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 mockRepresentation = 'Fully parameterized observation\nConclusion with all params';
mockClient.workspaces.peers.representation.mockResolvedValue({
representation: mockRepresentation,
});
const result = await peer.workingRep(
const result = await peer.getRepresentation(
session,
targetPeer,
{ searchQuery: 'Python programming', maxObservations: 25 }
{ searchQuery: 'Python programming', maxConclusions: 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(result).toBe(mockRepresentation);
expect(
mockClient.workspaces.peers.workingRepresentation
mockClient.workspaces.peers.representation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: 'session-123',
target: 'target-peer',
search_query: 'Python programming',
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: 25,
include_most_frequent: undefined,
max_conclusions: 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 mockRepresentation = 'String params observation';
mockClient.workspaces.peers.representation.mockResolvedValue({
representation: mockRepresentation,
});
const result = await peer.workingRep(
const result = await peer.getRepresentation(
'session-456',
'target-peer-123',
{ searchQuery: 'machine learning', maxObservations: 50 }
{ searchQuery: 'machine learning', maxConclusions: 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(result).toBe(mockRepresentation);
expect(
mockClient.workspaces.peers.workingRepresentation
mockClient.workspaces.peers.representation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: 'session-456',
target: 'target-peer-123',
search_query: 'machine learning',
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: 50,
include_most_frequent: undefined,
max_conclusions: 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,
const mockRepresentationString = 'Boundary test representation';
mockClient.workspaces.peers.representation.mockResolvedValue({
representation: mockRepresentationString,
});
// Test size = 1
const result1 = await peer.workingRep(undefined, undefined, { maxObservations: 1 });
expect(result1).toBeInstanceOf(Representation);
const result1 = await peer.getRepresentation(undefined, undefined, { maxConclusions: 1 });
expect(result1).toBe(mockRepresentationString);
expect(
mockClient.workspaces.peers.workingRepresentation
mockClient.workspaces.peers.representation
).toHaveBeenLastCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: 1,
include_most_frequent: undefined,
max_conclusions: 1,
});
// Test size = 100
const result2 = await peer.workingRep(undefined, undefined, { maxObservations: 100 });
expect(result2).toBeInstanceOf(Representation);
const result2 = await peer.getRepresentation(undefined, undefined, { maxConclusions: 100 });
expect(result2).toBe(mockRepresentationString);
expect(
mockClient.workspaces.peers.workingRepresentation
mockClient.workspaces.peers.representation
).toHaveBeenLastCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: 100,
include_most_frequent: undefined,
max_conclusions: 100,
});
});
it('should handle API errors', async () => {
mockClient.workspaces.peers.workingRepresentation.mockRejectedValue(
mockClient.workspaces.peers.representation.mockRejectedValue(
new Error('Working representation fetch failed')
);
await expect(peer.workingRep()).rejects.toThrow(
await expect(peer.getRepresentation()).rejects.toThrow(
'Working representation fetch failed'
);
});

View File

@ -3,7 +3,6 @@ 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', () => {
@ -15,7 +14,7 @@ jest.mock('@honcho-ai/core', () => {
set: jest.fn(),
remove: jest.fn(),
list: jest.fn(),
getConfig: jest.fn(),
config: jest.fn(),
setConfig: jest.fn(),
},
messages: {
@ -27,13 +26,15 @@ jest.mock('@honcho-ai/core', () => {
update: jest.fn(),
delete: jest.fn(),
clone: jest.fn(),
getContext: jest.fn(),
context: jest.fn(),
search: jest.fn(),
},
peers: {
workingRepresentation: jest.fn(),
representation: jest.fn(),
},
queue: {
status: jest.fn(),
},
deriverStatus: jest.fn(),
getOrCreate: jest.fn(),
update: jest.fn(),
list: jest.fn(),
@ -332,7 +333,7 @@ describe('Session', () => {
describe('getPeerConfig', () => {
it('should return peer configuration', async () => {
const mockConfig = { observe_me: true, observe_others: false }
mockClient.workspaces.sessions.peers.getConfig.mockResolvedValue(
mockClient.workspaces.sessions.peers.config.mockResolvedValue(
mockConfig
)
@ -340,14 +341,14 @@ describe('Session', () => {
expect(config).toEqual(mockConfig)
expect(
mockClient.workspaces.sessions.peers.getConfig
mockClient.workspaces.sessions.peers.config
).toHaveBeenCalledWith('test-workspace', 'test-session', 'peer1')
})
it('should handle Peer object input', async () => {
const peer = new Peer('peer1', 'test-workspace', mockClient)
const mockConfig = { observe_me: false, observe_others: true }
mockClient.workspaces.sessions.peers.getConfig.mockResolvedValue(
mockClient.workspaces.sessions.peers.config.mockResolvedValue(
mockConfig
)
@ -355,7 +356,7 @@ describe('Session', () => {
expect(config).toEqual(mockConfig)
expect(
mockClient.workspaces.sessions.peers.getConfig
mockClient.workspaces.sessions.peers.config
).toHaveBeenCalledWith('test-workspace', 'test-session', 'peer1')
})
})
@ -774,7 +775,7 @@ describe('Session', () => {
token_count: 100,
},
}
mockClient.workspaces.sessions.getContext.mockResolvedValue(mockContext)
mockClient.workspaces.sessions.context.mockResolvedValue(mockContext)
const context = await session.getContext()
@ -782,7 +783,7 @@ describe('Session', () => {
expect(context.sessionId).toBe('test-session')
expect(context.messages).toEqual(mockContext.messages)
expect(context.summary?.content).toBe('Conversation summary')
expect(mockClient.workspaces.sessions.getContext).toHaveBeenCalledWith(
expect(mockClient.workspaces.sessions.context).toHaveBeenCalledWith(
'test-workspace',
'test-session',
{ tokens: undefined, summary: undefined }
@ -800,12 +801,12 @@ describe('Session', () => {
token_count: 50,
},
}
mockClient.workspaces.sessions.getContext.mockResolvedValue(mockContext)
mockClient.workspaces.sessions.context.mockResolvedValue(mockContext)
const context = await session.getContext({ summary: true, tokens: 1000 })
expect(context).toBeInstanceOf(SessionContext)
expect(mockClient.workspaces.sessions.getContext).toHaveBeenCalledWith(
expect(mockClient.workspaces.sessions.context).toHaveBeenCalledWith(
'test-workspace',
'test-session',
{ tokens: 1000, summary: true }
@ -816,7 +817,7 @@ describe('Session', () => {
const mockContext = {
messages: [{ id: 'msg1', content: 'Hello', peer_id: 'peer1' }],
}
mockClient.workspaces.sessions.getContext.mockResolvedValue(mockContext)
mockClient.workspaces.sessions.context.mockResolvedValue(mockContext)
const context = await session.getContext()
@ -824,7 +825,7 @@ describe('Session', () => {
})
it('should handle API errors', async () => {
mockClient.workspaces.sessions.getContext.mockRejectedValue(
mockClient.workspaces.sessions.context.mockRejectedValue(
new Error('Failed to get context')
)
@ -903,136 +904,108 @@ describe('Session', () => {
})
})
describe('workingRep', () => {
describe('getRepresentation', () => {
it('should get working representation with peer string', async () => {
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({
representation: mockRepresentationData,
const mockRepresentationString = 'Some knowledge about the peer'
mockClient.workspaces.peers.representation.mockResolvedValue({
representation: mockRepresentationString,
})
const result = await session.workingRep('peer1')
const result = await session.getRepresentation('peer1')
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(typeof result).toBe('string')
expect(result).toBe(mockRepresentationString)
expect(
mockClient.workspaces.peers.workingRepresentation
mockClient.workspaces.peers.representation
).toHaveBeenCalledWith('test-workspace', 'peer1', {
session_id: 'test-session',
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_frequent: undefined,
max_conclusions: undefined,
})
})
it('should get working representation with Peer object', async () => {
const peer = new Peer('peer1', 'test-workspace', mockClient)
const 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({
representation: mockRepresentationData,
const mockRepresentationString = 'Some knowledge'
mockClient.workspaces.peers.representation.mockResolvedValue({
representation: mockRepresentationString,
})
const result = await session.workingRep(peer)
const result = await session.getRepresentation(peer)
expect(result).toBeInstanceOf(Representation)
expect(result.explicit).toHaveLength(1)
expect(result.explicit[0].content).toBe('Some knowledge')
expect(result.deductive).toHaveLength(0)
expect(typeof result).toBe('string')
expect(result).toBe(mockRepresentationString)
expect(
mockClient.workspaces.peers.workingRepresentation
mockClient.workspaces.peers.representation
).toHaveBeenCalledWith('test-workspace', 'peer1', {
session_id: 'test-session',
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_frequent: undefined,
max_conclusions: undefined,
})
})
it('should get working representation with target peer string', async () => {
const 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({
representation: mockRepresentationData,
const mockRepresentationString = 'What peer1 knows about target'
mockClient.workspaces.peers.representation.mockResolvedValue({
representation: mockRepresentationString,
})
const result = await session.workingRep('peer1', 'target-peer')
const result = await session.getRepresentation('peer1', 'target-peer')
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(typeof result).toBe('string')
expect(result).toBe(mockRepresentationString)
expect(
mockClient.workspaces.peers.workingRepresentation
mockClient.workspaces.peers.representation
).toHaveBeenCalledWith('test-workspace', 'peer1', {
session_id: 'test-session',
target: 'target-peer',
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_frequent: undefined,
max_conclusions: undefined,
})
})
it('should get working representation with target Peer object', async () => {
const peer = new Peer('peer1', 'test-workspace', mockClient)
const target = new Peer('target-peer', 'test-workspace', mockClient)
const 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({
representation: mockRepresentationData,
const mockRepresentationString = 'What peer1 knows about target'
mockClient.workspaces.peers.representation.mockResolvedValue({
representation: mockRepresentationString,
})
const result = await session.workingRep(peer, target)
const result = await session.getRepresentation(peer, target)
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(typeof result).toBe('string')
expect(result).toBe(mockRepresentationString)
expect(
mockClient.workspaces.peers.workingRepresentation
mockClient.workspaces.peers.representation
).toHaveBeenCalledWith('test-workspace', 'peer1', {
session_id: 'test-session',
target: 'target-peer',
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_frequent: undefined,
max_conclusions: undefined,
})
})
it('should handle API errors', async () => {
mockClient.workspaces.peers.workingRepresentation.mockRejectedValue(
mockClient.workspaces.peers.representation.mockRejectedValue(
new Error('Failed to get working representation')
)
await expect(session.workingRep('peer1')).rejects.toThrow()
await expect(session.getRepresentation('peer1')).rejects.toThrow()
})
})
describe('delete', () => {
@ -1124,8 +1097,8 @@ describe('Session', () => {
})
})
describe('getDeriverStatus', () => {
it('should return deriver status without options', async () => {
describe('getQueueStatus', () => {
it('should return queue status without options', async () => {
const mockStatus = {
total_work_units: 10,
completed_work_units: 5,
@ -1133,9 +1106,9 @@ describe('Session', () => {
pending_work_units: 2,
sessions: { session1: { status: 'active' } },
}
mockClient.workspaces.deriverStatus.mockResolvedValue(mockStatus)
mockClient.workspaces.queue.status.mockResolvedValue(mockStatus)
const status = await session.getDeriverStatus()
const status = await session.getQueueStatus()
expect(status).toEqual({
totalWorkUnits: 10,
@ -1144,22 +1117,22 @@ describe('Session', () => {
pendingWorkUnits: 2,
sessions: { session1: { status: 'active' } },
})
expect(mockClient.workspaces.deriverStatus).toHaveBeenCalledWith(
expect(mockClient.workspaces.queue.status).toHaveBeenCalledWith(
'test-workspace',
{ session_id: 'test-session' }
)
})
it('should return deriver status with options', async () => {
it('should return queue status with options', async () => {
const mockStatus = {
total_work_units: 5,
completed_work_units: 3,
in_progress_work_units: 1,
pending_work_units: 1,
}
mockClient.workspaces.deriverStatus.mockResolvedValue(mockStatus)
mockClient.workspaces.queue.status.mockResolvedValue(mockStatus)
const status = await session.getDeriverStatus({
const status = await session.getQueueStatus({
observer: 'observer1',
sender: 'sender1',
})
@ -1171,7 +1144,7 @@ describe('Session', () => {
pendingWorkUnits: 1,
sessions: undefined,
})
expect(mockClient.workspaces.deriverStatus).toHaveBeenCalledWith(
expect(mockClient.workspaces.queue.status).toHaveBeenCalledWith(
'test-workspace',
{
observer_id: 'observer1',
@ -1181,7 +1154,7 @@ describe('Session', () => {
)
})
describe('pollDeriverStatus', () => {
describe('pollQueueStatus', () => {
it('should poll until processing is complete', async () => {
const mockStatusComplete = {
total_work_units: 5,
@ -1189,11 +1162,11 @@ describe('Session', () => {
in_progress_work_units: 0,
pending_work_units: 0,
}
mockClient.workspaces.deriverStatus.mockResolvedValue(
mockClient.workspaces.queue.status.mockResolvedValue(
mockStatusComplete
)
const status = await session.pollDeriverStatus()
const status = await session.pollQueueStatus()
expect(status).toEqual({
totalWorkUnits: 5,
@ -1203,7 +1176,7 @@ describe('Session', () => {
sessions: undefined,
})
expect(mockClient.workspaces.deriverStatus).toHaveBeenCalledWith(
expect(mockClient.workspaces.queue.status).toHaveBeenCalledWith(
'test-workspace',
{ session_id: 'test-session' }
)
@ -1216,10 +1189,10 @@ describe('Session', () => {
in_progress_work_units: 2,
pending_work_units: 1,
}
mockClient.workspaces.deriverStatus.mockResolvedValue(mockStatusPending)
mockClient.workspaces.queue.status.mockResolvedValue(mockStatusPending)
await expect(
session.pollDeriverStatus({ timeoutMs: 100 })
session.pollQueueStatus({ timeoutMs: 100 })
).rejects.toThrow()
})
})

View File

@ -4,7 +4,7 @@
"": {
"name": "@honcho-ai/sdk",
"dependencies": {
"@honcho-ai/core": "^1.8.0",
"@honcho-ai/core": "2.1.0",
"@types/node": "^24.0.1",
"zod": "4.0.0",
},
@ -106,7 +106,7 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w=="],
"@honcho-ai/core": ["@honcho-ai/core@1.8.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-qxBNoXLezH8yx4iBoz4Bsxkm9zp4Gm1fNwuP8gHRdSelxhR0dXpvLffx8B5V0XFWsx+SfPaJFyaKw0X2sYMwLA=="],
"@honcho-ai/core": ["@honcho-ai/core@2.1.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-BssURYjiBmFF/guQQkr6Dpiz1ZOzhJsf/rdp6Ek/A3T2vNz3pop0fNgHK6SjCFcqjSKF8SHWJMF2VqqH0y4J6Q=="],
"@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=="],

View File

@ -1,4 +1,4 @@
import { Honcho, Message } from '../src';
import { Honcho, type MessageCreate } from '../src';
/**
* Example demonstrating how to get peer representations.
@ -27,7 +27,7 @@ async function main() {
console.log('Generating random messages...');
// Generate some random messages from alice, bob, and charlie and add them to the session
const messages: Message[] = [];
const messages: MessageCreate[] = [];
for (let i = 0; i < 10; i++) {
const randomPeer = peers[Math.floor(Math.random() * peers.length)];
messages.push(
@ -43,13 +43,13 @@ async function main() {
console.log('Getting alice\'s working representation in session...');
// Get alice's working representation in the session
const workingRepresentation = await session.workingRep(alice);
console.log('Working representation returned:', workingRepresentation);
const representation = await session.getRepresentation(alice);
console.log('Representation returned:', representation);
console.log('Getting alice\'s working representation *of bob* in session...');
// Get alice's working representation *of bob* in the session
const workingRepresentationOfBob = await session.workingRep(alice, bob);
console.log('Working representation returned:', workingRepresentationOfBob);
const representationOfBob = await session.getRepresentation(alice, bob);
console.log('Representation returned:', representationOfBob);
console.log('Example completed successfully!');
}

View File

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

View File

@ -1,15 +1,14 @@
import HonchoCore from '@honcho-ai/core'
import type { DefaultQuery } from '@honcho-ai/core/core'
import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages'
import type {
DeriverStatus,
WorkspaceDeriverStatusParams,
} from '@honcho-ai/core/resources/workspaces/workspaces'
QueueStatusParams,
QueueStatusResponse,
} from '@honcho-ai/core/resources/workspaces/queue'
import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages'
import { Page } from './pagination'
import { Peer } from './peer'
import { Session } from './session'
import {
type DeriverStatusOptions,
FilterSchema,
type Filters,
type HonchoConfig,
@ -21,6 +20,7 @@ import {
PeerIdSchema,
type PeerMetadata,
PeerMetadataSchema,
type QueueStatusOptions,
SearchQuerySchema,
type SessionConfig,
SessionConfigSchema,
@ -479,20 +479,20 @@ export class Honcho {
}
/**
* Get the deriver processing status, optionally scoped to an observer, sender, and/or session.
* Get the queue processing status, optionally scoped to an observer, sender, and/or session.
*
* Makes an API call to retrieve the current status of the deriver processing queue.
* The deriver is responsible for processing messages and updating peer representations.
* Makes an API call to retrieve the current status of the queue processing queue.
* The queue is responsible for processing messages and updating peer representations.
*
* @param options - Configuration options for the status request
* @param options.observer - Optional observer (ID string or Peer object) to scope the status to
* @param options.sender - Optional sender (ID string or Peer object) to scope the status to
* @param options.session - Optional session (ID string or Session object) to scope the status to
* @returns Promise resolving to the deriver status information including work unit counts
* @returns Promise resolving to the queue status information including work unit counts
*/
async getDeriverStatus(
async getQueueStatus(
options?: Omit<
DeriverStatusOptions,
QueueStatusOptions,
'observerId' | 'senderId' | 'sessionId'
> & {
observer?: string | Peer
@ -504,7 +504,7 @@ export class Honcho {
completedWorkUnits: number
inProgressWorkUnits: number
pendingWorkUnits: number
sessions?: Record<string, DeriverStatus.Sessions>
sessions?: Record<string, QueueStatusResponse.Sessions>
}> {
const resolvedObserverId = options?.observer
? typeof options.observer === 'string'
@ -522,12 +522,12 @@ export class Honcho {
: options.session.id
: undefined
const queryParams: WorkspaceDeriverStatusParams = {}
const queryParams: QueueStatusParams = {}
if (resolvedObserverId) queryParams.observer_id = resolvedObserverId
if (resolvedSenderId) queryParams.sender_id = resolvedSenderId
if (resolvedSessionId) queryParams.session_id = resolvedSessionId
const status = await this._client.workspaces.deriverStatus(
const status = await this._client.workspaces.queue.status(
this.workspaceId,
queryParams
)
@ -542,8 +542,8 @@ export class Honcho {
}
/**
* Poll getDeriverStatus until pendingWorkUnits and inProgressWorkUnits are both 0.
* This allows you to guarantee that all messages have been processed by the deriver for
* Poll getQueueStatus until pendingWorkUnits and inProgressWorkUnits are both 0.
* This allows you to guarantee that all messages have been processed by the queue for
* use with the dialectic endpoint.
*
* The polling estimates sleep time by assuming each work unit takes 1 second.
@ -553,12 +553,12 @@ export class Honcho {
* @param options.sender - Optional sender (ID string or Peer object) to scope the status to
* @param options.session - Optional session (ID string or Session object) to scope the status to
* @param options.timeoutMs - Optional timeout in milliseconds (default: 300000 - 5 minutes)
* @returns Promise resolving to the final deriver status when processing is complete
* @returns Promise resolving to the final queue status when processing is complete
* @throws Error if timeout is exceeded before processing completes
*/
async pollDeriverStatus(
async pollQueueStatus(
options?: Omit<
DeriverStatusOptions,
QueueStatusOptions,
'observerId' | 'senderId' | 'sessionId'
> & {
observer?: string | Peer
@ -570,13 +570,13 @@ export class Honcho {
completedWorkUnits: number
inProgressWorkUnits: number
pendingWorkUnits: number
sessions?: Record<string, DeriverStatus.Sessions>
sessions?: Record<string, QueueStatusResponse.Sessions>
}> {
const timeoutMs = options?.timeoutMs ?? 300000 // Default to 5 minutes
const startTime = Date.now()
while (true) {
const status = await this.getDeriverStatus(options)
const status = await this.getQueueStatus(options)
if (status.pendingWorkUnits === 0 && status.inProgressWorkUnits === 0) {
return status
}

View File

@ -1,34 +1,30 @@
import type HonchoCore from '@honcho-ai/core'
import {
Representation,
type RepresentationData,
type RepresentationOptions,
} from './representation'
import type { RepresentationOptions } from './representation'
import type { Session } from './session'
import type { ObservationCreateParam } from './types'
import type { ConclusionCreateParam } from './types'
// Re-export for consumers who import from this module
export type { RepresentationOptions, ObservationCreateParam }
export type { RepresentationOptions, ConclusionCreateParam }
/**
* An observation from the theory-of-mind system.
* A conclusion from Honcho's reasoning system.
*
* Observations are facts derived from messages that help build a representation
* Conclusions are facts derived from messages that help build a representation
* of a peer.
*/
export class Observation {
export class Conclusion {
/**
* Unique identifier for this observation.
* Unique identifier for this conclusion.
*/
readonly id: string
/**
* The observation content/text.
* The conclusion content/text.
*/
readonly content: string
/**
* The peer who made the observation.
* The peer who made the conclusion.
*/
readonly observerId: string
@ -38,12 +34,12 @@ export class Observation {
readonly observedId: string
/**
* The session where this observation was made.
* The session where this conclusion was made.
*/
readonly sessionId: string
/**
* When the observation was created.
* When the conclusion was created.
*/
readonly createdAt: string
@ -64,13 +60,13 @@ export class Observation {
}
/**
* Create an Observation from an API response object.
* Create a Conclusion from an API response object.
*
* @param data - API response data
* @returns A new Observation instance
* @returns A new Conclusion instance
*/
static fromApiResponse(data: Record<string, unknown>): Observation {
return new Observation(
static fromApiResponse(data: Record<string, unknown>): Conclusion {
return new Conclusion(
(data.id as string) ?? '',
(data.content as string) ?? '',
(data.observer_id as string) ?? '',
@ -81,46 +77,46 @@ export class Observation {
}
/**
* Return a string representation of the Observation.
* Return a string representation of the Conclusion.
*/
toString(): string {
const truncatedContent =
this.content.length > 50
? `${this.content.slice(0, 50)}...`
: this.content
return `Observation(id='${this.id}', content='${truncatedContent}')`
return `Conclusion(id='${this.id}', content='${truncatedContent}')`
}
}
/**
* Scoped access to observations for a specific observer/observed relationship.
* Scoped access to conclusions for a specific observer/observed relationship.
*
* This class provides convenient methods to list, query, and delete observations
* This class provides convenient methods to list, query, and delete conclusions
* that are automatically scoped to a specific observer/observed pair.
*
* Typically accessed via `peer.observations` (for self-observations) or
* `peer.observationsOf(target)` (for observations about another peer).
* Typically accessed via `peer.conclusions` (for self-conclusions) or
* `peer.conclusionsOf(target)` (for conclusions about another peer).
*
* @example
* ```typescript
* // Get self-observations
* const observations = peer.observations
* const obsList = await observations.list()
* const searchResults = await observations.query('preferences')
* // Get self-conclusions
* const conclusions = peer.conclusions
* const obsList = await conclusions.list()
* const searchResults = await conclusions.query('preferences')
*
* // Get observations about another peer
* const bobObservations = peer.observationsOf('bob')
* const bobList = await bobObservations.list()
* // Get conclusions about another peer
* const bobConclusions = peer.conclusionsOf('bob')
* const bobList = await bobConclusions.list()
* ```
*
* @note
* This class requires the core Honcho SDK to support 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}
* This class requires the core Honcho SDK to support conclusion endpoints.
* The conclusion endpoints are:
* - POST /workspaces/{workspace_id}/conclusions/list
* - POST /workspaces/{workspace_id}/conclusions/query
* - DELETE /workspaces/{workspace_id}/conclusions/{conclusion_id}
*/
export class ObservationScope {
export class ConclusionScope {
private _client: HonchoCore
/**
@ -139,7 +135,7 @@ export class ObservationScope {
readonly observed: string
/**
* Initialize an ObservationScope.
* Initialize a ConclusionScope.
*
* @param client - The Honcho client instance
* @param workspaceId - The workspace ID
@ -159,18 +155,18 @@ export class ObservationScope {
}
/**
* List observations in this scope.
* List conclusions in this scope.
*
* @param page - Page number (1-indexed)
* @param size - Number of results per page
* @param session - Optional session (ID string or Session object) to filter by
* @returns Promise resolving to list of Observation objects
* @returns Promise resolving to list of Conclusion objects
*/
async list(
page: number = 1,
size: number = 50,
session?: string | Session
): Promise<Observation[]> {
): Promise<Conclusion[]> {
const resolvedSessionId = session
? typeof session === 'string'
? session
@ -184,8 +180,8 @@ export class ObservationScope {
filters.session_id = resolvedSessionId
}
// biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include observations
const response = await (this._client.workspaces as any).observations.list(
// biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include conclusions
const response = await (this._client.workspaces as any).conclusions.list(
this.workspaceId,
{
filters,
@ -195,30 +191,30 @@ export class ObservationScope {
)
return (response.items ?? []).map((item: unknown) =>
Observation.fromApiResponse(item as Record<string, unknown>)
Conclusion.fromApiResponse(item as Record<string, unknown>)
)
}
/**
* Semantic search for observations in this scope.
* Semantic search for conclusions in this scope.
*
* @param query - The search query string
* @param topK - Maximum number of results to return
* @param distance - Maximum cosine distance threshold (0.0-1.0)
* @returns Promise resolving to list of matching Observation objects
* @returns Promise resolving to list of matching Conclusion objects
*/
async query(
query: string,
topK: number = 10,
distance?: number
): Promise<Observation[]> {
): Promise<Conclusion[]> {
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(
// biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include conclusions
const response = await (this._client.workspaces as any).conclusions.query(
this.workspaceId,
{
query,
@ -229,53 +225,53 @@ export class ObservationScope {
)
return (response ?? []).map((item: unknown) =>
Observation.fromApiResponse(item as Record<string, unknown>)
Conclusion.fromApiResponse(item as Record<string, unknown>)
)
}
/**
* Delete an observation by ID.
* Delete a conclusion by ID.
*
* @param observationId - The ID of the observation to delete
* @param conclusionId - The ID of the conclusion 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(
async delete(conclusionId: string): Promise<void> {
// biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include conclusions
await (this._client.workspaces as any).conclusions.delete(
this.workspaceId,
observationId
conclusionId
)
}
/**
* Create observations in this scope.
* Create conclusions in this scope.
*
* @param observations - Single observation or array of observations with content and sessionId
* @returns Promise resolving to list of created Observation objects
* @param conclusions - Single conclusion or array of conclusions with content and sessionId
* @returns Promise resolving to list of created Conclusion objects
*
* @example
* ```typescript
* // Create a single observation
* const observations = await peer.observations.create(
* // Create a single conclusion
* const conclusions = await peer.conclusions.create(
* { content: 'User prefers dark mode', sessionId: 'session1' }
* )
*
* // Create multiple observations
* const observations = await peer.observations.create([
* // Create multiple conclusions
* const conclusions = await peer.conclusions.create([
* { content: 'User prefers dark mode', sessionId: 'session1' },
* { content: 'User is interested in AI', sessionId: 'session1' },
* ])
* ```
*/
async create(
observations: ObservationCreateParam | ObservationCreateParam[]
): Promise<Observation[]> {
conclusions: ConclusionCreateParam | ConclusionCreateParam[]
): Promise<Conclusion[]> {
// Normalize to array
const observationArray = Array.isArray(observations)
? observations
: [observations]
const conclusionArray = Array.isArray(conclusions)
? conclusions
: [conclusions]
// Build the request body with observer/observed from scope
const requestObservations = observationArray.map((obs) => ({
const requestConclusions = conclusionArray.map((obs) => ({
content: obs.content,
session_id:
typeof obs.sessionId === 'string' ? obs.sessionId : obs.sessionId.id,
@ -283,14 +279,14 @@ export class ObservationScope {
observed_id: this.observed,
}))
// biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include observations
const response = await (this._client.workspaces as any).observations.create(
// biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include conclusions
const response = await (this._client.workspaces as any).conclusions.create(
this.workspaceId,
{ observations: requestObservations }
{ conclusions: requestConclusions }
)
return (response ?? []).map((item: unknown) =>
Observation.fromApiResponse(item as Record<string, unknown>)
Conclusion.fromApiResponse(item as Record<string, unknown>)
)
}
@ -298,15 +294,13 @@ export class ObservationScope {
* Get the computed representation for this scope.
*
* This returns the working representation (narrative) built from the
* observations in this scope.
* conclusions in this scope.
*
* @param options - Optional options to configure the representation
* @returns Promise resolving to a Representation object
* @returns Promise resolving to a string of the representation
*/
async getRepresentation(
options?: RepresentationOptions
): Promise<Representation> {
const response = await this._client.workspaces.peers.workingRepresentation(
async getRepresentation(options?: RepresentationOptions): Promise<string> {
const response = await this._client.workspaces.peers.representation(
this.workspaceId,
this.observer,
{
@ -314,25 +308,17 @@ export class ObservationScope {
search_query: options?.searchQuery,
search_top_k: options?.searchTopK,
search_max_distance: options?.searchMaxDistance,
include_most_derived: options?.includeMostDerived,
max_observations: options?.maxObservations,
include_most_frequent: options?.includeMostFrequent,
max_conclusions: options?.maxConclusions,
}
)
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 response.representation
}
/**
* Return a string representation of the ObservationScope.
* Return a string representation of the ConclusionScope.
*/
toString(): string {
return `ObservationScope(workspaceId='${this.workspaceId}', observer='${this.observer}', observed='${this.observed}')`
return `ConclusionScope(workspaceId='${this.workspaceId}', observer='${this.observer}', observed='${this.observed}')`
}
}

View File

@ -3,7 +3,7 @@
export type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages'
export { Honcho } from './client'
export { Observation, ObservationScope } from './observations'
export { Conclusion, ConclusionScope } from './conclusions'
export { Page } from './pagination'
export { Peer, PeerContext } from './peer'
export { Session, SessionPeerConfig } from './session'
@ -14,30 +14,31 @@ export {
type SummaryData,
} from './session_context'
export {
type Conclusion as ConclusionData,
type ConclusionQueryParams,
type DialecticStreamChunk,
type DialecticStreamDelta,
DialecticStreamResponse,
type Observation as ObservationData,
type ObservationQueryParams,
} from './types'
// Export validation types for advanced usage
export type {
ChatQuery,
ContextParams,
DeriverStatusOptions,
FileUpload,
Filters,
GetRepresentationParams,
HonchoConfig,
MessageAddition,
MessageCreate,
PeerAddition,
PeerConfig,
PeerGetRepresentationParams,
PeerMetadata,
PeerRemoval,
QueueStatusOptions,
SessionConfig,
SessionMetadata,
WorkingRepParams,
WorkspaceConfig,
WorkspaceMetadata,
} from './validation'

View File

@ -1,6 +1,6 @@
import type HonchoCore from '@honcho-ai/core'
import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages'
import { ObservationScope } from './observations'
import { ConclusionScope } from './conclusions'
import { Page } from './pagination'
import {
Representation,
@ -16,7 +16,7 @@ import {
LimitSchema,
MessageContentSchema,
MessageMetadataSchema,
PeerWorkingRepParamsSchema,
PeerGetRepresentationParamsSchema,
SearchQuerySchema,
type MessageCreate as ValidatedMessageCreate,
} from './validation'
@ -111,6 +111,8 @@ export class Peer {
* @param session - Optional session to scope the query to. If provided, only
* information from that session is considered. Can be a session
* ID string or a Session object.
* @param reasoningLevel - Optional reasoning level for the query: "minimal", "low", "medium",
* "high", or "extra-high". Defaults to "low" if not provided.
* @returns Promise resolving to:
* - For non-streaming: response string or null if no relevant information
* - For streaming: DialecticStreamResponse that can be iterated over
@ -121,6 +123,7 @@ export class Peer {
stream?: boolean
target?: string | Peer
session?: string | Session
reasoningLevel?: string
}
): Promise<string | DialecticStreamResponse | null> {
const targetId = options?.target
@ -139,6 +142,7 @@ export class Peer {
stream: options?.stream,
target: targetId,
session: resolvedSessionId,
reasoningLevel: options?.reasoningLevel,
})
if (chatParams.stream) {
@ -147,6 +151,7 @@ export class Peer {
stream: true,
target: chatParams.target,
session_id: chatParams.session,
reasoning_level: chatParams.reasoningLevel,
}
const url = `${this._client.baseURL}/v2/workspaces/${this.workspaceId}/peers/${this.id}/chat`
@ -223,6 +228,7 @@ export class Peer {
stream: false,
target: chatParams.target,
session_id: chatParams.session,
reasoning_level: chatParams.reasoningLevel,
}
)
if (!response.content || response.content === 'None') {
@ -264,7 +270,7 @@ export class Peer {
*
* @param content - The text content for the message
* @param options.metadata - Optional metadata to associate with the message
* @param options.configuration - Optional message-level configuration (e.g., deriver settings)
* @param options.configuration - Optional message-level configuration (e.g., reasoning settings)
* @param options.created_at - Optional ISO 8601 timestamp for the message
* @returns A new message object with this peer's ID and the provided content
*/
@ -481,75 +487,69 @@ export class Peer {
}
/**
* Get a working representation for this peer.
* Get a subset of Honcho's Representation of a peer.
*
* Makes an API call to retrieve the working representation for this peer.
* Makes an API call to retrieve the representation for this peer.
*
* @param session - Optional session to scope the representation to.
* @param target - Optional target peer to get the representation of. If provided,
* returns the representation of the target from the perspective of this peer.
* @param options - Optional representation options to filter and configure the results
* @returns Promise resolving to a Representation object containing explicit and deductive observations
* @returns Promise resolving to a Representation object containing explicit and deductive conclusions
*
* @example
* ```typescript
* // Get global representation
* const globalRep = await peer.workingRep()
* const globalRep = await peer.getRepresentation()
* console.log(globalRep.toString())
*
* // Get representation scoped to a session
* const sessionRep = await peer.workingRep('session-123')
* const sessionRep = await peer.getRepresentation('session-123')
*
* // Get representation with semantic search
* const searchedRep = await peer.workingRep(undefined, undefined, {
* const searchedRep = await peer.getRepresentation(undefined, undefined, {
* searchQuery: 'preferences',
* searchTopK: 10,
* maxObservations: 50
* maxConclusions: 50
* })
* ```
*/
async workingRep(
async getRepresentation(
session?: string | Session,
target?: string | Peer,
options?: RepresentationOptions
): Promise<Representation> {
const workingRepParams = PeerWorkingRepParamsSchema.parse({
): Promise<string> {
const getRepresentationParams = PeerGetRepresentationParamsSchema.parse({
session,
target,
options,
})
const sessionId = workingRepParams.session
? typeof workingRepParams.session === 'string'
? workingRepParams.session
: workingRepParams.session.id
const sessionId = getRepresentationParams.session
? typeof getRepresentationParams.session === 'string'
? getRepresentationParams.session
: getRepresentationParams.session.id
: undefined
const targetId = workingRepParams.target
? typeof workingRepParams.target === 'string'
? workingRepParams.target
: workingRepParams.target.id
const targetId = getRepresentationParams.target
? typeof getRepresentationParams.target === 'string'
? getRepresentationParams.target
: getRepresentationParams.target.id
: undefined
const response = await this._client.workspaces.peers.workingRepresentation(
const response = await this._client.workspaces.peers.representation(
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,
search_query: getRepresentationParams.options?.searchQuery,
search_top_k: getRepresentationParams.options?.searchTopK,
search_max_distance: getRepresentationParams.options?.searchMaxDistance,
include_most_frequent:
getRepresentationParams.options?.includeMostFrequent,
max_conclusions: getRepresentationParams.options?.maxConclusions,
}
)
const 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 response.representation
}
/**
@ -590,7 +590,7 @@ export class Peer {
: target.id
: undefined
const response = await this._client.workspaces.peers.getContext(
const response = await this._client.workspaces.peers.context(
this.workspaceId,
this.id,
{
@ -598,8 +598,8 @@ export class Peer {
search_query: options?.searchQuery,
search_top_k: options?.searchTopK,
search_max_distance: options?.searchMaxDistance,
include_most_derived: options?.includeMostDerived,
max_observations: options?.maxObservations,
include_most_frequent: options?.includeMostFrequent,
max_conclusions: options?.maxConclusions,
}
)
@ -609,61 +609,56 @@ export class Peer {
}
/**
* Access this peer's self-observations (where observer == observed == self).
* Access this peer's self-conclusions (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.
* This property provides a convenient way to access conclusions that this peer
* has made about themselves. Use this for self-conclusion scenarios.
*
* @returns An ObservationScope scoped to this peer's self-observations
* @returns A ConclusionScope scoped to this peer's self-conclusions
*
* @example
* ```typescript
* // List self-observations
* const obsList = await peer.observations.list()
* // List self-conclusions
* const obsList = await peer.conclusions.list()
*
* // Search self-observations
* const results = await peer.observations.query('preferences')
* // Search self-conclusions
* const results = await peer.conclusions.query('preferences')
*
* // Delete a self-observation
* await peer.observations.delete('obs-123')
* // Delete a self-conclusion
* await peer.conclusions.delete('obs-123')
* ```
*/
get observations(): ObservationScope {
return new ObservationScope(
this._client,
this.workspaceId,
this.id,
this.id
)
get conclusions(): ConclusionScope {
return new ConclusionScope(this._client, this.workspaceId, this.id, this.id)
}
/**
* Access observations this peer has made about another peer.
* Access conclusions this peer has made about another peer.
*
* This method provides scoped access to observations where this peer is the
* This method provides scoped access to conclusions 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
* @returns A ConclusionScope scoped to this peer's conclusions of the target
*
* @example
* ```typescript
* // Get observations about another peer
* const bobObservations = peer.observationsOf('bob')
* // Get conclusions about another peer
* const bobConclusions = peer.conclusionsOf('bob')
*
* // List observations
* const obsList = await bobObservations.list()
* // List conclusions
* const obsList = await bobConclusions.list()
*
* // Search observations
* const results = await bobObservations.query('work history')
* // Search conclusions
* const results = await bobConclusions.query('work history')
*
* // Get the representation from these observations
* const rep = await bobObservations.getRepresentation()
* // Get the representation from these conclusions
* const rep = await bobConclusions.getRepresentation()
* ```
*/
observationsOf(target: string | Peer): ObservationScope {
conclusionsOf(target: string | Peer): ConclusionScope {
const targetId = typeof target === 'string' ? target : target.id
return new ObservationScope(
return new ConclusionScope(
this._client,
this.workspaceId,
this.id,
@ -699,7 +694,7 @@ export class PeerContext {
readonly targetId: string
/**
* The working representation (may be null if no observations exist).
* The working representation (may be null if no conclusions exist).
*/
readonly representation: Representation | null

View File

@ -3,12 +3,12 @@
*/
export interface RepresentationOptions {
/**
* Semantic search query to filter relevant observations.
* Semantic search query to filter relevant conclusions.
*/
searchQuery?: string
/**
* Number of semantically relevant facts to return.
* Number of semantically relevant conclusions to return.
*/
searchTopK?: number
@ -18,94 +18,94 @@ export interface RepresentationOptions {
searchMaxDistance?: number
/**
* Whether to include the most derived observations.
* Whether to include the most frequent conclusions.
*/
includeMostDerived?: boolean
includeMostFrequent?: boolean
/**
* Maximum number of observations to include.
* Maximum number of conclusions to include.
*/
maxObservations?: number
maxConclusions?: number
}
/**
* Metadata associated with an observation.
* Metadata associated with a conclusion.
*/
export interface ObservationMetadata {
export interface ConclusionMetadata {
created_at: string
message_ids: Array<[number, number]>
session_name: string
}
/**
* An explicit observation with full metadata.
* An explicit conclusion with full metadata.
* Represents facts LITERALLY stated - direct quotes or clear paraphrases only.
*/
export interface ExplicitObservationBase {
export interface ExplicitConclusionBase {
content: string
}
/**
* Base interface for deductive observations - logical conclusions.
* Base interface for deductive conclusions - logical conclusions.
*/
export interface DeductiveObservationBase {
export interface DeductiveConclusionBase {
premises: string[]
conclusion: string
}
export interface ExplicitObservation
extends ExplicitObservationBase,
ObservationMetadata {}
export interface ExplicitConclusion
extends ExplicitConclusionBase,
ConclusionMetadata {}
/**
* A deductive observation with full metadata.
* A deductive conclusion with full metadata.
* Represents conclusions that MUST be true given explicit facts and premises.
*/
export interface DeductiveObservation
extends DeductiveObservationBase,
ObservationMetadata {}
export interface DeductiveConclusion
extends DeductiveConclusionBase,
ConclusionMetadata {}
/**
* Raw representation data structure returned from the API.
*/
export interface RepresentationData {
explicit: ExplicitObservation[]
deductive: DeductiveObservation[]
explicit: ExplicitConclusion[]
deductive: DeductiveConclusion[]
}
/**
* A Representation is a traversable and diffable map of observations.
* A Representation is a traversable and diffable map of conclusions.
*
* 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.
* At the base, we have a list of explicit conclusions, derived from a peer's messages.
* From there, deductive conclusions can be made by establishing logical relationships
* between explicit conclusions.
*
* All of a peer's observations are stored as documents in a collection. These documents
* All of a peer's conclusions are stored as documents in a collection. These documents
* can be queried in various ways to produce this Representation object.
*
* A "working representation" is a version of this data structure representing the most
* recent observations within a single session.
* recent conclusions within a single session.
*/
export class Representation {
/**
* Facts LITERALLY stated - direct quotes or clear paraphrases only, no interpretation or inference.
*/
explicit: ExplicitObservation[]
explicit: ExplicitConclusion[]
/**
* Conclusions that MUST be true given explicit facts and premises - strict logical necessities.
*/
deductive: DeductiveObservation[]
deductive: DeductiveConclusion[]
/**
* Create a new Representation from observation lists.
* Create a new Representation from conclusion lists.
*
* @param explicit - List of explicit observations
* @param deductive - List of deductive observations
* @param explicit - List of explicit conclusions
* @param deductive - List of deductive conclusions
*/
constructor(
explicit: ExplicitObservation[] = [],
deductive: DeductiveObservation[] = []
explicit: ExplicitConclusion[] = [],
deductive: DeductiveConclusion[] = []
) {
this.explicit = explicit
this.deductive = deductive
@ -114,7 +114,7 @@ export class Representation {
/**
* Check if the representation is empty.
*
* @returns True if both explicit and deductive observation lists are empty
* @returns True if both explicit and deductive conclusion lists are empty
*/
isEmpty(): boolean {
return this.explicit.length === 0 && this.deductive.length === 0
@ -122,13 +122,13 @@ export class Representation {
/**
* Given this and another representation, return a new representation with only
* observations that are unique to the other.
* conclusions that are unique to the other.
*
* Note: This only removes literal duplicates based on stringified comparison,
* not semantically equivalent ones.
*
* @param other - The representation to compare against
* @returns A new Representation containing only observations unique to other
* @returns A new Representation containing only conclusions unique to other
*/
diff(other: Representation): Representation {
const thisExplicitSet = new Set(
@ -151,21 +151,21 @@ export class Representation {
/**
* Merge another representation into this one.
*
* This automatically deduplicates explicit and deductive observations.
* Preserves order of observations to retain FIFO order.
* This automatically deduplicates explicit and deductive conclusions.
* Preserves order of conclusions to retain FIFO order.
*
* Note: Observations with the same timestamp may not have order preserved,
* Note: Conclusions with the same timestamp may not have order preserved,
* but that's acceptable since they're from the same timestamp.
*
* @param other - The representation to merge into this one
* @param maxObservations - Optional maximum number of observations to keep per type
* @param maxConclusions - Optional maximum number of conclusions to keep per type
*/
merge(other: Representation, maxObservations?: number): void {
merge(other: Representation, maxConclusions?: 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>()
const explicitMap = new Map<string, ExplicitConclusion>()
const deductiveMap = new Map<string, DeductiveConclusion>()
// Add existing observations
// Add existing conclusions
for (const obs of this.explicit) {
explicitMap.set(this._hashExplicit(obs), obs)
}
@ -173,7 +173,7 @@ export class Representation {
deductiveMap.set(this._hashDeductive(obs), obs)
}
// Add new observations (overwrites duplicates)
// Add new conclusions (overwrites duplicates)
for (const obs of other.explicit) {
explicitMap.set(this._hashExplicit(obs), obs)
}
@ -193,10 +193,10 @@ export class Representation {
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)
// Apply max conclusions limit if specified
if (maxConclusions !== undefined) {
this.explicit = this.explicit.slice(-maxConclusions)
this.deductive = this.deductive.slice(-maxConclusions)
}
}
@ -293,7 +293,7 @@ export class Representation {
toMarkdown(): string {
const parts: string[] = []
parts.push('## Explicit Observations\n')
parts.push('## Explicit Conclusions\n')
for (let i = 0; i < this.explicit.length; i++) {
const obs = this.explicit[i]
const timestamp = this._stripMicroseconds(obs.created_at)
@ -301,7 +301,7 @@ export class Representation {
}
parts.push('')
parts.push('## Deductive Observations\n')
parts.push('## Deductive Conclusions\n')
for (let i = 0; i < this.deductive.length; i++) {
const obs = this.deductive[i]
const timestamp = this._stripMicroseconds(obs.created_at)
@ -330,10 +330,10 @@ export class Representation {
}
/**
* Create a hash string for an explicit observation for deduplication.
* Create a hash string for an explicit conclusion for deduplication.
* Based on content, created_at, and session_name.
*/
private _hashExplicit(obs: ExplicitObservation): string {
private _hashExplicit(obs: ExplicitConclusion): string {
return JSON.stringify({
content: obs.content,
created_at: obs.created_at,
@ -342,10 +342,10 @@ export class Representation {
}
/**
* Create a hash string for a deductive observation for deduplication.
* Create a hash string for a deductive conclusion for deduplication.
* Based on conclusion, created_at, and session_name (premises not included).
*/
private _hashDeductive(obs: DeductiveObservation): string {
private _hashDeductive(obs: DeductiveConclusion): string {
return JSON.stringify({
conclusion: obs.conclusion,
created_at: obs.created_at,

View File

@ -1,37 +1,30 @@
import type HonchoCore from '@honcho-ai/core'
import type {
DeriverStatus,
WorkspaceDeriverStatusParams,
} from '@honcho-ai/core/resources/index'
QueueStatusParams,
QueueStatusResponse,
} from '@honcho-ai/core/resources/workspaces/queue'
import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages'
import type { Uploadable } from '@honcho-ai/core/uploads'
import { Page } from './pagination'
import { Peer } from './peer'
import {
Representation,
type RepresentationData,
type RepresentationOptions,
} from './representation'
import 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,
FileUploadSchema,
FilterSchema,
type Filters,
GetRepresentationParamsSchema,
LimitSchema,
type MessageAddition,
MessageAdditionSchema,
// ObservationQueryParamsSchema, // Disabled: observations not ready for release
type PeerAddition,
PeerAdditionSchema,
type PeerRemoval,
PeerRemovalSchema,
type QueueStatusOptions,
SearchQuerySchema,
SessionPeerConfigSchema,
WorkingRepParamsSchema,
} from './validation'
/**
@ -57,7 +50,7 @@ export class SessionPeerConfig {
observe_others?: boolean | null
/**
* Initialize SessionPeerConfig with observation settings.
* Initialize SessionPeerConfig with conclusion settings.
*
* @param observe_me - Whether other peers should observe this peer in the session
* @param observe_others - Whether this peer should observe others in the session
@ -82,7 +75,7 @@ export class SessionPeerConfig {
* representations of each other.
*
* Key features:
* - Multi-peer conversations with configurable observation settings
* - Multi-peer conversations with configurable conclusion settings
* - Message storage and retrieval with filtering capabilities
* - Context optimization for token-limited scenarios
* - File upload support with automatic message creation
@ -179,7 +172,7 @@ export class Session {
* Makes an API call to add one or more peers to this session. Adding peers
* creates bidirectional relationships and allows them to participate in
* the session's conversations. Peers can be added with optional session-specific
* configuration to control observation behaviors.
* configuration to control conclusion behaviors.
*
* @param peers - Peers to add to the session. Can be:
* - string: Single peer ID
@ -336,7 +329,7 @@ export class Session {
* Get the configuration for a peer in this session.
*
* Makes an API call to retrieve the session-specific configuration for a peer.
* This includes observation settings that control how this peer interacts
* This includes conclusion settings that control how this peer interacts
* with other peers within this session context.
*
* @param peer - The peer to get configuration for. Can be peer ID string or Peer object
@ -344,7 +337,7 @@ export class Session {
*/
async getPeerConfig(peer: string | Peer): Promise<SessionPeerConfig> {
const peerId = typeof peer === 'string' ? peer : peer.id
return await this._client.workspaces.sessions.peers.getConfig(
return await this._client.workspaces.sessions.peers.config(
this.workspaceId,
this.id,
peerId
@ -355,11 +348,11 @@ export class Session {
* Set the configuration for a peer in this session.
*
* Makes an API call to update the session-specific configuration for a peer.
* This controls observation behaviors and theory-of-mind formation within
* This controls conclusion behaviors and theory-of-mind formation within
* this session context.
*
* @param peer - The peer to configure. Can be peer ID string or Peer object
* @param config - SessionPeerConfig object specifying the observation settings
* @param config - SessionPeerConfig object specifying the conclusion settings
*/
async setPeerConfig(
peer: string | Peer,
@ -541,7 +534,7 @@ export class Session {
* Makes an API call to permanently delete this session and all related data including:
* - Messages
* - Message embeddings
* - Observations
* - Conclusions
* - Session-Peer associations
* - Background processing queue items
*
@ -608,7 +601,7 @@ export class Session {
* If given with `peerPerspective`, will get the representation and card
* for this peer from the perspective of that peer.
* @param options.lastUserMessage - The most recent message, used to fetch semantically relevant
* observations and returned as part of the context object.
* conclusions and returned as part of the context object.
* Can be either a message ID string or a Message object.
* @param options.peerPerspective - A peer to get context for. If given, response will attempt to
* include representation and card from the perspective of that peer.
@ -707,7 +700,7 @@ export class Session {
? contextParams.lastUserMessage
: contextParams.lastUserMessage?.id
const context = await this._client.workspaces.sessions.getContext(
const context = await this._client.workspaces.sessions.context(
this.workspaceId,
this.id,
{
@ -720,9 +713,9 @@ export class Session {
search_top_k: contextParams.representationOptions?.searchTopK,
search_max_distance:
contextParams.representationOptions?.searchMaxDistance,
include_most_derived:
contextParams.representationOptions?.includeMostDerived,
max_observations: contextParams.representationOptions?.maxObservations,
include_most_frequent:
contextParams.representationOptions?.includeMostFrequent,
max_conclusions: contextParams.representationOptions?.maxConclusions,
}
)
// Convert the summary response to Summary object if present
@ -803,105 +796,20 @@ export class Session {
}
/**
* List all observations for this session.
* Get the queue processing status for this session, optionally scoped to an observer or sender.
*
* 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.
*
* Makes an API call to retrieve the current status of the deriver processing queue.
* The deriver is responsible for processing messages and updating peer representations.
* Makes an API call to retrieve the current status of the queue processing queue.
* The queue is responsible for processing messages and updating peer representations.
* This method automatically scopes the status to this session.
*
* @param options - Configuration options for the status request
* @param options.observer - Optional observer (ID string or Peer object) to scope the status to
* @param options.sender - Optional sender (ID string or Peer object) to scope the status to
* @returns Promise resolving to the deriver status information including work unit counts
* @returns Promise resolving to the queue status information including work unit counts
*/
async getDeriverStatus(
async getQueueStatus(
options?: Omit<
DeriverStatusOptions,
QueueStatusOptions,
'sessionId' | 'observerId' | 'senderId'
> & {
observer?: string | Peer
@ -912,7 +820,7 @@ export class Session {
completedWorkUnits: number
inProgressWorkUnits: number
pendingWorkUnits: number
sessions?: Record<string, DeriverStatus.Sessions>
sessions?: Record<string, QueueStatusResponse.Sessions>
}> {
const resolvedObserverId = options?.observer
? typeof options.observer === 'string'
@ -925,13 +833,13 @@ export class Session {
: options.sender.id
: undefined
const queryParams: WorkspaceDeriverStatusParams = {
const queryParams: QueueStatusParams = {
session_id: this.id, // Always use this session's ID
}
if (resolvedObserverId) queryParams.observer_id = resolvedObserverId
if (resolvedSenderId) queryParams.sender_id = resolvedSenderId
const status = await this._client.workspaces.deriverStatus(
const status = await this._client.workspaces.queue.status(
this.workspaceId,
queryParams
)
@ -946,8 +854,8 @@ export class Session {
}
/**
* Poll getDeriverStatus until pending_work_units and in_progress_work_units are both 0.
* This allows you to guarantee that all messages have been processed by the deriver for
* Poll getQueueStatus until pending_work_units and in_progress_work_units are both 0.
* This allows you to guarantee that all messages have been processed by the queue for
* use with the dialectic endpoint.
*
* The polling estimates sleep time by assuming each work unit takes 1 second.
@ -956,12 +864,12 @@ export class Session {
* @param options.observer - Optional observer (ID string or Peer object) to scope the status to
* @param options.sender - Optional sender (ID string or Peer object) to scope the status to
* @param options.timeoutMs - Optional timeout in milliseconds (default: 300000 - 5 minutes)
* @returns Promise resolving to the final deriver status when processing is complete
* @returns Promise resolving to the final queue status when processing is complete
* @throws Error if timeout is exceeded before processing completes
*/
async pollDeriverStatus(
async pollQueueStatus(
options?: Omit<
DeriverStatusOptions,
QueueStatusOptions,
'sessionId' | 'observerId' | 'senderId'
> & {
observer?: string | Peer
@ -972,13 +880,13 @@ export class Session {
completedWorkUnits: number
inProgressWorkUnits: number
pendingWorkUnits: number
sessions?: Record<string, DeriverStatus.Sessions>
sessions?: Record<string, QueueStatusResponse.Sessions>
}> {
const timeoutMs = options?.timeoutMs ?? 300000 // Default to 5 minutes
const startTime = Date.now()
while (true) {
const status = await this.getDeriverStatus(options)
const status = await this.getQueueStatus(options)
if (status.pendingWorkUnits === 0 && status.inProgressWorkUnits === 0) {
return status
}
@ -1095,7 +1003,7 @@ export class Session {
}
/**
* Get the current working representation of a peer in this session.
* Get a subset of Honcho's Representation of a peer in this session.
*
* Makes an API call to retrieve the session-scoped representation that has been
* built for a peer. This can be either the peer's global representation or
@ -1105,71 +1013,65 @@ export class Session {
* @param target - Optional target peer. If provided, returns what `peer` knows about
* `target` within this session context rather than `peer`'s global representation
* @param options - Optional representation options to filter and configure the results
* @returns Promise resolving to a Representation object containing explicit and deductive observations
* @returns Promise resolving to a Representation string
*
* @example
* ```typescript
* // Get peer's global representation in this session
* const globalRep = await session.workingRep('user123')
* console.log(globalRep.toString())
* const globalRep = await session.getRepresentation('user123')
* console.log(globalRep)
*
* // Get what user123 knows about assistant in this session
* const localRep = await session.workingRep('user123', 'assistant')
* const localRep = await session.getRepresentation('user123', 'assistant')
*
* // Get representation with semantic search
* const searchedRep = await session.workingRep('user123', undefined, {
* const searchedRep = await session.getRepresentation('user123', undefined, {
* searchQuery: 'preferences',
* searchTopK: 10
* })
* ```
*/
async workingRep(
async getRepresentation(
peer: string | Peer,
target?: string | Peer,
options?: {
searchQuery?: string
searchTopK?: number
searchMaxDistance?: number
includeMostDerived?: boolean
maxObservations?: number
includeMostFrequent?: boolean
maxConclusions?: number
}
): Promise<Representation> {
const workingRepParams = WorkingRepParamsSchema.parse({
): Promise<string> {
const getRepresentationParams = GetRepresentationParamsSchema.parse({
peer,
target,
options,
})
const peerId =
typeof workingRepParams.peer === 'string'
? workingRepParams.peer
: workingRepParams.peer.id
const targetId = workingRepParams.target
? typeof workingRepParams.target === 'string'
? workingRepParams.target
: workingRepParams.target.id
typeof getRepresentationParams.peer === 'string'
? getRepresentationParams.peer
: getRepresentationParams.peer.id
const targetId = getRepresentationParams.target
? typeof getRepresentationParams.target === 'string'
? getRepresentationParams.target
: getRepresentationParams.target.id
: undefined
const response = await this._client.workspaces.peers.workingRepresentation(
const response = await this._client.workspaces.peers.representation(
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,
search_query: getRepresentationParams.options?.searchQuery,
search_top_k: getRepresentationParams.options?.searchTopK,
search_max_distance: getRepresentationParams.options?.searchMaxDistance,
include_most_frequent:
getRepresentationParams.options?.includeMostFrequent,
max_conclusions: getRepresentationParams.options?.maxConclusions,
}
)
const 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 response.representation
}
/**

View File

@ -5,9 +5,9 @@ import type { Session } from './session'
*/
/**
* Observation - external view of a document (theory-of-mind data).
* Conclusion - external view of a document (theory-of-mind data).
*/
export interface Observation {
export interface Conclusion {
id: string
content: string
observer_id: string
@ -17,19 +17,19 @@ export interface Observation {
}
/**
* Parameters for creating an observation.
* Parameters for creating a conclusion.
*/
export interface ObservationCreateParam {
/** The observation content/text */
export interface ConclusionCreateParam {
/** The conclusion content/text */
content: string
/** The session this observation relates to (ID string or Session object) */
/** The session this conclusion relates to (ID string or Session object) */
sessionId: string | Session
}
/**
* Parameters for semantic search of observations.
* Parameters for semantic search of conclusions.
*/
export interface ObservationQueryParams {
export interface ConclusionQueryParams {
query: string
top_k?: number
distance?: number
@ -41,10 +41,6 @@ export interface ObservationQueryParams {
*/
export interface DialecticStreamDelta {
content?: string
// Future fields can be added here:
// premises?: string[]
// tokens?: number
// analytics?: Record<string, unknown>
}
/**

View File

@ -14,7 +14,7 @@ import { z } from 'zod'
export const HonchoConfigSchema = z.object({
apiKey: z.string().optional(),
environment: z.enum(['local', 'production']).optional(),
baseURL: z.string().url('Base URL must be a valid URL').optional(),
baseURL: z.url('Base URL must be a valid URL').optional(),
workspaceId: z
.string()
.min(1, 'Workspace ID must be a non-empty string')
@ -142,6 +142,9 @@ export const ChatQuerySchema = z.object({
.transform((val) =>
val ? (typeof val === 'string' ? val : val.id) : undefined
),
reasoningLevel: z
.enum(['minimal', 'low', 'medium', 'high', 'extra-high'])
.optional(),
})
/**
@ -173,12 +176,12 @@ export const RepresentationOptionsSchema = z.object({
.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
includeMostFrequent: z.boolean().optional(),
maxConclusions: z
.number()
.int()
.min(1, 'maxObservations must be at least 1')
.max(100, 'maxObservations must be at most 100')
.min(1, 'maxConclusions must be at least 1')
.max(100, 'maxConclusions must be at most 100')
.optional(),
})
@ -224,7 +227,7 @@ export const ContextParamsSchema = z
/**
* Schema for deriver status options.
*/
export const DeriverStatusOptionsSchema = z.object({
export const QueueStatusOptionsSchema = z.object({
observer: z.union([z.string(), z.object({ id: z.string() })]).optional(),
sender: z.union([z.string(), z.object({ id: z.string() })]).optional(),
session: z.union([z.string(), z.object({ id: z.string() })]).optional(),
@ -269,9 +272,9 @@ export const FileUploadSchema = z.object({
})
/**
* Schema for working representation parameters.
* Schema for get representation parameters.
*/
export const WorkingRepParamsSchema = z.object({
export const GetRepresentationParamsSchema = z.object({
peer: z.union([z.string(), z.object({ id: z.string() })]),
target: z.union([z.string(), z.object({ id: z.string() })]).optional(),
options: RepresentationOptionsSchema.extend({
@ -280,9 +283,9 @@ export const WorkingRepParamsSchema = z.object({
})
/**
* Schema for peer working representation parameters.
* Schema for peer get representation parameters.
*/
export const PeerWorkingRepParamsSchema = z.object({
export const PeerGetRepresentationParamsSchema = z.object({
session: z.union([z.string(), z.object({ id: z.string() })]).optional(),
target: z.union([z.string(), z.object({ id: z.string() })]).optional(),
options: RepresentationOptionsSchema.extend({
@ -349,9 +352,9 @@ export const LimitSchema = z
.max(100, 'Limit must be less than or equal to 100')
/**
* Schema for observation query parameters.
* Schema for conclusion query parameters.
*/
export const ObservationQueryParamsSchema = z.object({
export const ConclusionQueryParamsSchema = z.object({
query: SearchQuerySchema,
top_k: z
.number()
@ -380,16 +383,18 @@ export type MessageCreate = z.infer<typeof MessageCreateSchema>
export type Filters = z.infer<typeof FilterSchema>
export type ChatQuery = z.infer<typeof ChatQuerySchema>
export type ContextParams = z.infer<typeof ContextParamsSchema>
export type DeriverStatusOptions = z.infer<typeof DeriverStatusOptionsSchema>
export type QueueStatusOptions = z.infer<typeof QueueStatusOptionsSchema>
export type FileUpload = z.infer<typeof FileUploadSchema>
export type WorkingRepParams = z.infer<typeof WorkingRepParamsSchema>
export type PeerWorkingRepParams = z.infer<typeof PeerWorkingRepParamsSchema>
export type GetRepresentationParams = z.infer<
typeof GetRepresentationParamsSchema
>
export type PeerGetRepresentationParams = z.infer<
typeof PeerGetRepresentationParamsSchema
>
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
>
export type ConclusionQueryParams = z.infer<typeof ConclusionQueryParamsSchema>

View File

@ -323,6 +323,19 @@ class DialecticLevelSettings(BaseModel):
)
return self
@model_validator(mode="after")
def _validate_anthropic_thinking_budget(self) -> "DialecticLevelSettings":
"""Ensure Anthropic thinking budget is >= 1024 when enabled."""
if (
self.PROVIDER == "anthropic"
and self.THINKING_BUDGET_TOKENS > 0
and self.THINKING_BUDGET_TOKENS < 1024
):
raise ValueError(
f"THINKING_BUDGET_TOKENS must be >= 1024 for Anthropic provider when enabled (got {self.THINKING_BUDGET_TOKENS})"
)
return self
class DialecticSettings(HonchoSettings):
model_config = SettingsConfigDict( # pyright: ignore
@ -341,14 +354,14 @@ class DialecticSettings(HonchoSettings):
),
"low": DialecticLevelSettings(
PROVIDER="google",
MODEL="gemini-3-flash",
MODEL="gemini-3-flash-preview",
THINKING_BUDGET_TOKENS=0,
MAX_TOOL_ITERATIONS=5,
),
"medium": DialecticLevelSettings(
PROVIDER="anthropic",
MODEL="claude-haiku-4-5",
THINKING_BUDGET_TOKENS=512,
THINKING_BUDGET_TOKENS=1024,
MAX_TOOL_ITERATIONS=4,
),
"high": DialecticLevelSettings(
@ -360,7 +373,7 @@ class DialecticSettings(HonchoSettings):
"extra-high": DialecticLevelSettings(
PROVIDER="anthropic",
MODEL="claude-opus-4-5",
THINKING_BUDGET_TOKENS=512,
THINKING_BUDGET_TOKENS=2048,
MAX_TOOL_ITERATIONS=10,
),
}

View File

@ -13,6 +13,7 @@ from src.crud.workspace import get_or_create_workspace
from src.exceptions import ConflictException, ResourceNotFoundException
from src.models import Peer
from src.utils.filter import apply_filter
from src.utils.types import GetOrCreateResult
logger = getLogger(__name__)
@ -38,7 +39,7 @@ async def get_or_create_peers(
peers: list[schemas.PeerCreate],
*,
_retry: bool = False,
) -> list[models.Peer]:
) -> GetOrCreateResult[list[models.Peer]]:
"""
Get an existing list of peers or create new peers if they don't exist.
Updates existing peers with metadata and configuration if provided.
@ -50,7 +51,7 @@ async def get_or_create_peers(
_retry: Whether to retry the operation
Returns:
List of peers if found or created
GetOrCreateResult containing the list of peers and whether any were created
Raises:
ConflictException: If we fail to get or create the peers
@ -132,7 +133,8 @@ async def get_or_create_peers(
)
# Return combined list of existing and new peers
return existing_peers + new_peers
# created=True if any new peers were created
return GetOrCreateResult(existing_peers + new_peers, created=len(new_peers) > 0)
@cache(
@ -224,7 +226,7 @@ async def update_peer(
await get_or_create_peers(
db, workspace_name, [schemas.PeerCreate(name=peer_name)]
)
)[0]
).resource[0]
needs_update = False

View File

@ -18,6 +18,7 @@ from src.exceptions import (
ResourceNotFoundException,
)
from src.utils.filter import apply_filter
from src.utils.types import GetOrCreateResult
from .peer import get_or_create_peers, get_peer
from .workspace import get_or_create_workspace
@ -102,7 +103,7 @@ async def get_or_create_session(
workspace_name: str,
*,
_retry: bool = False,
) -> models.Session:
) -> GetOrCreateResult[models.Session]:
"""
Get or create a session in a workspace with specified peers.
If the session already exists, the peers are added to the session.
@ -114,7 +115,7 @@ async def get_or_create_session(
peer_names: List of peer names to add to the session
_retry: Whether to retry the operation
Returns:
The created session
GetOrCreateResult containing the session and whether it was created
Raises:
ResourceNotFoundException: If the session does not exist and create is false
@ -136,8 +137,9 @@ async def get_or_create_session(
f"Session {session.name} not found in workspace {workspace_name}"
)
# Track if we need to update cache
# Track if we need to update cache and if session was created
needs_cache_update = False
created = False
# Check if session already exists
if honcho_session is None:
@ -167,6 +169,7 @@ async def get_or_create_session(
# Flush to ensure session exists in DB before adding peers and set flag to warm cache
await db.flush()
needs_cache_update = True
created = True
except IntegrityError:
await db.rollback()
@ -224,7 +227,7 @@ async def get_or_create_session(
"Session %s cache updated in workspace %s", session.name, workspace_name
)
return honcho_session
return GetOrCreateResult(honcho_session, created=created)
async def get_session(
@ -290,9 +293,11 @@ async def update_session(
Raises:
ResourceNotFoundException: If the session does not exist or peer is not in session
"""
honcho_session = await get_or_create_session(
db, schemas.SessionCreate(name=session_name), workspace_name=workspace_name
)
honcho_session: models.Session = (
await get_or_create_session(
db, schemas.SessionCreate(name=session_name), workspace_name=workspace_name
)
).resource
# Track if anything changed
needs_update = False

View File

@ -7,6 +7,7 @@ from src import models, schemas
from src.config import settings
from src.crud.workspace import get_workspace
from src.exceptions import ResourceNotFoundException
from src.utils.types import GetOrCreateResult
logger = getLogger(__name__)
@ -15,7 +16,7 @@ async def get_or_create_webhook_endpoint(
db: AsyncSession,
workspace_name: str,
webhook: schemas.WebhookEndpointCreate,
) -> schemas.WebhookEndpoint:
) -> GetOrCreateResult[schemas.WebhookEndpoint]:
"""
Get or create a webhook endpoint, optionally for a workspace.
@ -24,7 +25,7 @@ async def get_or_create_webhook_endpoint(
webhook: Webhook endpoint creation schema
Returns:
The webhook endpoint
GetOrCreateResult containing the webhook endpoint and whether it was created
Raises:
ResourceNotFoundException: If the workspace is specified and does not exist
@ -47,7 +48,9 @@ async def get_or_create_webhook_endpoint(
# Check if webhook already exists for this workspace
for endpoint in endpoints:
if endpoint.url == webhook.url:
return schemas.WebhookEndpoint.model_validate(endpoint)
return GetOrCreateResult(
schemas.WebhookEndpoint.model_validate(endpoint), created=False
)
# Create new webhook endpoint
webhook_endpoint = models.WebhookEndpoint(
@ -59,7 +62,9 @@ async def get_or_create_webhook_endpoint(
await db.refresh(webhook_endpoint)
logger.debug("Webhook endpoint created: %s", webhook.url)
return schemas.WebhookEndpoint.model_validate(webhook_endpoint)
return GetOrCreateResult(
schemas.WebhookEndpoint.model_validate(webhook_endpoint), created=True
)
async def list_webhook_endpoints(

View File

@ -11,6 +11,7 @@ from src.cache.client import cache, get_cache_namespace
from src.config import settings
from src.exceptions import ConflictException, ResourceNotFoundException
from src.utils.filter import apply_filter
from src.utils.types import GetOrCreateResult
logger = getLogger(__name__)
@ -52,7 +53,7 @@ async def get_or_create_workspace(
workspace: schemas.WorkspaceCreate,
*,
_retry: bool = False,
) -> models.Workspace:
) -> GetOrCreateResult[models.Workspace]:
"""
Get an existing workspace or create a new one if it doesn't exist.
@ -61,7 +62,7 @@ async def get_or_create_workspace(
workspace: Workspace creation schema
Returns:
The workspace if found or created
GetOrCreateResult containing the workspace and whether it was created
Raises:
ConflictException: If we fail to get or create the workspace
@ -77,7 +78,7 @@ async def get_or_create_workspace(
logger.debug("Found existing workspace: %s", workspace.name)
# Merge cached object into session (cached objects are detached)
existing_workspace = await db.merge(existing_workspace, load=False)
return existing_workspace
return GetOrCreateResult(existing_workspace, created=False)
# Workspace doesn't exist, create a new one
honcho_workspace = models.Workspace(
@ -96,7 +97,7 @@ async def get_or_create_workspace(
await cache.set(
cache_key, honcho_workspace, expire=settings.CACHE.DEFAULT_TTL_SECONDS
)
return honcho_workspace
return GetOrCreateResult(honcho_workspace, created=True)
except IntegrityError:
await db.rollback()
if _retry:
@ -164,13 +165,16 @@ async def update_workspace(
Returns:
The updated workspace
"""
honcho_workspace = await get_or_create_workspace(
db,
schemas.WorkspaceCreate(
name=workspace_name,
metadata=workspace.metadata or {}, # Provide empty dict if metadata is None
),
)
honcho_workspace: models.Workspace = (
await get_or_create_workspace(
db,
schemas.WorkspaceCreate(
name=workspace_name,
metadata=workspace.metadata
or {}, # Provide empty dict if metadata is None
),
)
).resource
# Track if anything changed
needs_update = False

View File

@ -61,8 +61,8 @@ async def process_representation_tasks_batch(
),
)
# Skip if deriver disabled
if message_level_configuration.deriver.enabled is False:
# Skip if disabled
if message_level_configuration.reasoning.enabled is False:
return
accumulate_metric(

View File

@ -2,8 +2,7 @@ import logging
from datetime import datetime, timezone
from typing import Any, Literal
from sqlalchemy import exists, insert, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy import exists, insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models, schemas
@ -94,11 +93,13 @@ async def handle_session(
Returns:
List of queue records to insert
"""
session = await crud.get_or_create_session(
db_session,
session=schemas.SessionCreate(name=session_name),
workspace_name=workspace_name,
)
session = (
await crud.get_or_create_session(
db_session,
session=schemas.SessionCreate(name=session_name),
workspace_name=workspace_name,
)
).resource
# Fetch workspace for configuration resolution
workspace = await crud.get_workspace(db_session, workspace_name=workspace_name)
@ -337,7 +338,7 @@ async def generate_queue_records(
# Check if the sender should be observed based on peer configuration
should_observe = get_effective_observe_me(observed, peers_with_configuration)
if not conf.deriver.enabled:
if not conf.reasoning.enabled:
return records
if should_observe:
@ -486,18 +487,18 @@ async def enqueue_dream(
)
return
stmt = (
pg_insert(QueueItem)
.values(dream_record)
.on_conflict_do_nothing(
index_elements=[QueueItem.work_unit_key],
index_where=text("task_type = 'dream' AND processed = false"),
# Check if there's already a pending dream with the same work_unit_key
pending_check = select(
exists(
select(QueueItem.id).where(
QueueItem.work_unit_key == work_unit_key,
QueueItem.processed == False, # noqa: E712
)
)
.returning(QueueItem.id)
)
result = await db_session.execute(stmt)
inserted_id = result.scalar_one_or_none()
if inserted_id is None:
is_pending = await db_session.scalar(pending_check)
if is_pending:
logger.info(
"Dream already pending in queue: %s/%s/%s (type: %s)",
workspace_name,
@ -507,6 +508,10 @@ async def enqueue_dream(
)
return
# 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 = (

View File

@ -12,6 +12,7 @@ from src import crud
from src.config import ReasoningLevel
from src.dependencies import tracked_db
from src.dialectic.core import DialecticAgent
from src.utils.config_helpers import get_configuration
logger = logging.getLogger(__name__)
@ -39,15 +40,26 @@ async def agentic_chat(
The synthesized answer string
"""
async with tracked_db("dialectic.agentic_chat") as db:
# Get peer cards for context
observer_peer_card = await crud.get_peer_card(
db, workspace_name, observer=observer, observed=observer
)
observed_peer_card = None
if observer != observed:
observed_peer_card = await crud.get_peer_card(
db, workspace_name, observer=observer, observed=observed
# Resolve configuration to check if peer cards should be used
session = None
if session_name:
session = await crud.get_session(
db, workspace_name=workspace_name, session_name=session_name
)
workspace = await crud.get_workspace(db, workspace_name=workspace_name)
configuration = get_configuration(None, session, workspace)
# Get peer cards for context (if enabled)
observer_peer_card = None
observed_peer_card = None
if configuration.peer_card.use:
observer_peer_card = await crud.get_peer_card(
db, workspace_name, observer=observer, observed=observer
)
if observer != observed:
observed_peer_card = await crud.get_peer_card(
db, workspace_name, observer=observer, observed=observed
)
# Create and run the dialectic agent
agent = DialecticAgent(
@ -89,15 +101,26 @@ async def agentic_chat_stream(
Chunks of the response text as they are generated
"""
async with tracked_db("dialectic.agentic_chat_stream") as db:
# Get peer cards for context
observer_peer_card = await crud.get_peer_card(
db, workspace_name, observer=observer, observed=observer
)
observed_peer_card = None
if observer != observed:
observed_peer_card = await crud.get_peer_card(
db, workspace_name, observer=observer, observed=observed
# Resolve configuration to check if peer cards should be used
session = None
if session_name:
session = await crud.get_session(
db, workspace_name=workspace_name, session_name=session_name
)
workspace = await crud.get_workspace(db, workspace_name=workspace_name)
configuration = get_configuration(None, session, workspace)
# Get peer cards for context (if enabled)
observer_peer_card = None
observed_peer_card = None
if configuration.peer_card.use:
observer_peer_card = await crud.get_peer_card(
db, workspace_name, observer=observer, observed=observer
)
if observer != observed:
observed_peer_card = await crud.get_peer_card(
db, workspace_name, observer=observer, observed=observed
)
# Create and run the dialectic agent
agent = DialecticAgent(

View File

@ -21,6 +21,10 @@ def agent_system_prompt(
Returns:
Formatted system prompt string for the agent
"""
# Determine if we have any peer card data
peer_cards_enabled = (
observer_peer_card is not None or observed_peer_card is not None
)
# Build peer card sections
if observer != observed:
# Directional query: observer asking about observed
@ -64,6 +68,15 @@ Known biographical information about {observed}:
You are answering queries about '{observed}'.
{peer_card_section}
"""
# Build peer card explanation section (only if peer cards are being used)
peer_card_explanation = ""
if peer_cards_enabled:
peer_card_explanation = """
Peer cards are **constructed summaries** - they are synthesized from the same observations stored in memory. This means:
- Information in a peer card originates from observations you can also find via `search_memory`
- The peer card is a convenience summary, not a separate source of truth
"""
return f"""
@ -72,11 +85,7 @@ You are a helpful and concise context synthesis agent that answers questions abo
Always give users the answer *they expect* based on the message history -- the goal is to help recall and *reason through* insights that the memory system has already gathered. You have many tools for gathering context. Search wisely.
{perspective_section}
Peer cards are **constructed summaries** - they are synthesized from the same observations stored in memory. This means:
- Information in a peer card originates from observations you can also find via `search_memory`
- The peer card is a convenience summary, not a separate source of truth
{peer_card_explanation}
## AVAILABLE TOOLS
**Observation Tools (read):**

View File

@ -200,7 +200,9 @@ class DreamScheduler:
) -> None:
"""Execute the dream by enqueueing it and updating collection metadata."""
# Import here to avoid circular dependency
from src import crud
from src.deriver.enqueue import enqueue_dream
from src.utils.config_helpers import get_configuration
# Find the most recent session for this observer/observed pair
async with tracked_db("dream_session_lookup") as db:
@ -216,11 +218,24 @@ class DreamScheduler:
)
session_name = await db.scalar(stmt)
if not session_name:
logger.warning(
f"No documents found for {workspace_name}/{observer}/{observed}, skipping dream"
if not session_name:
logger.warning(
f"No documents found for {workspace_name}/{observer}/{observed}, skipping dream"
)
return
session = await crud.get_session(
db, workspace_name=workspace_name, session_name=session_name
)
return
workspace = await crud.get_workspace(db, workspace_name=workspace_name)
configuration = get_configuration(None, session, workspace)
if not configuration.dream.enabled:
logger.info(
f"Dreams disabled for {workspace_name}/{session_name}, skipping dream"
)
return
await enqueue_dream(
workspace_name,

View File

@ -17,10 +17,12 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud
from src.config import settings
from src.dreamer.specialists import SPECIALISTS
from src.dreamer.surprisal import SurprisalScore # type: ignore
from src.exceptions import SpecialistExecutionError, SurprisalError
from src.utils.config_helpers import get_configuration
from src.utils.logging import (
accumulate_metric,
log_performance_metrics,
@ -71,6 +73,8 @@ async def run_dream(
observed: Observed peer name
session_name: Session identifier
"""
if not settings.DREAM.ENABLED:
return
run_id = str(uuid.uuid4())[:8]
task_name = f"dream_orchestrator_{run_id}"
@ -80,6 +84,18 @@ async def run_dream(
f"[{run_id}] Starting dream cycle for {workspace_name}/{observer}/{observed}"
)
session = await crud.get_session(
db, workspace_name=workspace_name, session_name=session_name
)
workspace = await crud.get_workspace(db, workspace_name=workspace_name)
configuration = get_configuration(None, session, workspace)
if not configuration.dream.enabled:
logger.info(
f"[{run_id}] Dreams disabled for {workspace_name}/{session_name}, skipping dream"
)
return
# Phase 0: Surprisal-based sampling (if enabled)
probing_questions = PROBING_QUESTIONS # Default
@ -149,6 +165,7 @@ async def run_dream(
observed=observed,
session_name=session_name,
probing_questions=probing_questions,
configuration=configuration,
)
logger.info(f"[{run_id}] Deduction completed: {deduction_result[:200]}...")
accumulate_metric(task_name, "deduction_result", deduction_result, "blob")
@ -167,6 +184,7 @@ async def run_dream(
observed=observed,
session_name=session_name,
probing_questions=probing_questions,
configuration=configuration,
)
logger.info(f"[{run_id}] Induction completed: {induction_result[:200]}...")
accumulate_metric(task_name, "induction_result", induction_result, "blob")

View File

@ -21,6 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import prometheus
from src.config import settings
from src.schemas import ResolvedConfiguration
from src.utils.agent_tools import (
DEDUCTION_SPECIALIST_TOOLS,
INDUCTION_SPECIALIST_TOOLS,
@ -32,13 +33,17 @@ from src.utils.logging import accumulate_metric, log_performance_metrics
logger = logging.getLogger(__name__)
# Tool names to exclude when peer card creation is disabled
PEER_CARD_TOOL_NAMES = {"update_peer_card", "get_peer_card"}
class BaseSpecialist(ABC):
"""Base class for agentic specialists."""
name: str = "base"
@abstractmethod
def get_tools(self) -> list[dict[str, Any]]:
def get_tools(self, *, peer_card_enabled: bool = True) -> list[dict[str, Any]]:
"""Get the tools available to this specialist."""
...
@ -56,7 +61,9 @@ class BaseSpecialist(ABC):
return 15
@abstractmethod
def build_system_prompt(self, observed: str) -> str:
def build_system_prompt(
self, observed: str, *, peer_card_enabled: bool = True
) -> str:
"""Build the system prompt for this specialist."""
...
@ -73,6 +80,7 @@ class BaseSpecialist(ABC):
observed: str,
session_name: str,
probing_questions: list[str],
configuration: ResolvedConfiguration | None = None,
) -> str:
"""
Run the specialist agent.
@ -84,6 +92,7 @@ class BaseSpecialist(ABC):
observed: The peer being observed
session_name: Session identifier
probing_questions: Entry point questions to guide exploration
configuration: Resolved configuration for checking feature flags (optional)
Returns:
Summary of work done
@ -92,9 +101,17 @@ class BaseSpecialist(ABC):
task_name = f"dreamer_{self.name}_{run_id}"
start_time = time.perf_counter()
# Determine if peer card tools should be included
peer_card_enabled = configuration is None or configuration.peer_card.create
# Build messages
messages: list[dict[str, str]] = [
{"role": "system", "content": self.build_system_prompt(observed)},
{
"role": "system",
"content": self.build_system_prompt(
observed, peer_card_enabled=peer_card_enabled
),
},
{"role": "user", "content": self.build_user_prompt(probing_questions)},
]
@ -109,6 +126,7 @@ class BaseSpecialist(ABC):
session_name=session_name,
include_observation_ids=True,
history_token_limit=settings.DREAM.HISTORY_TOKEN_LIMIT,
configuration=configuration,
)
# Get model with potential override
@ -120,7 +138,7 @@ class BaseSpecialist(ABC):
llm_settings=llm_settings,
prompt="", # Ignored since we pass messages
max_tokens=self.get_max_tokens(),
tools=self.get_tools(),
tools=self.get_tools(peer_card_enabled=peer_card_enabled),
tool_choice=None,
tool_executor=tool_executor,
max_tool_iterations=self.get_max_iterations(),
@ -172,8 +190,14 @@ class DeductionSpecialist(BaseSpecialist):
name: str = "deduction"
def get_tools(self) -> list[dict[str, Any]]:
return DEDUCTION_SPECIALIST_TOOLS
def get_tools(self, *, peer_card_enabled: bool = True) -> list[dict[str, Any]]:
if peer_card_enabled:
return DEDUCTION_SPECIALIST_TOOLS
return [
t
for t in DEDUCTION_SPECIALIST_TOOLS
if t["name"] not in PEER_CARD_TOOL_NAMES
]
def get_model(self) -> str:
return settings.DREAM.DEDUCTION_MODEL
@ -184,7 +208,50 @@ class DeductionSpecialist(BaseSpecialist):
def get_max_iterations(self) -> int:
return 12
def build_system_prompt(self, observed: str) -> str:
def build_system_prompt(
self, observed: str, *, peer_card_enabled: bool = True
) -> str:
# Base tools list
tools_section = """## TOOLS
- `search_memory`: Find observations by semantic query
- `create_observations`: Create new deductive OR contradiction observations (USE THIS!)
- `delete_observations`: Remove outdated observations (USE AFTER KNOWLEDGE UPDATES!)
- `get_recent_observations`: See recent activity"""
if peer_card_enabled:
tools_section += """
- `get_peer_card`: Retrieve current peer card contents
- `update_peer_card`: Update the peer card with key facts"""
# Peer card section (only if enabled)
peer_card_section = ""
if peer_card_enabled:
peer_card_section = """
## PEER CARD UPDATES
The peer card is a concise summary of permanent, stable information about the peer. Update it when you discover important facts that should be easily accessible.
**Peer card format** - Use these prefixes to organize entries:
- Plain facts for biographical info: "Name: Alice", "Works at Google", "Lives in NYC"
- `INSTRUCTION: ...` for standing instructions: "INSTRUCTION: Always call me Al", "INSTRUCTION: Send meeting agendas 24h in advance"
- `PREFERENCE: ...` for preferences: "PREFERENCE: Prefers morning meetings", "PREFERENCE: Likes detailed explanations"
- `TRAIT: ...` for personality traits: "TRAIT: Analytical thinker", "TRAIT: Detail-oriented"
Call `get_peer_card` first to see current contents, then `update_peer_card` with the complete updated list."""
# Remember section
remember_section = """
REMEMBER:
1. Knowledge updates are your #1 priority. When the same fact has different values at different times, CREATE an update observation AND DELETE the outdated observation.
2. Flag contradictions when statements are logically incompatible (can't both be true)."""
if peer_card_enabled:
remember_section += """
3. Update the peer card with permanent biographical facts and key insights."""
return f"""You are a deductive reasoning specialist for {observed}. Your ONLY job is to create deductive observations by calling tools. Do NOT explain your reasoning - just make tool calls.
## MANDATORY WORKFLOW - YOU MUST FOLLOW THIS PATTERN
@ -290,31 +357,7 @@ Create deductions that make implicit information explicit:
}}
```
## TOOLS
- `search_memory`: Find observations by semantic query
- `create_observations`: Create new deductive OR contradiction observations (USE THIS!)
- `delete_observations`: Remove outdated observations (USE AFTER KNOWLEDGE UPDATES!)
- `get_recent_observations`: See recent activity
- `get_peer_card`: Retrieve current peer card contents
- `update_peer_card`: Update the peer card with key facts
## PEER CARD UPDATES
The peer card is a concise summary of permanent, stable information about the peer. Update it when you discover important facts that should be easily accessible.
**Peer card format** - Use these prefixes to organize entries:
- Plain facts for biographical info: "Name: Alice", "Works at Google", "Lives in NYC"
- `INSTRUCTION: ...` for standing instructions: "INSTRUCTION: Always call me Al", "INSTRUCTION: Send meeting agendas 24h in advance"
- `PREFERENCE: ...` for preferences: "PREFERENCE: Prefers morning meetings", "PREFERENCE: Likes detailed explanations"
- `TRAIT: ...` for personality traits: "TRAIT: Analytical thinker", "TRAIT: Detail-oriented"
Call `get_peer_card` first to see current contents, then `update_peer_card` with the complete updated list.
REMEMBER:
1. Knowledge updates are your #1 priority. When the same fact has different values at different times, CREATE an update observation AND DELETE the outdated observation.
2. Flag contradictions when statements are logically incompatible (can't both be true).
3. Update the peer card with permanent biographical facts and key insights."""
{tools_section}{peer_card_section}{remember_section}"""
def build_user_prompt(self, probing_questions: list[str]) -> str:
questions_text = "\n".join(f"- {q}" for q in probing_questions)
@ -342,8 +385,14 @@ class InductionSpecialist(BaseSpecialist):
name: str = "induction"
def get_tools(self) -> list[dict[str, Any]]:
return INDUCTION_SPECIALIST_TOOLS
def get_tools(self, *, peer_card_enabled: bool = True) -> list[dict[str, Any]]:
if peer_card_enabled:
return INDUCTION_SPECIALIST_TOOLS
return [
t
for t in INDUCTION_SPECIALIST_TOOLS
if t["name"] not in PEER_CARD_TOOL_NAMES
]
def get_model(self) -> str:
return settings.DREAM.INDUCTION_MODEL
@ -354,7 +403,48 @@ class InductionSpecialist(BaseSpecialist):
def get_max_iterations(self) -> int:
return 10
def build_system_prompt(self, observed: str) -> str:
def build_system_prompt(
self, observed: str, *, peer_card_enabled: bool = True
) -> str:
# Base tools list
tools_section = """## TOOLS
- `search_memory`: Find observations by semantic query
- `create_observations`: Create new inductive observations (USE THIS!)
- `get_recent_observations`: See recent activity"""
if peer_card_enabled:
tools_section += """
- `get_peer_card`: Retrieve current peer card contents
- `update_peer_card`: Update the peer card with key facts"""
# Peer card section (only if enabled)
peer_card_section = ""
if peer_card_enabled:
peer_card_section = """
## PEER CARD UPDATES
The peer card is a concise summary of permanent, stable information about the peer. After identifying high-confidence patterns, update the peer card.
**Peer card format** - Use these prefixes to organize entries:
- Plain facts for biographical info: "Name: Alice", "Works at Google", "Lives in NYC"
- `INSTRUCTION: ...` for standing instructions: "INSTRUCTION: Always call me Al"
- `PREFERENCE: ...` for preferences: "PREFERENCE: Prefers morning meetings"
- `TRAIT: ...` for personality/behavioral traits: "TRAIT: Analytical thinker", "TRAIT: Tends to reschedule when stressed"
Call `get_peer_card` first to see current contents, then `update_peer_card` with the complete updated list."""
# Remember section
remember_section = """
REMEMBER: Focus on temporal patterns and how things change. Create observations, don't just search."""
if peer_card_enabled:
remember_section += (
" Update the peer card with high-confidence patterns and traits."
)
return f"""You are an inductive reasoning specialist for {observed}. Your ONLY job is to create inductive observations by calling tools. Do NOT explain your reasoning - just make tool calls.
## MANDATORY WORKFLOW - YOU MUST FOLLOW THIS PATTERN
@ -432,27 +522,7 @@ REQUIREMENTS:
- Confidence based on source count: low=2, medium=3-4, high=5+
- Pattern must generalize, not just restate one fact
## TOOLS
- `search_memory`: Find observations by semantic query
- `create_observations`: Create new inductive observations (USE THIS!)
- `get_recent_observations`: See recent activity
- `get_peer_card`: Retrieve current peer card contents
- `update_peer_card`: Update the peer card with key facts
## PEER CARD UPDATES
The peer card is a concise summary of permanent, stable information about the peer. After identifying high-confidence patterns, update the peer card.
**Peer card format** - Use these prefixes to organize entries:
- Plain facts for biographical info: "Name: Alice", "Works at Google", "Lives in NYC"
- `INSTRUCTION: ...` for standing instructions: "INSTRUCTION: Always call me Al"
- `PREFERENCE: ...` for preferences: "PREFERENCE: Prefers morning meetings"
- `TRAIT: ...` for personality/behavioral traits: "TRAIT: Analytical thinker", "TRAIT: Tends to reschedule when stressed"
Call `get_peer_card` first to see current contents, then `update_peer_card` with the complete updated list.
REMEMBER: Focus on temporal patterns and how things change. Create observations, don't just search. Update the peer card with high-confidence patterns and traits."""
{tools_section}{peer_card_section}{remember_section}"""
def build_user_prompt(self, probing_questions: list[str]) -> str:
questions_text = "\n".join(f"- {q}" for q in probing_questions)

View File

@ -24,7 +24,6 @@ from src.routers import (
conclusions,
keys,
messages,
observations,
peers,
sessions,
webhooks,
@ -133,14 +132,13 @@ async def lifespan(_: FastAPI):
app = FastAPI(
lifespan=lifespan,
servers=[
{"url": "http://localhost:8000", "description": "Local Development Server"},
{"url": "https://demo.honcho.dev", "description": "Demo Server"},
{"url": "https://api.honcho.dev", "description": "Production SaaS Platform"},
{"url": "http://localhost:8000", "description": "Local Development Server"},
],
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.5.1",
description="""Honcho is a platform for giving agents user-centric memory and social cognition.""",
version="2.6.0",
contact={
"name": "Plastic Labs",
"url": "https://honcho.dev",
@ -156,7 +154,6 @@ app = FastAPI(
origins = [
"http://localhost",
"http://127.0.0.1:8000",
"https://demo.honcho.dev",
"https://api.honcho.dev",
]
@ -176,7 +173,6 @@ app.include_router(peers.router, prefix="/v2")
app.include_router(sessions.router, prefix="/v2")
app.include_router(messages.router, prefix="/v2")
app.include_router(conclusions.router, prefix="/v2")
app.include_router(observations.router, prefix="/v2")
app.include_router(keys.router, prefix="/v2")
app.include_router(webhooks.router, prefix="/v2")

View File

@ -467,12 +467,6 @@ class QueueItem(Base):
"message_id",
postgresql_where=text("message_id IS NOT NULL"),
),
Index(
"ux_queue_dream_pending_work_unit_key",
"work_unit_key",
unique=True,
postgresql_where=text("task_type = 'dream' AND processed = false"),
),
Index(
"ix_queue_work_unit_key_processed_id",
"work_unit_key",

View File

@ -22,19 +22,20 @@ router = APIRouter(
@router.post(
"",
response_model=list[schemas.Conclusion],
status_code=201,
)
async def create_conclusions(
workspace_id: str = Path(..., description="ID of the workspace"),
workspace_id: str = Path(...),
body: schemas.ConclusionBatchCreate = Body(
...,
description="Batch of conclusions to create",
description="Batch of Conclusions to create",
),
db: AsyncSession = db,
) -> list[schemas.Conclusion]:
"""
Create one or more conclusions.
Create one or more Conclusions.
Conclusions are theory-of-mind facts derived from interactions between peers.
Conclusions are logical certainties derived from interactions between Peers. They form the basis of a Peer's Representation.
"""
documents = await crud.create_observations(
db,
@ -55,10 +56,10 @@ async def create_conclusions(
response_model=Page[schemas.Conclusion],
)
async def list_conclusions(
workspace_id: str = Path(..., description="ID of the workspace"),
workspace_id: str = Path(...),
options: schemas.ConclusionGet | None = Body(
None,
description="Filtering options for the conclusions list",
description="Filtering options for the Conclusions list",
),
reverse: bool | None = Query(
False,
@ -67,7 +68,7 @@ async def list_conclusions(
db: AsyncSession = db,
):
"""
List conclusions using custom filters, ordered by recency unless `reverse` is true.
List Conclusions using optional filters, ordered by recency unless `reverse` is true. Results are paginated.
"""
filters = None
if options and hasattr(options, "filters"):
@ -89,15 +90,15 @@ async def list_conclusions(
response_model=list[schemas.Conclusion],
)
async def query_conclusions(
workspace_id: str = Path(..., description="ID of the workspace"),
workspace_id: str = Path(...),
body: schemas.ConclusionQuery = Body(
...,
description="Semantic search parameters for conclusions",
description="Semantic search parameters for Conclusions",
),
db: AsyncSession = db,
) -> list[schemas.Conclusion]:
"""
Query conclusions using semantic search.
Query Conclusions using semantic search. Use `top_k` to control the number of results returned.
"""
observer = None
observed = None
@ -125,14 +126,18 @@ async def query_conclusions(
@router.delete(
"/{conclusion_id}",
status_code=204,
response_model=None,
)
async def delete_conclusion(
workspace_id: str = Path(..., description="ID of the workspace"),
conclusion_id: str = Path(..., description="ID of the conclusion to delete"),
workspace_id: str = Path(...),
conclusion_id: str = Path(...),
db: AsyncSession = db,
):
"""
Delete a specific conclusion (document).
Delete a single Conclusion by ID.
This action cannot be undone.
"""
try:
await crud.delete_document_by_id(
@ -142,7 +147,6 @@ async def delete_conclusion(
)
logger.debug("Conclusion %s deleted successfully", conclusion_id)
return {"message": "Conclusion deleted successfully"}
except ResourceNotFoundException:
raise
except ValueError as e:

View File

@ -79,7 +79,7 @@ async def parse_upload_form(
)
@router.post("/", response_model=list[schemas.Message])
@router.post("", response_model=list[schemas.Message], status_code=201)
async def create_messages_for_session(
background_tasks: BackgroundTasks,
messages: schemas.MessageBatchCreate,
@ -127,7 +127,7 @@ async def create_messages_for_session(
raise
@router.post("/upload", response_model=list[schemas.Message])
@router.post("/upload", response_model=list[schemas.Message], status_code=201)
async def create_messages_with_file(
background_tasks: BackgroundTasks,
workspace_id: str = Path(...),
@ -200,17 +200,17 @@ async def create_messages_with_file(
@router.post("/list", response_model=Page[schemas.Message])
async def get_messages(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
workspace_id: str = Path(...),
session_id: str = Path(...),
options: schemas.MessageGet | None = Body(
None, description="Filtering options for the messages list"
None, description="Filtering options for the message list"
),
reverse: bool | None = Query(
False, description="Whether to reverse the order of results"
),
db: AsyncSession = db,
):
"""Get all messages for a session"""
"""Get all messages for a Session with optional filters. Results are paginated."""
try:
filters = None
if options and hasattr(options, "filters"):
@ -233,12 +233,12 @@ async def get_messages(
@router.get("/{message_id}", response_model=schemas.Message)
async def get_message(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
message_id: str = Path(..., description="ID of the message to retrieve"),
workspace_id: str = Path(...),
session_id: str = Path(...),
message_id: str = Path(...),
db: AsyncSession = db,
):
"""Get a Message by ID"""
"""Get a single message by ID from a Session."""
honcho_message = await crud.get_message(
db, workspace_name=workspace_id, session_name=session_id, message_id=message_id
)
@ -250,15 +250,19 @@ async def get_message(
@router.put("/{message_id}", response_model=schemas.Message)
async def update_message(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
message_id: str = Path(..., description="ID of the message to update"),
workspace_id: str = Path(...),
session_id: str = Path(...),
message_id: str = Path(...),
message: schemas.MessageUpdate = Body(
..., description="Updated message parameters"
),
db: AsyncSession = db,
):
"""Update the metadata of a Message"""
"""
Update the metadata of a message.
This will overwrite any existing metadata for the message.
"""
try:
updated_message = await crud.update_message(
db,

View File

@ -1,170 +0,0 @@
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(
"",
response_model=list[schemas.Observation],
deprecated=True,
)
async def create_observations(
workspace_id: str = Path(..., description="ID of the workspace"),
body: schemas.ObservationBatchCreate = Body(
..., description="Batch of observations to create"
),
db: AsyncSession = db,
) -> list[schemas.Observation]:
"""
Create one or more observations.
Creates observations (theory-of-mind facts) for the specified observer/observed peer pairs.
Each observation must reference existing peers and a session within the workspace.
Embeddings are automatically generated for semantic search.
Maximum of 100 observations per request.
"""
documents = await crud.create_observations(
db,
observations=body.conclusions,
workspace_name=workspace_id,
)
logger.debug(
"Created %d observations in workspace %s",
len(documents),
workspace_id,
)
return [schemas.Observation.model_validate(doc) for doc in documents]
@router.post(
"/list",
response_model=Page[schemas.Observation],
deprecated=True,
)
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],
deprecated=True,
)
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}",
deprecated=True,
)
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

@ -2,7 +2,7 @@ import json
import logging
from collections.abc import AsyncIterator
from fastapi import APIRouter, Body, Depends, Path, Query
from fastapi import APIRouter, Body, Depends, Path, Query, Response
from fastapi.responses import StreamingResponse
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import apaginate
@ -30,13 +30,13 @@ router = APIRouter(
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
)
async def get_peers(
workspace_id: str = Path(..., description="ID of the workspace"),
workspace_id: str = Path(...),
options: schemas.PeerGet | None = Body(
None, description="Filtering options for the peers list"
),
db: AsyncSession = db,
):
"""Get All Peers for a Workspace"""
"""Get all Peers for a Workspace, paginated with optional filters."""
filter_param = None
if options and hasattr(options, "filters"):
filter_param = options.filters
@ -54,13 +54,14 @@ async def get_peers(
response_model=schemas.Peer,
)
async def get_or_create_peer(
workspace_id: str = Path(..., description="ID of the workspace"),
response: Response,
workspace_id: str = Path(...),
peer: schemas.PeerCreate = Body(..., description="Peer creation parameters"),
jwt_params: JWTParams = Depends(require_auth()),
db: AsyncSession = db,
):
"""
Get a Peer by ID
Get a Peer by ID or create a new Peer with the given ID.
If peer_id is provided as a query parameter, it uses that (must match JWT workspace_id).
Otherwise, it uses the peer_id from the JWT.
@ -77,10 +78,11 @@ async def get_or_create_peer(
if not jwt_params.p:
raise AuthenticationException("Peer ID not found in query parameter or JWT")
peer.name = jwt_params.p
peer = (
await crud.get_or_create_peers(db, workspace_name=workspace_id, peers=[peer])
)[0]
return peer
result = await crud.get_or_create_peers(
db, workspace_name=workspace_id, peers=[peer]
)
response.status_code = 201 if result.created else 200
return result.resource[0]
@router.put(
@ -91,12 +93,12 @@ async def get_or_create_peer(
],
)
async def update_peer(
workspace_id: str = Path(..., description="ID of the workspace"),
peer_id: str = Path(..., description="ID of the peer to update"),
workspace_id: str = Path(...),
peer_id: str = Path(...),
peer: schemas.PeerUpdate = Body(..., description="Updated peer parameters"),
db: AsyncSession = db,
):
"""Update a Peer's name and/or metadata"""
"""Update a Peer's metadata and/or configuration."""
updated_peer = await crud.update_peer(
db, workspace_name=workspace_id, peer_name=peer_id, peer=peer
)
@ -111,14 +113,14 @@ async def update_peer(
],
)
async def get_sessions_for_peer(
workspace_id: str = Path(..., description="ID of the workspace"),
peer_id: str = Path(..., description="ID of the peer"),
workspace_id: str = Path(...),
peer_id: str = Path(...),
options: schemas.SessionGet | None = Body(
None, description="Filtering options for the sessions list"
),
db: AsyncSession = db,
):
"""Get All Sessions for a Peer"""
"""Get all Sessions for a Peer, paginated with optional filters."""
filter_param = None
if options and hasattr(options, "filters"):
@ -138,9 +140,9 @@ async def get_sessions_for_peer(
@router.post(
"/{peer_id}/chat",
summary="Query a Peer's representation using natural language",
responses={
200: {
"description": "Response to a question informed by Honcho's User Representation",
"content": {
"application/json": {
"schema": schemas.DialecticResponse.model_json_schema()
@ -154,12 +156,14 @@ async def get_sessions_for_peer(
],
)
async def chat(
workspace_id: str = Path(..., description="ID of the workspace"),
peer_id: str = Path(..., description="ID of the peer"),
options: schemas.DialecticOptions = Body(
..., description="Dialectic Endpoint Parameters"
),
workspace_id: str = Path(...),
peer_id: str = Path(...),
options: schemas.DialecticOptions = Body(...),
):
"""
Query a Peer's representation using natural language. Performs agentic search and reasoning to comprehensively
answer the query based on all latent knowledge gathered about the peer from their messages and conclusions.
"""
# Get or create the peer to ensure it exists
async with tracked_db("peers.chat.get_or_create_peer") as peer_db:
await crud.get_or_create_peers(
@ -221,23 +225,25 @@ async def chat(
@router.post(
"/{peer_id}/representation",
response_model=dict[str, object],
response_model=schemas.RepresentationResponse,
dependencies=[
Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id"))
],
)
async def get_working_representation(
workspace_id: str = Path(..., description="ID of the workspace"),
peer_id: str = Path(..., description="ID of the peer"),
async def get_representation(
workspace_id: str = Path(...),
peer_id: str = Path(...),
options: schemas.PeerRepresentationGet = Body(
..., description="Options for getting the peer representation"
),
):
"""Get a peer's working representation for a session.
"""Get a curated subset of a Peer's Representation. A Representation is always a subset of the total
knowledge about the Peer. The subset can be scoped and filtered in various ways.
If a session_id is provided in the body, we get the working representation of the peer in that session.
If a target is provided, we get the representation of the target from the perspective of the peer.
If no target is provided, we get the omniscient Honcho representation of the peer.
If a session_id is provided in the body, we get the Representation of the Peer scoped to that Session.
If a target is provided, we get the Representation of the target from the perspective of the Peer.
If no target is provided, we get the omniscient Honcho Representation of the Peer.
"""
try:
# If no target specified, get global representation (omniscient Honcho perspective)
@ -249,18 +255,18 @@ async def get_working_representation(
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
include_most_derived=options.include_most_frequent
if options.include_most_frequent is not None
else False,
max_observations=options.max_observations
if options.max_observations is not None
max_observations=options.max_conclusions
if options.max_conclusions is not None
else settings.DERIVER.WORKING_REPRESENTATION_MAX_OBSERVATIONS,
)
return {"representation": representation}
except ValueError as e:
logger.warning(
f"Failed to get working representation for peer {peer_id}: {str(e)}"
return schemas.RepresentationResponse(
representation=representation.format_as_markdown()
)
except ValueError as e:
logger.warning(f"Failed to get representation for peer {peer_id}: {str(e)}")
raise ResourceNotFoundException("Peer or session not found") from e
@ -272,11 +278,11 @@ async def get_working_representation(
],
)
async def get_peer_card(
workspace_id: str = Path(..., description="ID of the workspace"),
workspace_id: str = Path(...),
peer_id: str = Path(..., description="ID of the observer peer"),
target: str | None = Query(
None,
description="The peer whose card to retrieve. If not provided, returns the observer's own card",
description="Optional target peer to retrieve a card for, from the observer's perspective. If not provided, returns the observer's own card",
),
db: AsyncSession = db,
):
@ -302,14 +308,14 @@ async def get_peer_card(
],
)
async def set_peer_card(
workspace_id: str = Path(..., description="ID of the workspace"),
workspace_id: str = Path(...),
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",
description="Optional target peer to set a card for, from the observer's perspective. If not provided, sets the observer's own card",
),
db: AsyncSession = db,
):
@ -344,11 +350,11 @@ async def set_peer_card(
],
)
async def get_peer_context(
workspace_id: str = Path(..., description="ID of the workspace"),
peer_id: str = Path(..., description="ID of the peer (observer)"),
workspace_id: str = Path(...),
peer_id: str = Path(..., description="ID of the observer peer"),
target: str | None = Query(
None,
description="The target peer to get context for. If not provided, returns the peer's own context (self-observation)",
description="Optional target peer to get context for, from the observer's perspective. If not provided, returns the observer's own context (self-observation)",
),
search_query: str | None = Query(
None,
@ -358,30 +364,30 @@ async def get_peer_context(
None,
ge=1,
le=100,
description="Only used if `search_query` is provided. Number of semantic-search-retrieved observations to include",
description="Only used if `search_query` is provided. Number of semantic-search-retrieved conclusions to include",
),
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",
description="Only used if `search_query` is provided. Maximum distance for semantically relevant conclusions",
),
include_most_derived: bool = Query(
include_most_frequent: bool = Query(
default=True,
description="Whether to include the most derived observations in the representation",
description="Whether to include the most frequent conclusions in the representation",
),
max_observations: int | None = Query(
max_conclusions: int | None = Query(
None,
ge=1,
le=100,
description="Maximum number of observations to include in the representation",
description="Maximum number of conclusions 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.
This endpoint returns a curated subset of the 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).
@ -402,9 +408,9 @@ async def get_peer_context(
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
include_most_derived=include_most_frequent,
max_observations=max_conclusions
if max_conclusions is not None
else settings.DERIVER.WORKING_REPRESENTATION_MAX_OBSERVATIONS,
)
@ -416,7 +422,7 @@ async def get_peer_context(
return schemas.PeerContext(
peer_id=peer_id,
target_id=observed,
representation=representation,
representation=representation.format_as_markdown(),
peer_card=peer_card,
)
except ValueError as e:
@ -432,14 +438,15 @@ async def get_peer_context(
],
)
async def search_peer(
workspace_id: str = Path(..., description="ID of the workspace"),
peer_id: str = Path(..., description="ID of the peer"),
workspace_id: str = Path(...),
peer_id: str = Path(...),
body: schemas.MessageSearchOptions = Body(
..., description="Message search parameters "
...,
description="Message search parameters. Use `limit` to control the number of results returned.",
),
db: AsyncSession = db,
):
"""Search a Peer"""
"""Search a Peer's messages, optionally filtered by various criteria."""
# take user-provided filter and add workspace_id and peer_id to it
filters = body.filters or {}
filters["workspace_id"] = workspace_id

View File

@ -128,12 +128,38 @@ async def _get_session_context_task(
return summary, message_schemas
@router.post(
"/list",
response_model=Page[schemas.Session],
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
)
async def get_sessions(
workspace_id: str = Path(...),
options: schemas.SessionGet | None = Body(
None, description="Filtering and pagination options for the sessions list"
),
db: AsyncSession = db,
):
"""Get all Sessions for a Workspace, paginated with optional filters."""
filter_param = None
if options and hasattr(options, "filters") and options.filters:
filter_param = options.filters
if filter_param == {}: # Explicitly check for empty dict
filter_param = None
return await apaginate(
db, await crud.get_sessions(workspace_name=workspace_id, filters=filter_param)
)
@router.post(
"",
response_model=schemas.Session,
)
async def get_or_create_session(
workspace_id: str = Path(..., description="ID of the workspace"),
response: Response,
workspace_id: str = Path(...),
session: schemas.SessionCreate = Body(
..., description="Session creation parameters"
),
@ -141,9 +167,9 @@ async def get_or_create_session(
db: AsyncSession = db,
):
"""
Get a specific session in a workspace.
Get a Session by ID or create a new Session with the given ID.
If session_id is provided as a query parameter, it verifies the session is in the workspace.
If Session ID is provided as a parameter, it verifies the Session is in the Workspace.
Otherwise, it uses the session_id from the JWT for verification.
"""
# Verify JWT has access to the requested resource
@ -167,39 +193,16 @@ async def get_or_create_session(
# Handle session creation with proper error handling
try:
return await crud.get_or_create_session(
result = await crud.get_or_create_session(
db, workspace_name=workspace_id, session=session
)
response.status_code = 201 if result.created else 200
return result.resource
except ValueError as e:
logger.warning(f"Failed to get or create session {session.name}: {str(e)}")
raise ValidationException(str(e)) from e
@router.post(
"/list",
response_model=Page[schemas.Session],
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
)
async def get_sessions(
workspace_id: str = Path(..., description="ID of the workspace"),
options: schemas.SessionGet | None = Body(
None, description="Filtering and pagination options for the sessions list"
),
db: AsyncSession = db,
):
"""Get All Sessions in a Workspace"""
filter_param = None
if options and hasattr(options, "filters") and options.filters:
filter_param = options.filters
if filter_param == {}: # Explicitly check for empty dict
filter_param = None
return await apaginate(
db, await crud.get_sessions(workspace_name=workspace_id, filters=filter_param)
)
@router.put(
"/{session_id}",
response_model=schemas.Session,
@ -208,19 +211,18 @@ async def get_sessions(
],
)
async def update_session(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session to update"),
workspace_id: str = Path(...),
session_id: str = Path(...),
session: schemas.SessionUpdate = Body(
..., description="Updated session parameters"
),
db: AsyncSession = db,
):
"""Update the metadata of a Session"""
"""Update a Session's metadata and/or configuration."""
try:
updated_session = await crud.update_session(
db, workspace_name=workspace_id, session_name=session_id, session=session
)
logger.debug("Session %s updated successfully", session_id)
return updated_session
except ValueError as e:
logger.warning(f"Failed to update session {session_id}: {str(e)}")
@ -235,16 +237,15 @@ async def update_session(
],
)
async def delete_session(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session to delete"),
workspace_id: str = Path(...),
session_id: str = Path(...),
db: AsyncSession = db,
):
"""
Delete a session and all associated data.
Delete a Session and all associated messages.
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.
The Session is marked as inactive immediately and returns 202 Accepted. The actual
deletion of all related data happens asynchronously via the queue with retry support.
This action cannot be undone.
"""
@ -271,22 +272,23 @@ async def delete_session(
raise ResourceNotFoundException("Session not found") from e
@router.get(
@router.post(
"/{session_id}/clone",
response_model=schemas.Session,
status_code=201,
dependencies=[
Depends(require_auth(workspace_name="workspace_id", session_name="session_id"))
],
)
async def clone_session(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session to clone"),
db: AsyncSession = db,
workspace_id: str = Path(...),
session_id: str = Path(...),
message_id: str | None = Query(
None, description="Message ID to cut off the clone at"
),
db: AsyncSession = db,
):
"""Clone a session, optionally up to a specific message"""
"""Clone a Session, optionally up to a specific message ID."""
try:
# TODO: Update crud.clone_session to work with new paradigm
cloned_session = await crud.clone_session(
@ -310,16 +312,17 @@ async def clone_session(
],
)
async def add_peers_to_session(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
workspace_id: str = Path(...),
session_id: str = Path(...),
peers: dict[str, schemas.SessionPeerConfig] = Body(
..., description="List of peer IDs to add to the session"
...,
description="List of peer IDs (with session-level configuration) to add to the session",
),
db: AsyncSession = db,
):
"""Add peers to a session"""
"""Add Peers to a Session. If a Peer does not yet exist, it will be created automatically."""
try:
session = await crud.get_or_create_session(
result = await crud.get_or_create_session(
db,
session=schemas.SessionCreate(
name=session_id,
@ -327,8 +330,7 @@ async def add_peers_to_session(
),
workspace_name=workspace_id,
)
logger.debug("Added peers to session %s successfully", session_id)
return session
return result.resource
except ValueError as e:
logger.warning(f"Failed to add peers to session {session_id}: {str(e)}")
raise ResourceNotFoundException("Session not found") from e
@ -342,14 +344,19 @@ async def add_peers_to_session(
],
)
async def set_session_peers(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
workspace_id: str = Path(...),
session_id: str = Path(...),
peers: dict[str, schemas.SessionPeerConfig] = Body(
..., description="List of peer IDs to set for the session"
...,
description="List of peer IDs (with session-level configuration) to set for the session",
),
db: AsyncSession = db,
):
"""Set the peers in a session"""
"""
Set the Peers in a Session. If a Peer does not yet exist, it will be created automatically.
This will fully replace the current set of Peers in the Session.
"""
try:
await crud.set_peers_for_session(
db,
@ -358,13 +365,13 @@ async def set_session_peers(
peer_names=peers,
)
# Get the session to return
session = await crud.get_or_create_session(
result = await crud.get_or_create_session(
db,
session=schemas.SessionCreate(name=session_id),
workspace_name=workspace_id,
)
logger.debug("Set peers for session %s successfully", session_id)
return session
return result.resource
except ValueError as e:
logger.warning(f"Failed to set peers for session {session_id}: {str(e)}")
raise ResourceNotFoundException("Failed to set peers for session") from e
@ -378,14 +385,14 @@ async def set_session_peers(
],
)
async def remove_peers_from_session(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
workspace_id: str = Path(...),
session_id: str = Path(...),
peers: list[str] = Body(
..., description="List of peer IDs to remove from the session"
),
db: AsyncSession = db,
):
"""Remove peers from a session"""
"""Remove Peers by ID from a Session."""
try:
await crud.remove_peers_from_session(
db,
@ -394,13 +401,13 @@ async def remove_peers_from_session(
peer_names=set(peers),
)
# Get the session to return
session = await crud.get_or_create_session(
result = await crud.get_or_create_session(
db,
session=schemas.SessionCreate(name=session_id),
workspace_name=workspace_id,
)
logger.debug("Removed peers from session %s successfully", session_id)
return session
return result.resource
except ValueError as e:
logger.warning(f"Failed to remove peers from session {session_id}: {str(e)}")
raise ResourceNotFoundException("Session not found") from e
@ -414,12 +421,12 @@ async def remove_peers_from_session(
],
)
async def get_peer_config(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
peer_id: str = Path(..., description="ID of the peer"),
workspace_id: str = Path(...),
session_id: str = Path(...),
peer_id: str = Path(...),
db: AsyncSession = db,
):
"""Get the configuration for a peer in a session"""
"""Get the configuration for a Peer in a Session."""
return await crud.get_peer_config(
db,
workspace_name=workspace_id,
@ -428,20 +435,22 @@ async def get_peer_config(
)
@router.post(
@router.put(
"/{session_id}/peers/{peer_id}/config",
status_code=204,
response_model=None,
dependencies=[
Depends(require_auth(workspace_name="workspace_id", session_name="session_id"))
],
)
async def set_peer_config(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
peer_id: str = Path(..., description="ID of the peer"),
config: schemas.SessionPeerConfig = Body(..., description="Peer configuration"),
workspace_id: str = Path(...),
session_id: str = Path(...),
peer_id: str = Path(...),
config: schemas.SessionPeerConfig = Body(..., description="New peer configuration"),
db: AsyncSession = db,
):
"""Set the configuration for a peer in a session"""
"""Set the configuration for a Peer in a Session."""
try:
await crud.set_peer_config(
db,
@ -453,7 +462,6 @@ async def set_peer_config(
logger.debug(
"Set peer config for %s in session %s successfully", peer_id, session_id
)
return Response(status_code=200)
except ValueError as e:
logger.warning(
f"Failed to set peer config for {peer_id} in session {session_id}: {str(e)}"
@ -469,11 +477,11 @@ async def set_peer_config(
],
)
async def get_session_peers(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
workspace_id: str = Path(...),
session_id: str = Path(...),
db: AsyncSession = db,
):
"""Get peers from a session"""
"""Get all Peers in a Session. Results are paginated."""
try:
peers_query = await crud.get_peers_from_session(
workspace_name=workspace_id, session_name=session_id
@ -492,8 +500,8 @@ async def get_session_peers(
],
)
async def get_session_context(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
workspace_id: str = Path(...),
session_id: str = Path(...),
tokens: int | None = Query(
None,
le=config.settings.GET_CONTEXT_MAX_TOKENS,
@ -502,7 +510,7 @@ async def get_session_context(
*,
last_message: str | None = Query(
None,
description="The most recent message, used to fetch semantically relevant observations",
description="The most recent message, used to fetch semantically relevant conclusions",
),
include_summary: bool = Query(
default=True,
@ -525,27 +533,27 @@ async def get_session_context(
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",
description="Only used if `last_message` is provided. The number of semantic-search-retrieved conclusions 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",
description="Only used if `last_message` is provided. The maximum distance to search for semantically relevant conclusions",
),
include_most_derived: bool = Query(
include_most_frequent: bool = Query(
default=False,
description="Only used if `last_message` is provided. Whether to include the most derived observations in the representation",
description="Only used if `last_message` is provided. Whether to include the most frequent conclusions in the representation",
),
max_observations: int | None = Query(
max_conclusions: 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",
description="Only used if `last_message` is provided. The maximum number of conclusions 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.
Produce a context object from the Session. The caller provides an optional token limit which the entire context must fit into.
If not provided, the context will be exhaustive (within configured max tokens). To do this, we allocate 40% of the token limit
to the summary, and 60% to recent messages -- as many as can fit. Note that the summary will usually take up less space than
this. If the caller does not want a summary, we allocate all the tokens to recent messages.
@ -583,8 +591,8 @@ async def get_session_context(
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,
include_most_derived=include_most_frequent,
max_observations=max_conclusions,
)
card = await _get_peer_card_task(workspace_id, observer=observer, observed=observed)
@ -603,7 +611,7 @@ async def get_session_context(
name=session_id,
messages=messages,
summary=summary,
peer_representation=representation,
peer_representation=representation.format_as_markdown(),
peer_card=card,
)
@ -616,12 +624,12 @@ async def get_session_context(
],
)
async def get_session_summaries(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
workspace_id: str = Path(...),
session_id: str = Path(...),
db: AsyncSession = db,
) -> schemas.SessionSummaries:
"""
Get available summaries for a session.
Get available summaries for a Session.
Returns both short and long summaries if available, including metadata like
the message ID they cover up to, creation timestamp, and token count.
@ -657,14 +665,16 @@ async def get_session_summaries(
],
)
async def search_session(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
workspace_id: str = Path(...),
session_id: str = Path(...),
body: schemas.MessageSearchOptions = Body(
..., description="Message search parameters"
),
db: AsyncSession = db,
):
"""Search a Session"""
"""
Search a Session with optional filters. Use `limit` to control the number of results returned.
"""
# take user-provided filter and add workspace_id and session_id to it
filters = body.filters or {}
filters["workspace_id"] = workspace_id

View File

@ -1,6 +1,6 @@
import logging
from fastapi import APIRouter, Body, Depends, Path
from fastapi import APIRouter, Body, Depends, Path, Response
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import apaginate
from sqlalchemy.ext.asyncio import AsyncSession
@ -26,6 +26,7 @@ router = APIRouter(
@router.post("", response_model=schemas.WebhookEndpoint)
async def get_or_create_webhook_endpoint(
response: Response,
workspace_id: str = Path(..., description="Workspace ID"),
webhook: schemas.WebhookEndpointCreate = Body(
..., description="Webhook endpoint parameters"
@ -40,9 +41,11 @@ async def get_or_create_webhook_endpoint(
raise AuthenticationException("Unauthorized access to resource")
try:
return await crud.get_or_create_webhook_endpoint(
result = await crud.get_or_create_webhook_endpoint(
db, workspace_id, webhook=webhook
)
response.status_code = 201 if result.created else 200
return result.resource
except ValueError as e:
raise ConflictException(
f"Maximum number of webhook endpoints ({settings.WEBHOOK.MAX_WORKSPACE_LIMIT}) reached for this workspace."
@ -65,7 +68,7 @@ async def list_webhook_endpoints(
return await apaginate(db, stmt)
@router.delete("/{endpoint_id}", response_model=None)
@router.delete("/{endpoint_id}", response_model=None, status_code=204)
async def delete_webhook_endpoint(
workspace_id: str = Path(..., description="Workspace ID"),
endpoint_id: str = Path(..., description="Webhook endpoint ID"),

View File

@ -1,6 +1,6 @@
import logging
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import apaginate
from sqlalchemy import func, select
@ -24,6 +24,7 @@ router = APIRouter(
@router.post("", response_model=schemas.Workspace)
async def get_or_create_workspace(
response: Response,
workspace: schemas.WorkspaceCreate = Body(
..., description="Workspace creation parameters"
),
@ -48,7 +49,9 @@ async def get_or_create_workspace(
)
workspace.name = jwt_params.w
return await crud.get_or_create_workspace(db, workspace=workspace)
result = await crud.get_or_create_workspace(db, workspace=workspace)
response.status_code = 201 if result.created else 200
return result.resource
@router.post(
@ -62,7 +65,7 @@ async def get_all_workspaces(
),
db: AsyncSession = db,
):
"""Get all Workspaces"""
"""Get all Workspaces, paginated with optional filters."""
filter_param = None
if options and hasattr(options, "filters"):
filter_param = options.filters
@ -81,13 +84,13 @@ async def get_all_workspaces(
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
)
async def update_workspace(
workspace_id: str = Path(..., description="ID of the workspace to update"),
workspace_id: str = Path(...),
workspace: schemas.WorkspaceUpdate = Body(
..., description="Updated workspace parameters"
),
db: AsyncSession = db,
):
"""Update a Workspace"""
"""Update Workspace metadata and/or configuration."""
# ResourceNotFoundException will be caught by global handler if workspace not found
honcho_workspace = await crud.update_workspace(
db, workspace_name=workspace_id, workspace=workspace
@ -97,15 +100,21 @@ async def update_workspace(
@router.delete(
"/{workspace_id}",
response_model=schemas.Workspace,
status_code=204,
response_model=None,
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
)
async def delete_workspace(
workspace_id: str = Path(..., description="ID of the workspace to delete"),
workspace_id: str = Path(...),
db: AsyncSession = db,
):
"""Delete a Workspace"""
return await crud.delete_workspace(db, workspace_name=workspace_id)
"""
Delete a Workspace. This will permanently delete all sessions, peers, messages, and conclusions
associated with the workspace.
This action cannot be undone.
"""
await crud.delete_workspace(db, workspace_name=workspace_id)
@router.post(
@ -114,13 +123,16 @@ async def delete_workspace(
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
)
async def search_workspace(
workspace_id: str = Path(..., description="ID of the workspace to search"),
workspace_id: str = Path(...),
body: schemas.MessageSearchOptions = Body(
..., description="Message search parameters "
..., description="Message search parameters"
),
db: AsyncSession = db,
):
"""Search a Workspace"""
"""
Search messages in a Workspace using optional filters. Use `limit` to control the number of
results returned.
"""
# take user-provided filter and add workspace_id to it
filters = body.filters or {}
filters["workspace_id"] = workspace_id
@ -133,7 +145,7 @@ async def search_workspace(
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
)
async def get_queue_status(
workspace_id: str = Path(..., description="ID of the workspace"),
workspace_id: str = Path(...),
observer_id: str | None = Query(
None, description="Optional observer ID to filter by"
),
@ -143,38 +155,10 @@ async def get_queue_status(
),
db: AsyncSession = db,
):
"""Get the processing queue status, optionally scoped to an observer, sender, and/or session."""
try:
return await crud.get_queue_status(
db,
workspace_name=workspace_id,
session_name=session_id,
observer=observer_id,
observed=sender_id,
)
except ValueError as e:
logger.warning(f"Invalid request parameters: {str(e)}")
raise HTTPException(status_code=400, detail=str(e)) from e
@router.get(
"/{workspace_id}/deriver/status",
response_model=schemas.QueueStatus,
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
deprecated=True,
)
async def get_deriver_status(
workspace_id: str = Path(..., description="ID of the workspace"),
observer_id: str | None = Query(
None, description="Optional observer ID to filter by"
),
sender_id: str | None = Query(None, description="Optional sender ID to filter by"),
session_id: str | None = Query(
None, description="Optional session ID to filter by"
),
db: AsyncSession = db,
):
"""Deprecated: use /queue/status. Provides identical response payload."""
"""
Get the processing queue status for a Workspace, optionally scoped to an observer, sender,
and/or session.
"""
try:
return await crud.get_queue_status(
db,
@ -189,22 +173,25 @@ async def get_deriver_status(
@router.post(
"/{workspace_id}/trigger_dream",
"/{workspace_id}/schedule_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"
async def schedule_dream(
workspace_id: str = Path(...),
request: schemas.ScheduleDreamRequest = Body(
..., description="Dream scheduling parameters"
),
db: AsyncSession = db,
):
"""
Manually trigger a dream task immediately for a specific collection.
Manually schedule a dream task 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.
minimum hours between dreams) and schedules the dream task for a future execution.
Currently this endpoint only supports scheduling immediate dreams. In the future,
users may pass a cron-style expression to schedule dreams at specific times.
"""
# Check if dreams are enabled
if not settings.DREAM.ENABLED:
@ -237,7 +224,7 @@ async def trigger_dream(
)
logger.info(
"Manually triggered dream: %s for %s/%s/%s (session: %s)",
"Manually scheduled dream: %s for %s/%s/%s (session: %s)",
dream_type.value,
workspace_id,
observer,

View File

@ -16,7 +16,6 @@ from pydantic import (
)
from src.config import ReasoningLevel, settings
from src.utils.representation import Representation
from src.utils.types import DocumentLevel
RESOURCE_NAME_PATTERN = r"^[a-zA-Z0-9_-]+$"
@ -28,21 +27,21 @@ class DreamType(str, Enum):
OMNI = "omni"
class DeriverConfiguration(BaseModel):
class ReasoningConfiguration(BaseModel):
enabled: bool | None = Field(
default=None,
description="Whether to enable deriver functionality.",
description="Whether to enable reasoning functionality.",
)
custom_instructions: str | None = Field(
default=None,
description="TODO: currently unused. Custom instructions to use for the deriver on this workspace/session/message.",
description="TODO: currently unused. Custom instructions to use for the reasoning system on this workspace/session/message.",
)
class PeerCardConfiguration(BaseModel):
use: bool | None = Field(
default=None,
description="Whether to use peer card related to this peer during deriver process.",
description="Whether to use peer card related to this peer during reasoning process.",
)
create: bool | None = Field(
default=None,
@ -83,7 +82,7 @@ class SummaryConfiguration(BaseModel):
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.",
description="Whether to enable dream functionality. If reasoning is disabled, dreams will also be disabled and this setting will be ignored.",
)
@ -96,13 +95,13 @@ class WorkspaceConfiguration(BaseModel):
model_config = ConfigDict(extra="allow") # pyright: ignore
deriver: DeriverConfiguration | None = Field(
reasoning: ReasoningConfiguration | None = Field(
default=None,
description="Configuration for deriver functionality.",
description="Configuration for reasoning 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.",
description="Configuration for peer card functionality. If reasoning is disabled, peer cards will also be disabled and these settings will be ignored.",
)
summary: SummaryConfiguration | None = Field(
default=None,
@ -110,7 +109,7 @@ class WorkspaceConfiguration(BaseModel):
)
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.",
description="Configuration for dream functionality. If reasoning is disabled, dreams will also be disabled and these settings will be ignored.",
)
@ -131,17 +130,13 @@ class MessageConfiguration(BaseModel):
All fields are optional. Message-level configuration overrides all other configurations.
"""
deriver: DeriverConfiguration | None = Field(
reasoning: ReasoningConfiguration | 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.",
description="Configuration for reasoning functionality.",
)
class ResolvedDeriverConfiguration(BaseModel):
class ResolvedReasoningConfiguration(BaseModel):
enabled: bool
@ -166,7 +161,7 @@ class ResolvedConfiguration(BaseModel):
Hierarchy: message > session > workspace > global configuration
"""
deriver: ResolvedDeriverConfiguration
reasoning: ResolvedReasoningConfiguration
peer_card: ResolvedPeerCardConfiguration
summary: ResolvedSummaryConfiguration
dream: ResolvedDreamConfiguration
@ -176,7 +171,7 @@ class PeerConfig(BaseModel):
# TODO: Update description - should say "Whether honcho forms a representation of the peer itself"
observe_me: bool | None = Field(
default=None,
description="Whether honcho should form a global theory-of-mind representation of this peer",
description="Whether Honcho will use reasoning to form a representation of this peer",
)
@ -267,7 +262,7 @@ class Peer(PeerBase):
class PeerRepresentationGet(BaseModel):
session_id: str | None = Field(
None, description="Get the working representation within this session"
None, description="Optional session ID within which to scope the representation"
)
target: str | None = Field(
None,
@ -281,26 +276,30 @@ class PeerRepresentationGet(BaseModel):
None,
ge=1,
le=100,
description="Only used if `search_query` is provided. Number of semantic-search-retrieved observations to include in the representation",
description="Only used if `search_query` is provided. Number of semantic-search-retrieved conclusions 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",
description="Only used if `search_query` is provided. Maximum distance to search for semantically relevant conclusions",
)
include_most_derived: bool | None = Field(
include_most_frequent: bool | None = Field(
default=None,
description="Only used if `search_query` is provided. Whether to include the most derived observations in the representation",
description="Only used if `search_query` is provided. Whether to include the most frequent conclusions in the representation",
)
max_observations: int | None = Field(
max_conclusions: 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",
description="Only used if `search_query` is provided. Maximum number of conclusions to include in the representation",
)
class RepresentationResponse(BaseModel):
representation: str
class PeerCardResponse(BaseModel):
peer_card: list[str] | None = Field(
None, description="The peer card content, or None if not found"
@ -442,9 +441,9 @@ class SessionContext(SessionBase):
summary: Summary | None = Field(
default=None, description="The summary if available"
)
peer_representation: Representation | None = Field(
peer_representation: str | None = Field(
default=None,
description="The peer representation, if context is requested from a specific perspective",
description="A curated subset of a peer representation, if context is requested from a specific perspective",
)
peer_card: list[str] | None = Field(
default=None,
@ -461,9 +460,9 @@ class PeerContext(BaseModel):
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(
representation: str | None = Field(
default=None,
description="The working representation of the target peer from the observer's perspective",
description="A curated subset of the representation of the target peer from the observer's perspective",
)
peer_card: list[str] | None = Field(
default=None,
@ -498,24 +497,23 @@ class DocumentMetadata(BaseModel):
)
source_ids: list[str] | None = Field(
default=None,
description="Document IDs of source observations for tree traversal -- required for deductive and inductive observations",
description="Document IDs of source documents for tree traversal -- required for deductive and inductive documents",
)
# Deductive observation fields
premises: list[str] | None = Field(
default=None,
description="Human-readable premise text for display -- only applicable for deductive observations",
description="Human-readable premise text for display -- only applicable for deductive documents",
)
sources: list[str] | None = Field(
default=None,
description="Human-readable source text for display -- only applicable for inductive observations",
description="Human-readable source text for display -- only applicable for inductive documents",
)
pattern_type: str | None = Field(
default=None,
description="Type of pattern identified (preference, behavior, personality, tendency, correlation) -- only applicable for inductive observations",
description="Type of pattern identified (preference, behavior, personality, tendency, correlation) -- only applicable for inductive documents",
)
confidence: str | None = Field(
default=None,
description="Confidence level (high, medium, low) -- only applicable for inductive observations",
description="Confidence level (high, medium, low) -- only applicable for inductive documents",
)
@ -538,7 +536,7 @@ class DocumentCreate(DocumentBase):
# Tree linkage field
source_ids: list[str] | None = Field(
default=None,
description="Document IDs of source/premise observations -- for deductive and inductive observations",
description="Document IDs of source/premise documents -- for deductive and inductive documents",
)
@ -628,26 +626,6 @@ class ConclusionBatchCreate(BaseModel):
)
class ObservationGet(ConclusionGet):
"""Deprecated: use ConclusionGet."""
class Observation(Conclusion):
"""Deprecated: use Conclusion."""
class ObservationQuery(ConclusionQuery):
"""Deprecated: use ConclusionQuery."""
class ObservationCreate(ConclusionCreate):
"""Deprecated: use ConclusionCreate."""
class ObservationBatchCreate(ConclusionBatchCreate):
"""Deprecated: use ConclusionBatchCreate."""
class MessageSearchOptions(BaseModel):
query: str = Field(..., description="Search query")
filters: dict[str, Any] | None = Field(
@ -776,21 +754,12 @@ class QueueStatus(BaseModel):
)
class SessionDeriverStatus(SessionQueueStatus):
"""Deprecated: use SessionQueueStatus."""
class DeriverStatus(QueueStatus):
"""Deprecated: use QueueStatus."""
# Dream trigger schema
class TriggerDreamRequest(BaseModel):
class ScheduleDreamRequest(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")
dream_type: DreamType = Field(..., description="Type of dream to schedule")
session_id: str = Field(..., description="Session ID to scope the dream to")

View File

@ -12,6 +12,7 @@ from src import crud, models, schemas
from src.config import settings
from src.embedding_client import embedding_client
from src.models import Document
from src.schemas import ResolvedConfiguration
from src.utils import summarizer
from src.utils.formatting import format_new_turn_with_timestamp, utc_now_iso
from src.utils.representation import Representation
@ -884,6 +885,8 @@ class ToolContext:
# This lock is obtained from the module-level registry to ensure all concurrent
# tool executors for the same data share the same lock.
db_lock: asyncio.Lock
# Optional resolved configuration for checking feature flags
configuration: ResolvedConfiguration | None = None
async def _handle_create_observations(
@ -998,6 +1001,15 @@ async def _handle_create_observations(
async def _handle_update_peer_card(ctx: ToolContext, tool_input: dict[str, Any]) -> str:
"""Handle update_peer_card tool."""
# Check if peer card creation is disabled via configuration
if ctx.configuration is not None and not ctx.configuration.peer_card.create:
logger.info(
f"Peer card creation disabled for {ctx.workspace_name}, skipping update"
)
return (
"Peer card creation is disabled for this workspace/session configuration."
)
async with ctx.db_lock:
await crud.set_peer_card(
ctx.db,
@ -1544,6 +1556,7 @@ async def create_tool_executor(
current_messages: list[models.Message] | None = None,
include_observation_ids: bool = False,
history_token_limit: int = 8192,
configuration: ResolvedConfiguration | None = None,
) -> Callable[[str, dict[str, Any]], Any]:
"""
Create a unified tool executor function for all agent operations.
@ -1560,6 +1573,7 @@ async def create_tool_executor(
current_messages: List of current messages being processed (optional, for deriver)
include_observation_ids: If True, include observation IDs in output (for dreamer agent)
history_token_limit: Maximum tokens for get_recent_history (default: 8192)
configuration: Resolved configuration for checking feature flags (optional)
Returns:
An async callable that executes tools with the captured context
@ -1578,6 +1592,7 @@ async def create_tool_executor(
include_observation_ids=include_observation_ids,
history_token_limit=history_token_limit,
db_lock=shared_lock,
configuration=configuration,
)
async def execute_tool(tool_name: str, tool_input: dict[str, Any]) -> str:

View File

@ -28,9 +28,58 @@ def deep_update(base: dict[str, Any], update: dict[str, Any]) -> None:
base[key] = value
def normalize_configuration_dict(raw: dict[str, Any]) -> dict[str, Any]:
"""
Normalize a workspace/session/message configuration dict to match the current
`ResolvedConfiguration` schema.
This function exists to preserve backwards compatibility with older clients/tests
that used legacy configuration keys (e.g. `deriver.enabled` or `skip_deriver`).
Behavior:
- If `reasoning.enabled` is not explicitly set, but `deriver.enabled` is present,
`reasoning.enabled` is derived from `deriver.enabled`.
- If `skip_deriver` is explicitly `True`, `reasoning.enabled` is forced to `False`
unless `reasoning.enabled` was already explicitly set.
- Legacy keys are removed from the returned dict to avoid polluting the resolved
configuration with unused fields.
"""
normalized: dict[str, Any] = dict(raw)
reasoning_raw = normalized.get("reasoning")
reasoning_present = "reasoning" in normalized
reasoning: dict[str, Any]
if isinstance(reasoning_raw, dict):
reasoning = dict(cast(dict[str, Any], reasoning_raw))
else:
reasoning = {}
reasoning_enabled_explicit = reasoning.get("enabled") is not None
if not reasoning_enabled_explicit:
deriver_raw = normalized.get("deriver")
deriver: dict[str, Any]
if isinstance(deriver_raw, dict):
deriver = dict(cast(dict[str, Any], deriver_raw))
else:
deriver = {}
if deriver.get("enabled") is not None:
reasoning["enabled"] = bool(deriver["enabled"])
if not reasoning_enabled_explicit and normalized.get("skip_deriver") is True:
reasoning["enabled"] = False
if reasoning_present or reasoning:
normalized["reasoning"] = reasoning
normalized.pop("deriver", None)
normalized.pop("skip_deriver", None)
return normalized
def get_configuration(
message_configuration: MessageConfiguration | None,
session: models.Session,
session: models.Session | None,
workspace: models.Workspace | None = None,
) -> ResolvedConfiguration:
"""
@ -43,15 +92,16 @@ def get_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)
message_configuration: Optional message configuration
session: Optional session model
workspace: Optional workspace model
Returns:
ResolvedConfiguration
"""
# Start with defaults
config_dict: dict[str, Any] = {
"deriver": {"enabled": settings.DERIVER.ENABLED},
"reasoning": {"enabled": settings.DERIVER.ENABLED},
"peer_card": {
"use": settings.PEER_CARD.ENABLED,
"create": settings.PEER_CARD.ENABLED,
@ -68,11 +118,17 @@ def get_configuration(
# Note: deep_update modifies config_dict in place
if workspace is not None:
deep_update(config_dict, workspace.configuration)
deep_update(config_dict, normalize_configuration_dict(workspace.configuration))
deep_update(config_dict, session.configuration)
if session is not None:
deep_update(config_dict, normalize_configuration_dict(session.configuration))
if message_configuration is not None:
deep_update(config_dict, message_configuration.model_dump(exclude_none=True))
deep_update(
config_dict,
normalize_configuration_dict(
message_configuration.model_dump(exclude_none=True)
),
)
return ResolvedConfiguration(**config_dict)

View File

@ -1,4 +1,14 @@
from typing import Literal
from typing import Generic, Literal, NamedTuple, TypeVar
T = TypeVar("T")
class GetOrCreateResult(NamedTuple, Generic[T]):
"""Result of a get_or_create operation indicating whether the resource was created."""
resource: T
created: bool
SupportedProviders = Literal["anthropic", "openai", "google", "groq", "custom", "vllm"]
TaskType = Literal["webhook", "summary", "representation", "dream", "deletion"]

View File

@ -212,7 +212,7 @@ class BEAMRunner:
start_time = time.time()
while True:
try:
status = await honcho_client.get_deriver_status(session=session_id)
status = await honcho_client.get_queue_status(session=session_id)
except Exception:
await asyncio.sleep(1)
elapsed_time = time.time() - start_time
@ -254,7 +254,7 @@ class BEAMRunner:
observed = observed or observer
honcho_url = self.get_honcho_url_for_index(0)
url = f"{honcho_url}/v2/workspaces/{workspace_id}/trigger_dream"
url = f"{honcho_url}/v2/workspaces/{workspace_id}/schedule_dream"
payload: dict[str, Any] = {
"observer": observer,
"observed": observed,

View File

@ -256,7 +256,7 @@ class LoCoMoRunner:
start_time = time.time()
while True:
try:
status = await honcho_client.get_deriver_status(session=session_id)
status = await honcho_client.get_queue_status(session=session_id)
except Exception:
await asyncio.sleep(1)
elapsed_time = time.time() - start_time
@ -296,7 +296,7 @@ class LoCoMoRunner:
observed = observed or observer
honcho_url = self.get_honcho_url_for_index(0)
url = f"{honcho_url}/v2/workspaces/{workspace_id}/trigger_dream"
url = f"{honcho_url}/v2/workspaces/{workspace_id}/schedule_dream"
payload = {
"observer": observer,
"observed": observed,

View File

@ -270,7 +270,7 @@ class LongMemEvalRunner:
start_time = time.time()
while True:
try:
status = await honcho_client.get_deriver_status(session=session_id)
status = await honcho_client.get_queue_status(session=session_id)
except Exception as _e:
await asyncio.sleep(1)
elapsed_time = time.time() - start_time
@ -310,7 +310,7 @@ class LongMemEvalRunner:
observed = observed or observer
honcho_url = self.get_honcho_url_for_index(0)
url = f"{honcho_url}/v2/workspaces/{workspace_id}/trigger_dream"
url = f"{honcho_url}/v2/workspaces/{workspace_id}/schedule_dream"
payload: dict[str, Any] = {
"observer": observer,
"observed": observed,

View File

@ -171,7 +171,7 @@ class TestRunner:
True if queue is empty, False if timeout exceeded
"""
try:
await honcho_client.poll_deriver_status(
await honcho_client.poll_queue_status(
session=session_id,
timeout=float(self.timeout_seconds)
if self.timeout_seconds

View File

@ -32,18 +32,20 @@ async def sample_session_with_peers(
await db_session.flush()
# Create session with peer configurations
session = await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
peer1.name: schemas.SessionPeerConfig(observe_me=True),
peer2.name: schemas.SessionPeerConfig(observe_others=True),
peer3.name: schemas.SessionPeerConfig(), # No special observation settings
},
),
workspace.name,
)
session = (
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
peer1.name: schemas.SessionPeerConfig(observe_me=True),
peer2.name: schemas.SessionPeerConfig(observe_others=True),
peer3.name: schemas.SessionPeerConfig(), # No special observation settings
},
),
workspace.name,
)
).resource
await db_session.commit()
return session, [peer1, peer2, peer3]
@ -124,7 +126,7 @@ def create_queue_payload() -> Callable[..., Any]:
}
configuration = schemas.ResolvedConfiguration(
deriver=schemas.ResolvedDeriverConfiguration(enabled=True),
reasoning=schemas.ResolvedReasoningConfiguration(enabled=True),
peer_card=schemas.ResolvedPeerCardConfiguration(use=True, create=True),
summary=schemas.ResolvedSummaryConfiguration(
enabled=True,

View File

@ -132,14 +132,16 @@ class TestEnqueueFunction:
):
test_workspace, test_peer = sample_data
test_session = await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={test_peer.name: schemas.SessionPeerConfig(observe_me=True)},
),
test_workspace.name,
)
test_session = (
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={test_peer.name: schemas.SessionPeerConfig(observe_me=True)},
),
test_workspace.name,
)
).resource
await db_session.commit()
payload = await self.create_sample_payload(
@ -180,17 +182,19 @@ class TestEnqueueFunction:
)
db_session.add(test_peer2)
test_session = await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
test_peer1.name: schemas.SessionPeerConfig(),
test_peer2.name: schemas.SessionPeerConfig(),
},
),
test_workspace.name,
)
test_session = (
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
test_peer1.name: schemas.SessionPeerConfig(),
test_peer2.name: schemas.SessionPeerConfig(),
},
),
test_workspace.name,
)
).resource
await db_session.commit()
@ -256,17 +260,19 @@ class TestEnqueueFunction:
)
db_session.add(test_peer2)
test_session = await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
test_peer1.name: schemas.SessionPeerConfig(),
test_peer2.name: schemas.SessionPeerConfig(observe_others=True),
},
),
test_workspace.name,
)
test_session = (
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
test_peer1.name: schemas.SessionPeerConfig(),
test_peer2.name: schemas.SessionPeerConfig(observe_others=True),
},
),
test_workspace.name,
)
).resource
await db_session.commit()
NUM_MESSAGES = 3
@ -345,18 +351,22 @@ class TestEnqueueFunction:
db_session.add(unobserving_peer)
# Create session
test_session = await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
test_peer1.name: schemas.SessionPeerConfig(observe_me=True),
observing_peer.name: schemas.SessionPeerConfig(observe_others=True),
unobserving_peer.name: schemas.SessionPeerConfig(),
},
),
test_workspace.name,
)
test_session = (
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
test_peer1.name: schemas.SessionPeerConfig(observe_me=True),
observing_peer.name: schemas.SessionPeerConfig(
observe_others=True
),
unobserving_peer.name: schemas.SessionPeerConfig(),
},
),
test_workspace.name,
)
).resource
await db_session.commit()
NUM_MESSAGES = 3
@ -430,14 +440,16 @@ class TestEnqueueFunction:
test_peer.configuration = {"observe_me": True}
# Create session
test_session = await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={test_peer.name: schemas.SessionPeerConfig(observe_me=False)},
),
test_workspace.name,
)
test_session = (
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={test_peer.name: schemas.SessionPeerConfig(observe_me=False)},
),
test_workspace.name,
)
).resource
await db_session.commit()
@ -475,18 +487,22 @@ class TestEnqueueFunction:
db_session.add_all([additional_sender_peer, observer_peer])
test_session = await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
test_peer1.name: schemas.SessionPeerConfig(),
additional_sender_peer.name: schemas.SessionPeerConfig(),
observer_peer.name: schemas.SessionPeerConfig(observe_others=True),
},
),
test_workspace.name,
)
test_session = (
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
test_peer1.name: schemas.SessionPeerConfig(),
additional_sender_peer.name: schemas.SessionPeerConfig(),
observer_peer.name: schemas.SessionPeerConfig(
observe_others=True
),
},
),
test_workspace.name,
)
).resource
await db_session.commit()
payload1 = await self.create_sample_payload(
@ -574,17 +590,21 @@ class TestEnqueueFunction:
db_session.add(observer_peer)
# Create session with both peers
test_session = await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
sender_peer.name: schemas.SessionPeerConfig(observe_me=True),
observer_peer.name: schemas.SessionPeerConfig(observe_others=True),
},
),
test_workspace.name,
)
test_session = (
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
sender_peer.name: schemas.SessionPeerConfig(observe_me=True),
observer_peer.name: schemas.SessionPeerConfig(
observe_others=True
),
},
),
test_workspace.name,
)
).resource
await db_session.commit()
# Simulate sender leaving the session by setting left_at
@ -666,22 +686,24 @@ class TestEnqueueFunction:
db_session.add_all([observer_who_left, observer_who_stayed])
# Create session with all peers
test_session = await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
sender_peer.name: schemas.SessionPeerConfig(observe_me=True),
observer_who_left.name: schemas.SessionPeerConfig(
observe_others=True
),
observer_who_stayed.name: schemas.SessionPeerConfig(
observe_others=True
),
},
),
test_workspace.name,
)
test_session = (
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
sender_peer.name: schemas.SessionPeerConfig(observe_me=True),
observer_who_left.name: schemas.SessionPeerConfig(
observe_others=True
),
observer_who_stayed.name: schemas.SessionPeerConfig(
observe_others=True
),
},
),
test_workspace.name,
)
).resource
await db_session.commit()
# Simulate one observer leaving the session
@ -741,16 +763,20 @@ class TestEnqueueFunction:
db_session.add(observer_peer)
# Create session with only observer (sender not in peers_with_configuration)
test_session = await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
observer_peer.name: schemas.SessionPeerConfig(observe_others=True),
},
),
test_workspace.name,
)
test_session = (
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
observer_peer.name: schemas.SessionPeerConfig(
observe_others=True
),
},
),
test_workspace.name,
)
).resource
await db_session.commit()
# Create message from peer NOT in the session configuration
@ -834,28 +860,30 @@ class TestEnqueueFunction:
)
# Create session with all peers having different configurations
test_session = await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
sender_peer.name: schemas.SessionPeerConfig(observe_me=True),
active_observer.name: schemas.SessionPeerConfig(
observe_others=True
),
inactive_observer.name: schemas.SessionPeerConfig(
observe_others=True
),
active_non_observer.name: schemas.SessionPeerConfig(
observe_others=False
),
inactive_non_observer.name: schemas.SessionPeerConfig(
observe_others=False
),
},
),
test_workspace.name,
)
test_session = (
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
sender_peer.name: schemas.SessionPeerConfig(observe_me=True),
active_observer.name: schemas.SessionPeerConfig(
observe_others=True
),
inactive_observer.name: schemas.SessionPeerConfig(
observe_others=True
),
active_non_observer.name: schemas.SessionPeerConfig(
observe_others=False
),
inactive_non_observer.name: schemas.SessionPeerConfig(
observe_others=False
),
},
),
test_workspace.name,
)
).resource
await db_session.commit()
# Mark some peers as having left the session
@ -1116,18 +1144,20 @@ class TestAdvancedEnqueueEdgeCases:
db_session.add_all([observer1, observer2])
# Create session with all peers
test_session = await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
sender_peer.name: schemas.SessionPeerConfig(observe_me=True),
observer1.name: schemas.SessionPeerConfig(observe_others=True),
observer2.name: schemas.SessionPeerConfig(observe_others=True),
},
),
test_workspace.name,
)
test_session = (
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
sender_peer.name: schemas.SessionPeerConfig(observe_me=True),
observer1.name: schemas.SessionPeerConfig(observe_others=True),
observer2.name: schemas.SessionPeerConfig(observe_others=True),
},
),
test_workspace.name,
)
).resource
await db_session.commit()
# Mark all observers as having left
@ -1184,17 +1214,21 @@ class TestAdvancedEnqueueEdgeCases:
db_session.add(observer_peer)
# Create session
test_session = await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
sender_peer.name: schemas.SessionPeerConfig(observe_me=True),
observer_peer.name: schemas.SessionPeerConfig(observe_others=True),
},
),
test_workspace.name,
)
test_session = (
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
sender_peer.name: schemas.SessionPeerConfig(observe_me=True),
observer_peer.name: schemas.SessionPeerConfig(
observe_others=True
),
},
),
test_workspace.name,
)
).resource
await db_session.commit()
# Mark both as having left (observer left first, then sender)
@ -1266,16 +1300,20 @@ class TestAdvancedEnqueueEdgeCases:
db_session.add(observer_peer)
# Create session with only observer peer
test_session = await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
observer_peer.name: schemas.SessionPeerConfig(observe_others=True),
},
),
test_workspace.name,
)
test_session = (
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={
observer_peer.name: schemas.SessionPeerConfig(
observe_others=True
),
},
),
test_workspace.name,
)
).resource
await db_session.commit()
# Create message from peer who was NEVER in the session
@ -1376,7 +1414,7 @@ class TestGenerateQueueRecordsSeqInSession:
]
}
resolved_configuration = schemas.ResolvedConfiguration(
deriver=schemas.ResolvedDeriverConfiguration(enabled=True),
reasoning=schemas.ResolvedReasoningConfiguration(enabled=True),
summary=schemas.ResolvedSummaryConfiguration(
enabled=True,
messages_per_short_summary=20,
@ -1456,7 +1494,7 @@ class TestGenerateQueueRecordsSeqInSession:
]
}
resolved_configuration = schemas.ResolvedConfiguration(
deriver=schemas.ResolvedDeriverConfiguration(enabled=True),
reasoning=schemas.ResolvedReasoningConfiguration(enabled=True),
summary=schemas.ResolvedSummaryConfiguration(
enabled=True,
messages_per_short_summary=20,

View File

@ -247,10 +247,10 @@ class TestDocumentCreationWorkflow:
assert len(representation.explicit) == 1
assert representation.explicit[0].content == "User likes dogs"
async def test_get_working_representation_with_most_derived(
async def test_get_working_representation_with_include_most_frequent(
self, db_session: AsyncSession
):
"""Test working representation retrieval prioritizing most derived observations"""
"""Test working representation retrieval prioritizing most frequent observations"""
workspace, observer_peer = await self.create_test_workspace_and_peer(db_session)
_, observed_peer = await self.create_test_workspace_and_peer(
db_session, workspace.name
@ -297,7 +297,7 @@ class TestDocumentCreationWorkflow:
await db_session.commit()
# Retrieve with most_derived=True
# Retrieve with include_most_frequent=True
representation = await crud.get_working_representation(
workspace.name,
include_most_derived=True,

View File

@ -21,9 +21,9 @@ from src import crud, models, schemas
from src.models import Peer, Workspace
from src.schemas import (
ResolvedConfiguration,
ResolvedDeriverConfiguration,
ResolvedDreamConfiguration,
ResolvedPeerCardConfiguration,
ResolvedReasoningConfiguration,
ResolvedSummaryConfiguration,
)
from src.utils.clients import HonchoLLMCallResponse
@ -97,14 +97,16 @@ async def create_test_session_with_peer(
peer: Peer,
) -> models.Session:
"""Create a session with a peer configured for observation."""
session = await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={peer.name: schemas.SessionPeerConfig(observe_me=True)},
),
workspace.name,
)
session = (
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=str(generate_nanoid()),
peers={peer.name: schemas.SessionPeerConfig(observe_me=True)},
),
workspace.name,
)
).resource
await db_session.commit()
return session
@ -142,7 +144,7 @@ async def create_test_messages(
def create_test_configuration() -> ResolvedConfiguration:
"""Create a test configuration to avoid DB lookups in tests."""
return ResolvedConfiguration(
deriver=ResolvedDeriverConfiguration(enabled=True),
reasoning=ResolvedReasoningConfiguration(enabled=True),
peer_card=ResolvedPeerCardConfiguration(use=False, create=False),
summary=ResolvedSummaryConfiguration(
enabled=True, messages_per_short_summary=20, messages_per_long_summary=60

View File

@ -7,8 +7,8 @@ from src import models
from src.models import Peer, Workspace
class TestObservationRoutes:
"""Test suite for observation API endpoints"""
class TestConclusionRoutes:
"""Test suite for conclusion API endpoints"""
async def _create_collection(
self,
@ -28,13 +28,13 @@ class TestObservationRoutes:
return collection
@pytest.mark.asyncio
async def test_list_observations_success(
async def test_list_conclusions_success(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test listing observations for a session"""
"""Test listing conclusions for a session"""
test_workspace, test_peer = sample_data
# Create another peer
@ -56,7 +56,7 @@ class TestObservationRoutes:
db_session, test_workspace.name, test_peer.name, test_peer2.name
)
# Create test observations (documents)
# Create test conclusions (documents)
doc1 = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
@ -76,9 +76,9 @@ class TestObservationRoutes:
db_session.add_all([doc1, doc2])
await db_session.commit()
# List observations
# List conclusions
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list",
f"/v2/workspaces/{test_workspace.name}/conclusions/list",
json={"filters": {"session_id": test_session.name}},
)
@ -87,14 +87,14 @@ class TestObservationRoutes:
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
# Check conclusion structure
conclusion = data["items"][0]
assert "id" in conclusion
assert "content" in conclusion
assert "observer_id" in conclusion
assert "observed_id" in conclusion
assert "session_id" in conclusion
assert "created_at" in conclusion
# Verify content
contents = [item["content"] for item in data["items"]]
@ -102,25 +102,25 @@ class TestObservationRoutes:
assert "User works late at night" in contents
@pytest.mark.asyncio
async def test_list_observations_empty_session(
async def test_list_conclusions_empty_session(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test listing observations for a session with no observations"""
"""Test listing conclusions for a session with no conclusions"""
test_workspace, _test_peer = sample_data
# Create a session without any observations
# Create a session without any conclusions
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_session)
await db_session.commit()
# List observations
# List conclusions
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list",
f"/v2/workspaces/{test_workspace.name}/conclusions/list",
json={"filters": {"session_id": test_session.name}},
)
@ -130,13 +130,13 @@ class TestObservationRoutes:
assert len(data["items"]) == 0
@pytest.mark.asyncio
async def test_list_observations_with_filters(
async def test_list_conclusions_with_filters(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test listing observations with observer/observed filters"""
"""Test listing conclusions with observer/observed filters"""
test_workspace, test_peer = sample_data
# Create two more peers
@ -164,7 +164,7 @@ class TestObservationRoutes:
db_session, test_workspace.name, test_peer2.name, test_peer3.name
)
# Create observations with different observer/observed pairs
# Create conclusions with different observer/observed pairs
doc1 = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
@ -184,9 +184,9 @@ class TestObservationRoutes:
db_session.add_all([doc1, doc2])
await db_session.commit()
# List observations filtered by observer
# List conclusions filtered by observer
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list",
f"/v2/workspaces/{test_workspace.name}/conclusions/list",
json={
"filters": {"observer": test_peer.name, "session_id": test_session.name}
},
@ -199,13 +199,13 @@ class TestObservationRoutes:
assert data["items"][0]["observer_id"] == test_peer.name
@pytest.mark.asyncio
async def test_list_observations_reverse_order(
async def test_list_conclusions_reverse_order(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test listing observations in reverse chronological order"""
"""Test listing conclusions in reverse chronological order"""
test_workspace, test_peer = sample_data
# Create another peer
@ -227,12 +227,12 @@ class TestObservationRoutes:
db_session, test_workspace.name, test_peer.name, test_peer2.name
)
# Create observations
# Create conclusions
doc1 = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="First observation",
content="First conclusion",
embedding=[0.1] * 1536,
session_name=test_session.name,
)
@ -243,33 +243,33 @@ class TestObservationRoutes:
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="Second observation",
content="Second conclusion",
embedding=[0.2] * 1536,
session_name=test_session.name,
)
db_session.add(doc2)
await db_session.commit()
# List observations in reverse (oldest first)
# List conclusions in reverse (oldest first)
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list?reverse=true",
f"/v2/workspaces/{test_workspace.name}/conclusions/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"
assert data["items"][0]["content"] == "First conclusion"
assert data["items"][1]["content"] == "Second conclusion"
@pytest.mark.asyncio
async def test_list_observations_pagination(
async def test_list_conclusions_pagination(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test pagination of observations list"""
"""Test pagination of conclusions list"""
test_workspace, test_peer = sample_data
# Create another peer
@ -291,13 +291,13 @@ class TestObservationRoutes:
db_session, test_workspace.name, test_peer.name, test_peer2.name
)
# Create multiple observations
# Create multiple conclusions
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}",
content=f"Conclusion {i}",
embedding=[0.1 * i] * 1536,
session_name=test_session.name,
)
@ -306,7 +306,7 @@ class TestObservationRoutes:
# Get first page (default size)
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list?page=1&size=10",
f"/v2/workspaces/{test_workspace.name}/conclusions/list?page=1&size=10",
json={"filters": {"session_id": test_session.name}},
)
@ -317,7 +317,7 @@ class TestObservationRoutes:
# Get second page
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list?page=2&size=10",
f"/v2/workspaces/{test_workspace.name}/conclusions/list?page=2&size=10",
json={"filters": {"session_id": test_session.name}},
)
@ -327,13 +327,13 @@ class TestObservationRoutes:
assert data["total"] == 15
@pytest.mark.asyncio
async def test_query_observations_success(
async def test_query_conclusions_success(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test querying observations with semantic search"""
"""Test querying conclusions with semantic search"""
test_workspace, test_peer = sample_data
# Create another peer
@ -355,7 +355,7 @@ class TestObservationRoutes:
db_session, test_workspace.name, test_peer.name, test_peer2.name
)
# Create test observations
# Create test conclusions
doc1 = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
@ -375,9 +375,9 @@ class TestObservationRoutes:
db_session.add_all([doc1, doc2])
await db_session.commit()
# Query observations
# Query conclusions
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/query",
f"/v2/workspaces/{test_workspace.name}/conclusions/query",
json={
"query": "food preferences",
"filters": {
@ -393,21 +393,21 @@ class TestObservationRoutes:
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
# Check conclusion structure
conclusion = data[0] # pyright: ignore
assert "id" in conclusion
assert "content" in conclusion
assert "observer_id" in conclusion
assert "observed_id" in conclusion
@pytest.mark.asyncio
async def test_query_observations_with_top_k(
async def test_query_conclusions_with_top_k(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test querying observations with top_k limit"""
"""Test querying conclusions with top_k limit"""
test_workspace, test_peer = sample_data
# Create another peer
@ -429,13 +429,13 @@ class TestObservationRoutes:
db_session, test_workspace.name, test_peer.name, test_peer2.name
)
# Create multiple observations
# Create multiple conclusions
for i in range(5):
doc = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content=f"Observation about topic {i}",
content=f"Conclusion about topic {i}",
embedding=[0.1 * i] * 1536,
session_name=test_session.name,
)
@ -444,7 +444,7 @@ class TestObservationRoutes:
# Query with top_k=2
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/query",
f"/v2/workspaces/{test_workspace.name}/conclusions/query",
json={
"query": "relevant topic",
"top_k": 2,
@ -462,13 +462,13 @@ class TestObservationRoutes:
assert len(data) <= 2 # pyright: ignore
@pytest.mark.asyncio
async def test_query_observations_with_distance_threshold(
async def test_query_conclusions_with_distance_threshold(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test querying observations with distance threshold"""
"""Test querying conclusions with distance threshold"""
test_workspace, test_peer = sample_data
# Create another peer
@ -490,12 +490,12 @@ class TestObservationRoutes:
db_session, test_workspace.name, test_peer.name, test_peer2.name
)
# Create test observation
# Create test conclusion
doc = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="Test observation",
content="Test conclusion",
embedding=[0.5] * 1536,
session_name=test_session.name,
)
@ -504,7 +504,7 @@ class TestObservationRoutes:
# Query with distance threshold
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/query",
f"/v2/workspaces/{test_workspace.name}/conclusions/query",
json={
"query": "test",
"distance": 0.8,
@ -521,13 +521,13 @@ class TestObservationRoutes:
assert isinstance(data, list)
@pytest.mark.asyncio
async def test_query_observations_requires_observer_observed(
async def test_query_conclusions_requires_observer_observed(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test query observations requires observer and observed in filters"""
"""Test query conclusions requires observer and observed in filters"""
test_workspace, _test_peer = sample_data
# Create a session
@ -539,20 +539,20 @@ class TestObservationRoutes:
# Query without observer/observed filters should fail
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/query",
f"/v2/workspaces/{test_workspace.name}/conclusions/query",
json={"query": "test"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_query_observations_invalid_top_k(
async def test_query_conclusions_invalid_top_k(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test query observations validates top_k range"""
"""Test query conclusions validates top_k range"""
test_workspace, test_peer = sample_data
# Create another peer
@ -571,7 +571,7 @@ class TestObservationRoutes:
# Query with invalid top_k (too high)
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/query",
f"/v2/workspaces/{test_workspace.name}/conclusions/query",
json={
"query": "test",
"top_k": 101, # Max is 100
@ -586,13 +586,13 @@ class TestObservationRoutes:
assert response.status_code == 422
@pytest.mark.asyncio
async def test_delete_observation_success(
async def test_delete_conclusion_success(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test deleting an observation"""
"""Test deleting a conclusion"""
test_workspace, test_peer = sample_data
# Create another peer
@ -614,44 +614,42 @@ class TestObservationRoutes:
db_session, test_workspace.name, test_peer.name, test_peer2.name
)
# Create a test observation
# Create a test conclusion
doc = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="Test observation to delete",
content="Test conclusion to delete",
embedding=[0.1] * 1536,
session_name=test_session.name,
)
db_session.add(doc)
await db_session.commit()
observation_id = doc.id
conclusion_id = doc.id
# Delete observation
# Delete conclusion
response = client.delete(
f"/v2/workspaces/{test_workspace.name}/observations/{observation_id}"
f"/v2/workspaces/{test_workspace.name}/conclusions/{conclusion_id}"
)
assert response.status_code == 200
data = response.json()
assert data["message"] == "Observation deleted successfully"
assert response.status_code == 204
# Verify observation is deleted
# Verify conclusion is deleted
from sqlalchemy import select
stmt = select(models.Document).where(models.Document.id == observation_id)
stmt = select(models.Document).where(models.Document.id == conclusion_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(
async def test_delete_conclusion_not_found(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test deleting a non-existent observation"""
"""Test deleting a non-existent conclusion"""
test_workspace, _test_peer = sample_data
# Create a session
@ -661,9 +659,9 @@ class TestObservationRoutes:
db_session.add(test_session)
await db_session.commit()
# Try to delete non-existent observation
# Try to delete non-existent conclusion
response = client.delete(
f"/v2/workspaces/{test_workspace.name}/observations/nonexistent_id"
f"/v2/workspaces/{test_workspace.name}/conclusions/nonexistent_id"
)
assert response.status_code == 404
@ -671,33 +669,33 @@ class TestObservationRoutes:
assert "not found" in data["detail"].lower()
@pytest.mark.asyncio
async def test_list_observations_nonexistent_session(
async def test_list_conclusions_nonexistent_session(
self,
client: TestClient,
sample_data: tuple[Workspace, Peer],
):
"""Test listing observations for non-existent session"""
"""Test listing conclusions for non-existent session"""
test_workspace, _test_peer = sample_data
# Try to list observations for non-existent session
# Try to list conclusions for non-existent session
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list",
f"/v2/workspaces/{test_workspace.name}/conclusions/list",
json={"filters": {"session_id": "nonexistent_session"}},
)
# Should return empty result, not error (session might exist but no observations)
# Should return empty result, not error (session might exist but no conclusions)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 0
@pytest.mark.asyncio
async def test_observations_field_mapping(
async def test_conclusions_field_mapping(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test that observation fields are properly mapped from document model"""
"""Test that conclusion fields are properly mapped from document model"""
test_workspace, test_peer = sample_data
# Create another peer
@ -719,49 +717,49 @@ class TestObservationRoutes:
db_session, test_workspace.name, test_peer.name, test_peer2.name
)
# Create test observation
# Create test conclusion
doc = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="Test observation content",
content="Test conclusion content",
embedding=[0.1] * 1536,
session_name=test_session.name,
)
db_session.add(doc)
await db_session.commit()
# List observations
# List conclusions
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list",
f"/v2/workspaces/{test_workspace.name}/conclusions/list",
json={"filters": {"session_id": test_session.name}},
)
assert response.status_code == 200
data = response.json()
observation = data["items"][0]
conclusion = 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
assert conclusion["id"] == doc.id
assert conclusion["content"] == doc.content
assert conclusion["observer_id"] == doc.observer
assert conclusion["observed_id"] == doc.observed
assert conclusion["session_id"] == doc.session_name
assert "created_at" in conclusion
# Verify internal fields are NOT exposed
assert "embedding" not in observation
assert "internal_metadata" not in observation
assert "collection" not in observation
assert "embedding" not in conclusion
assert "internal_metadata" not in conclusion
assert "collection" not in conclusion
@pytest.mark.asyncio
async def test_create_observation_success(
async def test_create_conclusion_success(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test creating a single observation"""
"""Test creating a single conclusion"""
test_workspace, test_peer = sample_data
# Create another peer
@ -778,11 +776,11 @@ class TestObservationRoutes:
db_session.add(test_session)
await db_session.commit()
# Create observation via API
# Create conclusion via API
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
f"/v2/workspaces/{test_workspace.name}/conclusions",
json={
"observations": [
"conclusions": [
{
"content": "User prefers dark mode",
"observer_id": test_peer.name,
@ -793,26 +791,26 @@ class TestObservationRoutes:
},
)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 1
observation = data[0]
assert observation["content"] == "User prefers dark mode"
assert observation["observer_id"] == test_peer.name
assert observation["observed_id"] == test_peer2.name
assert observation["session_id"] == test_session.name
assert "id" in observation
assert "created_at" in observation
conclusion = data[0]
assert conclusion["content"] == "User prefers dark mode"
assert conclusion["observer_id"] == test_peer.name
assert conclusion["observed_id"] == test_peer2.name
assert conclusion["session_id"] == test_session.name
assert "id" in conclusion
assert "created_at" in conclusion
@pytest.mark.asyncio
async def test_create_observations_batch(
async def test_create_conclusions_batch(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test creating multiple observations in batch"""
"""Test creating multiple conclusions in batch"""
test_workspace, test_peer = sample_data
# Create another peer
@ -829,11 +827,11 @@ class TestObservationRoutes:
db_session.add(test_session)
await db_session.commit()
# Create multiple observations via API
# Create multiple conclusions via API
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
f"/v2/workspaces/{test_workspace.name}/conclusions",
json={
"observations": [
"conclusions": [
{
"content": "User prefers dark mode",
"observer_id": test_peer.name,
@ -856,7 +854,7 @@ class TestObservationRoutes:
},
)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 3
@ -866,13 +864,13 @@ class TestObservationRoutes:
assert "User enjoys programming" in contents
@pytest.mark.asyncio
async def test_create_observation_nonexistent_session(
async def test_create_conclusion_nonexistent_session(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test creating observation with non-existent session fails"""
"""Test creating conclusion with non-existent session fails"""
test_workspace, test_peer = sample_data
# Create another peer
@ -882,13 +880,13 @@ class TestObservationRoutes:
db_session.add(test_peer2)
await db_session.commit()
# Try to create observation with non-existent session
# Try to create conclusion with non-existent session
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
f"/v2/workspaces/{test_workspace.name}/conclusions",
json={
"observations": [
"conclusions": [
{
"content": "Test observation",
"content": "Test conclusion",
"observer_id": test_peer.name,
"observed_id": test_peer2.name,
"session_id": "nonexistent_session",
@ -900,13 +898,13 @@ class TestObservationRoutes:
assert response.status_code == 404
@pytest.mark.asyncio
async def test_create_observation_nonexistent_peer(
async def test_create_conclusion_nonexistent_peer(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test creating observation with non-existent peer fails"""
"""Test creating conclusion with non-existent peer fails"""
test_workspace, test_peer = sample_data
# Create a session
@ -916,13 +914,13 @@ class TestObservationRoutes:
db_session.add(test_session)
await db_session.commit()
# Try to create observation with non-existent observer
# Try to create conclusion with non-existent observer
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
f"/v2/workspaces/{test_workspace.name}/conclusions",
json={
"observations": [
"conclusions": [
{
"content": "Test observation",
"content": "Test conclusion",
"observer_id": "nonexistent_peer",
"observed_id": test_peer.name,
"session_id": test_session.name,
@ -934,13 +932,13 @@ class TestObservationRoutes:
assert response.status_code == 404
@pytest.mark.asyncio
async def test_create_observation_empty_content(
async def test_create_conclusion_empty_content(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test creating observation with empty content fails validation"""
"""Test creating conclusion with empty content fails validation"""
test_workspace, test_peer = sample_data
# Create another peer
@ -957,11 +955,11 @@ class TestObservationRoutes:
db_session.add(test_session)
await db_session.commit()
# Try to create observation with empty content
# Try to create conclusion with empty content
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
f"/v2/workspaces/{test_workspace.name}/conclusions",
json={
"observations": [
"conclusions": [
{
"content": "",
"observer_id": test_peer.name,
@ -975,30 +973,30 @@ class TestObservationRoutes:
assert response.status_code == 422
@pytest.mark.asyncio
async def test_create_observation_empty_list(
async def test_create_conclusion_empty_list(
self,
client: TestClient,
sample_data: tuple[Workspace, Peer],
):
"""Test creating observations with empty list fails validation"""
"""Test creating conclusions with empty list fails validation"""
test_workspace, _test_peer = sample_data
# Try to create with empty observations list
# Try to create with empty conclusions list
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
json={"observations": []},
f"/v2/workspaces/{test_workspace.name}/conclusions",
json={"conclusions": []},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_create_observation_creates_collection(
async def test_create_conclusion_creates_collection(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test that creating observation auto-creates collection if needed"""
"""Test that creating conclusion auto-creates collection if needed"""
test_workspace, test_peer = sample_data
# Create another peer
@ -1015,13 +1013,13 @@ class TestObservationRoutes:
db_session.add(test_session)
await db_session.commit()
# Create observation via API (this should auto-create collection)
# Create conclusion via API (this should auto-create collection)
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
f"/v2/workspaces/{test_workspace.name}/conclusions",
json={
"observations": [
"conclusions": [
{
"content": "Test observation",
"content": "Test conclusion",
"observer_id": test_peer.name,
"observed_id": test_peer2.name,
"session_id": test_session.name,
@ -1030,24 +1028,24 @@ class TestObservationRoutes:
},
)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 1
# The observation was created successfully, which means the collection
# The conclusion was created successfully, which means the collection
# was created (since documents require a collection)
observation = data[0]
assert observation["observer_id"] == test_peer.name
assert observation["observed_id"] == test_peer2.name
conclusion = data[0]
assert conclusion["observer_id"] == test_peer.name
assert conclusion["observed_id"] == test_peer2.name
@pytest.mark.asyncio
async def test_create_observation_different_observer_observed_pairs(
async def test_create_conclusion_different_observer_observed_pairs(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test creating observations with different observer/observed pairs in single batch"""
"""Test creating conclusions with different observer/observed pairs in single batch"""
test_workspace, test_peer = sample_data
# Create two more peers
@ -1067,11 +1065,11 @@ class TestObservationRoutes:
db_session.add(test_session)
await db_session.commit()
# Create observations with different observer/observed pairs
# Create conclusions with different observer/observed pairs
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
f"/v2/workspaces/{test_workspace.name}/conclusions",
json={
"observations": [
"conclusions": [
{
"content": "Peer1 observes Peer2",
"observer_id": test_peer.name,
@ -1088,11 +1086,11 @@ class TestObservationRoutes:
},
)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 2
# Verify each observation has correct observer/observed
# Verify each conclusion has correct observer/observed
obs1 = next(o for o in data if o["content"] == "Peer1 observes Peer2")
assert obs1["observer_id"] == test_peer.name
assert obs1["observed_id"] == test_peer2.name
@ -1102,13 +1100,13 @@ class TestObservationRoutes:
assert obs2["observed_id"] == test_peer3.name
@pytest.mark.asyncio
async def test_created_observations_are_searchable(
async def test_created_conclusions_are_searchable(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test that created observations can be found via list endpoint"""
"""Test that created conclusions can be found via list endpoint"""
test_workspace, test_peer = sample_data
# Create another peer
@ -1125,11 +1123,11 @@ class TestObservationRoutes:
db_session.add(test_session)
await db_session.commit()
# Create observation via API
# Create conclusion via API
create_response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
f"/v2/workspaces/{test_workspace.name}/conclusions",
json={
"observations": [
"conclusions": [
{
"content": "Unique test content for searchability",
"observer_id": test_peer.name,
@ -1140,12 +1138,12 @@ class TestObservationRoutes:
},
)
assert create_response.status_code == 200
assert create_response.status_code == 201
created_id = create_response.json()[0]["id"]
# List observations and verify the created one is there
# List conclusions and verify the created one is there
list_response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list",
f"/v2/workspaces/{test_workspace.name}/conclusions/list",
json={
"filters": {
"observer": test_peer.name,

View File

@ -57,7 +57,7 @@ async def test_create_messages_with_text_file(
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 1 # Should be 1 message since text is short
@ -90,7 +90,7 @@ async def test_create_messages_with_large_file_chunking(
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) > 1 # Should be multiple messages due to chunking
@ -124,7 +124,7 @@ async def test_create_messages_with_json_file(
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 1
@ -206,7 +206,7 @@ async def test_create_messages_with_empty_file(
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
# Should create one message with empty content
assert len(data) == 1
@ -233,7 +233,7 @@ async def test_file_metadata_stored_in_internal_metadata(
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
message_id = data[0]["id"]
@ -298,7 +298,7 @@ async def test_pdf_file_processing(
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) >= 1 # PDF should create at least one message
@ -364,7 +364,7 @@ async def test_file_upload_with_metadata(
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 1
@ -405,7 +405,7 @@ async def test_file_upload_with_configuration(
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 1
@ -449,7 +449,7 @@ async def test_file_upload_with_created_at(
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 1
@ -500,7 +500,7 @@ async def test_file_upload_with_all_parameters(
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 1
@ -543,7 +543,7 @@ async def test_file_upload_with_invalid_metadata_json(
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
assert response.status_code == 201
data = response.json()
# Metadata parsing failure is logged but doesn't fail the request
assert len(data) == 1
@ -577,7 +577,7 @@ async def test_large_file_upload_with_metadata(
url = _get_upload_url(test_workspace.name, session_name)
response = client.post(url, files=files, data=form_data)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) > 1 # Should be multiple messages due to chunking

View File

@ -35,7 +35,7 @@ async def test_create_message(
]
},
)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 1
message = data[0]
@ -77,7 +77,7 @@ async def test_create_batch_messages_with_metadata(
]
},
)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 2
@ -115,7 +115,7 @@ async def test_create_batch_messages_without_metadata(
]
},
)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 1
assert data[0]["content"] == "Message without metadata"
@ -148,7 +148,7 @@ async def test_create_batch_messages_with_null_metadata(
]
},
)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 1
assert data[0]["content"] == "Message with null metadata"
@ -732,8 +732,8 @@ async def test_create_messages_for_nonexistent_session(
]
},
)
# Should create the session and return 200 with the created message
assert response.status_code == 200
# Should create the session and return 201 with the created message
assert response.status_code == 201
data = response.json()
assert len(data) == 1
assert data[0]["content"] == "Test message"
@ -807,7 +807,7 @@ async def test_create_batch_messages_max_limit(
f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages",
json={"messages": messages},
)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 100
assert data[0]["content"] == "Message 0"
@ -973,7 +973,7 @@ async def test_create_message_with_timestamp(
]
},
)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 1
message = data[0]
@ -1020,7 +1020,7 @@ async def test_create_message_without_timestamp_uses_default(
# Record time after request
after_request = datetime.datetime.now(datetime.timezone.utc)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 1
message = data[0]
@ -1083,7 +1083,7 @@ async def test_create_batch_messages_with_mixed_timestamps(
after_request = datetime.datetime.now(datetime.timezone.utc)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 3
@ -1143,7 +1143,7 @@ async def test_create_message_with_null_timestamp(
after_request = datetime.datetime.now(datetime.timezone.utc)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert len(data) == 1
message = data[0]

View File

@ -16,7 +16,7 @@ def test_get_or_create_peer(client: TestClient, sample_data: tuple[Workspace, Pe
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": name, "metadata": {"peer_key": "peer_value"}},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
data = response.json()
assert data["id"] == name
assert data["metadata"] == {"peer_key": "peer_value"}
@ -35,7 +35,7 @@ def test_get_or_create_peer_with_configuration(
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": name, "configuration": configuration},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
data = response.json()
assert data["id"] == name
assert data["configuration"] == configuration
@ -54,7 +54,7 @@ def test_get_or_create_peer_with_all_optional_params(
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": name, "metadata": metadata, "configuration": configuration},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
data = response.json()
assert data["id"] == name
assert data["metadata"] == metadata
@ -72,7 +72,7 @@ def test_get_or_create_existing_peer(
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": name, "metadata": {"peer_key": "peer_value"}},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
peer1 = response.json()
# Try to create the same peer again - should return existing peer
@ -80,7 +80,7 @@ def test_get_or_create_existing_peer(
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": name, "metadata": {"peer_key": "peer_value"}},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
peer2 = response.json()
# Both should be the same peer
@ -275,7 +275,7 @@ def test_get_sessions_for_peer(client: TestClient, sample_data: tuple[Workspace,
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"id": session_name, "peer_names": {test_peer.name: {}}},
)
assert create_response.status_code == 200
assert create_response.status_code in [200, 201]
created_session = create_response.json()
assert created_session["id"] == session_name
@ -381,7 +381,7 @@ def test_get_peer_representation_with_session(
assert response.status_code == 200
data = response.json()
assert "representation" in data
assert isinstance(data["representation"], dict)
assert isinstance(data["representation"], str)
def test_get_peer_representation_global(
@ -398,7 +398,7 @@ def test_get_peer_representation_global(
assert response.status_code == 200
data = response.json()
assert "representation" in data
assert isinstance(data["representation"], dict)
assert isinstance(data["representation"], str)
def test_get_peer_representation_with_target(
@ -424,7 +424,7 @@ def test_get_peer_representation_with_target(
assert response.status_code == 200
data = response.json()
assert "representation" in data
assert isinstance(data["representation"], dict)
assert isinstance(data["representation"], str)
def test_get_peer_representation_with_search_query(
@ -485,30 +485,30 @@ def test_get_peer_representation_with_search_max_distance(
assert "representation" in data
def test_get_peer_representation_with_include_most_derived(
def test_get_peer_representation_with_include_most_frequent(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer representation with include_most_derived parameter"""
"""Test peer representation with include_most_frequent parameter"""
test_workspace, test_peer = sample_data
# Test with include_most_derived=True
# Test with include_most_frequent=True
response = client.post(
f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation",
json={
"search_query": "test query",
"include_most_derived": True,
"include_most_frequent": True,
},
)
assert response.status_code == 200
data = response.json()
assert "representation" in data
# Test with include_most_derived=False
# Test with include_most_frequent=False
response = client.post(
f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation",
json={
"search_query": "test query",
"include_most_derived": False,
"include_most_frequent": False,
},
)
assert response.status_code == 200
@ -566,14 +566,14 @@ def test_get_peer_representation_with_all_parameters(
"search_query": "What do I know about this peer?",
"search_top_k": 15,
"search_max_distance": 0.75,
"include_most_derived": True,
"include_most_frequent": True,
"max_observations": 30,
},
)
assert response.status_code == 200
data = response.json()
assert "representation" in data
assert isinstance(data["representation"], dict)
assert isinstance(data["representation"], str)
def test_get_peer_representation_structure(
@ -592,13 +592,7 @@ def test_get_peer_representation_structure(
# 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)
assert isinstance(data["representation"], str)
def test_get_peer_representation_boundary_values(
@ -841,7 +835,7 @@ def test_get_peer_card(client: TestClient, sample_data: tuple[Workspace, Peer]):
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": target_peer_name},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Test getting observer's own card (should return null initially)
response = client.get(
@ -876,7 +870,7 @@ async def test_get_peer_card_with_data(
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": target_peer_name},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Set up peer cards using the database directly
# Set a self-card for the observer peer

View File

@ -8,7 +8,7 @@ from src.utils.work_unit import construct_work_unit_key
@pytest.mark.asyncio
class TestDeriverStatusEndpoint:
"""Test suite for the /deriver/status endpoint"""
"""Test suite for the /queue/status endpoint"""
async def test_get_deriver_status_peer_only(
self,
@ -18,7 +18,7 @@ class TestDeriverStatusEndpoint:
"""Test getting deriver status filtered by peer only"""
workspace, peer = sample_data
response = client.get(
f"/v2/workspaces/{workspace.name}/deriver/status",
f"/v2/workspaces/{workspace.name}/queue/status",
params={"observer_id": peer.name},
)
assert response.status_code == 200
@ -36,7 +36,7 @@ class TestDeriverStatusEndpoint:
db_session.add(session)
await db_session.commit()
response = client.get(
f"/v2/workspaces/{workspace.name}/deriver/status",
f"/v2/workspaces/{workspace.name}/queue/status",
params={"session_id": session.name},
)
assert response.status_code == 200
@ -54,7 +54,7 @@ class TestDeriverStatusEndpoint:
db_session.add(session)
await db_session.commit()
response = client.get(
f"/v2/workspaces/{workspace.name}/deriver/status",
f"/v2/workspaces/{workspace.name}/queue/status",
params={"observer_id": peer.name, "session_id": session.name},
)
assert response.status_code == 200
@ -68,7 +68,7 @@ class TestDeriverStatusEndpoint:
"""Test getting deriver status with include_sender=True"""
workspace, peer = sample_data
response = client.get(
f"/v2/workspaces/{workspace.name}/deriver/status",
f"/v2/workspaces/{workspace.name}/queue/status",
params={"observer_id": peer.name, "sender_id": peer.name},
)
assert response.status_code == 200
@ -82,7 +82,7 @@ class TestDeriverStatusEndpoint:
"""Test getting deriver status with include_sender=False (default)"""
workspace, peer = sample_data
response = client.get(
f"/v2/workspaces/{workspace.name}/deriver/status",
f"/v2/workspaces/{workspace.name}/queue/status",
params={"observer_id": peer.name},
)
assert response.status_code == 200
@ -93,7 +93,7 @@ class TestDeriverStatusEndpoint:
):
"""Test getting deriver status without required parameters returns 200"""
workspace, _ = sample_data
response = client.get(f"/v2/workspaces/{workspace.name}/deriver/status")
response = client.get(f"/v2/workspaces/{workspace.name}/queue/status")
assert response.status_code == 200
async def test_get_deriver_status_nonexistent_peer(
@ -102,7 +102,7 @@ class TestDeriverStatusEndpoint:
"""Test getting deriver status for nonexistent peer returns empty result"""
workspace, _ = sample_data
response = client.get(
f"/v2/workspaces/{workspace.name}/deriver/status",
f"/v2/workspaces/{workspace.name}/queue/status",
params={"observer_id": "nonexistent"},
)
assert response.status_code == 200
@ -117,7 +117,7 @@ class TestDeriverStatusEndpoint:
"""Test getting deriver status for nonexistent session returns empty result"""
workspace, _ = sample_data
response = client.get(
f"/v2/workspaces/{workspace.name}/deriver/status",
f"/v2/workspaces/{workspace.name}/queue/status",
params={"session_id": "nonexistent"},
)
assert response.status_code == 200
@ -128,7 +128,7 @@ class TestDeriverStatusEndpoint:
async def test_get_deriver_status_nonexistent_workspace(self, client: TestClient):
"""Test getting deriver status for nonexistent workspace returns empty result"""
response = client.get("/v2/workspaces/nonexistent/deriver/status")
response = client.get("/v2/workspaces/nonexistent/queue/status")
assert response.status_code == 200
assert response.json()["total_work_units"] == 0
@ -166,13 +166,13 @@ class TestDeriverStatusEndpoint:
db_session.add_all(queue_items)
await db_session.commit()
# Test without parameters
response = client.get(f"/v2/workspaces/{workspace.name}/deriver/status")
response = client.get(f"/v2/workspaces/{workspace.name}/queue/status")
assert response.status_code == 200
assert response.json()["total_work_units"] == 5
assert response.json()["pending_work_units"] == 5
# Test with observer_id
response = client.get(
f"/v2/workspaces/{workspace.name}/deriver/status",
f"/v2/workspaces/{workspace.name}/queue/status",
params={"observer_id": peer.name},
)
assert response.status_code == 200
@ -180,7 +180,7 @@ class TestDeriverStatusEndpoint:
assert response.json()["pending_work_units"] == 5
# Test with sender_id (new capability)
response = client.get(
f"/v2/workspaces/{workspace.name}/deriver/status",
f"/v2/workspaces/{workspace.name}/queue/status",
params={"sender_id": peer.name},
)
assert response.status_code == 200
@ -188,7 +188,7 @@ class TestDeriverStatusEndpoint:
assert response.json()["pending_work_units"] == 5
# Test with both (OR filter)
response = client.get(
f"/v2/workspaces/{workspace.name}/deriver/status",
f"/v2/workspaces/{workspace.name}/queue/status",
params={"observer_id": peer.name, "sender_id": peer.name},
)
assert response.status_code == 200
@ -196,7 +196,7 @@ class TestDeriverStatusEndpoint:
assert response.json()["pending_work_units"] == 5
# Test with different observer and sender (should be ok)
response = client.get(
f"/v2/workspaces/{workspace.name}/deriver/status",
f"/v2/workspaces/{workspace.name}/queue/status",
params={"observer_id": peer.name, "sender_id": "different"},
)
assert response.status_code == 200
@ -241,7 +241,7 @@ class TestDeriverStatusEndpoint:
db_session.add_all(queue_items)
await db_session.commit()
response = client.get(
f"/v2/workspaces/{workspace.name}/deriver/status",
f"/v2/workspaces/{workspace.name}/queue/status",
params={"observer_id": peer.name},
)
assert response.status_code == 200
@ -262,7 +262,7 @@ class TestDeriverStatusEndpoint:
"""Test various edge cases with empty or invalid parameters"""
workspace, _ = sample_data
response = client.get(
f"/v2/workspaces/{workspace.name}/deriver/status",
f"/v2/workspaces/{workspace.name}/queue/status",
params={
"observer_id": "",
"session_id": "",
@ -305,7 +305,7 @@ class TestDeriverStatusEndpoint:
responses = []
for _ in range(3):
response = client.get(
f"/v2/workspaces/{workspace.name}/deriver/status",
f"/v2/workspaces/{workspace.name}/queue/status",
params={"observer_id": peer.name},
)
assert response.status_code == 200

View File

@ -17,7 +17,7 @@ def test_create_workspace_with_auth(auth_client: AuthClient):
assert response.status_code == 401
return
assert response.status_code == 200
assert response.status_code in [200, 201]
def test_auth_response_time(auth_client: AuthClient):
@ -42,7 +42,7 @@ def test_auth_response_time(auth_client: AuthClient):
assert response.status_code == 401
return
assert response.status_code == 200
assert response.status_code in [200, 201]
def test_get_or_create_workspace_with_auth(auth_client: AuthClient):
@ -56,7 +56,7 @@ def test_get_or_create_workspace_with_auth(auth_client: AuthClient):
assert response.status_code == 401
return
assert response.status_code == 200
assert response.status_code in [200, 201]
def test_get_workspace_with_auth(
@ -74,7 +74,7 @@ def test_get_workspace_with_auth(
# Admin JWT or JWT with matching workspace should be allowed
if auth_client.auth_type in ["admin", "empty"]:
assert response.status_code == 200
assert response.status_code in [200, 201]
else:
assert response.status_code == 401
@ -149,7 +149,7 @@ def test_create_peer_with_auth(
# Only admin JWT or JWT with matching workspace should be allowed
if auth_client.auth_type in ["admin", "empty"]:
assert response.status_code == 200
assert response.status_code in [200, 201]
else:
assert response.status_code == 401
@ -188,7 +188,7 @@ def test_get_peer_by_name_with_auth(
f"/v2/workspaces/{test_workspace.name}/peers", json={"name": test_peer.name}
)
assert response.status_code == 200
assert response.status_code in [200, 201]
def test_update_peer_with_auth(
@ -250,7 +250,7 @@ def test_create_session_with_auth(
# Only admin JWT or JWT with matching workspace should be allowed
if auth_client.auth_type in ["admin", "empty"]:
assert response.status_code == 200
assert response.status_code in [200, 201]
else:
assert response.status_code == 401
@ -266,7 +266,7 @@ def test_create_session_with_auth(
json={"name": session_name2, "peer_names": {test_peer.name: {}}},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
def test_get_session_by_name_with_auth(
@ -290,13 +290,13 @@ def test_get_session_by_name_with_auth(
assert create_response.status_code == 401
return
assert create_response.status_code == 200
assert create_response.status_code in [200, 201]
# Test with workspace scoped JWT - get the same session
response = auth_client.post(
f"/v2/workspaces/{test_workspace.name}/sessions", json={"name": session_name}
)
assert response.status_code == 200
assert response.status_code in [200, 201]
if auth_client.auth_type == "empty":
# Test with session-scoped JWT
@ -308,7 +308,7 @@ def test_get_session_by_name_with_auth(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"name": session_name},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Test with wrong session_name (should be 401 since we have a session-scoped JWT)
assert (
@ -324,26 +324,20 @@ def test_get_session_by_name_with_auth(
f"Bearer {create_jwt(JWTParams(p=test_peer.name))}"
)
assert (
auth_client.post(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"name": session_name},
).status_code
== 200
)
assert auth_client.post(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"name": session_name},
).status_code in [200, 201]
# Test with workspace-scoped JWT
auth_client.headers["Authorization"] = (
f"Bearer {create_jwt(JWTParams(w=test_workspace.name))}"
)
assert (
auth_client.post(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"name": session_name},
).status_code
== 200
)
assert auth_client.post(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"name": session_name},
).status_code in [200, 201]
# Test with wrong session_name using DELETE endpoint (should be 404 since session doesn't exist)
wrong_session_name = generate_nanoid()

View File

@ -15,7 +15,7 @@ def test_get_or_create_session(client: TestClient, sample_data: tuple[Workspace,
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"id": str(generate_nanoid())},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
data = response.json()
assert isinstance(data["id"], str)
assert data["metadata"] == {}
@ -27,7 +27,7 @@ def test_get_or_create_session(client: TestClient, sample_data: tuple[Workspace,
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"id": session_id, "peer_names": {test_peer.name: {}}},
)
assert response2.status_code == 200
assert response2.status_code in [200, 201]
data2 = response2.json()
assert data2["id"] == session_id
assert data2["metadata"] == {}
@ -38,7 +38,7 @@ def test_get_or_create_session(client: TestClient, sample_data: tuple[Workspace,
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"id": session_id, "peer_names": {test_peer.name: {}}},
)
assert response3.status_code == 200
assert response3.status_code in [200, 201]
data3 = response3.json()
assert data3["id"] == session_id
assert data3["metadata"] == {}
@ -58,7 +58,7 @@ def test_create_session_with_metadata(
"metadata": {"session_key": "session_value"},
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
data = response.json()
assert data["metadata"] == {"session_key": "session_value"}
assert "id" in data
@ -82,7 +82,7 @@ def test_create_session_with_configuration(
"configuration": configuration,
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
data = response.json()
assert data["configuration"] == configuration
assert data["id"] == session_id
@ -107,7 +107,7 @@ def test_create_session_with_all_optional_params(
"configuration": configuration,
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
data = response.json()
assert data["metadata"] == metadata
assert data["configuration"] == configuration
@ -129,7 +129,7 @@ def test_create_session_with_too_many_peers(
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": peer_name, "metadata": {}},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
peer_names.append(peer_name)
# Test 1: Create session with 11 non-observers should succeed
@ -140,7 +140,7 @@ def test_create_session_with_too_many_peers(
"peer_names": {peer_name: {} for peer_name in peer_names},
},
)
assert response.status_code == 200 # Should succeed since no observers
assert response.status_code in [200, 201] # Should succeed since no observers
# Test 2: Try to create session with 11 observers (exceeds limit)
response = client.post(
@ -173,7 +173,7 @@ def test_create_session_with_too_many_peers(
"peer_names": {peer_name: {} for peer_name in peer_names},
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
data = response.json()
assert data["id"] == "test_session"
assert data["workspace_id"] == test_workspace.name
@ -191,7 +191,7 @@ def test_get_sessions(client: TestClient, sample_data: tuple[Workspace, Peer]):
"metadata": {"test_key": "test_value"},
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
data = response.json()
assert data["metadata"] == {"test_key": "test_value"}
assert "id" in data
@ -237,7 +237,7 @@ def test_update_delete_metadata(
"metadata": {"default": "value"},
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
response = client.put(
f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}",
@ -259,7 +259,7 @@ def test_update_session(client: TestClient, sample_data: tuple[Workspace, Peer])
"peer_names": {test_peer.name: {}},
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
response = client.put(
f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}",
@ -283,7 +283,7 @@ def test_delete_session(client: TestClient, sample_data: tuple[Workspace, Peer])
"metadata": {"test_key": "test_value"},
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Delete the session
response = client.delete(
@ -392,7 +392,7 @@ def test_clone_session(client: TestClient, sample_data: tuple[Workspace, Peer]):
"metadata": {"test": "key"},
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Create some messages in the session
response = client.post(
@ -412,12 +412,12 @@ def test_clone_session(client: TestClient, sample_data: tuple[Workspace, Peer]):
]
},
)
assert response.status_code == 200
assert response.status_code == 201
response = client.get(
response = client.post(
f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/clone",
)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert data["metadata"] == {"test": "key"}
@ -462,16 +462,16 @@ def test_clone_session_with_cutoff(
]
},
)
assert response.status_code == 200
assert response.status_code == 201
messages_data = response.json()
# The response is a list of messages, not a paginated response
first_message_id = messages_data[0]["id"]
# Clone with cutoff at first message
response = client.get(
response = client.post(
f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/clone?message_id={first_message_id}",
)
assert response.status_code == 200
assert response.status_code == 201
data = response.json()
assert "id" in data
@ -484,7 +484,7 @@ def test_add_peers_to_session(client: TestClient, sample_data: tuple[Workspace,
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": peer2_name, "metadata": {}},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Create a test session
session_id = str(generate_nanoid())
@ -495,7 +495,7 @@ def test_add_peers_to_session(client: TestClient, sample_data: tuple[Workspace,
"peer_names": {test_peer.name: {}},
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Add another peer to the session
response = client.post(
@ -513,7 +513,7 @@ def test_get_session_peers(client: TestClient, sample_data: tuple[Workspace, Pee
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": peer2_name, "metadata": {}},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Create a test session with multiple peers
session_id = str(generate_nanoid())
@ -524,7 +524,7 @@ def test_get_session_peers(client: TestClient, sample_data: tuple[Workspace, Pee
"peer_names": {test_peer.name: {}, peer2_name: {}},
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Get peers from the session
response = client.get(
@ -547,7 +547,7 @@ def test_set_session_peers(client: TestClient, sample_data: tuple[Workspace, Pee
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": peer2_name, "metadata": {}},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Create a test session
session_id = str(generate_nanoid())
@ -558,7 +558,7 @@ def test_set_session_peers(client: TestClient, sample_data: tuple[Workspace, Pee
"peer_names": {test_peer.name: {}},
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Set peers for the session (should replace existing peers)
response = client.put(
@ -593,7 +593,7 @@ def test_set_session_peers_with_observer_limit(
"id": session_id,
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Create 15 peers (more than the limit of 10 observers)
peer_names = [test_peer.name]
@ -603,7 +603,7 @@ def test_set_session_peers_with_observer_limit(
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": peer_name, "metadata": {}},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
peer_names.append(peer_name)
# Test 1: Adding 15 peers with observe_others=False should succeed
@ -654,7 +654,7 @@ def test_update_peer_config_observer_limit(
"id": session_id,
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Create exactly 10 peers and add them as observers
peer_names: list[str] = []
@ -664,7 +664,7 @@ def test_update_peer_config_observer_limit(
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": peer_name, "metadata": {}},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
peer_names.append(peer_name)
# Add all 10 peers as observers
@ -681,7 +681,7 @@ def test_update_peer_config_observer_limit(
assert response.status_code == 200 # Should succeed with exactly 10 observers
# Now try to update test_peer to become an observer (would exceed limit)
response = client.post(
response = client.put(
f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers/{test_peer.name}/config",
json={"observe_others": True},
)
@ -690,25 +690,25 @@ def test_update_peer_config_observer_limit(
assert "Maximum allowed is 10 observers" in response.json()["detail"]
# Verify that updating a peer that's already an observer still works
response = client.post(
response = client.put(
f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers/{peer_names[0]}/config",
json={"observe_others": True, "observe_me": False}, # Still an observer
)
assert response.status_code == 200 # Should succeed since count doesn't change
assert response.status_code == 204 # Should succeed since count doesn't change
# Change one observer to non-observer
response = client.post(
response = client.put(
f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers/{peer_names[0]}/config",
json={"observe_others": False},
)
assert response.status_code == 200
assert response.status_code == 204
# Now test_peer can become an observer (9 + 1 = 10)
response = client.post(
response = client.put(
f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers/{test_peer.name}/config",
json={"observe_others": True},
)
assert response.status_code == 200 # Should succeed now
assert response.status_code == 204 # Should succeed now
def test_remove_peers_from_session(
@ -721,7 +721,7 @@ def test_remove_peers_from_session(
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": peer2_name, "metadata": {}},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Create a test session with multiple peers
session_id = str(generate_nanoid())
@ -732,7 +732,7 @@ def test_remove_peers_from_session(
"peer_names": {test_peer.name: {}, peer2_name: {}},
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Remove one peer from the session
response = client.request(
@ -1092,7 +1092,7 @@ def test_get_session_context_with_peer_target(
assert "peer_card" in data
# Representation should be present
assert data["peer_representation"] is not None
assert isinstance(data["peer_representation"], dict)
assert isinstance(data["peer_representation"], str)
def test_get_session_context_with_peer_perspective(
@ -1104,10 +1104,11 @@ def test_get_session_context_with_peer_perspective(
# Create another peer
peer2_name = str(generate_nanoid())
client.post(
response = client.post(
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": peer2_name, "metadata": {}},
)
assert response.status_code in [200, 201]
# Create session with both peers
client.post(
@ -1229,10 +1230,10 @@ def test_get_session_context_with_search_parameters(
assert "peer_representation" in data
def test_get_session_context_with_include_most_derived(
def test_get_session_context_with_include_most_frequent(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session context with include_most_derived parameter"""
"""Test session context with include_most_frequent parameter"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
@ -1242,13 +1243,13 @@ def test_get_session_context_with_include_most_derived(
json={"id": session_id, "peers": {test_peer.name: {}}},
)
# Get context with include_most_derived
# Get context with include_most_frequent
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,
"include_most_frequent": True,
},
)
assert response.status_code == 200
@ -1292,10 +1293,11 @@ def test_get_session_context_with_all_representation_params(
# Create another peer
peer2_name = str(generate_nanoid())
client.post(
response = client.post(
f"/v2/workspaces/{test_workspace.name}/peers",
json={"name": peer2_name, "metadata": {}},
)
assert response.status_code in [200, 201]
# Create session
client.post(
@ -1314,7 +1316,7 @@ def test_get_session_context_with_all_representation_params(
"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,
"include_most_frequent": True,
"max_observations": 15,
"summary": True,
},
@ -1329,7 +1331,7 @@ def test_get_session_context_with_all_representation_params(
assert "peer_representation" in data
assert "peer_card" in data
# Validate representation structure
assert isinstance(data["peer_representation"], dict)
assert isinstance(data["peer_representation"], str)
def test_get_session_context_response_structure(
@ -1355,7 +1357,7 @@ def test_get_session_context_response_structure(
]
},
)
assert response.status_code == 200
assert response.status_code == 201
# Get context and validate response structure
response = client.get(

View File

@ -70,7 +70,7 @@ def test_message_validations_api(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"id": session_id, "peer_names": {test_peer.name: {}}},
)
assert session_response.status_code == 200
assert session_response.status_code == 201
# Test content too long
response = client.post(
@ -110,7 +110,7 @@ def test_session_validations_api(
"configuration": {"test_flag": "test_value"},
},
)
assert session_response.status_code == 200
assert session_response.status_code == 201
# Test invalid metadata type
response = client.put(
@ -137,7 +137,7 @@ def test_session_validations_api(
"id": session_id,
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
data = response.json()
assert data["metadata"] == {"test_key": "test_value"}
assert data["configuration"] == {"test_flag": "test_value"}
@ -159,7 +159,7 @@ def test_agent_query_validations_api(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"id": session_id, "peer_names": {test_peer.name: {}}},
)
assert session_response.status_code == 200
assert session_response.status_code == 201
# Test valid string query (under 10000 chars)
response = client.post(
@ -199,7 +199,7 @@ def test_required_field_validations_api(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"id": session_id, "peer_names": {test_peer.name: {}}},
)
assert session_response.status_code == 200
assert session_response.status_code == 201
# Test missing required content in message
response = client.post(
@ -232,7 +232,7 @@ def test_filter_validations_api(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"id": session_id, "peer_names": {test_peer.name: {}}},
)
assert session_response.status_code == 200
assert session_response.status_code == 201
# Test invalid filter type in message list (at session level)
response = client.post(

View File

@ -18,7 +18,7 @@ async def test_create_webhook_endpoint(
"url": "http://example.com/webhook",
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
response_json = response.json()
assert response_json["url"] == "http://example.com/webhook"
assert "id" in response_json
@ -71,7 +71,7 @@ async def test_list_webhook_endpoints_with_data(
"url": "http://example1.com/webhook",
},
)
assert response1.status_code == 200
assert response1.status_code in [200, 201]
# Create second endpoint
response2 = client.post(
@ -80,7 +80,7 @@ async def test_list_webhook_endpoints_with_data(
"url": "http://example2.com/webhook",
},
)
assert response2.status_code == 200
assert response2.status_code in [200, 201]
# List endpoints
list_response = client.get(f"/v2/workspaces/{test_workspace.name}/webhooks")
@ -108,7 +108,7 @@ async def test_delete_webhook_endpoint(
"url": "http://example.com/webhook",
},
)
assert create_response.status_code == 200
assert create_response.status_code in [200, 201]
endpoint = create_response.json()
endpoint_id = endpoint["id"]
@ -116,7 +116,7 @@ async def test_delete_webhook_endpoint(
delete_response = client.delete(
f"/v2/workspaces/{test_workspace.name}/webhooks/{endpoint_id}"
)
assert delete_response.status_code == 200
assert delete_response.status_code == 204
# Verify endpoint is deleted
list_response = client.get(f"/v2/workspaces/{test_workspace.name}/webhooks")
@ -160,7 +160,7 @@ async def test_multiple_endpoints_per_workspace(
f"/v2/workspaces/{test_workspace.name}/webhooks",
json={"url": url},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
created_endpoints.append(response.json())
# List all endpoints
@ -179,7 +179,7 @@ async def test_multiple_endpoints_per_workspace(
delete_response = client.delete(
f"/v2/workspaces/{test_workspace.name}/webhooks/{created_endpoints[0]['id']}"
)
assert delete_response.status_code == 200
assert delete_response.status_code == 204
# Verify only 2 endpoints remain
list_response = client.get(f"/v2/workspaces/{test_workspace.name}/webhooks")
@ -201,14 +201,14 @@ async def test_create_duplicate_webhook_endpoint(
f"/v2/workspaces/{test_workspace.name}/webhooks",
json={"url": url},
)
assert response1.status_code == 200
assert response1.status_code in [200, 201]
# Try to create it again
response2 = client.post(
f"/v2/workspaces/{test_workspace.name}/webhooks",
json={"url": url},
)
assert response2.status_code == 200
assert response2.status_code in [200, 201]
assert response1.json() == response2.json()
# Verify only one endpoint exists
@ -235,7 +235,7 @@ async def test_max_webhook_endpoints_per_workspace(
"url": f"http://example{i}.com/webhook",
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Try to create one more
response = client.post(
@ -253,7 +253,7 @@ async def test_same_endpoint_in_different_workspaces(
):
ws1, _ = sample_data
ws2_response = client.post("/v2/workspaces", json={"name": "workspace-2"})
assert ws2_response.status_code == 200
assert ws2_response.status_code in [200, 201]
ws2 = ws2_response.json()
url = "http://example.com/shared"
@ -263,14 +263,14 @@ async def test_same_endpoint_in_different_workspaces(
f"/v2/workspaces/{ws1.name}/webhooks",
json={"url": url},
)
assert response1.status_code == 200
assert response1.status_code in [200, 201]
# Create same endpoint in workspace 2
response2 = client.post(
f"/v2/workspaces/{ws2['id']}/webhooks",
json={"url": url},
)
assert response2.status_code == 200
assert response2.status_code in [200, 201]
# Verify they are different resources
assert response1.json()["id"] != response2.json()["id"]

View File

@ -12,7 +12,7 @@ def test_get_or_create_workspace(client: TestClient):
# This should create the workspace using POST /v2/workspaces
response = client.post("/v2/workspaces", json={"name": name})
assert response.status_code == 200
assert response.status_code in [200, 201]
data = response.json()
assert data["id"] == name
assert "id" in data
@ -26,7 +26,7 @@ def test_get_or_create_workspace_with_configuration(client: TestClient):
response = client.post(
"/v2/workspaces", json={"name": name, "configuration": configuration}
)
assert response.status_code == 200
assert response.status_code in [200, 201]
data = response.json()
assert data["id"] == name
assert data["configuration"] == configuration
@ -42,7 +42,7 @@ def test_get_or_create_workspace_with_all_optional_params(client: TestClient):
"/v2/workspaces",
json={"name": name, "metadata": metadata, "configuration": configuration},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
data = response.json()
assert data["id"] == name
assert data["metadata"] == metadata
@ -56,14 +56,14 @@ def test_get_or_create_existing_workspace(client: TestClient):
response = client.post(
"/v2/workspaces", json={"name": name, "metadata": {"key": "value"}}
)
assert response.status_code == 200
assert response.status_code in [200, 201]
workspace1 = response.json()
# Try to create the same workspace again - should return existing workspace
response = client.post(
"/v2/workspaces", json={"name": name, "metadata": {"key": "value"}}
)
assert response.status_code == 200
assert response.status_code in [200, 201]
workspace2 = response.json()
# Both should be the same workspace
@ -207,13 +207,13 @@ def test_create_duplicate_workspace_name(client: TestClient):
# Create an workspace
name = str(generate_nanoid())
response = client.post("/v2/workspaces", json={"name": name})
assert response.status_code == 200
assert response.status_code in [200, 201]
# Try to create another workspace with the same name - should return existing workspace
response = client.post("/v2/workspaces", json={"name": name})
# Should return the existing workspace with 200 status (get_or_create behavior)
assert response.status_code == 200
assert response.status_code in [200, 201]
data = response.json()
assert data["id"] == name
@ -272,15 +272,13 @@ def test_delete_workspace(client: TestClient):
# Create a workspace
response = client.post("/v2/workspaces", json={"name": name})
assert response.status_code == 200
assert response.status_code in [200, 201]
workspace = response.json()
assert workspace["id"] == name
# Delete the workspace
response = client.delete(f"/v2/workspaces/{name}")
assert response.status_code == 200
deleted_workspace = response.json()
assert deleted_workspace["id"] == name
assert response.status_code == 204
# Verify the workspace no longer exists by trying to update it
response = client.put(
@ -307,7 +305,7 @@ def test_delete_workspace_with_peers(client: TestClient):
# Create workspace
response = client.post("/v2/workspaces", json={"name": workspace_name})
assert response.status_code == 200
assert response.status_code in [200, 201]
# Create peers
peer1_name = str(generate_nanoid())
@ -315,15 +313,15 @@ def test_delete_workspace_with_peers(client: TestClient):
response = client.post(
f"/v2/workspaces/{workspace_name}/peers", json={"name": peer1_name}
)
assert response.status_code == 200
assert response.status_code in [200, 201]
response = client.post(
f"/v2/workspaces/{workspace_name}/peers", json={"name": peer2_name}
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Delete workspace
response = client.delete(f"/v2/workspaces/{workspace_name}")
assert response.status_code == 200
assert response.status_code == 204
def test_delete_workspace_with_sessions(client: TestClient):
@ -332,14 +330,14 @@ def test_delete_workspace_with_sessions(client: TestClient):
# Create workspace
response = client.post("/v2/workspaces", json={"name": workspace_name})
assert response.status_code == 200
assert response.status_code in [200, 201]
# Create peer
peer_name = str(generate_nanoid())
response = client.post(
f"/v2/workspaces/{workspace_name}/peers", json={"name": peer_name}
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Create sessions
session1_name = str(generate_nanoid())
@ -347,15 +345,15 @@ def test_delete_workspace_with_sessions(client: TestClient):
response = client.post(
f"/v2/workspaces/{workspace_name}/sessions", json={"name": session1_name}
)
assert response.status_code == 200
assert response.status_code in [200, 201]
response = client.post(
f"/v2/workspaces/{workspace_name}/sessions", json={"name": session2_name}
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Delete workspace
response = client.delete(f"/v2/workspaces/{workspace_name}")
assert response.status_code == 200
assert response.status_code == 204
def test_delete_workspace_with_messages(client: TestClient):
@ -364,21 +362,21 @@ def test_delete_workspace_with_messages(client: TestClient):
# Create workspace
response = client.post("/v2/workspaces", json={"name": workspace_name})
assert response.status_code == 200
assert response.status_code in [200, 201]
# Create peer
peer_name = str(generate_nanoid())
response = client.post(
f"/v2/workspaces/{workspace_name}/peers", json={"name": peer_name}
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Create session
session_name = str(generate_nanoid())
response = client.post(
f"/v2/workspaces/{workspace_name}/sessions", json={"name": session_name}
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Add peer to session
response = client.post(
@ -397,11 +395,11 @@ def test_delete_workspace_with_messages(client: TestClient):
]
},
)
assert response.status_code == 200
assert response.status_code == 201
# Delete workspace
response = client.delete(f"/v2/workspaces/{workspace_name}")
assert response.status_code == 200
assert response.status_code == 204
def test_delete_workspace_with_webhooks(client: TestClient):
@ -410,7 +408,7 @@ def test_delete_workspace_with_webhooks(client: TestClient):
# Create workspace
response = client.post("/v2/workspaces", json={"name": workspace_name})
assert response.status_code == 200
assert response.status_code in [200, 201]
# Create webhook
response = client.post(
@ -419,11 +417,11 @@ def test_delete_workspace_with_webhooks(client: TestClient):
"url": "https://example.com/webhook",
},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Delete workspace
response = client.delete(f"/v2/workspaces/{workspace_name}")
assert response.status_code == 200
assert response.status_code == 204
# Verify webhook is deleted by checking workspace doesn't exist
response = client.get(f"/v2/workspaces/{workspace_name}/webhooks")
@ -440,7 +438,7 @@ def test_delete_workspace_cascade(client: TestClient):
"/v2/workspaces",
json={"name": workspace_name, "metadata": {"test": "cascade"}},
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Create multiple peers
peer_names = [str(generate_nanoid()) for _ in range(3)]
@ -448,7 +446,7 @@ def test_delete_workspace_cascade(client: TestClient):
response = client.post(
f"/v2/workspaces/{workspace_name}/peers", json={"name": peer_name}
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Create multiple sessions
session_names = [str(generate_nanoid()) for _ in range(2)]
@ -456,7 +454,7 @@ def test_delete_workspace_cascade(client: TestClient):
response = client.post(
f"/v2/workspaces/{workspace_name}/sessions", json={"name": session_name}
)
assert response.status_code == 200
assert response.status_code in [200, 201]
# Add peers to sessions and create messages
for session_name in session_names:
@ -479,18 +477,15 @@ def test_delete_workspace_cascade(client: TestClient):
]
},
)
assert response.status_code == 200
assert response.status_code == 201
# Delete the workspace
response = client.delete(f"/v2/workspaces/{workspace_name}")
assert response.status_code == 200
deleted_workspace = response.json()
assert deleted_workspace["id"] == workspace_name
assert deleted_workspace["metadata"]["test"] == "cascade"
assert response.status_code == 204
def test_delete_workspace_returns_workspace_data(client: TestClient):
"""Test that delete workspace returns the deleted workspace data"""
def test_delete_workspace_returns_no_content(client: TestClient):
"""Test that delete workspace returns 204 No Content"""
name = str(generate_nanoid())
metadata = {"key": "value", "number": 42}
configuration = {"feature": True}
@ -500,16 +495,8 @@ def test_delete_workspace_returns_workspace_data(client: TestClient):
"/v2/workspaces",
json={"name": name, "metadata": metadata, "configuration": configuration},
)
assert response.status_code == 200
created_workspace = response.json()
assert response.status_code in [200, 201]
# Delete workspace
response = client.delete(f"/v2/workspaces/{name}")
assert response.status_code == 200
deleted_workspace = response.json()
# Verify returned data matches original workspace
assert deleted_workspace["id"] == created_workspace["id"]
assert deleted_workspace["metadata"] == metadata
assert deleted_workspace["configuration"] == configuration
assert "created_at" in deleted_workspace
assert response.status_code == 204

View File

@ -2,7 +2,7 @@ from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from honcho_core.types import DeriverStatus
from honcho_core.types.workspaces import QueueStatusResponse
from honcho_core.types.workspaces.sessions.message import Message
from sdks.python.src.honcho.async_client.client import AsyncHoncho
@ -200,8 +200,8 @@ async def test_get_deriver_status(client_fixture: tuple[Honcho | AsyncHoncho, st
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
# Test with no parameters - this should work in the SDK even though API requires at least one
status = await honcho_client.get_deriver_status()
assert isinstance(status, DeriverStatus)
status = await honcho_client.get_queue_status()
assert isinstance(status, QueueStatusResponse)
assert hasattr(status, "total_work_units")
assert hasattr(status, "completed_work_units")
assert hasattr(status, "in_progress_work_units")
@ -210,67 +210,65 @@ async def test_get_deriver_status(client_fixture: tuple[Honcho | AsyncHoncho, st
# Test with peer_id only
peer = await honcho_client.peer(id="test-peer-deriver-status")
await peer.get_metadata() # Create the peer
status = await honcho_client.get_deriver_status(observer=peer.id)
assert isinstance(status, DeriverStatus)
status = await honcho_client.get_queue_status(observer=peer.id)
assert isinstance(status, QueueStatusResponse)
# Test with session_id only
session = await honcho_client.session(id="test-session-deriver-status")
await session.get_metadata() # Create the session
status = await honcho_client.get_deriver_status(session=session.id)
assert isinstance(status, DeriverStatus)
status = await honcho_client.get_queue_status(session=session.id)
assert isinstance(status, QueueStatusResponse)
# Test with both peer and session
status = await honcho_client.get_deriver_status(
status = await honcho_client.get_queue_status(
observer=peer.id, session=session.id
)
assert isinstance(status, DeriverStatus)
assert isinstance(status, QueueStatusResponse)
# Test with sender
status = await honcho_client.get_deriver_status(
observer=peer.id, sender=peer.id
)
assert isinstance(status, DeriverStatus)
status = await honcho_client.get_queue_status(observer=peer.id, sender=peer.id)
assert isinstance(status, QueueStatusResponse)
else:
assert isinstance(honcho_client, Honcho)
# Test with no parameters
status = honcho_client.get_deriver_status()
assert isinstance(status, DeriverStatus)
status = honcho_client.get_queue_status()
assert isinstance(status, QueueStatusResponse)
assert hasattr(status, "total_work_units")
assert hasattr(status, "completed_work_units")
assert hasattr(status, "in_progress_work_units")
assert hasattr(status, "pending_work_units")
# Test with peer_id only
peer = honcho_client.peer(id="test-peer-deriver-status")
peer = honcho_client.peer(id="test-peer-queue-status")
peer.get_metadata() # Create the peer
status = honcho_client.get_deriver_status(observer=peer.id)
assert isinstance(status, DeriverStatus)
status = honcho_client.get_queue_status(observer=peer.id)
assert isinstance(status, QueueStatusResponse)
# Test with session_id only
session = honcho_client.session(id="test-session-deriver-status")
session = honcho_client.session(id="test-session-queue-status")
session.get_metadata() # Create the session
status = honcho_client.get_deriver_status(session=session.id)
assert isinstance(status, DeriverStatus)
status = honcho_client.get_queue_status(session=session.id)
assert isinstance(status, QueueStatusResponse)
# Test with both peer and session
status = honcho_client.get_deriver_status(observer=peer.id, session=session.id)
assert isinstance(status, DeriverStatus)
status = honcho_client.get_queue_status(observer=peer.id, session=session.id)
assert isinstance(status, QueueStatusResponse)
# Test with sender
status = honcho_client.get_deriver_status(observer=peer.id, sender=peer.id)
assert isinstance(status, DeriverStatus)
status = honcho_client.get_queue_status(observer=peer.id, sender=peer.id)
assert isinstance(status, QueueStatusResponse)
@pytest.mark.asyncio
async def test_poll_deriver_status(client_fixture: tuple[Honcho | AsyncHoncho, str]):
async def test_poll_queue_status(client_fixture: tuple[Honcho | AsyncHoncho, str]):
"""
Tests polling deriver status until completion.
Tests polling queue status until completion.
"""
honcho_client, client_type = client_fixture
# Mock the get_deriver_status method to return a "completed" status
# Mock the get_queue_status method to return a "completed" status
# to avoid infinite polling in tests
completed_status = DeriverStatus(
completed_status = QueueStatusResponse(
total_work_units=0,
completed_work_units=0,
in_progress_work_units=0,
@ -280,39 +278,39 @@ async def test_poll_deriver_status(client_fixture: tuple[Honcho | AsyncHoncho, s
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
with patch.object(
honcho_client, "get_deriver_status", return_value=completed_status
honcho_client, "get_queue_status", return_value=completed_status
):
status = await honcho_client.poll_deriver_status()
assert isinstance(status, DeriverStatus)
status = await honcho_client.poll_queue_status()
assert isinstance(status, QueueStatusResponse)
assert status.pending_work_units == 0
assert status.in_progress_work_units == 0
# Test with parameters
peer = await honcho_client.peer(id="test-peer-poll-status")
with patch.object(
honcho_client, "get_deriver_status", return_value=completed_status
honcho_client, "get_queue_status", return_value=completed_status
):
status = await honcho_client.poll_deriver_status(
status = await honcho_client.poll_queue_status(
observer=peer.id, sender=peer.id
)
assert isinstance(status, DeriverStatus)
assert isinstance(status, QueueStatusResponse)
else:
assert isinstance(honcho_client, Honcho)
with patch.object(
honcho_client, "get_deriver_status", return_value=completed_status
honcho_client, "get_queue_status", return_value=completed_status
):
status = honcho_client.poll_deriver_status()
assert isinstance(status, DeriverStatus)
status = honcho_client.poll_queue_status()
assert isinstance(status, QueueStatusResponse)
assert status.pending_work_units == 0
assert status.in_progress_work_units == 0
# Test with parameters
peer = honcho_client.peer(id="test-peer-poll-status")
with patch.object(
honcho_client, "get_deriver_status", return_value=completed_status
honcho_client, "get_queue_status", return_value=completed_status
):
status = honcho_client.poll_deriver_status(observer=peer.id, sender=peer.id)
assert isinstance(status, DeriverStatus)
status = honcho_client.poll_queue_status(observer=peer.id, sender=peer.id)
assert isinstance(status, QueueStatusResponse)
@pytest.mark.asyncio

View File

@ -1,13 +1,14 @@
"""Tests for observation SDK methods."""
import pytest
from honcho_core.types.workspaces.conclusion import Conclusion
from sdks.python.src.honcho.async_client.client import AsyncHoncho
from sdks.python.src.honcho.client import Honcho
from sdks.python.src.honcho.observations import (
AsyncObservationScope,
Observation,
ObservationScope,
from sdks.python.src.honcho.conclusions import (
AsyncConclusionScope,
ConclusionCreateParams,
ConclusionScope,
)
@ -35,16 +36,21 @@ async def test_observation_create_single(
)
# Get observation scope for observer -> target
obs_scope = observer.observations_of(target)
assert isinstance(obs_scope, AsyncObservationScope)
obs_scope = observer.conclusions_of(target)
assert isinstance(obs_scope, AsyncConclusionScope)
# Create a single observation
created = await obs_scope.create(
{"content": "User prefers dark mode", "session_id": session.id}
[
ConclusionCreateParams(
content="User prefers dark mode",
session_id=session.id,
)
]
)
assert len(created) == 1
assert isinstance(created[0], Observation)
assert isinstance(created[0], Conclusion)
assert created[0].content == "User prefers dark mode"
assert created[0].observer_id == observer.id
assert created[0].observed_id == target.id
@ -65,16 +71,21 @@ async def test_observation_create_single(
)
# Get observation scope for observer -> target
obs_scope = observer.observations_of(target)
assert isinstance(obs_scope, ObservationScope)
obs_scope = observer.conclusions_of(target)
assert isinstance(obs_scope, ConclusionScope)
# Create a single observation
created = obs_scope.create(
{"content": "User prefers dark mode", "session_id": session.id}
[
ConclusionCreateParams(
content="User prefers dark mode",
session_id=session.id,
)
]
)
assert len(created) == 1
assert isinstance(created[0], Observation)
assert isinstance(created[0], Conclusion)
assert created[0].content == "User prefers dark mode"
assert created[0].observer_id == observer.id
assert created[0].observed_id == target.id
@ -106,14 +117,23 @@ async def test_observation_create_batch(
)
# Get observation scope
obs_scope = observer.observations_of(target)
obs_scope = observer.conclusions_of(target)
# Create multiple observations
created = await obs_scope.create(
[
{"content": "User prefers dark mode", "session_id": session.id},
{"content": "User works late at night", "session_id": session.id},
{"content": "User enjoys programming", "session_id": session.id},
ConclusionCreateParams(
content="User prefers dark mode",
session_id=session.id,
),
ConclusionCreateParams(
content="User works late at night",
session_id=session.id,
),
ConclusionCreateParams(
content="User enjoys programming",
session_id=session.id,
),
]
)
@ -143,7 +163,7 @@ async def test_observation_create_batch(
)
# Get observation scope
obs_scope = observer.observations_of(target)
obs_scope = observer.conclusions_of(target)
# Create multiple observations
created = obs_scope.create(
@ -191,7 +211,7 @@ async def test_observation_create_then_list(
)
# Get observation scope
obs_scope = observer.observations_of(target)
obs_scope = observer.conclusions_of(target)
# Create observations
created = await obs_scope.create(
@ -206,8 +226,12 @@ async def test_observation_create_then_list(
# List observations
listed = await obs_scope.list()
listed_all: list[Conclusion] = [
Conclusion.model_validate(item) for item in listed.items
]
# The created observation should be in the list
listed_ids = {obs.id for obs in listed}
listed_ids = {obs.id for obs in listed_all}
assert created[0].id in listed_ids
else:
assert isinstance(honcho_client, Honcho)
@ -224,7 +248,7 @@ async def test_observation_create_then_list(
)
# Get observation scope
obs_scope = observer.observations_of(target)
obs_scope = observer.conclusions_of(target)
# Create observations
created = obs_scope.create(
@ -268,7 +292,7 @@ async def test_observation_create_then_query(
)
# Get observation scope
obs_scope = observer.observations_of(target)
obs_scope = observer.conclusions_of(target)
# Create observation with specific content
await obs_scope.create(
@ -302,7 +326,7 @@ async def test_observation_create_then_query(
)
# Get observation scope
obs_scope = observer.observations_of(target)
obs_scope = observer.conclusions_of(target)
# Create observation with specific content
obs_scope.create(
@ -347,7 +371,7 @@ async def test_observation_create_then_delete(
)
# Get observation scope
obs_scope = observer.observations_of(target)
obs_scope = observer.conclusions_of(target)
# Create observations
created = await obs_scope.create(
@ -363,7 +387,10 @@ async def test_observation_create_then_delete(
# List observations - should not contain deleted one
listed = await obs_scope.list()
listed_ids = {obs.id for obs in listed}
listed_all: list[Conclusion] = [
Conclusion.model_validate(item) for item in listed.items
]
listed_ids = {obs.id for obs in listed_all}
assert observation_id not in listed_ids
else:
assert isinstance(honcho_client, Honcho)
@ -380,7 +407,7 @@ async def test_observation_create_then_delete(
)
# Get observation scope
obs_scope = observer.observations_of(target)
obs_scope = observer.conclusions_of(target)
# Create observations
created = obs_scope.create(
@ -418,14 +445,14 @@ async def test_self_observation_create(
await session.add_messages([peer.message("Hello")])
# Get self-observation scope
obs_scope = peer.observations
assert isinstance(obs_scope, AsyncObservationScope)
obs_scope = peer.conclusions
assert isinstance(obs_scope, AsyncConclusionScope)
assert obs_scope.observer == peer.id
assert obs_scope.observed == peer.id
# Create a self-observation
created = await obs_scope.create(
{"content": "I prefer morning workouts", "session_id": session.id}
[{"content": "I prefer morning workouts", "session_id": session.id}]
)
assert len(created) == 1
@ -440,14 +467,14 @@ async def test_self_observation_create(
session.add_messages([peer.message("Hello")])
# Get self-observation scope
obs_scope = peer.observations
assert isinstance(obs_scope, ObservationScope)
obs_scope = peer.conclusions
assert isinstance(obs_scope, ConclusionScope)
assert obs_scope.observer == peer.id
assert obs_scope.observed == peer.id
# Create a self-observation
created = obs_scope.create(
{"content": "I prefer morning workouts", "session_id": session.id}
[{"content": "I prefer morning workouts", "session_id": session.id}]
)
assert len(created) == 1
@ -486,7 +513,7 @@ async def test_observation_create_with_session_filter(
)
# Get observation scope
obs_scope = observer.observations_of(target)
obs_scope = observer.conclusions_of(target)
# Create observations in different sessions
await obs_scope.create(
@ -502,13 +529,19 @@ async def test_observation_create_with_session_filter(
# List filtered by session1
s1_obs = await obs_scope.list(session=session1)
s1_contents = [obs.content for obs in s1_obs]
s1_obs_all: list[Conclusion] = [
Conclusion.model_validate(item) for item in s1_obs.items
]
s1_contents = [obs.content for obs in s1_obs_all]
assert "Session 1 observation" in s1_contents
assert "Session 2 observation" not in s1_contents
# List filtered by session2
s2_obs = await obs_scope.list(session=session2)
s2_contents = [obs.content for obs in s2_obs]
s2_obs_all: list[Conclusion] = [
Conclusion.model_validate(item) for item in s2_obs.items
]
s2_contents = [obs.content for obs in s2_obs_all]
assert "Session 2 observation" in s2_contents
assert "Session 1 observation" not in s2_contents
else:
@ -533,7 +566,7 @@ async def test_observation_create_with_session_filter(
)
# Get observation scope
obs_scope = observer.observations_of(target)
obs_scope = observer.conclusions_of(target)
# Create observations in different sessions
obs_scope.create(
@ -565,7 +598,7 @@ async def test_observation_scope_via_peer_string(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests creating observations via observations_of(string).
Tests creating observations via conclusions_of(string).
"""
honcho_client, client_type = client_fixture
@ -584,12 +617,12 @@ async def test_observation_scope_via_peer_string(
)
# Get observation scope using string ID
obs_scope = observer.observations_of(target.id)
obs_scope = observer.conclusions_of(target.id)
assert obs_scope.observed == target.id
# Create observation
created = await obs_scope.create(
{"content": "Created via string target", "session_id": session.id}
[{"content": "Created via string target", "session_id": session.id}]
)
assert len(created) == 1
@ -609,12 +642,12 @@ async def test_observation_scope_via_peer_string(
)
# Get observation scope using string ID
obs_scope = observer.observations_of(target.id)
obs_scope = observer.conclusions_of(target.id)
assert obs_scope.observed == target.id
# Create observation
created = obs_scope.create(
{"content": "Created via string target", "session_id": session.id}
[{"content": "Created via string target", "session_id": session.id}]
)
assert len(created) == 1

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, Representation
from sdks.python.src.honcho.types import DialecticStreamResponse
@pytest.mark.asyncio
@ -292,11 +292,11 @@ async def test_peer_chat_non_streaming(
@pytest.mark.asyncio
async def test_peer_working_rep_no_params(
async def test_peer_get_representation_no_params(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests peer.working_rep() with no parameters (default behavior).
Tests peer.get_representation() with no parameters (default behavior).
"""
honcho_client, client_type = client_fixture
@ -309,10 +309,8 @@ async def test_peer_working_rep_no_params(
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")
result = await peer.get_representation()
assert isinstance(result, str)
else:
assert isinstance(honcho_client, Honcho)
peer = honcho_client.peer(id="test-working-rep-no-params")
@ -322,18 +320,16 @@ async def test_peer_working_rep_no_params(
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")
result = peer.get_representation()
assert isinstance(result, str)
@pytest.mark.asyncio
async def test_peer_working_rep_with_session_string(
async def test_peer_get_representation_with_session_string(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests peer.working_rep() with session parameter as string.
Tests peer.get_representation() with session parameter as string.
"""
honcho_client, client_type = client_fixture
@ -346,10 +342,8 @@ async def test_peer_working_rep_with_session_string(
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")
result = await peer.get_representation(session=session.id)
assert isinstance(result, str)
else:
assert isinstance(honcho_client, Honcho)
peer = honcho_client.peer(id="test-working-rep-session-str")
@ -359,18 +353,16 @@ async def test_peer_working_rep_with_session_string(
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")
result = peer.get_representation(session=session.id)
assert isinstance(result, str)
@pytest.mark.asyncio
async def test_peer_working_rep_with_session_object(
async def test_peer_get_representation_with_session_object(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests peer.working_rep() with session parameter as Session object.
Tests peer.get_representation() with session parameter as Session object.
"""
honcho_client, client_type = client_fixture
@ -386,10 +378,8 @@ async def test_peer_working_rep_with_session_object(
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")
result = await peer.get_representation(session=session)
assert isinstance(result, str)
else:
assert isinstance(honcho_client, Honcho)
peer = honcho_client.peer(id="test-working-rep-session-obj")
@ -402,18 +392,16 @@ async def test_peer_working_rep_with_session_object(
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")
result = peer.get_representation(session=session)
assert isinstance(result, str)
@pytest.mark.asyncio
async def test_peer_working_rep_with_target_string(
async def test_peer_get_representation_with_target_string(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests peer.working_rep() with target parameter as string.
Tests peer.get_representation() with target parameter as string.
"""
honcho_client, client_type = client_fixture
@ -432,10 +420,8 @@ async def test_peer_working_rep_with_target_string(
)
# 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")
result = await observer.get_representation(target=target.id)
assert isinstance(result, str)
else:
assert isinstance(honcho_client, Honcho)
observer = honcho_client.peer(id="test-working-rep-target-str-observer")
@ -451,18 +437,16 @@ async def test_peer_working_rep_with_target_string(
)
# 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")
result = observer.get_representation(target=target.id)
assert isinstance(result, str)
@pytest.mark.asyncio
async def test_peer_working_rep_with_target_object(
async def test_peer_get_representation_with_target_object(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests peer.working_rep() with target parameter as Peer object.
Tests peer.get_representation() with target parameter as Peer object.
"""
honcho_client, client_type = client_fixture
@ -484,10 +468,8 @@ async def test_peer_working_rep_with_target_object(
)
# 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")
result = await observer.get_representation(target=target)
assert isinstance(result, str)
else:
assert isinstance(honcho_client, Honcho)
observer = honcho_client.peer(id="test-working-rep-target-obj-observer")
@ -506,18 +488,16 @@ async def test_peer_working_rep_with_target_object(
)
# 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")
result = observer.get_representation(target=target)
assert isinstance(result, str)
@pytest.mark.asyncio
async def test_peer_working_rep_with_search_query(
async def test_peer_get_representation_with_search_query(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests peer.working_rep() with search_query parameter.
Tests peer.get_representation() with search_query parameter.
"""
honcho_client, client_type = client_fixture
@ -535,10 +515,8 @@ async def test_peer_working_rep_with_search_query(
)
# 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")
result = await peer.get_representation(search_query="programming")
assert isinstance(result, str)
else:
assert isinstance(honcho_client, Honcho)
peer = honcho_client.peer(id="test-working-rep-search-query")
@ -553,18 +531,16 @@ async def test_peer_working_rep_with_search_query(
)
# 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")
result = peer.get_representation(search_query="programming")
assert isinstance(result, str)
@pytest.mark.asyncio
async def test_peer_working_rep_with_size(
async def test_peer_get_representation_with_size(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests peer.working_rep() with size parameter.
Tests peer.get_representation() with size parameter.
"""
honcho_client, client_type = client_fixture
@ -578,22 +554,16 @@ async def test_peer_working_rep_with_size(
[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")
# Get working representation with custom max_conclusions
result = await peer.get_representation(max_conclusions=5)
assert isinstance(result, str)
# 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")
# Test with different max_conclusions values
result = await peer.get_representation(max_conclusions=1)
assert isinstance(result, str)
result = await peer.working_rep(max_observations=100)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
result = await peer.get_representation(max_conclusions=100)
assert isinstance(result, str)
else:
assert isinstance(honcho_client, Honcho)
peer = honcho_client.peer(id="test-working-rep-size")
@ -603,29 +573,23 @@ async def test_peer_working_rep_with_size(
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")
result = peer.get_representation(max_conclusions=5)
assert isinstance(result, str)
# 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")
# Test with different max_conclusions values
result = peer.get_representation(max_conclusions=1)
assert isinstance(result, str)
result = peer.working_rep(max_observations=100)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
result = peer.get_representation(max_conclusions=100)
assert isinstance(result, str)
@pytest.mark.asyncio
async def test_peer_working_rep_with_all_params(
async def test_peer_get_representation_with_all_params(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests peer.working_rep() with all parameters combined.
Tests peer.get_representation() with all parameters combined.
"""
honcho_client, client_type = client_fixture
@ -646,23 +610,19 @@ async def test_peer_working_rep_with_all_params(
)
# Get working representation with all parameters
result = await observer.working_rep(
session=session, target=target, search_query="Python", max_observations=10
result = await observer.get_representation(
session=session, target=target, search_query="Python", max_conclusions=10
)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
assert isinstance(result, str)
# Test with session as string and target as string
result = await observer.working_rep(
result = await observer.get_representation(
session=session.id,
target=target.id,
search_query="machine learning",
max_observations=5,
max_conclusions=5,
)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
assert isinstance(result, str)
else:
assert isinstance(honcho_client, Honcho)
observer = honcho_client.peer(id="test-working-rep-all-observer")
@ -680,20 +640,16 @@ async def test_peer_working_rep_with_all_params(
)
# Get working representation with all parameters
result = observer.working_rep(
session=session, target=target, search_query="Python", max_observations=10
result = observer.get_representation(
session=session, target=target, search_query="Python", max_conclusions=10
)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
assert isinstance(result, str)
# Test with session as string and target as string
result = observer.working_rep(
result = observer.get_representation(
session=session.id,
target=target.id,
search_query="machine learning",
max_observations=5,
max_conclusions=5,
)
assert isinstance(result, Representation)
assert hasattr(result, "explicit")
assert hasattr(result, "deductive")
assert isinstance(result, str)

View File

@ -1,7 +1,7 @@
from unittest.mock import AsyncMock, patch
import pytest
from honcho_core.types import DeriverStatus
from honcho_core.types.workspaces import QueueStatusResponse
from sdks.python.src.honcho.async_client.client import AsyncHoncho
from sdks.python.src.honcho.async_client.peer import AsyncPeer
@ -355,7 +355,9 @@ async def test_session_add_messages_return_value(
@pytest.mark.asyncio
async def test_session_working_rep(client_fixture: tuple[Honcho | AsyncHoncho, str]):
async def test_session_get_representation(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests getting the working representation of a peer in a session.
"""
@ -368,7 +370,7 @@ async def test_session_working_rep(client_fixture: tuple[Honcho | AsyncHoncho, s
peer = await honcho_client.peer(id="peer-wr")
assert isinstance(peer, AsyncPeer)
await session.add_messages([peer.message("test message for working rep")])
_working_rep = await session.working_rep(peer)
await session.get_representation(peer)
else:
assert isinstance(honcho_client, Honcho)
session = honcho_client.session(id="test-session-wr")
@ -376,7 +378,7 @@ async def test_session_working_rep(client_fixture: tuple[Honcho | AsyncHoncho, s
peer = honcho_client.peer(id="peer-wr")
assert isinstance(peer, Peer)
session.add_messages([peer.message("test message for working rep")])
_working_rep = session.working_rep(peer)
session.get_representation(peer)
@pytest.mark.asyncio
@ -449,7 +451,7 @@ async def test_session_delete(client_fixture: tuple[Honcho | AsyncHoncho, str])
@pytest.mark.asyncio
async def test_session_get_deriver_status(
async def test_session_get_queue_status(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
@ -462,8 +464,8 @@ async def test_session_get_deriver_status(
session = await honcho_client.session(id="test-session-deriver-status")
assert isinstance(session, AsyncSession)
status = await session.get_deriver_status()
assert isinstance(status, DeriverStatus)
status = await session.get_queue_status()
assert isinstance(status, QueueStatusResponse)
assert hasattr(status, "total_work_units")
assert hasattr(status, "completed_work_units")
assert hasattr(status, "in_progress_work_units")
@ -473,24 +475,24 @@ async def test_session_get_deriver_status(
# Test with observer only
peer = await honcho_client.peer(id="test-peer-session-deriver")
await peer.get_metadata() # Create the peer
status = await session.get_deriver_status(observer=peer.id)
assert isinstance(status, DeriverStatus)
status = await session.get_queue_status(observer=peer.id)
assert isinstance(status, QueueStatusResponse)
# Test with sender only
status = await session.get_deriver_status(sender=peer.id)
assert isinstance(status, DeriverStatus)
status = await session.get_queue_status(sender=peer.id)
assert isinstance(status, QueueStatusResponse)
# Test with both observer and sender
status = await session.get_deriver_status(observer=peer.id, sender=peer.id)
assert isinstance(status, DeriverStatus)
status = await session.get_queue_status(observer=peer.id, sender=peer.id)
assert isinstance(status, QueueStatusResponse)
else:
assert isinstance(honcho_client, Honcho)
session = honcho_client.session(id="test-session-deriver-status")
assert isinstance(session, Session)
# Test with no parameters
status = session.get_deriver_status()
assert isinstance(status, DeriverStatus)
status = session.get_queue_status()
assert isinstance(status, QueueStatusResponse)
assert hasattr(status, "total_work_units")
assert hasattr(status, "completed_work_units")
assert hasattr(status, "in_progress_work_units")
@ -500,20 +502,20 @@ async def test_session_get_deriver_status(
# Test with observer only
peer = honcho_client.peer(id="test-peer-session-deriver")
peer.get_metadata() # Create the peer
status = session.get_deriver_status(observer=peer.id)
assert isinstance(status, DeriverStatus)
status = session.get_queue_status(observer=peer.id)
assert isinstance(status, QueueStatusResponse)
# Test with sender only
status = session.get_deriver_status(sender=peer.id)
assert isinstance(status, DeriverStatus)
status = session.get_queue_status(sender=peer.id)
assert isinstance(status, QueueStatusResponse)
# Test with both observer and sender
status = session.get_deriver_status(observer=peer.id, sender=peer.id)
assert isinstance(status, DeriverStatus)
status = session.get_queue_status(observer=peer.id, sender=peer.id)
assert isinstance(status, QueueStatusResponse)
@pytest.mark.asyncio
async def test_session_poll_deriver_status(
async def test_session_poll_queue_status(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
@ -521,9 +523,9 @@ async def test_session_poll_deriver_status(
"""
honcho_client, client_type = client_fixture
# Mock the get_deriver_status method to return a "completed" status
# Mock the get_queue_status method to return a "completed" status
# to avoid infinite polling in tests
completed_status = DeriverStatus(
completed_status = QueueStatusResponse(
total_work_units=0,
completed_work_units=0,
in_progress_work_units=0,
@ -532,16 +534,16 @@ async def test_session_poll_deriver_status(
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
session = await honcho_client.session(id="test-session-poll-deriver")
session = await honcho_client.session(id="test-session-poll-queue")
assert isinstance(session, AsyncSession)
with patch.object(
session.__class__,
"get_deriver_status",
"get_queue_status",
new=AsyncMock(return_value=completed_status),
):
status = await session.poll_deriver_status()
assert isinstance(status, DeriverStatus)
status = await session.poll_queue_status()
assert isinstance(status, QueueStatusResponse)
assert status.pending_work_units == 0
assert status.in_progress_work_units == 0
@ -549,31 +551,31 @@ async def test_session_poll_deriver_status(
peer = await honcho_client.peer(id="test-peer-session-poll")
with patch.object(
session.__class__,
"get_deriver_status",
"get_queue_status",
new=AsyncMock(return_value=completed_status),
):
status = await session.poll_deriver_status(observer=peer.id, sender=peer.id)
assert isinstance(status, DeriverStatus)
status = await session.poll_queue_status(observer=peer.id, sender=peer.id)
assert isinstance(status, QueueStatusResponse)
else:
assert isinstance(honcho_client, Honcho)
session = honcho_client.session(id="test-session-poll-deriver")
session = honcho_client.session(id="test-session-poll-queue")
assert isinstance(session, Session)
with patch.object(
session.__class__, "get_deriver_status", return_value=completed_status
session.__class__, "get_queue_status", return_value=completed_status
):
status = session.poll_deriver_status()
assert isinstance(status, DeriverStatus)
status = session.poll_queue_status()
assert isinstance(status, QueueStatusResponse)
assert status.pending_work_units == 0
assert status.in_progress_work_units == 0
# Test with parameters
peer = honcho_client.peer(id="test-peer-session-poll")
with patch.object(
session.__class__, "get_deriver_status", return_value=completed_status
session.__class__, "get_queue_status", return_value=completed_status
):
status = session.poll_deriver_status(observer=peer.id, sender=peer.id)
assert isinstance(status, DeriverStatus)
status = session.poll_queue_status(observer=peer.id, sender=peer.id)
assert isinstance(status, QueueStatusResponse)
@pytest.mark.asyncio

View File

@ -174,7 +174,7 @@ async def test_comparison_operators_filters(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"id": session_id, "peer_names": {test_peer.name: {}}},
)
assert session_response.status_code == 200
assert session_response.status_code == 201
# Create messages with numeric metadata for comparison tests
message_configs = [
@ -203,7 +203,7 @@ async def test_comparison_operators_filters(
f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/messages",
json={"messages": message_configs},
)
assert messages_response.status_code == 200
assert messages_response.status_code == 201
# Test the filter configuration
response = client.post(
@ -870,7 +870,7 @@ async def test_all_message_columns_filtering(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"id": session_id, "peer_names": {test_peer.name: {}}},
)
assert session_response.status_code == 200
assert session_response.status_code == 201
# Create messages with various data
messages_response = client.post(
@ -895,7 +895,7 @@ async def test_all_message_columns_filtering(
]
},
)
assert messages_response.status_code == 200
assert messages_response.status_code == 201
# Test filtering by session_id (maps to session_name internally)
response = client.post(
@ -2090,13 +2090,13 @@ async def test_float_precision_edge_cases(client: TestClient):
# Create workspace
response = client.post("/v2/workspaces", json={"name": workspace_name})
assert response.status_code == 200
assert response.status_code == 201
# Create peer
response = client.post(
f"/v2/workspaces/{workspace_name}/peers", json={"name": peer_name}
)
assert response.status_code == 200
assert response.status_code == 201
# Create messages with problematic floating point values using correct endpoint
messages_response = client.post(
@ -2160,9 +2160,9 @@ async def test_float_precision_edge_cases(client: TestClient):
]
},
)
assert messages_response.status_code == 200
assert messages_response.status_code == 201
# Test exact equality - this may or may not work due to floating point precision
# Test exact equality
response = client.post(
f"/v2/workspaces/{workspace_name}/sessions/ilovefloatingpoints/messages/list",
json={"filters": {"metadata": {"value": 0.3}}},
@ -2255,13 +2255,13 @@ async def test_mixed_type_comparisons(client: TestClient):
# Create workspace
response = client.post("/v2/workspaces", json={"name": workspace_name})
assert response.status_code == 200
assert response.status_code == 201
# Create peer
response = client.post(
f"/v2/workspaces/{workspace_name}/peers", json={"name": peer_name}
)
assert response.status_code == 200
assert response.status_code == 201
# Create messages with mixed data types for the same logical field
messages_response = client.post(
@ -2331,7 +2331,7 @@ async def test_mixed_type_comparisons(client: TestClient):
]
},
)
assert messages_response.status_code == 200
assert messages_response.status_code == 201
# Test string vs numeric equality
response = client.post(

View File

@ -13,7 +13,7 @@ import httpx
from anthropic import AsyncAnthropic
from honcho.async_client.session import AsyncSession
from honcho.session_context import SessionContext
from honcho_core.types.deriver_status import DeriverStatus
from honcho_core.types.workspaces import QueueStatusResponse
from pydantic import ValidationError
# Adjust path to allow imports from tests.bench
@ -38,10 +38,10 @@ from tests.unified.schema import (
LLMJudgeAssertion,
NotContainsAssertion,
QueryAction,
ScheduleDreamAction,
SetSessionConfigAction,
SetWorkspaceConfigAction,
TestDefinition,
TriggerDreamAction,
WaitAction,
)
@ -271,10 +271,11 @@ class UnifiedTestExecutor:
if step.target == "queue_empty":
await self.wait_for_queue(step.timeout)
elif isinstance(step, TriggerDreamAction):
elif isinstance(step, ScheduleDreamAction):
# Use the core SDK to trigger a dream
await self.client.core.workspaces.trigger_dream(
await self.client.core.workspaces.schedule_dream(
workspace_id=self.client.workspace_id,
session_id=step.session_id,
observer=step.observer,
observed=step.observed,
dream_type=step.dream_type.value,
@ -291,7 +292,7 @@ class UnifiedTestExecutor:
await asyncio.sleep(1)
start = time.time()
while time.time() - start < timeout:
status: DeriverStatus = await self.client.get_deriver_status()
status: QueueStatusResponse = await self.client.get_queue_status()
# status structure from schema: DeriverStatus with pending_work_units, in_progress_work_units
if status.pending_work_units == 0 and status.in_progress_work_units == 0:
return
@ -339,7 +340,7 @@ class UnifiedTestExecutor:
raise ValueError("observer_peer_id required for get_representation")
peer = await self.client.peer(id=step.observer_peer_id)
representation = await peer.working_rep(
representation = await peer.get_representation(
step.session_id, target=step.observed_peer_id, search_query=step.input
)
return representation

View File

@ -77,13 +77,14 @@ class WaitAction(TestStep):
# --- Dream Actions ---
class TriggerDreamAction(TestStep):
step_type: Literal["trigger_dream"] = "trigger_dream"
class ScheduleDreamAction(TestStep):
step_type: Literal["schedule_dream"] = "schedule_dream"
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")
session_id: str = Field(..., description="Session ID to scope the dream to")
dream_type: DreamType = Field(..., description="Type of dream to schedule")
# --- Assertions ---
@ -163,7 +164,7 @@ class TestDefinition(BaseModel):
| AddMessageAction
| AddMessagesAction
| WaitAction
| TriggerDreamAction
| ScheduleDreamAction
| QueryAction,
Field(discriminator="step_type"),
]

View File

@ -108,9 +108,10 @@
]
},
{
"step_type": "trigger_dream",
"step_type": "schedule_dream",
"observer": "user",
"dream_type": "omni"
"dream_type": "omni",
"session_id": "session_dream_test"
},
{
"step_type": "wait",

10
uv.lock
View File

@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.13'",
@ -859,7 +859,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "honcho-core", specifier = ">=1.8.0" },
{ name = "honcho-core", specifier = "==1.10.0" },
{ name = "httpx", specifier = ">=0.28.0,<1" },
{ name = "pydantic", specifier = ">=2.0.0,<3" },
{ name = "typing-extensions", marker = "python_full_version < '3.12'", specifier = ">=4.12.0" },
@ -870,7 +870,7 @@ dev = [{ name = "ruff", specifier = ">=0.11.13" }]
[[package]]
name = "honcho-core"
version = "1.8.0"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -880,9 +880,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c8/ea/c0949bbac5a9f20625bdb152b7da2350e89ffc15b5862cd6094b464cde14/honcho_core-1.8.0.tar.gz", hash = "sha256:ffe0840639651640722ad0ed38d193cc9402b077dac3e6726ac7be551398d952", size = 142469, upload-time = "2025-12-15T19:27:59.555Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/5f/912716e911966d8cefb34a23f9b3a452348f147e040c1025113a202363a2/honcho_core-1.10.0.tar.gz", hash = "sha256:1dc139f6199a7589781636b4535cc97da6016477fbaeeaee53376e7a227a5c51", size = 144284, upload-time = "2026-01-12T23:30:30.87Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/9a/5aba73353c7e70d331a21e01a931c951c5bd8688fb25e5fc318517f2adf9/honcho_core-1.8.0-py3-none-any.whl", hash = "sha256:30a44b7d421328dfac015e8a6ecbe09c89b6cac9f3a913262244e7d15698a8a8", size = 140580, upload-time = "2025-12-15T19:27:58.562Z" },
{ url = "https://files.pythonhosted.org/packages/63/27/7cc9c5d0a91921339b843bed3d520225731199156c3410604375e5faf487/honcho_core-1.10.0-py3-none-any.whl", hash = "sha256:d0c7a1285b8e75c106b540773c0077e563c9bf6abda040da583bb7566828877d", size = 138588, upload-time = "2026-01-12T23:30:29.351Z" },
]
[[package]]