Resolve SDK Inconsistencies and Add Observation Creation Endpoints (#288)

* feat: Add Observation Creation Endpoints and SDK Cleanup

* fix: Resolve linting errors

* chore: Code Rabbit Nits

* chore: Code Rabbit Nits
This commit is contained in:
Vineeth Voruganti 2025-12-04 15:24:45 -05:00 committed by GitHub
parent 72eb0827ec
commit ca702cfd10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 2212 additions and 408 deletions

View File

@ -145,6 +145,7 @@
{
"group": "observations",
"pages": [
"v2/api-reference/endpoint/observations/create-observations",
"v2/api-reference/endpoint/observations/list-observations",
"v2/api-reference/endpoint/observations/query-observations",
"v2/api-reference/endpoint/observations/delete-observation"

View File

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

View File

@ -111,7 +111,7 @@ response = alice.chat("What does the user know about weather?")
response = alice.chat("What does the user know about the assistant?", target=assistant)
# Query scoped to a specific session
response = alice.chat("What happened in our conversation?", session_id=session.id)
response = alice.chat("What happened in our conversation?", session=session.id)
```
```typescript TypeScript
@ -258,7 +258,7 @@ print(f"Workspace: {alice.workspace_id}")
# Chat with peer's representations (supports streaming)
response = alice.chat("What did I have for breakfast?")
response = alice.chat("What do I know about Bob?", target="bob")
response = alice.chat("What happened in session-1?", session_id="session-1")
response = alice.chat("What happened in session-1?", session="session-1")
# Add content to a session with a peer
session = honcho.session("session-1")
@ -435,6 +435,59 @@ const bobSearch = await bobObs.query("work history");
```
</CodeGroup>
#### Creating Observations Manually
You can also create observations directly, which is useful for importing data or adding explicit facts:
<CodeGroup>
```python Python
# Create observations for what alice knows about bob
bob_obs = alice.observations_of("bob")
# Create a single observation
created = bob_obs.create([
{"content": "User prefers dark mode", "session_id": "session-1"}
])
# Create multiple observations in batch
created = bob_obs.create([
{"content": "User prefers dark mode", "session_id": "session-1"},
{"content": "User works late at night", "session_id": "session-1"},
{"content": "User enjoys programming", "session_id": "session-1"},
])
# Returns list of created Observation objects with IDs
for obs in created:
print(f"Created observation: {obs.id} - {obs.content}")
```
```typescript TypeScript
// Create observations for what alice knows about bob
const bobObs = alice.observationsOf("bob");
// Create a single observation
const created = await bobObs.create([
{ content: "User prefers dark mode", sessionId: "session-1" }
]);
// Create multiple observations in batch
const batchCreated = await bobObs.create([
{ content: "User prefers dark mode", sessionId: "session-1" },
{ content: "User works late at night", sessionId: "session-1" },
{ content: "User enjoys programming", sessionId: "session-1" },
]);
// Returns array of created Observation objects with IDs
for (const obs of batchCreated) {
console.log(`Created observation: ${obs.id} - ${obs.content}`);
}
```
</CodeGroup>
<Info>
Manually created observations are marked as "explicit" and are treated the same as system-derived observations. Each observation must be tied to a session and the content length is validated against the embedding token limit.
</Info>
### Session
Manages multi-party conversations:
@ -504,7 +557,7 @@ searched_rep = session.working_rep(
# Upload a file to create messages
messages = session.upload_file(
file=open("document.pdf", "rb"),
peer_id="user",
peer="user",
metadata={"source": "upload"},
created_at="2024-01-15T10:30:00Z"
)
@ -729,7 +782,7 @@ group_chat.add_messages([
# Query different perspectives
user_perspective = users[0].chat("What are people's concerns?")
moderator_view = moderator.chat("What feedback am I getting?", session_id=group_chat.id)
moderator_view = moderator.chat("What feedback am I getting?", session=group_chat.id)
```
```typescript TypeScript

View File

@ -2475,6 +2475,61 @@
}
}
},
"/v2/workspaces/{workspace_id}/observations": {
"post": {
"tags": ["observations"],
"summary": "Create Observations",
"description": "Create one or more observations.\n\nCreates observations (theory-of-mind facts) for the specified observer/observed peer pairs.\nEach observation must reference existing peers and a session within the workspace.\nEmbeddings are automatically generated for semantic search.\n\nMaximum of 100 observations per request.",
"operationId": "create_observations_v2_workspaces__workspace_id__observations_post",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "workspace_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"description": "ID of the workspace",
"title": "Workspace Id"
},
"description": "ID of the workspace"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ObservationBatchCreate",
"description": "Batch of observations to create"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": { "$ref": "#/components/schemas/Observation" },
"title": "Response Create Observations V2 Workspaces Workspace Id Observations Post"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/v2/workspaces/{workspace_id}/observations/list": {
"post": {
"tags": ["observations"],
@ -3334,6 +3389,50 @@
"title": "Observation",
"description": "Observation response - external view of a document"
},
"ObservationBatchCreate": {
"properties": {
"observations": {
"items": { "$ref": "#/components/schemas/ObservationCreate" },
"type": "array",
"maxItems": 100,
"minItems": 1,
"title": "Observations"
}
},
"type": "object",
"required": ["observations"],
"title": "ObservationBatchCreate",
"description": "Schema for batch observation creation with a max of 100 observations"
},
"ObservationCreate": {
"properties": {
"content": {
"type": "string",
"maxLength": 65535,
"minLength": 1,
"title": "Content"
},
"observer_id": {
"type": "string",
"title": "Observer Id",
"description": "The peer making the observation"
},
"observed_id": {
"type": "string",
"title": "Observed Id",
"description": "The peer being observed"
},
"session_id": {
"type": "string",
"title": "Session Id",
"description": "The session this observation relates to"
}
},
"type": "object",
"required": ["content", "observer_id", "observed_id", "session_id"],
"title": "ObservationCreate",
"description": "Schema for creating a single observation"
},
"ObservationGet": {
"properties": {
"filters": {

View File

@ -33,7 +33,7 @@ response = alice.chat("what did alice have for breakfast today?")
print("response returned:", response)
# Chat with alice in the session
response = alice.chat("what did alice have for breakfast today?", session_id=session.id)
response = alice.chat("what did alice have for breakfast today?", session=session.id)
print("response returned:", response)
# Chat with alice in the session with a target

View File

@ -10,7 +10,7 @@ session = honcho.session("file_upload_test_" + str(uuid.uuid4()))
# Upload the current file directly using a file object
with open(__file__, "rb") as file:
session.upload_file(file, peer_id="alice")
session.upload_file(file, peer="alice")
# get the messages from the session
# should contain the contents of this file!

View File

@ -60,7 +60,7 @@ async def get_summaries_async():
async_client = AsyncHoncho(api_key=api_key)
# Get a session
async_session = async_client.session("my-conversation-session")
async_session = await async_client.session("my-conversation-session")
# Get summaries asynchronously
summaries = await async_session.get_summaries()

View File

@ -66,7 +66,7 @@ print(
"\n\n\033[1m asking bob what alice had for breakfast -- scoped to session 1 \033[0m"
)
response = bob.chat(
"what did alice have for breakfast today?", target=alice, session_id=session.id
"what did alice have for breakfast today?", target=alice, session=session.id
)
print("response:", response)
@ -74,7 +74,7 @@ print(
"\n\n\033[1m asking bob what alice had for breakfast -- scoped to session 2 \033[0m"
)
response = bob.chat(
"what did alice have for breakfast today?", target=alice, session_id=session2.id
"what did alice have for breakfast today?", target=alice, session=session2.id
)
print("response:", response)

View File

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

View File

@ -40,6 +40,7 @@ from .async_client import (
AsyncPeer,
AsyncSession,
)
from .base import PeerBase, SessionBase
from .client import Honcho
from .observations import AsyncObservationScope, Observation, ObservationScope
from .pagination import SyncPage
@ -68,8 +69,10 @@ __all__ = [
"Observation",
"ObservationScope",
"Peer",
"PeerBase",
"PeerContext",
"Session",
"SessionBase",
"SessionContext",
"SessionSummaries",
"Summary",

View File

@ -14,6 +14,7 @@ from honcho_core.types.workspaces.session import Session as SessionCore
from honcho_core.types.workspaces.sessions.message import Message
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from ..base import PeerBase, SessionBase
from .pagination import AsyncPage
from .peer import AsyncPeer
from .session import AsyncSession
@ -470,29 +471,50 @@ class AsyncHoncho(BaseModel):
limit=limit,
)
@validate_call
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def get_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
session_id: str | None = None,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
session: str | SessionBase | None = None,
) -> DeriverStatus:
"""
Get the deriver processing status, optionally scoped to an observer, sender, and/or session
Get the deriver processing status, optionally scoped to an observer, sender, and/or session.
Args:
observer: Optional observer (ID string or Peer object) to scope the status check
sender: Optional sender (ID string or Peer object) to scope the status check
session: Optional session (ID string or Session object) to scope the status check
"""
return await self._client.workspaces.deriver_status(
workspace_id=self.workspace_id,
observer_id=observer_id,
sender_id=sender_id,
session_id=session_id,
resolved_observer_id = (
None
if observer is None
else (observer if isinstance(observer, str) else observer.id)
)
resolved_sender_id = (
None
if sender is None
else (sender if isinstance(sender, str) else sender.id)
)
resolved_session_id = (
None
if session is None
else (session if isinstance(session, str) else session.id)
)
@validate_call
return await self._client.workspaces.deriver_status(
workspace_id=self.workspace_id,
observer_id=resolved_observer_id,
sender_id=resolved_sender_id,
session_id=resolved_session_id,
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def poll_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
session_id: str | None = None,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
session: str | SessionBase | None = None,
timeout: float = Field(
300.0,
gt=0,
@ -507,9 +529,9 @@ class AsyncHoncho(BaseModel):
The polling estimates sleep time by assuming each work unit takes 1 second.
Args:
observer_id: Optional observer ID to scope the status check
sender_id: Optional sender ID to scope the status check
session_id: Optional session ID to scope the status check
observer: Optional observer (ID string or AsyncPeer object) to scope the status check
sender: Optional sender (ID string or AsyncPeer object) to scope the status check
session: Optional session (ID string or AsyncSession object) to scope the status check
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
Returns:
@ -523,9 +545,7 @@ class AsyncHoncho(BaseModel):
while True:
try:
status = await self.get_deriver_status(
observer_id, sender_id, session_id
)
status = await self.get_deriver_status(observer, sender, session)
except Exception as e:
logger.warning(f"Failed to get deriver status: {e}")
# Sleep briefly before retrying
@ -578,10 +598,9 @@ class AsyncHoncho(BaseModel):
metadata: dict[str, object] = Field(
..., description="The metadata to update for the message"
),
session_id: str | None = Field(
session: str | SessionBase | None = Field(
None,
min_length=1,
description="The ID of the session (required if message is a string ID)",
description="The session (ID string or Session object) - required if message is a string ID",
),
) -> Message:
"""
@ -592,7 +611,7 @@ class AsyncHoncho(BaseModel):
Args:
message: Either a Message object or a message ID string
metadata: The metadata to update for the message
session_id: The ID of the session (required if message is a string ID, ignored if message is a Message object)
session: The session (ID string or Session object) - required if message is a string ID, ignored if message is a Message object
Returns:
The updated Message object
@ -605,9 +624,9 @@ class AsyncHoncho(BaseModel):
resolved_session_id = message.session_id
else:
message_id = message
if not session_id:
raise ValueError("session_id is required when message is a string ID")
resolved_session_id = session_id
if not session:
raise ValueError("session is required when message is a string ID")
resolved_session_id = session if isinstance(session, str) else session.id
return await self._client.workspaces.sessions.messages.update(
message_id=message_id,

View File

@ -14,18 +14,20 @@ from honcho_core.types.workspaces.session import Session as SessionCore
from honcho_core.types.workspaces.sessions import MessageCreateParam
from honcho_core.types.workspaces.sessions.message import Message
from honcho_core.types.workspaces.sessions.message_create_param import Configuration
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from pydantic import ConfigDict, Field, PrivateAttr, validate_call
from ..base import PeerBase, SessionBase
from ..types import DialecticStreamResponse
from .pagination import AsyncPage
if TYPE_CHECKING:
from ..observations import AsyncObservationScope
from ..types import PeerContext, Representation
from .session import AsyncSession
from .session import AsyncSession
class AsyncPeer(BaseModel):
class AsyncPeer(PeerBase):
"""
Represents a peer in the Honcho system with async operations.
@ -42,10 +44,6 @@ class AsyncPeer(BaseModel):
recently fetched. Call get_config() for fresh data.
"""
id: str = Field(..., min_length=1, description="Unique identifier for this peer")
workspace_id: str = Field(
..., min_length=1, description="Workspace ID for scoping operations"
)
_metadata: dict[str, object] | None = PrivateAttr(default=None)
_configuration: dict[str, object] | None = PrivateAttr(default=None)
_client: AsyncHonchoCore = PrivateAttr()
@ -146,8 +144,8 @@ class AsyncPeer(BaseModel):
query: str,
*,
stream: bool = False,
target: str | AsyncPeer | None = None,
session_id: str | None = None,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
) -> str | DialecticStreamResponse | None:
"""
Query the peer's representation with a natural language question.
@ -161,14 +159,28 @@ class AsyncPeer(BaseModel):
stream: Whether to stream the response
target: Optional target peer for local representation query. If provided,
queries what this peer knows about the target peer rather than
querying the peer's global representation
session_id: Optional session ID to scope the query to a specific session.
If provided, only information from that session is considered
querying the peer's global representation. Can be a peer ID string
or an AsyncPeer object.
session: Optional session to scope the query to. If provided, only
information from that session is considered. Can be a session
ID string or an AsyncSession object.
Returns:
For non-streaming: Response string containing the answer, or None if no relevant information
For streaming: DialecticStreamResponse object that can be iterated over and provides final response
"""
# Extract IDs from objects if needed
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
resolved_session_id = (
None
if session is None
else (session if isinstance(session, str) else session.id)
)
if stream:
async def stream_response() -> AsyncGenerator[str]:
@ -180,8 +192,8 @@ class AsyncPeer(BaseModel):
workspace_id=self.workspace_id,
query=query,
stream=True,
target=str(target.id) if isinstance(target, AsyncPeer) else target,
session_id=session_id,
target=target_id,
session_id=resolved_session_id,
) as response:
response.http_response.raise_for_status()
async for line in response.iter_lines():
@ -205,8 +217,8 @@ class AsyncPeer(BaseModel):
workspace_id=self.workspace_id,
query=query,
stream=stream,
target=str(target.id) if isinstance(target, AsyncPeer) else target,
session_id=session_id,
target=target_id,
session_id=resolved_session_id,
)
# "If the context provided doesn't help address the query, write absolutely NOTHING but "None""
if response.content in ("", None, "None"):
@ -452,7 +464,7 @@ class AsyncPeer(BaseModel):
async def card(
self,
target: str | AsyncPeer | None = None,
target: str | PeerBase | None = None,
) -> str:
"""
Get the peer card for this peer.
@ -472,10 +484,15 @@ class AsyncPeer(BaseModel):
if isinstance(target, str) and len(target.strip()) == 0:
raise ValueError("target string cannot be empty")
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
response: PeerCardResponse = await self._client.workspaces.peers.card(
peer_id=self.id,
workspace_id=self.workspace_id,
target=str(target.id) if isinstance(target, AsyncPeer) else target,
target=target_id,
)
if response.peer_card is None:
@ -486,8 +503,8 @@ class AsyncPeer(BaseModel):
async def working_rep(
self,
session: str | AsyncSession | None = None,
target: str | AsyncPeer | None = None,
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,
@ -537,12 +554,17 @@ class AsyncPeer(BaseModel):
else session.id
)
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
data: PeerWorkingRepresentationResponse = (
await self._client.workspaces.peers.working_representation(
peer_id=self.id,
workspace_id=self.workspace_id,
session_id=session_id,
target=str(target.id) if isinstance(target, AsyncPeer) else target,
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
@ -564,7 +586,7 @@ class AsyncPeer(BaseModel):
async def get_context(
self,
target: str | AsyncPeer | None = None,
target: str | PeerBase | None = None,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
@ -609,7 +631,11 @@ class AsyncPeer(BaseModel):
"""
from ..types import PeerContext as _PeerContext
target_id = str(target.id) if isinstance(target, AsyncPeer) else target
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
response = await self._client.workspaces.peers.get_context(
peer_id=self.id,
@ -655,7 +681,7 @@ class AsyncPeer(BaseModel):
return _AsyncObservationScope(self._client, self.workspace_id, self.id, self.id)
def observations_of(self, target: str | AsyncPeer) -> "AsyncObservationScope":
def observations_of(self, target: str | PeerBase) -> "AsyncObservationScope":
"""
Access observations this peer has made about another peer.
@ -685,7 +711,7 @@ class AsyncPeer(BaseModel):
"""
from ..observations import AsyncObservationScope as _AsyncObservationScope
target_id = target.id if isinstance(target, AsyncPeer) else target
target_id = target.id if isinstance(target, PeerBase) else target
return _AsyncObservationScope(
self._client, self.workspace_id, self.id, target_id
)

View File

@ -15,6 +15,7 @@ from honcho_core.types.workspaces.sessions.message import Message
from honcho_core.types.workspaces.sessions.message_create_param import Configuration
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from ..base import PeerBase, SessionBase
from ..session_context import SessionContext, SessionSummaries, Summary
from ..utils import prepare_file_for_upload
from .pagination import AsyncPage
@ -37,7 +38,7 @@ class SessionPeerConfig(BaseModel):
)
class AsyncSession(BaseModel):
class AsyncSession(SessionBase):
"""
Represents a session in Honcho with async operations.
@ -54,10 +55,6 @@ class AsyncSession(BaseModel):
recently fetched. Call get_config() for fresh data.
"""
id: str = Field(..., min_length=1, description="Unique identifier for this session")
workspace_id: str = Field(
..., min_length=1, description="Workspace ID for scoping operations"
)
_metadata: dict[str, object] | None = PrivateAttr(default=None)
_configuration: dict[str, object] | None = PrivateAttr(default=None)
_client: AsyncHonchoCore = PrivateAttr()
@ -154,12 +151,12 @@ class AsyncSession(BaseModel):
async def add_peers(
self,
peers: str
| AsyncPeer
| PeerBase
| tuple[str, SessionPeerConfig]
| tuple[AsyncPeer, SessionPeerConfig]
| list[AsyncPeer | str]
| list[tuple[AsyncPeer | str, SessionPeerConfig]]
| list[AsyncPeer | str | tuple[AsyncPeer | str, SessionPeerConfig]] = Field(
| tuple[PeerBase, SessionPeerConfig]
| list[PeerBase | str]
| list[tuple[PeerBase | str, SessionPeerConfig]]
| list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]] = Field(
..., description="Peers to add to the session"
),
) -> None:
@ -204,12 +201,12 @@ class AsyncSession(BaseModel):
async def set_peers(
self,
peers: str
| AsyncPeer
| PeerBase
| tuple[str, SessionPeerConfig]
| tuple[AsyncPeer, SessionPeerConfig]
| list[AsyncPeer | str]
| list[tuple[AsyncPeer | str, SessionPeerConfig]]
| list[AsyncPeer | str | tuple[AsyncPeer | str, SessionPeerConfig]] = Field(
| tuple[PeerBase, SessionPeerConfig]
| list[PeerBase | str]
| list[tuple[PeerBase | str, SessionPeerConfig]]
| list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]] = Field(
..., description="Peers to set for the session"
),
) -> None:
@ -252,7 +249,7 @@ class AsyncSession(BaseModel):
async def remove_peers(
self,
peers: str | AsyncPeer | list[AsyncPeer | str] = Field(
peers: str | PeerBase | list[PeerBase | str] = Field(
..., description="Peers to remove from the session"
),
) -> None:
@ -302,15 +299,14 @@ class AsyncSession(BaseModel):
for peer in peers_page.items
]
async def get_peer_config(self, peer: str | AsyncPeer) -> SessionPeerConfig:
async def get_peer_config(self, peer: str | PeerBase) -> SessionPeerConfig:
"""
Get the configuration for a peer in this session.
"""
from .peer import AsyncPeer
peer_id = peer if isinstance(peer, str) else peer.id
peer_get_config_response = (
await self._client.workspaces.sessions.peers.get_config(
peer_id=str(peer.id) if isinstance(peer, AsyncPeer) else peer,
peer_id=peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
)
@ -321,15 +317,14 @@ class AsyncSession(BaseModel):
)
async def set_peer_config(
self, peer: str | AsyncPeer, config: SessionPeerConfig
self, peer: str | PeerBase, config: SessionPeerConfig
) -> None:
"""
Set the configuration for a peer in this session.
"""
from .peer import AsyncPeer
peer_id = peer if isinstance(peer, str) else peer.id
await self._client.workspaces.sessions.peers.set_config(
peer_id=str(peer.id) if isinstance(peer, AsyncPeer) else peer,
peer_id=peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
observe_others=omit
@ -785,14 +780,16 @@ class AsyncSession(BaseModel):
limit=limit,
)
@validate_call
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def upload_file(
self,
file: tuple[str, bytes, str] | tuple[str, Any, str] | Any = Field(
...,
description="File to upload. Can be a file object, (filename, bytes, content_type) tuple, or (filename, fileobj, content_type) tuple.",
),
peer_id: str = Field(..., description="ID of the peer creating the messages"),
peer: str | PeerBase = Field(
..., description="The peer creating the messages (ID string or Peer object)"
),
metadata: dict[str, object] | None = Field(
None,
description="Optional metadata dictionary to associate with the messages",
@ -821,7 +818,8 @@ class AsyncSession(BaseModel):
- a file object (must have .name and .read())
- a tuple (filename, bytes, content_type)
- a tuple (filename, fileobj, content_type)
peer_id: ID of the peer who will be attributed as the creator of the messages
peer: The peer who will be attributed as the creator of the messages.
Can be a peer ID string or an AsyncPeer object.
metadata: Optional metadata dictionary to associate with the messages
configuration: Optional configuration dictionary to associate with the messages
created_at: Optional created-at timestamp for the messages. Should be an ISO 8601 formatted string.
@ -838,6 +836,9 @@ class AsyncSession(BaseModel):
# Prepare file for upload using shared utility
filename, content_bytes, content_type = prepare_file_for_upload(file)
# Extract peer ID from AsyncPeer object if needed
resolved_peer_id = peer if isinstance(peer, str) else peer.id
# Build extra_body dict with optional fields as JSON strings (backend expects Form fields)
extra_body_data: dict[str, str] = {}
if metadata is not None:
@ -856,7 +857,7 @@ class AsyncSession(BaseModel):
session_id=self.id,
workspace_id=self.workspace_id,
file=(filename, content_bytes, content_type),
peer_id=peer_id,
peer_id=resolved_peer_id,
extra_body=extra_body_data if extra_body_data else None,
)
@ -864,9 +865,9 @@ class AsyncSession(BaseModel):
async def working_rep(
self,
peer: str | AsyncPeer,
peer: str | PeerBase,
*,
target: str | AsyncPeer | None = None,
target: str | PeerBase | None = None,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
@ -907,13 +908,19 @@ class AsyncSession(BaseModel):
```
"""
from ..types import Representation as _Representation
from .peer import AsyncPeer as _AsyncPeer
peer_id = peer if isinstance(peer, str) else peer.id
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
data = await self._client.workspaces.peers.working_representation(
str(peer.id) if isinstance(peer, _AsyncPeer) else peer,
peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
target=str(target.id) if isinstance(target, _AsyncPeer) else target,
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
@ -926,27 +933,42 @@ class AsyncSession(BaseModel):
)
return _Representation.from_dict(data) # type: ignore
@validate_call
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def get_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
) -> DeriverStatus:
"""
Get the deriver processing status, optionally scoped to an observer, sender, and/or session
Get the deriver processing status, optionally scoped to an observer, sender, and/or session.
Args:
observer: Optional observer (ID string or AsyncPeer object) to scope the status check
sender: Optional sender (ID string or AsyncPeer object) to scope the status check
"""
resolved_observer_id = (
None
if observer is None
else (observer if isinstance(observer, str) else observer.id)
)
resolved_sender_id = (
None
if sender is None
else (sender if isinstance(sender, str) else sender.id)
)
return await self._client.workspaces.deriver_status(
workspace_id=self.workspace_id,
observer_id=observer_id,
sender_id=sender_id,
observer_id=resolved_observer_id,
sender_id=resolved_sender_id,
session_id=self.id,
)
@validate_call
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def poll_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
timeout: float = Field(
300.0,
gt=0,
@ -961,8 +983,8 @@ class AsyncSession(BaseModel):
The polling estimates sleep time by assuming each work unit takes 1 second.
Args:
observer_id: Optional observer ID to scope the status check
sender_id: Optional sender ID to scope the status check
observer: Optional observer (ID string or AsyncPeer object) to scope the status check
sender: Optional sender (ID string or AsyncPeer object) to scope the status check
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
Returns:
@ -976,7 +998,7 @@ class AsyncSession(BaseModel):
while True:
try:
status = await self.get_deriver_status(observer_id, sender_id)
status = await self.get_deriver_status(observer, sender)
except Exception as e:
logger.warning(f"Failed to get deriver status: {e}")
# Sleep briefly before retrying

View File

@ -0,0 +1,45 @@
"""Base classes for Honcho SDK entities.
This module provides base classes that contain only the essential data fields
shared by both sync and async variants of Peer and Session. These base classes
can be imported anywhere without causing circular import issues, enabling
type-safe method signatures like `str | PeerBase`.
"""
from pydantic import BaseModel, Field
class PeerBase(BaseModel):
"""Base class for Peer objects (sync and async variants).
This class contains only the essential data fields shared by both
Peer and AsyncPeer. Use this type in method signatures to accept
either a peer ID string or any Peer object.
Attributes:
id: Unique identifier for this peer
workspace_id: Workspace ID for scoping operations
"""
id: str = Field(..., min_length=1, description="Unique identifier for this peer")
workspace_id: str = Field(
..., min_length=1, description="Workspace ID for scoping operations"
)
class SessionBase(BaseModel):
"""Base class for Session objects (sync and async variants).
This class contains only the essential data fields shared by both
Session and AsyncSession. Use this type in method signatures to accept
either a session ID string or any Session object.
Attributes:
id: Unique identifier for this session
workspace_id: Workspace ID for scoping operations
"""
id: str = Field(..., min_length=1, description="Unique identifier for this session")
workspace_id: str = Field(
..., min_length=1, description="Workspace ID for scoping operations"
)

View File

@ -12,6 +12,7 @@ from honcho_core.types.workspaces.session import Session as SessionCore
from honcho_core.types.workspaces.sessions.message import Message
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from .base import PeerBase, SessionBase
from .pagination import SyncPage
from .peer import Peer
from .session import Session
@ -445,29 +446,50 @@ class Honcho(BaseModel):
self.workspace_id, query=query, filters=filters, limit=limit
)
@validate_call
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def get_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
session_id: str | None = None,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
session: str | SessionBase | None = None,
) -> DeriverStatus:
"""
Get the deriver processing status, optionally scoped to an observer, sender, and/or session
Get the deriver processing status, optionally scoped to an observer, sender, and/or session.
Args:
observer: Optional observer (ID string or Peer object) to scope the status check
sender: Optional sender (ID string or Peer object) to scope the status check
session: Optional session (ID string or Session object) to scope the status check
"""
return self._client.workspaces.deriver_status(
workspace_id=self.workspace_id,
observer_id=observer_id,
sender_id=sender_id,
session_id=session_id,
resolved_observer_id = (
None
if observer is None
else (observer if isinstance(observer, str) else observer.id)
)
resolved_sender_id = (
None
if sender is None
else (sender if isinstance(sender, str) else sender.id)
)
resolved_session_id = (
None
if session is None
else (session if isinstance(session, str) else session.id)
)
@validate_call
return self._client.workspaces.deriver_status(
workspace_id=self.workspace_id,
observer_id=resolved_observer_id,
sender_id=resolved_sender_id,
session_id=resolved_session_id,
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def poll_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
session_id: str | None = None,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
session: str | SessionBase | None = None,
timeout: float = Field(
300.0,
gt=0,
@ -482,9 +504,9 @@ class Honcho(BaseModel):
The polling estimates sleep time by assuming each work unit takes 1 second.
Args:
observer_id: Optional observer ID to scope the status check
sender_id: Optional sender ID to scope the status check
session_id: Optional session ID to scope the status check
observer: Optional observer (ID string or Peer object) to scope the status check
sender: Optional sender (ID string or Peer object) to scope the status check
session: Optional session (ID string or Session object) to scope the status check
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
Returns:
@ -498,7 +520,7 @@ class Honcho(BaseModel):
while True:
try:
status = self.get_deriver_status(observer_id, sender_id, session_id)
status = self.get_deriver_status(observer, sender, session)
except Exception as e:
logger.warning(f"Failed to get deriver status: {e}")
# Sleep briefly before retrying
@ -551,10 +573,9 @@ class Honcho(BaseModel):
metadata: dict[str, object] = Field(
..., description="The metadata to update for the message"
),
session_id: str | None = Field(
session: str | SessionBase | None = Field(
None,
min_length=1,
description="The ID of the session (required if message is a string ID)",
description="The session (ID string or Session object) - required if message is a string ID",
),
) -> Message:
"""
@ -565,7 +586,7 @@ class Honcho(BaseModel):
Args:
message: Either a Message object or a message ID string
metadata: The metadata to update for the message
session_id: The ID of the session (required if message is a string ID, ignored if message is a Message object)
session: The session (ID string or Session object) - required if message is a string ID, ignored if message is a Message object
Returns:
The updated Message object
@ -578,9 +599,9 @@ class Honcho(BaseModel):
resolved_session_id = message.session_id
else:
message_id = message
if not session_id:
raise ValueError("session_id is required when message is a string ID")
resolved_session_id = session_id
if not session:
raise ValueError("session is required when message is a string ID")
resolved_session_id = session if isinstance(session, str) else session.id
return self._client.workspaces.sessions.messages.update(
message_id=message_id,

View File

@ -4,8 +4,29 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from .base import SessionBase
if TYPE_CHECKING:
from .types import Representation
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:
@ -56,7 +77,7 @@ class Observation:
observer_id=data.get("observer_id", ""),
observed_id=data.get("observed_id", ""),
session_id=data.get("session_id", ""),
created_at=data.get("created_at", ""),
created_at=str(data.get("created_at", "")),
)
def __repr__(self) -> str:
@ -70,7 +91,7 @@ class ObservationScope:
"""
Scoped access to observations 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, create, and delete observations
that are automatically scoped to a specific observer/observed pair.
Typically accessed via `peer.observations` (for self-observations) or
@ -87,13 +108,6 @@ class ObservationScope:
bob_observations = peer.observations_of("bob")
bob_list = bob_observations.list()
```
Note:
This class requires the core Honcho SDK to support observation endpoints.
The observation endpoints are:
- POST /workspaces/{workspace_id}/observations/list
- POST /workspaces/{workspace_id}/observations/query
- DELETE /workspaces/{workspace_id}/observations/{observation_id}
"""
_client: Any
@ -126,7 +140,7 @@ class ObservationScope:
self,
page: int = 1,
size: int = 50,
session_id: str | None = None,
session: str | SessionBase | None = None,
) -> list[Observation]:
"""
List observations in this scope.
@ -134,19 +148,23 @@ class ObservationScope:
Args:
page: Page number (1-indexed)
size: Number of results per page
session_id: Optional session ID to filter by
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 session_id:
filters["session_id"] = session_id
if resolved_session_id:
filters["session_id"] = resolved_session_id
# Note: This requires the core SDK to support observations.list()
response = self._client.workspaces.observations.list(
workspace_id=self.workspace_id,
filters=filters,
@ -154,7 +172,11 @@ class ObservationScope:
size=size,
)
return [Observation.from_api_response(item) for item in response.items]
# response.items is List[Observations] (Pydantic models)
return [
Observation.from_api_response(_convert_observation(item))
for item in response.items
]
def query(
self,
@ -178,7 +200,6 @@ class ObservationScope:
"observed": self.observed,
}
# Note: This requires the core SDK to support observations.query()
response = self._client.workspaces.observations.query(
workspace_id=self.workspace_id,
query=query,
@ -187,7 +208,11 @@ class ObservationScope:
filters=filters,
)
return [Observation.from_api_response(item) for item in response]
# 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:
"""
@ -196,12 +221,67 @@ class ObservationScope:
Args:
observation_id: The ID of the observation to delete
"""
# Note: This requires the core SDK to support observations.delete()
self._client.workspaces.observations.delete(
workspace_id=self.workspace_id,
observation_id=observation_id,
)
def 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,
@ -262,7 +342,7 @@ class AsyncObservationScope:
"""
Async scoped access to observations for a specific observer/observed relationship.
This class provides convenient async methods to list, query, and delete observations
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
@ -279,13 +359,6 @@ class AsyncObservationScope:
bob_observations = peer.observations_of("bob")
bob_list = await bob_observations.list()
```
Note:
This class requires the core Honcho SDK to support observation endpoints.
The observation endpoints are:
- POST /workspaces/{workspace_id}/observations/list
- POST /workspaces/{workspace_id}/observations/query
- DELETE /workspaces/{workspace_id}/observations/{observation_id}
"""
_client: Any
@ -318,7 +391,7 @@ class AsyncObservationScope:
self,
page: int = 1,
size: int = 50,
session_id: str | None = None,
session: str | SessionBase | None = None,
) -> list[Observation]:
"""
List observations in this scope.
@ -326,19 +399,23 @@ class AsyncObservationScope:
Args:
page: Page number (1-indexed)
size: Number of results per page
session_id: Optional session ID to filter by
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 session_id:
filters["session_id"] = session_id
if resolved_session_id:
filters["session_id"] = resolved_session_id
# Note: This requires the core SDK to support observations.list()
response = await self._client.workspaces.observations.list(
workspace_id=self.workspace_id,
filters=filters,
@ -346,7 +423,11 @@ class AsyncObservationScope:
size=size,
)
return [Observation.from_api_response(item) for item in response.items]
# response.items is List[Observations] (Pydantic models)
return [
Observation.from_api_response(_convert_observation(item))
for item in response.items
]
async def query(
self,
@ -370,7 +451,6 @@ class AsyncObservationScope:
"observed": self.observed,
}
# Note: This requires the core SDK to support observations.query()
response = await self._client.workspaces.observations.query(
workspace_id=self.workspace_id,
query=query,
@ -379,7 +459,11 @@ class AsyncObservationScope:
filters=filters,
)
return [Observation.from_api_response(item) for item in response]
# 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:
"""
@ -388,12 +472,67 @@ class AsyncObservationScope:
Args:
observation_id: The ID of the observation to delete
"""
# Note: This requires the core SDK to support observations.delete()
await self._client.workspaces.observations.delete(
workspace_id=self.workspace_id,
observation_id=observation_id,
)
async def 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,

View File

@ -11,18 +11,20 @@ from honcho_core.types.workspaces.session import Session as SessionCore
from honcho_core.types.workspaces.sessions import MessageCreateParam
from honcho_core.types.workspaces.sessions.message import Message
from honcho_core.types.workspaces.sessions.message_create_param import Configuration
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from pydantic import ConfigDict, Field, PrivateAttr, validate_call
from .base import PeerBase, SessionBase
from .pagination import SyncPage
from .types import DialecticStreamResponse
if TYPE_CHECKING:
from .observations import ObservationScope
from .session import Session
from .types import PeerContext, Representation
from .session import Session
class Peer(BaseModel):
class Peer(PeerBase):
"""
Represents a peer in the Honcho system.
@ -39,10 +41,6 @@ class Peer(BaseModel):
recently fetched. Call get_config() for fresh data.
"""
id: str = Field(..., min_length=1, description="Unique identifier for this peer")
workspace_id: str = Field(
..., min_length=1, description="Workspace ID for scoping operations"
)
_metadata: dict[str, object] | None = PrivateAttr(default=None)
_configuration: dict[str, object] | None = PrivateAttr(default=None)
_client: HonchoCore = PrivateAttr()
@ -120,8 +118,8 @@ class Peer(BaseModel):
query: str,
*,
stream: bool = False,
target: str | Peer | None = None,
session_id: str | None = None,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
) -> str | DialecticStreamResponse | None:
"""
Query the peer's representation with a natural language question.
@ -135,14 +133,28 @@ class Peer(BaseModel):
stream: Whether to stream the response
target: Optional target peer for local representation query. If provided,
queries what this peer knows about the target peer rather than
querying the peer's global representation
session_id: Optional session ID to scope the query to a specific session.
If provided, only information from that session is considered
querying the peer's global representation. Can be a peer ID string
or a Peer object.
session: Optional session to scope the query to. If provided, only
information from that session is considered. Can be a session
ID string or a Session object.
Returns:
For non-streaming: Response string containing the answer, or None if no relevant information
For streaming: DialecticStreamResponse object that can be iterated over and provides final response
"""
# Extract IDs from objects if needed
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
resolved_session_id = (
None
if session is None
else (session if isinstance(session, str) else session.id)
)
if stream:
def stream_response() -> Generator[str, None, None]:
@ -154,8 +166,8 @@ class Peer(BaseModel):
workspace_id=self.workspace_id,
query=query,
stream=True,
target=str(target.id) if isinstance(target, Peer) else target,
session_id=session_id,
target=target_id,
session_id=resolved_session_id,
) as response:
response.http_response.raise_for_status()
for line in response.iter_lines():
@ -179,8 +191,8 @@ class Peer(BaseModel):
workspace_id=self.workspace_id,
query=query,
stream=stream,
target=str(target.id) if isinstance(target, Peer) else target,
session_id=session_id,
target=target_id,
session_id=resolved_session_id,
)
if response.content in ("", None, "None"):
return None
@ -425,7 +437,7 @@ class Peer(BaseModel):
def card(
self,
target: str | Peer | None = None,
target: str | PeerBase | None = None,
) -> str:
"""
Get the peer card for this peer.
@ -445,10 +457,15 @@ class Peer(BaseModel):
if isinstance(target, str) and len(target.strip()) == 0:
raise ValueError("target string cannot be empty")
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
response: PeerCardResponse = self._client.workspaces.peers.card(
peer_id=self.id,
workspace_id=self.workspace_id,
target=str(target.id) if isinstance(target, Peer) else target,
target=target_id,
)
if response.peer_card is None:
return ""
@ -459,8 +476,8 @@ class Peer(BaseModel):
def working_rep(
self,
session: str | Session | None = None,
target: str | Peer | None = None,
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,
@ -510,11 +527,16 @@ class Peer(BaseModel):
else session.id
)
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
data = self._client.workspaces.peers.working_representation(
peer_id=self.id,
workspace_id=self.workspace_id,
session_id=session_id,
target=str(target.id) if isinstance(target, Peer) else target,
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
@ -533,7 +555,7 @@ class Peer(BaseModel):
def get_context(
self,
target: str | Peer | None = None,
target: str | PeerBase | None = None,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
@ -578,7 +600,11 @@ class Peer(BaseModel):
"""
from .types import PeerContext as _PeerContext
target_id = str(target.id) if isinstance(target, Peer) else target
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
response = self._client.workspaces.peers.get_context(
peer_id=self.id,
@ -624,7 +650,7 @@ class Peer(BaseModel):
return _ObservationScope(self._client, self.workspace_id, self.id, self.id)
def observations_of(self, target: str | Peer) -> "ObservationScope":
def observations_of(self, target: str | PeerBase) -> "ObservationScope":
"""
Access observations this peer has made about another peer.
@ -654,7 +680,7 @@ class Peer(BaseModel):
"""
from .observations import ObservationScope as _ObservationScope
target_id = target.id if isinstance(target, Peer) else target
target_id = target.id if isinstance(target, PeerBase) else target
return _ObservationScope(self._client, self.workspace_id, self.id, target_id)
def __repr__(self) -> str:

View File

@ -14,13 +14,14 @@ from honcho_core.types.workspaces.sessions.message import Message
from honcho_core.types.workspaces.sessions.message_create_param import Configuration
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from .base import PeerBase, SessionBase
from .pagination import SyncPage
from .session_context import SessionContext, SessionSummaries, Summary
from .utils import prepare_file_for_upload
if TYPE_CHECKING:
from .peer import Peer
from .types import Representation
from .peer import Peer
logger = logging.getLogger(__name__)
@ -36,7 +37,7 @@ class SessionPeerConfig(BaseModel):
)
class Session(BaseModel):
class Session(SessionBase):
"""
Represents a session in Honcho.
@ -53,10 +54,6 @@ class Session(BaseModel):
recently fetched. Call get_config() for fresh data.
"""
id: str = Field(..., min_length=1, description="Unique identifier for this session")
workspace_id: str = Field(
..., min_length=1, description="Workspace ID for scoping operations"
)
_metadata: dict[str, object] | None = PrivateAttr(default=None)
_configuration: dict[str, object] | None = PrivateAttr(default=None)
_client: HonchoCore = PrivateAttr()
@ -130,12 +127,12 @@ class Session(BaseModel):
def add_peers(
self,
peers: str
| Peer
| PeerBase
| tuple[str, SessionPeerConfig]
| tuple[Peer, SessionPeerConfig]
| list[Peer | str]
| list[tuple[Peer | str, SessionPeerConfig]]
| list[Peer | str | tuple[Peer | str, SessionPeerConfig]] = Field(
| tuple[PeerBase, SessionPeerConfig]
| list[PeerBase | str]
| list[tuple[PeerBase | str, SessionPeerConfig]]
| list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]] = Field(
..., description="Peers to add to the session"
),
) -> None:
@ -180,12 +177,12 @@ class Session(BaseModel):
def set_peers(
self,
peers: str
| Peer
| PeerBase
| tuple[str, SessionPeerConfig]
| tuple[Peer, SessionPeerConfig]
| list[Peer | str]
| list[tuple[Peer | str, SessionPeerConfig]]
| list[Peer | str | tuple[Peer | str, SessionPeerConfig]] = Field(
| tuple[PeerBase, SessionPeerConfig]
| list[PeerBase | str]
| list[tuple[PeerBase | str, SessionPeerConfig]]
| list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]] = Field(
..., description="Peers to set for the session"
),
) -> None:
@ -228,7 +225,7 @@ class Session(BaseModel):
def remove_peers(
self,
peers: str | Peer | list[Peer | str] = Field(
peers: str | PeerBase | list[PeerBase | str] = Field(
..., description="Peers to remove from the session"
),
) -> None:
@ -277,14 +274,13 @@ class Session(BaseModel):
Peer(peer.id, self.workspace_id, self._client) for peer in peers_page.items
]
def get_peer_config(self, peer: str | Peer) -> SessionPeerConfig:
def get_peer_config(self, peer: str | PeerBase) -> SessionPeerConfig:
"""
Get the configuration for a peer in this session.
"""
from .peer import Peer
peer_id = peer if isinstance(peer, str) else peer.id
peer_get_config_response = self._client.workspaces.sessions.peers.get_config(
peer_id=str(peer.id) if isinstance(peer, Peer) else peer,
peer_id=peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
)
@ -293,14 +289,13 @@ class Session(BaseModel):
observe_me=peer_get_config_response.observe_me,
)
def set_peer_config(self, peer: str | Peer, config: SessionPeerConfig) -> None:
def set_peer_config(self, peer: str | PeerBase, config: SessionPeerConfig) -> None:
"""
Set the configuration for a peer in this session.
"""
from .peer import Peer
peer_id = peer if isinstance(peer, str) else peer.id
self._client.workspaces.sessions.peers.set_config(
peer_id=str(peer.id) if isinstance(peer, Peer) else peer,
peer_id=peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
observe_others=omit
@ -756,14 +751,16 @@ class Session(BaseModel):
limit=limit,
)
@validate_call
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def upload_file(
self,
file: tuple[str, bytes, str] | tuple[str, Any, str] | Any = Field(
...,
description="File to upload. Can be a file object, (filename, bytes, content_type) tuple, or (filename, fileobj, content_type) tuple.",
),
peer_id: str = Field(..., description="ID of the peer creating the messages"),
peer: str | PeerBase = Field(
..., description="The peer creating the messages (ID string or Peer object)"
),
metadata: dict[str, object] | None = Field(
None,
description="Optional metadata dictionary to associate with the messages",
@ -792,7 +789,8 @@ class Session(BaseModel):
- a file object (must have .name and .read())
- a tuple (filename, bytes, content_type)
- a tuple (filename, fileobj, content_type)
peer_id: ID of the peer who will be attributed as the creator of the messages
peer: The peer who will be attributed as the creator of the messages.
Can be a peer ID string or a Peer object.
metadata: Optional metadata dictionary to associate with the messages
configuration: Optional configuration dictionary to associate with the messages
created_at: Optional created-at timestamp for the messages. Should be an ISO 8601 formatted string.
@ -809,6 +807,9 @@ class Session(BaseModel):
# Prepare file for upload using shared utility
filename, content_bytes, content_type = prepare_file_for_upload(file)
# Extract peer ID from Peer object if needed
resolved_peer_id = peer if isinstance(peer, str) else peer.id
# Build extra_body dict with optional fields as JSON strings (backend expects Form fields)
extra_body_data: dict[str, str] = {}
if metadata is not None:
@ -827,7 +828,7 @@ class Session(BaseModel):
session_id=self.id,
workspace_id=self.workspace_id,
file=(filename, content_bytes, content_type),
peer_id=peer_id,
peer_id=resolved_peer_id,
extra_body=extra_body_data if extra_body_data else None,
)
@ -835,9 +836,9 @@ class Session(BaseModel):
def working_rep(
self,
peer: str | Peer,
peer: str | PeerBase,
*,
target: str | Peer | None = None,
target: str | PeerBase | None = None,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
@ -877,14 +878,20 @@ class Session(BaseModel):
)
```
"""
from .peer import Peer as _Peer
from .types import Representation as _Representation
peer_id = peer if isinstance(peer, str) else peer.id
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
data = self._client.workspaces.peers.working_representation(
str(peer.id) if isinstance(peer, _Peer) else peer,
peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
target=str(target.id) if isinstance(target, _Peer) else target,
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
@ -897,27 +904,42 @@ class Session(BaseModel):
)
return _Representation.from_dict(data) # type: ignore
@validate_call
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def get_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
) -> DeriverStatus:
"""
Get the deriver processing status, optionally scoped to an observer, sender, and/or session
Get the deriver processing status, optionally scoped to an observer, sender, and/or session.
Args:
observer: Optional observer (ID string or Peer object) to scope the status check
sender: Optional sender (ID string or Peer object) to scope the status check
"""
resolved_observer_id = (
None
if observer is None
else (observer if isinstance(observer, str) else observer.id)
)
resolved_sender_id = (
None
if sender is None
else (sender if isinstance(sender, str) else sender.id)
)
return self._client.workspaces.deriver_status(
workspace_id=self.workspace_id,
observer_id=observer_id,
sender_id=sender_id,
observer_id=resolved_observer_id,
sender_id=resolved_sender_id,
session_id=self.id,
)
@validate_call
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def poll_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
timeout: float = Field(
300.0,
gt=0,
@ -932,8 +954,8 @@ class Session(BaseModel):
The polling estimates sleep time by assuming each work unit takes 1 second.
Args:
observer_id: Optional observer ID to scope the status check
sender_id: Optional sender ID to scope the status check
observer: Optional observer (ID string or Peer object) to scope the status check
sender: Optional sender (ID string or Peer object) to scope the status check
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
Returns:
@ -947,7 +969,7 @@ class Session(BaseModel):
while True:
try:
status = self.get_deriver_status(observer_id, sender_id)
status = self.get_deriver_status(observer, sender)
except Exception as e:
logger.warning(f"Failed to get deriver status: {e}")
# Sleep briefly before retrying

View File

@ -4,13 +4,17 @@ from __future__ import annotations
from collections.abc import AsyncIterator, Iterator
from datetime import datetime
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast
from typing_extensions import Required, TypedDict
from pydantic import BaseModel, Field
# Re-export observation types from dedicated module
from .observations import AsyncObservationScope, Observation, ObservationScope
if TYPE_CHECKING:
from .base import SessionBase
__all__ = [
"AsyncObservationScope",
"DeductiveObservation",
@ -19,6 +23,7 @@ __all__ = [
"ExplicitObservation",
"ExplicitObservationBase",
"Observation",
"ObservationCreateParam",
"ObservationMetadata",
"ObservationScope",
"PeerContext",
@ -26,6 +31,18 @@ __all__ = [
]
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."""

View File

@ -441,9 +441,9 @@ describe('Honcho Client', () => {
mockClient.workspaces.deriverStatus.mockResolvedValue(mockStatus);
const status = await honcho.getDeriverStatus({
observerId: 'observer1',
senderId: 'sender1',
sessionId: 'session1',
observer: 'observer1',
sender: 'sender1',
session: 'session1',
});
expect(status).toEqual({
@ -560,7 +560,7 @@ describe('Honcho Client', () => {
const metadata = { updated: true };
await expect(honcho.updateMessage(messageId, metadata)).rejects.toThrow(
'sessionId is required when message is a string ID'
'session is required when message is a string ID'
);
});

View File

@ -126,7 +126,7 @@ describe('Peer', () => {
const mockResponse = { content: 'Session-specific response' };
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
await peer.chat('Hello', { sessionId: 'session-123' });
await peer.chat('Hello', { session: 'session-123' });
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
'test-workspace',

View File

@ -1160,8 +1160,8 @@ describe('Session', () => {
mockClient.workspaces.deriverStatus.mockResolvedValue(mockStatus)
const status = await session.getDeriverStatus({
observerId: 'observer1',
senderId: 'sender1',
observer: 'observer1',
sender: 'sender1',
})
expect(status).toEqual({

View File

@ -4,7 +4,7 @@
"": {
"name": "@honcho-ai/sdk",
"dependencies": {
"@honcho-ai/core": "^1.6.0",
"@honcho-ai/core": "^1.6.1",
"@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.6.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-NqUWIs9FLt8dG6LF7rEx/uSY7HPdS67AGTceZPm48l60daddU3+e64glQHkGlmsNQ6TJc/z/BGrVVtQxyNilyw=="],
"@honcho-ai/core": ["@honcho-ai/core@1.6.1", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-sfKIqAIybP/yj6iXGQLFgrqlX1dA7OuAK86p8sy8XiT64ZpEYpEz6viifAsm65LRZMgb0HPTFeBGodseUSoqVQ=="],
"@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

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

View File

@ -10,7 +10,6 @@ import { Peer } from './peer'
import { Session } from './session'
import {
type DeriverStatusOptions,
DeriverStatusOptionsSchema,
FilterSchema,
type Filters,
type HonchoConfig,
@ -486,28 +485,47 @@ export class Honcho {
* The deriver is responsible for processing messages and updating peer representations.
*
* @param options - Configuration options for the status request
* @param options.observerId - Optional observer ID to scope the status to
* @param options.senderId - Optional sender ID to scope the status to
* @param options.sessionId - Optional session ID to scope the status to
* @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
*/
async getDeriverStatus(options?: DeriverStatusOptions): Promise<{
async getDeriverStatus(
options?: Omit<
DeriverStatusOptions,
'observerId' | 'senderId' | 'sessionId'
> & {
observer?: string | Peer
sender?: string | Peer
session?: string | Session
}
): Promise<{
totalWorkUnits: number
completedWorkUnits: number
inProgressWorkUnits: number
pendingWorkUnits: number
sessions?: Record<string, DeriverStatus.Sessions>
}> {
const validatedOptions = options
? DeriverStatusOptionsSchema.parse(options)
const resolvedObserverId = options?.observer
? typeof options.observer === 'string'
? options.observer
: options.observer.id
: undefined
const resolvedSenderId = options?.sender
? typeof options.sender === 'string'
? options.sender
: options.sender.id
: undefined
const resolvedSessionId = options?.session
? typeof options.session === 'string'
? options.session
: options.session.id
: undefined
const queryParams: WorkspaceDeriverStatusParams = {}
if (validatedOptions?.observerId)
queryParams.observer_id = validatedOptions.observerId
if (validatedOptions?.senderId)
queryParams.sender_id = validatedOptions.senderId
if (validatedOptions?.sessionId)
queryParams.session_id = validatedOptions.sessionId
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(
this.workspaceId,
@ -531,28 +549,34 @@ export class Honcho {
* The polling estimates sleep time by assuming each work unit takes 1 second.
*
* @param options - Configuration options for the status request
* @param options.observerId - Optional observer ID to scope the status to
* @param options.senderId - Optional sender ID to scope the status to
* @param options.sessionId - Optional session ID to scope the status to
* @param options.observer - Optional observer (ID string or Peer object) to scope the status to
* @param options.sender - Optional sender (ID string or Peer object) to scope the status to
* @param options.session - Optional session (ID string or Session object) to scope the status to
* @param options.timeoutMs - Optional timeout in milliseconds (default: 300000 - 5 minutes)
* @returns Promise resolving to the final deriver status when processing is complete
* @throws Error if timeout is exceeded before processing completes
*/
async pollDeriverStatus(options?: DeriverStatusOptions): Promise<{
async pollDeriverStatus(
options?: Omit<
DeriverStatusOptions,
'observerId' | 'senderId' | 'sessionId'
> & {
observer?: string | Peer
sender?: string | Peer
session?: string | Session
}
): Promise<{
totalWorkUnits: number
completedWorkUnits: number
inProgressWorkUnits: number
pendingWorkUnits: number
sessions?: Record<string, DeriverStatus.Sessions>
}> {
const validatedOptions = options
? DeriverStatusOptionsSchema.parse(options)
: undefined
const timeoutMs = validatedOptions?.timeoutMs ?? 300000 // Default to 5 minutes
const timeoutMs = options?.timeoutMs ?? 300000 // Default to 5 minutes
const startTime = Date.now()
while (true) {
const status = await this.getDeriverStatus(validatedOptions)
const status = await this.getDeriverStatus(options)
if (status.pendingWorkUnits === 0 && status.inProgressWorkUnits === 0) {
return status
}
@ -589,14 +613,14 @@ export class Honcho {
*
* @param message - Either a Message object or a message ID string
* @param metadata - The metadata to update for the message
* @param sessionId - The ID of the session (required if message is a string ID, ignored if message is a Message object)
* @param session - The session (ID string or Session object) - required if message is a string ID, ignored if message is a Message object
* @returns Promise resolving to the updated Message object
* @throws Error if message is a string ID but sessionId is not provided
* @throws Error if message is a string ID but session is not provided
*/
async updateMessage(
message: Message | string,
metadata: Record<string, unknown>,
sessionId?: string
session?: string | Session
): Promise<Message> {
const validatedMetadata = MessageMetadataSchema.parse(metadata)
let messageId: string
@ -604,10 +628,10 @@ export class Honcho {
if (typeof message === 'string') {
messageId = message
if (!sessionId) {
throw new Error('sessionId is required when message is a string ID')
if (!session) {
throw new Error('session is required when message is a string ID')
}
resolvedSessionId = sessionId
resolvedSessionId = typeof session === 'string' ? session : session.id
} else {
messageId = message.id
resolvedSessionId = message.session_id

View File

@ -4,9 +4,11 @@ import {
type RepresentationData,
type RepresentationOptions,
} from './representation'
import type { Session } from './session'
import type { ObservationCreateParam } from './types'
// Re-export for consumers who import from this module
export type { RepresentationOptions }
export type { RepresentationOptions, ObservationCreateParam }
/**
* An observation from the theory-of-mind system.
@ -161,20 +163,25 @@ export class ObservationScope {
*
* @param page - Page number (1-indexed)
* @param size - Number of results per page
* @param sessionId - Optional session ID to filter by
* @param session - Optional session (ID string or Session object) to filter by
* @returns Promise resolving to list of Observation objects
*/
async list(
page: number = 1,
size: number = 50,
sessionId?: string
session?: string | Session
): Promise<Observation[]> {
const resolvedSessionId = session
? typeof session === 'string'
? session
: session.id
: undefined
const filters: Record<string, unknown> = {
observer: this.observer,
observed: this.observed,
}
if (sessionId) {
filters.session_id = sessionId
if (resolvedSessionId) {
filters.session_id = resolvedSessionId
}
// biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include observations
@ -239,6 +246,54 @@ export class ObservationScope {
)
}
/**
* Create observations in this scope.
*
* @param observations - Single observation or array of observations with content and sessionId
* @returns Promise resolving to list of created Observation objects
*
* @example
* ```typescript
* // Create a single observation
* const observations = await peer.observations.create(
* { content: 'User prefers dark mode', sessionId: 'session1' }
* )
*
* // Create multiple observations
* const observations = await peer.observations.create([
* { content: 'User prefers dark mode', sessionId: 'session1' },
* { content: 'User is interested in AI', sessionId: 'session1' },
* ])
* ```
*/
async create(
observations: ObservationCreateParam | ObservationCreateParam[]
): Promise<Observation[]> {
// Normalize to array
const observationArray = Array.isArray(observations)
? observations
: [observations]
// Build the request body with observer/observed from scope
const requestObservations = observationArray.map((obs) => ({
content: obs.content,
session_id:
typeof obs.sessionId === 'string' ? obs.sessionId : obs.sessionId.id,
observer_id: this.observer,
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(
this.workspaceId,
{ observations: requestObservations }
)
return (response ?? []).map((item: unknown) =>
Observation.fromApiResponse(item as Record<string, unknown>)
)
}
/**
* Get the computed representation for this scope.
*

View File

@ -106,9 +106,11 @@ export class Peer {
* @param stream - Whether to stream the response
* @param target - Optional target peer for local representation query. If provided,
* queries what this peer knows about the target peer rather than
* querying the peer's global representation
* @param sessionId - Optional session ID to scope the query to a specific session.
* If provided, only information from that session is considered
* querying the peer's global representation. Can be a peer ID string
* or a Peer object.
* @param session - Optional session to scope the query to. If provided, only
* information from that session is considered. Can be a session
* ID string or a Session object.
* @returns Promise resolving to:
* - For non-streaming: response string or null if no relevant information
* - For streaming: DialecticStreamResponse that can be iterated over
@ -118,26 +120,33 @@ export class Peer {
options?: {
stream?: boolean
target?: string | Peer
sessionId?: string
session?: string | Session
}
): Promise<string | DialecticStreamResponse | null> {
const targetId = options?.target
? typeof options.target === 'string'
? options.target
: options.target.id
: undefined
const resolvedSessionId = options?.session
? typeof options.session === 'string'
? options.session
: options.session.id
: undefined
const chatParams = ChatQuerySchema.parse({
query,
stream: options?.stream,
target: options?.target,
sessionId: options?.sessionId,
target: targetId,
session: resolvedSessionId,
})
if (chatParams.stream) {
const body = {
query: chatParams.query,
stream: true,
target: chatParams.target
? typeof chatParams.target === 'string'
? chatParams.target
: chatParams.target.id
: undefined,
session_id: chatParams.sessionId,
target: chatParams.target,
session_id: chatParams.session,
}
const url = `${this._client.baseURL}/v2/workspaces/${this.workspaceId}/peers/${this.id}/chat`
@ -212,12 +221,8 @@ export class Peer {
{
query: chatParams.query,
stream: false,
target: chatParams.target
? typeof chatParams.target === 'string'
? chatParams.target
: chatParams.target.id
: undefined,
session_id: chatParams.sessionId,
target: chatParams.target,
session_id: chatParams.session,
}
)
if (!response.content || response.content === 'None') {

View File

@ -18,7 +18,6 @@ import { SessionContext, SessionSummaries, Summary } from './session_context'
import {
ContextParamsSchema,
type DeriverStatusOptions,
DeriverStatusOptionsSchema,
FileUploadSchema,
FilterSchema,
type Filters,
@ -896,12 +895,18 @@ export class Session {
* This method automatically scopes the status to this session.
*
* @param options - Configuration options for the status request
* @param options.observerId - Optional observer ID to scope the status to
* @param options.senderId - Optional sender ID to scope the status to
* @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
*/
async getDeriverStatus(
options?: Omit<DeriverStatusOptions, 'sessionId'>
options?: Omit<
DeriverStatusOptions,
'sessionId' | 'observerId' | 'senderId'
> & {
observer?: string | Peer
sender?: string | Peer
}
): Promise<{
totalWorkUnits: number
completedWorkUnits: number
@ -909,16 +914,22 @@ export class Session {
pendingWorkUnits: number
sessions?: Record<string, DeriverStatus.Sessions>
}> {
const validatedOptions = options
? DeriverStatusOptionsSchema.parse(options)
const resolvedObserverId = options?.observer
? typeof options.observer === 'string'
? options.observer
: options.observer.id
: undefined
const resolvedSenderId = options?.sender
? typeof options.sender === 'string'
? options.sender
: options.sender.id
: undefined
const queryParams: WorkspaceDeriverStatusParams = {
session_id: this.id, // Always use this session's ID
}
if (validatedOptions?.observerId)
queryParams.observer_id = validatedOptions.observerId
if (validatedOptions?.senderId)
queryParams.sender_id = validatedOptions.senderId
if (resolvedObserverId) queryParams.observer_id = resolvedObserverId
if (resolvedSenderId) queryParams.sender_id = resolvedSenderId
const status = await this._client.workspaces.deriverStatus(
this.workspaceId,
@ -942,14 +953,20 @@ export class Session {
* The polling estimates sleep time by assuming each work unit takes 1 second.
*
* @param options - Configuration options for the status request
* @param options.observerId - Optional observer ID to scope the status to
* @param options.senderId - Optional sender ID to scope the status to
* @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
* @throws Error if timeout is exceeded before processing completes
*/
async pollDeriverStatus(
options?: Omit<DeriverStatusOptions, 'sessionId'>
options?: Omit<
DeriverStatusOptions,
'sessionId' | 'observerId' | 'senderId'
> & {
observer?: string | Peer
sender?: string | Peer
}
): Promise<{
totalWorkUnits: number
completedWorkUnits: number
@ -957,14 +974,11 @@ export class Session {
pendingWorkUnits: number
sessions?: Record<string, DeriverStatus.Sessions>
}> {
const validatedOptions = options
? DeriverStatusOptionsSchema.parse(options)
: undefined
const timeoutMs = validatedOptions?.timeoutMs ?? 300000 // Default to 5 minutes
const timeoutMs = options?.timeoutMs ?? 300000 // Default to 5 minutes
const startTime = Date.now()
while (true) {
const status = await this.getDeriverStatus(validatedOptions)
const status = await this.getDeriverStatus(options)
if (status.pendingWorkUnits === 0 && status.inProgressWorkUnits === 0) {
return status
}
@ -1006,7 +1020,7 @@ export class Session {
* - File objects (browser File API)
* - Buffer or Uint8Array with filename and content_type
* - { filename: string, content: Buffer | Uint8Array, content_type: string }
* @param peerId - The peer ID to attribute the created messages to
* @param peer - The peer (ID string or Peer object) to attribute the created messages to
* @param options - Optional parameters for the uploaded messages
* @param options.metadata - Optional metadata dictionary to associate with the messages
* @param options.configuration - Optional configuration dictionary to associate with the messages
@ -1032,7 +1046,7 @@ export class Session {
*/
async uploadFile(
file: Uploadable,
peerId: string,
peer: string | Peer,
options?: {
metadata?: Record<string, unknown>
configuration?: Record<string, unknown>
@ -1044,9 +1058,11 @@ export class Session {
? options.created_at.toISOString()
: options?.created_at
const resolvedPeerId = typeof peer === 'string' ? peer : peer.id
const uploadParams = FileUploadSchema.parse({
file,
peerId,
peer: resolvedPeerId,
metadata: options?.metadata,
configuration: options?.configuration,
created_at: createdAt,
@ -1055,7 +1071,7 @@ export class Session {
// Build body with file and peer_id, plus optional fields as JSON strings
const body = {
file: uploadParams.file,
peer_id: uploadParams.peerId,
peer_id: resolvedPeerId,
...(uploadParams.metadata !== undefined && uploadParams.metadata !== null
? { metadata: JSON.stringify(uploadParams.metadata) }
: {}),

View File

@ -1,3 +1,5 @@
import type { Session } from './session'
/**
* Shared types for the Honcho TypeScript SDK.
*/
@ -14,6 +16,16 @@ export interface Observation {
created_at: string
}
/**
* Parameters for creating an observation.
*/
export interface ObservationCreateParam {
/** The observation content/text */
content: string
/** The session this observation relates to (ID string or Session object) */
sessionId: string | Session
}
/**
* Parameters for semantic search of observations.
*/

View File

@ -130,8 +130,18 @@ export const FilterSchema = z.record(z.string(), z.unknown()).optional()
export const ChatQuerySchema = z.object({
query: SearchQuerySchema,
stream: z.boolean().optional().default(false),
target: z.union([z.string(), z.object({ id: z.string() })]).optional(),
sessionId: z.string().optional(),
target: z
.union([z.string(), z.object({ id: z.string() })])
.optional()
.transform((val) =>
val ? (typeof val === 'string' ? val : val.id) : undefined
),
session: z
.union([z.string(), z.object({ id: z.string() })])
.optional()
.transform((val) =>
val ? (typeof val === 'string' ? val : val.id) : undefined
),
})
/**
@ -215,9 +225,9 @@ export const ContextParamsSchema = z
* Schema for deriver status options.
*/
export const DeriverStatusOptionsSchema = z.object({
observerId: z.string().optional(),
senderId: z.string().optional(),
sessionId: z.string().optional(),
observer: z.union([z.string(), z.object({ id: z.string() })]).optional(),
sender: z.union([z.string(), z.object({ id: z.string() })]).optional(),
session: z.union([z.string(), z.object({ id: z.string() })]).optional(),
timeoutMs: z
.number()
.positive('Timeout must be a positive number')
@ -252,7 +262,7 @@ export const FileUploadSchema = z.object({
'File must not be null or undefined'
),
]),
peerId: PeerIdSchema,
peer: z.union([PeerIdSchema, z.object({ id: z.string() })]),
metadata: MessageMetadataSchema,
configuration: z.record(z.string(), z.unknown()).optional(),
created_at: z.string().nullable().optional(),

View File

@ -2,6 +2,7 @@ from .collection import get_collection, get_or_create_collection
from .deriver import get_deriver_status
from .document import (
create_documents,
create_observations,
delete_document,
delete_document_by_id,
get_all_documents,
@ -62,6 +63,7 @@ __all__ = [
"get_deriver_status",
# Document
"create_documents",
"create_observations",
"get_all_documents",
"get_documents_with_filters",
"query_documents",

View File

@ -9,6 +9,9 @@ from sqlalchemy.sql import Select
from src import models, schemas
from src.config import settings
from src.crud.collection import get_or_create_collection
from src.crud.peer import get_peer
from src.crud.session import get_session
from src.embedding_client import embedding_client
from src.exceptions import ResourceNotFoundException, ValidationException
from src.utils.filter import apply_filter
@ -294,6 +297,101 @@ async def delete_document_by_id(
)
async def create_observations(
db: AsyncSession,
observations: list[schemas.ObservationCreate],
workspace_name: str,
) -> list[models.Document]:
"""
Create multiple observations (documents) from user input.
This function validates all referenced resources, generates embeddings
in batch, and creates the documents.
Args:
db: Database session
observations: List of observation creation schemas
workspace_name: Name of the workspace
Returns:
List of created Document objects
Raises:
ResourceNotFoundException: If any session or peer is not found
ValidationException: If embedding generation fails or integrity constraint is violated
"""
if not observations:
return []
# Collect unique sessions and peer pairs to validate
sessions_to_validate: set[str] = set()
peers_to_validate: set[str] = set()
collection_pairs: set[tuple[str, str]] = set()
for obs in observations:
sessions_to_validate.add(obs.session_id)
peers_to_validate.add(obs.observer_id)
peers_to_validate.add(obs.observed_id)
collection_pairs.add((obs.observer_id, obs.observed_id))
# Validate all sessions exist
for session_name in sessions_to_validate:
await get_session(db, session_name, workspace_name)
# Validate all peers exist
for peer_name in peers_to_validate:
await get_peer(db, workspace_name, schemas.PeerCreate(name=peer_name))
# Get or create all collections
for observer, observed in collection_pairs:
await get_or_create_collection(
db, workspace_name, observer=observer, observed=observed
)
# Generate embeddings in batch
contents = [obs.content for obs in observations]
try:
embeddings = await embedding_client.simple_batch_embed(contents)
except ValueError as e:
raise ValidationException(str(e)) from e
# Create document objects
honcho_documents: list[models.Document] = []
for obs, embedding in zip(observations, embeddings, strict=True):
honcho_documents.append(
models.Document(
workspace_name=workspace_name,
observer=obs.observer_id,
observed=obs.observed_id,
content=obs.content,
level="explicit", # Manually created observations are always explicit
times_derived=1,
internal_metadata={}, # No message_ids since not derived from messages
embedding=embedding,
session_name=obs.session_id,
)
)
try:
db.add_all(honcho_documents)
await db.commit()
# Refresh all documents to get generated IDs and timestamps
for doc in honcho_documents:
await db.refresh(doc)
except IntegrityError as e:
await db.rollback()
raise ValidationException(
"Failed to create observations due to integrity constraint violation"
) from e
logger.debug(
"Created %d observations in workspace %s",
len(honcho_documents),
workspace_name,
)
return honcho_documents
async def is_rejected_duplicate(
db: AsyncSession,
doc: schemas.DocumentCreate,

View File

@ -172,7 +172,7 @@ async def get_peer(
peer: Peer creation schema
Returns:
The peer if found or created
The peer if found
Raises:
ResourceNotFoundException: If the peer does not exist

View File

@ -50,7 +50,7 @@ class _EmbeddingClient:
self.max_embedding_tokens = settings.MAX_EMBEDDING_TOKENS
self.max_batch_size = 2048 # OpenAI batch limit
self.encoding: tiktoken.Encoding = tiktoken.get_encoding("cl100k_base")
self.encoding: tiktoken.Encoding = tiktoken.get_encoding("o200k_base")
self.max_embedding_tokens_per_request: int = (
settings.MAX_EMBEDDING_TOKENS_PER_REQUEST
)

View File

@ -19,6 +19,40 @@ router = APIRouter(
)
@router.post(
"",
response_model=list[schemas.Observation],
)
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.observations,
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],

View File

@ -562,6 +562,37 @@ class ObservationQuery(BaseModel):
)
class ObservationCreate(BaseModel):
"""Schema for creating a single observation"""
content: Annotated[str, Field(min_length=1, max_length=65535)]
observer_id: str = Field(..., description="The peer making the observation")
observed_id: str = Field(..., description="The peer being observed")
session_id: str = Field(..., description="The session this observation relates to")
_token_count: int = PrivateAttr(default=0)
@model_validator(mode="after")
def validate_token_count(self) -> Self:
"""Validate that content doesn't exceed embedding token limit."""
encoding = tiktoken.get_encoding("cl100k_base")
tokens = encoding.encode(self.content)
self._token_count = len(tokens)
if self._token_count > settings.MAX_EMBEDDING_TOKENS:
raise ValueError(
f"Content exceeds maximum embedding token limit of {settings.MAX_EMBEDDING_TOKENS} "
+ f"(got {self._token_count} tokens)"
)
return self
class ObservationBatchCreate(BaseModel):
"""Schema for batch observation creation with a max of 100 observations"""
observations: list[ObservationCreate] = Field(..., min_length=1, max_length=100)
class MessageSearchOptions(BaseModel):
query: str = Field(..., description="Search query")
filters: dict[str, Any] | None = Field(

View File

@ -295,7 +295,7 @@ class BEAMRunner:
start_time = time.time()
while True:
try:
status = await honcho_client.get_deriver_status(session_id=session_id)
status = await honcho_client.get_deriver_status(session=session_id)
except Exception:
await asyncio.sleep(1)
elapsed_time = time.time() - start_time

View File

@ -346,7 +346,7 @@ class LongMemEvalRunner:
start_time = time.time()
while True:
try:
status = await honcho_client.get_deriver_status(session_id=session_id)
status = await honcho_client.get_deriver_status(session=session_id)
except Exception as _e:
await asyncio.sleep(1)
elapsed_time = time.time() - start_time

View File

@ -172,7 +172,7 @@ class TestRunner:
"""
try:
await honcho_client.poll_deriver_status(
session_id=session_id,
session=session_id,
timeout=float(self.timeout_seconds)
if self.timeout_seconds
else 10000.0,

View File

@ -753,3 +753,409 @@ class TestObservationRoutes:
assert "embedding" not in observation
assert "internal_metadata" not in observation
assert "collection" not in observation
@pytest.mark.asyncio
async def test_create_observation_success(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test creating a single observation"""
test_workspace, test_peer = sample_data
# Create another peer
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_session)
await db_session.commit()
# Create observation via API
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
json={
"observations": [
{
"content": "User prefers dark mode",
"observer_id": test_peer.name,
"observed_id": test_peer2.name,
"session_id": test_session.name,
}
]
},
)
assert response.status_code == 200
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
@pytest.mark.asyncio
async def test_create_observations_batch(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test creating multiple observations in batch"""
test_workspace, test_peer = sample_data
# Create another peer
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_session)
await db_session.commit()
# Create multiple observations via API
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
json={
"observations": [
{
"content": "User prefers dark mode",
"observer_id": test_peer.name,
"observed_id": test_peer2.name,
"session_id": test_session.name,
},
{
"content": "User works late at night",
"observer_id": test_peer.name,
"observed_id": test_peer2.name,
"session_id": test_session.name,
},
{
"content": "User enjoys programming",
"observer_id": test_peer.name,
"observed_id": test_peer2.name,
"session_id": test_session.name,
},
]
},
)
assert response.status_code == 200
data = response.json()
assert len(data) == 3
contents = [obs["content"] for obs in data]
assert "User prefers dark mode" in contents
assert "User works late at night" in contents
assert "User enjoys programming" in contents
@pytest.mark.asyncio
async def test_create_observation_nonexistent_session(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test creating observation with non-existent session fails"""
test_workspace, test_peer = sample_data
# Create another peer
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.commit()
# Try to create observation with non-existent session
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
json={
"observations": [
{
"content": "Test observation",
"observer_id": test_peer.name,
"observed_id": test_peer2.name,
"session_id": "nonexistent_session",
}
]
},
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_create_observation_nonexistent_peer(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test creating observation with non-existent peer fails"""
test_workspace, test_peer = sample_data
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_session)
await db_session.commit()
# Try to create observation with non-existent observer
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
json={
"observations": [
{
"content": "Test observation",
"observer_id": "nonexistent_peer",
"observed_id": test_peer.name,
"session_id": test_session.name,
}
]
},
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_create_observation_empty_content(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test creating observation with empty content fails validation"""
test_workspace, test_peer = sample_data
# Create another peer
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_session)
await db_session.commit()
# Try to create observation with empty content
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
json={
"observations": [
{
"content": "",
"observer_id": test_peer.name,
"observed_id": test_peer2.name,
"session_id": test_session.name,
}
]
},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_create_observation_empty_list(
self,
client: TestClient,
sample_data: tuple[Workspace, Peer],
):
"""Test creating observations with empty list fails validation"""
test_workspace, _test_peer = sample_data
# Try to create with empty observations list
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
json={"observations": []},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_create_observation_creates_collection(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test that creating observation auto-creates collection if needed"""
test_workspace, test_peer = sample_data
# Create another peer
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_session)
await db_session.commit()
# Create observation via API (this should auto-create collection)
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
json={
"observations": [
{
"content": "Test observation",
"observer_id": test_peer.name,
"observed_id": test_peer2.name,
"session_id": test_session.name,
}
]
},
)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
# The observation 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
@pytest.mark.asyncio
async def test_create_observation_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_workspace, test_peer = sample_data
# Create two more peers
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
test_peer3 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add_all([test_peer2, test_peer3])
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_session)
await db_session.commit()
# Create observations with different observer/observed pairs
response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
json={
"observations": [
{
"content": "Peer1 observes Peer2",
"observer_id": test_peer.name,
"observed_id": test_peer2.name,
"session_id": test_session.name,
},
{
"content": "Peer2 observes Peer3",
"observer_id": test_peer2.name,
"observed_id": test_peer3.name,
"session_id": test_session.name,
},
]
},
)
assert response.status_code == 200
data = response.json()
assert len(data) == 2
# Verify each observation 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
obs2 = next(o for o in data if o["content"] == "Peer2 observes Peer3")
assert obs2["observer_id"] == test_peer2.name
assert obs2["observed_id"] == test_peer3.name
@pytest.mark.asyncio
async def test_created_observations_are_searchable(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test that created observations can be found via list endpoint"""
test_workspace, test_peer = sample_data
# Create another peer
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.flush()
# Create a session
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_session)
await db_session.commit()
# Create observation via API
create_response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations",
json={
"observations": [
{
"content": "Unique test content for searchability",
"observer_id": test_peer.name,
"observed_id": test_peer2.name,
"session_id": test_session.name,
}
]
},
)
assert create_response.status_code == 200
created_id = create_response.json()[0]["id"]
# List observations and verify the created one is there
list_response = client.post(
f"/v2/workspaces/{test_workspace.name}/observations/list",
json={
"filters": {
"observer": test_peer.name,
"observed": test_peer2.name,
"session_id": test_session.name,
}
},
)
assert list_response.status_code == 200
data = list_response.json()
ids = [obs["id"] for obs in data["items"]]
assert created_id in ids

View File

@ -210,24 +210,24 @@ 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_id=peer.id)
status = await honcho_client.get_deriver_status(observer=peer.id)
assert isinstance(status, DeriverStatus)
# 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_id=session.id)
status = await honcho_client.get_deriver_status(session=session.id)
assert isinstance(status, DeriverStatus)
# Test with both peer_id and session_id
# Test with both peer and session
status = await honcho_client.get_deriver_status(
observer_id=peer.id, session_id=session.id
observer=peer.id, session=session.id
)
assert isinstance(status, DeriverStatus)
# Test with include_sender=True
# Test with sender
status = await honcho_client.get_deriver_status(
observer_id=peer.id, sender_id=peer.id
observer=peer.id, sender=peer.id
)
assert isinstance(status, DeriverStatus)
else:
@ -243,25 +243,21 @@ async def test_get_deriver_status(client_fixture: tuple[Honcho | AsyncHoncho, st
# Test with peer_id only
peer = honcho_client.peer(id="test-peer-deriver-status")
peer.get_metadata() # Create the peer
status = honcho_client.get_deriver_status(observer_id=peer.id)
status = honcho_client.get_deriver_status(observer=peer.id)
assert isinstance(status, DeriverStatus)
# Test with session_id only
session = honcho_client.session(id="test-session-deriver-status")
session.get_metadata() # Create the session
status = honcho_client.get_deriver_status(session_id=session.id)
status = honcho_client.get_deriver_status(session=session.id)
assert isinstance(status, DeriverStatus)
# Test with both peer_id and session_id
status = honcho_client.get_deriver_status(
observer_id=peer.id, session_id=session.id
)
# Test with both peer and session
status = honcho_client.get_deriver_status(observer=peer.id, session=session.id)
assert isinstance(status, DeriverStatus)
# Test with include_sender=True
status = honcho_client.get_deriver_status(
observer_id=peer.id, sender_id=peer.id
)
# Test with sender
status = honcho_client.get_deriver_status(observer=peer.id, sender=peer.id)
assert isinstance(status, DeriverStatus)
@ -297,7 +293,7 @@ async def test_poll_deriver_status(client_fixture: tuple[Honcho | AsyncHoncho, s
honcho_client, "get_deriver_status", return_value=completed_status
):
status = await honcho_client.poll_deriver_status(
observer_id=peer.id, sender_id=peer.id
observer=peer.id, sender=peer.id
)
assert isinstance(status, DeriverStatus)
else:
@ -315,9 +311,7 @@ async def test_poll_deriver_status(client_fixture: tuple[Honcho | AsyncHoncho, s
with patch.object(
honcho_client, "get_deriver_status", return_value=completed_status
):
status = honcho_client.poll_deriver_status(
observer_id=peer.id, sender_id=peer.id
)
status = honcho_client.poll_deriver_status(observer=peer.id, sender=peer.id)
assert isinstance(status, DeriverStatus)
@ -390,7 +384,7 @@ async def test_update_message_with_message_id(
# Update using message_id string
updated = await honcho_client.update_message(
message.id, {"updated": True}, session_id=session.id
message.id, {"updated": True}, session=session.id
)
assert isinstance(updated, Message)
assert updated.metadata == {"updated": True}
@ -410,7 +404,7 @@ async def test_update_message_with_message_id(
# Update using message_id string
updated = honcho_client.update_message(
message.id, {"updated": True}, session_id=session.id
message.id, {"updated": True}, session=session.id
)
assert isinstance(updated, Message)
assert updated.metadata == {"updated": True}
@ -422,15 +416,19 @@ async def test_update_message_validation(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests that update_message raises ValueError when message_id is provided without session_id.
Tests that update_message raises ValueError when message ID is provided without session.
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
with pytest.raises(ValueError, match="session_id is required"):
with pytest.raises(
ValueError, match="session is required when message is a string ID"
):
await honcho_client.update_message("msg_123", {"key": "value"})
else:
assert isinstance(honcho_client, Honcho)
with pytest.raises(ValueError, match="session_id is required"):
with pytest.raises(
ValueError, match="session is required when message is a string ID"
):
honcho_client.update_message("msg_123", {"key": "value"})

View File

@ -33,7 +33,7 @@ async def test_session_upload_file(
user = honcho_client.peer(id="user-upload")
messages = session.upload_file(
file=text_file,
peer_id=user.id,
peer=user.id,
)
else:
# Async client
@ -41,7 +41,7 @@ async def test_session_upload_file(
user = await honcho_client.peer(id="user-upload")
messages = await session.upload_file(
file=text_file,
peer_id=user.id,
peer=user.id,
)
# Verify messages were created
@ -78,7 +78,7 @@ async def test_large_file_chunking(
user = honcho_client.peer(id="user-chunking")
messages = session.upload_file(
file=large_file,
peer_id=user.id,
peer=user.id,
)
else:
# Async client
@ -86,7 +86,7 @@ async def test_large_file_chunking(
user = await honcho_client.peer(id="user-chunking")
messages = await session.upload_file(
file=large_file,
peer_id=user.id,
peer=user.id,
)
# Should be multiple messages due to chunking
@ -129,16 +129,16 @@ async def test_multiple_files_upload(
# Sync client
session = honcho_client.session(id="test-session-multiple")
user = honcho_client.peer(id="user-multiple")
messages1 = session.upload_file(file=file1, peer_id=user.id)
messages2 = session.upload_file(file=file2, peer_id=user.id)
messages3 = session.upload_file(file=file3, peer_id=user.id)
messages1 = session.upload_file(file=file1, peer=user.id)
messages2 = session.upload_file(file=file2, peer=user.id)
messages3 = session.upload_file(file=file3, peer=user.id)
else:
# Async client
session = await honcho_client.session(id="test-session-multiple")
user = await honcho_client.peer(id="user-multiple")
messages1 = await session.upload_file(file=file1, peer_id=user.id)
messages2 = await session.upload_file(file=file2, peer_id=user.id)
messages3 = await session.upload_file(file=file3, peer_id=user.id)
messages1 = await session.upload_file(file=file1, peer=user.id)
messages2 = await session.upload_file(file=file2, peer=user.id)
messages3 = await session.upload_file(file=file3, peer=user.id)
# Should be at least one message per file
assert len(messages1) >= 1
@ -187,7 +187,7 @@ async def test_json_file_upload(client_fixture: tuple[Honcho | AsyncHoncho, str]
user = honcho_client.peer(id="user-json")
messages = session.upload_file(
file=json_file,
peer_id=user.id,
peer=user.id,
)
else:
# Async client
@ -195,7 +195,7 @@ async def test_json_file_upload(client_fixture: tuple[Honcho | AsyncHoncho, str]
user = await honcho_client.peer(id="user-json")
messages = await session.upload_file(
file=json_file,
peer_id=user.id,
peer=user.id,
)
# Should create at least one message
@ -236,7 +236,7 @@ async def test_file_upload_with_tuple_input(
user = honcho_client.peer(id="user-tuple")
messages = session.upload_file(
file=(filename, content.encode("utf-8"), content_type),
peer_id=user.id,
peer=user.id,
)
else:
# Async client
@ -244,7 +244,7 @@ async def test_file_upload_with_tuple_input(
user = await honcho_client.peer(id="user-tuple")
messages = await session.upload_file(
file=(filename, content.encode("utf-8"), content_type),
peer_id=user.id,
peer=user.id,
)
# Should create at least one message
@ -280,7 +280,7 @@ async def test_file_upload_with_metadata(
user = honcho_client.peer(id="user-metadata")
messages = session.upload_file(
file=text_file,
peer_id=user.id,
peer=user.id,
metadata=metadata,
)
else:
@ -288,7 +288,7 @@ async def test_file_upload_with_metadata(
user = await honcho_client.peer(id="user-metadata")
messages = await session.upload_file(
file=text_file,
peer_id=user.id,
peer=user.id,
metadata=metadata,
)
@ -328,7 +328,7 @@ async def test_file_upload_with_configuration(
user = honcho_client.peer(id="user-config")
messages = session.upload_file(
file=text_file,
peer_id=user.id,
peer=user.id,
configuration=configuration,
)
else:
@ -336,7 +336,7 @@ async def test_file_upload_with_configuration(
user = await honcho_client.peer(id="user-config")
messages = await session.upload_file(
file=text_file,
peer_id=user.id,
peer=user.id,
configuration=configuration,
)
@ -372,7 +372,7 @@ async def test_file_upload_with_created_at(
user = honcho_client.peer(id="user-timestamp")
messages = session.upload_file(
file=text_file,
peer_id=user.id,
peer=user.id,
created_at=test_timestamp.isoformat(),
)
else:
@ -380,7 +380,7 @@ async def test_file_upload_with_created_at(
user = await honcho_client.peer(id="user-timestamp")
messages = await session.upload_file(
file=text_file,
peer_id=user.id,
peer=user.id,
created_at=created_at_str,
)
@ -426,7 +426,7 @@ async def test_file_upload_with_all_parameters(
user = honcho_client.peer(id="user-all")
messages = session.upload_file(
file=text_file,
peer_id=user.id,
peer=user.id,
metadata=metadata,
configuration=configuration,
created_at=created_at_str,
@ -436,7 +436,7 @@ async def test_file_upload_with_all_parameters(
user = await honcho_client.peer(id="user-all")
messages = await session.upload_file(
file=text_file,
peer_id=user.id,
peer=user.id,
metadata=metadata,
configuration=configuration,
created_at=created_at_str,
@ -476,13 +476,13 @@ async def test_file_upload_with_datetime_object(
session = honcho_client.session(id="test-session-datetime")
user = honcho_client.peer(id="user-datetime")
messages = session.upload_file(
file=text_file, peer_id=user.id, created_at=test_timestamp
file=text_file, peer=user.id, created_at=test_timestamp
)
else:
session = await honcho_client.session(id="test-session-datetime")
user = await honcho_client.peer(id="user-datetime")
messages = await session.upload_file(
file=text_file, peer_id=user.id, created_at=test_timestamp
file=text_file, peer=user.id, created_at=test_timestamp
)
assert len(messages) >= 1

View File

@ -0,0 +1,621 @@
"""Tests for observation SDK methods."""
import pytest
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,
)
@pytest.mark.asyncio
async def test_observation_create_single(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests creating a single observation via the SDK.
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
observer = await honcho_client.peer(id="test-obs-create-single-observer")
target = await honcho_client.peer(id="test-obs-create-single-target")
session = await honcho_client.session(id="test-obs-create-single-session")
# Ensure session and both peers exist by adding messages from both
await session.add_messages(
[
observer.message("Hello from observer"),
target.message("Hello from target"),
]
)
# Get observation scope for observer -> target
obs_scope = observer.observations_of(target)
assert isinstance(obs_scope, AsyncObservationScope)
# Create a single observation
created = await obs_scope.create(
{"content": "User prefers dark mode", "session_id": session.id}
)
assert len(created) == 1
assert isinstance(created[0], Observation)
assert created[0].content == "User prefers dark mode"
assert created[0].observer_id == observer.id
assert created[0].observed_id == target.id
assert created[0].session_id == session.id
assert created[0].id # Has an ID
else:
assert isinstance(honcho_client, Honcho)
observer = honcho_client.peer(id="test-obs-create-single-observer")
target = honcho_client.peer(id="test-obs-create-single-target")
session = honcho_client.session(id="test-obs-create-single-session")
# Ensure session and both peers exist by adding messages from both
session.add_messages(
[
observer.message("Hello from observer"),
target.message("Hello from target"),
]
)
# Get observation scope for observer -> target
obs_scope = observer.observations_of(target)
assert isinstance(obs_scope, ObservationScope)
# Create a single observation
created = obs_scope.create(
{"content": "User prefers dark mode", "session_id": session.id}
)
assert len(created) == 1
assert isinstance(created[0], Observation)
assert created[0].content == "User prefers dark mode"
assert created[0].observer_id == observer.id
assert created[0].observed_id == target.id
assert created[0].session_id == session.id
assert created[0].id # Has an ID
@pytest.mark.asyncio
async def test_observation_create_batch(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests creating multiple observations in a batch via the SDK.
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
observer = await honcho_client.peer(id="test-obs-create-batch-observer")
target = await honcho_client.peer(id="test-obs-create-batch-target")
session = await honcho_client.session(id="test-obs-create-batch-session")
# Ensure session and both peers exist
await session.add_messages(
[
observer.message("Hello from observer"),
target.message("Hello from target"),
]
)
# Get observation scope
obs_scope = observer.observations_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},
]
)
assert len(created) == 3
contents = {obs.content for obs in created}
assert "User prefers dark mode" in contents
assert "User works late at night" in contents
assert "User enjoys programming" in contents
# All observations have correct observer/observed
for obs in created:
assert obs.observer_id == observer.id
assert obs.observed_id == target.id
assert obs.session_id == session.id
else:
assert isinstance(honcho_client, Honcho)
observer = honcho_client.peer(id="test-obs-create-batch-observer")
target = honcho_client.peer(id="test-obs-create-batch-target")
session = honcho_client.session(id="test-obs-create-batch-session")
# Ensure session and both peers exist
session.add_messages(
[
observer.message("Hello from observer"),
target.message("Hello from target"),
]
)
# Get observation scope
obs_scope = observer.observations_of(target)
# Create multiple observations
created = 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},
]
)
assert len(created) == 3
contents = {obs.content for obs in created}
assert "User prefers dark mode" in contents
assert "User works late at night" in contents
assert "User enjoys programming" in contents
# All observations have correct observer/observed
for obs in created:
assert obs.observer_id == observer.id
assert obs.observed_id == target.id
assert obs.session_id == session.id
@pytest.mark.asyncio
async def test_observation_create_then_list(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests that created observations can be listed.
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
observer = await honcho_client.peer(id="test-obs-create-list-observer")
target = await honcho_client.peer(id="test-obs-create-list-target")
session = await honcho_client.session(id="test-obs-create-list-session")
# Ensure session and both peers exist
await session.add_messages(
[
observer.message("Hello from observer"),
target.message("Hello from target"),
]
)
# Get observation scope
obs_scope = observer.observations_of(target)
# Create observations
created = await obs_scope.create(
[
{
"content": "Unique observation for list test",
"session_id": session.id,
},
]
)
# List observations
listed = await obs_scope.list()
# The created observation should be in the list
listed_ids = {obs.id for obs in listed}
assert created[0].id in listed_ids
else:
assert isinstance(honcho_client, Honcho)
observer = honcho_client.peer(id="test-obs-create-list-observer")
target = honcho_client.peer(id="test-obs-create-list-target")
session = honcho_client.session(id="test-obs-create-list-session")
# Ensure session and both peers exist
session.add_messages(
[
observer.message("Hello from observer"),
target.message("Hello from target"),
]
)
# Get observation scope
obs_scope = observer.observations_of(target)
# Create observations
created = obs_scope.create(
[
{
"content": "Unique observation for list test",
"session_id": session.id,
},
]
)
# List observations
listed = obs_scope.list()
# The created observation should be in the list
listed_ids = {obs.id for obs in listed}
assert created[0].id in listed_ids
@pytest.mark.asyncio
async def test_observation_create_then_query(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests that created observations can be queried semantically.
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
observer = await honcho_client.peer(id="test-obs-create-query-observer")
target = await honcho_client.peer(id="test-obs-create-query-target")
session = await honcho_client.session(id="test-obs-create-query-session")
# Ensure session and both peers exist
await session.add_messages(
[
observer.message("Hello from observer"),
target.message("Hello from target"),
]
)
# Get observation scope
obs_scope = observer.observations_of(target)
# Create observation with specific content
await obs_scope.create(
[
{
"content": "User loves Italian cuisine especially pasta and pizza",
"session_id": session.id,
},
]
)
# Query for food-related observations
results = await obs_scope.query("food preferences")
assert len(results) >= 1
# At least one result should mention Italian food
contents = " ".join(obs.content for obs in results)
assert "Italian" in contents or "pasta" in contents or "pizza" in contents
else:
assert isinstance(honcho_client, Honcho)
observer = honcho_client.peer(id="test-obs-create-query-observer")
target = honcho_client.peer(id="test-obs-create-query-target")
session = honcho_client.session(id="test-obs-create-query-session")
# Ensure session and both peers exist
session.add_messages(
[
observer.message("Hello from observer"),
target.message("Hello from target"),
]
)
# Get observation scope
obs_scope = observer.observations_of(target)
# Create observation with specific content
obs_scope.create(
[
{
"content": "User loves Italian cuisine especially pasta and pizza",
"session_id": session.id,
},
]
)
# Query for food-related observations
results = obs_scope.query("food preferences")
assert len(results) >= 1
# At least one result should mention Italian food
contents = " ".join(obs.content for obs in results)
assert "Italian" in contents or "pasta" in contents or "pizza" in contents
@pytest.mark.asyncio
async def test_observation_create_then_delete(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests that created observations can be deleted.
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
observer = await honcho_client.peer(id="test-obs-create-delete-observer")
target = await honcho_client.peer(id="test-obs-create-delete-target")
session = await honcho_client.session(id="test-obs-create-delete-session")
# Ensure session and both peers exist
await session.add_messages(
[
observer.message("Hello from observer"),
target.message("Hello from target"),
]
)
# Get observation scope
obs_scope = observer.observations_of(target)
# Create observations
created = await obs_scope.create(
[
{"content": "Observation to be deleted", "session_id": session.id},
]
)
observation_id = created[0].id
# Delete the observation
await obs_scope.delete(observation_id)
# List observations - should not contain deleted one
listed = await obs_scope.list()
listed_ids = {obs.id for obs in listed}
assert observation_id not in listed_ids
else:
assert isinstance(honcho_client, Honcho)
observer = honcho_client.peer(id="test-obs-create-delete-observer")
target = honcho_client.peer(id="test-obs-create-delete-target")
session = honcho_client.session(id="test-obs-create-delete-session")
# Ensure session and both peers exist
session.add_messages(
[
observer.message("Hello from observer"),
target.message("Hello from target"),
]
)
# Get observation scope
obs_scope = observer.observations_of(target)
# Create observations
created = obs_scope.create(
[
{"content": "Observation to be deleted", "session_id": session.id},
]
)
observation_id = created[0].id
# Delete the observation
obs_scope.delete(observation_id)
# List observations - should not contain deleted one
listed = obs_scope.list()
listed_ids = {obs.id for obs in listed}
assert observation_id not in listed_ids
@pytest.mark.asyncio
async def test_self_observation_create(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests creating self-observations (observer == observed).
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
peer = await honcho_client.peer(id="test-self-obs-create-peer")
session = await honcho_client.session(id="test-self-obs-create-session")
# Ensure session exists
await session.add_messages([peer.message("Hello")])
# Get self-observation scope
obs_scope = peer.observations
assert isinstance(obs_scope, AsyncObservationScope)
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}
)
assert len(created) == 1
assert created[0].observer_id == peer.id
assert created[0].observed_id == peer.id
else:
assert isinstance(honcho_client, Honcho)
peer = honcho_client.peer(id="test-self-obs-create-peer")
session = honcho_client.session(id="test-self-obs-create-session")
# Ensure session exists
session.add_messages([peer.message("Hello")])
# Get self-observation scope
obs_scope = peer.observations
assert isinstance(obs_scope, ObservationScope)
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}
)
assert len(created) == 1
assert created[0].observer_id == peer.id
assert created[0].observed_id == peer.id
@pytest.mark.asyncio
async def test_observation_create_with_session_filter(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests creating observations and filtering list by session.
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
observer = await honcho_client.peer(id="test-obs-session-filter-observer")
target = await honcho_client.peer(id="test-obs-session-filter-target")
session1 = await honcho_client.session(id="test-obs-session-filter-s1")
session2 = await honcho_client.session(id="test-obs-session-filter-s2")
# Ensure sessions and both peers exist
await session1.add_messages(
[
observer.message("Hello 1 from observer"),
target.message("Hello 1 from target"),
]
)
await session2.add_messages(
[
observer.message("Hello 2 from observer"),
target.message("Hello 2 from target"),
]
)
# Get observation scope
obs_scope = observer.observations_of(target)
# Create observations in different sessions
await obs_scope.create(
[
{"content": "Session 1 observation", "session_id": session1.id},
]
)
await obs_scope.create(
[
{"content": "Session 2 observation", "session_id": session2.id},
]
)
# List filtered by session1
s1_obs = await obs_scope.list(session=session1)
s1_contents = [obs.content for obs in s1_obs]
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]
assert "Session 2 observation" in s2_contents
assert "Session 1 observation" not in s2_contents
else:
assert isinstance(honcho_client, Honcho)
observer = honcho_client.peer(id="test-obs-session-filter-observer")
target = honcho_client.peer(id="test-obs-session-filter-target")
session1 = honcho_client.session(id="test-obs-session-filter-s1")
session2 = honcho_client.session(id="test-obs-session-filter-s2")
# Ensure sessions and both peers exist
session1.add_messages(
[
observer.message("Hello 1 from observer"),
target.message("Hello 1 from target"),
]
)
session2.add_messages(
[
observer.message("Hello 2 from observer"),
target.message("Hello 2 from target"),
]
)
# Get observation scope
obs_scope = observer.observations_of(target)
# Create observations in different sessions
obs_scope.create(
[
{"content": "Session 1 observation", "session_id": session1.id},
]
)
obs_scope.create(
[
{"content": "Session 2 observation", "session_id": session2.id},
]
)
# List filtered by session1
s1_obs = obs_scope.list(session=session1)
s1_contents = [obs.content for obs in s1_obs]
assert "Session 1 observation" in s1_contents
assert "Session 2 observation" not in s1_contents
# List filtered by session2
s2_obs = obs_scope.list(session=session2)
s2_contents = [obs.content for obs in s2_obs]
assert "Session 2 observation" in s2_contents
assert "Session 1 observation" not in s2_contents
@pytest.mark.asyncio
async def test_observation_scope_via_peer_string(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests creating observations via observations_of(string).
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
observer = await honcho_client.peer(id="test-obs-string-target-observer")
target = await honcho_client.peer(id="test-obs-string-target-target")
session = await honcho_client.session(id="test-obs-string-target-session")
# Ensure session and both peers exist
await session.add_messages(
[
observer.message("Hello from observer"),
target.message("Hello from target"),
]
)
# Get observation scope using string ID
obs_scope = observer.observations_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}
)
assert len(created) == 1
assert created[0].observed_id == target.id
else:
assert isinstance(honcho_client, Honcho)
observer = honcho_client.peer(id="test-obs-string-target-observer")
target = honcho_client.peer(id="test-obs-string-target-target")
session = honcho_client.session(id="test-obs-string-target-session")
# Ensure session and both peers exist
session.add_messages(
[
observer.message("Hello from observer"),
target.message("Hello from target"),
]
)
# Get observation scope using string ID
obs_scope = observer.observations_of(target.id)
assert obs_scope.observed == target.id
# Create observation
created = obs_scope.create(
{"content": "Created via string target", "session_id": session.id}
)
assert len(created) == 1
assert created[0].observed_id == target.id

View File

@ -470,20 +470,18 @@ async def test_session_get_deriver_status(
assert hasattr(status, "pending_work_units")
assert status.sessions is None
# Test with observer_id only
# 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_id=peer.id)
status = await session.get_deriver_status(observer=peer.id)
assert isinstance(status, DeriverStatus)
# Test with sender_id only
status = await session.get_deriver_status(sender_id=peer.id)
# Test with sender only
status = await session.get_deriver_status(sender=peer.id)
assert isinstance(status, DeriverStatus)
# Test with both observer_id and sender_id
status = await session.get_deriver_status(
observer_id=peer.id, sender_id=peer.id
)
# Test with both observer and sender
status = await session.get_deriver_status(observer=peer.id, sender=peer.id)
assert isinstance(status, DeriverStatus)
else:
assert isinstance(honcho_client, Honcho)
@ -499,18 +497,18 @@ async def test_session_get_deriver_status(
assert hasattr(status, "pending_work_units")
assert status.sessions is None
# Test with observer_id only
# 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_id=peer.id)
status = session.get_deriver_status(observer=peer.id)
assert isinstance(status, DeriverStatus)
# Test with sender_id only
status = session.get_deriver_status(sender_id=peer.id)
# Test with sender only
status = session.get_deriver_status(sender=peer.id)
assert isinstance(status, DeriverStatus)
# Test with both observer_id and sender_id
status = session.get_deriver_status(observer_id=peer.id, sender_id=peer.id)
# Test with both observer and sender
status = session.get_deriver_status(observer=peer.id, sender=peer.id)
assert isinstance(status, DeriverStatus)
@ -554,9 +552,7 @@ async def test_session_poll_deriver_status(
"get_deriver_status",
new=AsyncMock(return_value=completed_status),
):
status = await session.poll_deriver_status(
observer_id=peer.id, sender_id=peer.id
)
status = await session.poll_deriver_status(observer=peer.id, sender=peer.id)
assert isinstance(status, DeriverStatus)
else:
assert isinstance(honcho_client, Honcho)
@ -576,7 +572,7 @@ async def test_session_poll_deriver_status(
with patch.object(
session.__class__, "get_deriver_status", return_value=completed_status
):
status = session.poll_deriver_status(observer_id=peer.id, sender_id=peer.id)
status = session.poll_deriver_status(observer=peer.id, sender=peer.id)
assert isinstance(status, DeriverStatus)

View File

@ -18,8 +18,8 @@ from pydantic import ValidationError
sys.path.insert(0, str(Path(__file__).parents[2]))
from honcho import AsyncHoncho
from honcho.async_client.peer import AsyncPeer
from honcho.async_client.session import SessionPeerConfig as SDKSessionPeerConfig
from honcho.base import PeerBase
from honcho_core.types.workspaces.sessions.message_create_param import (
Configuration,
MessageCreateParam,
@ -110,7 +110,7 @@ class UnifiedTestExecutor:
)
if step.peer_configs:
peer_list: list[tuple[str | AsyncPeer, SDKSessionPeerConfig]] = []
peer_list: list[tuple[str | PeerBase, SDKSessionPeerConfig]] = []
for peer_id, config in step.peer_configs.items():
sdk_config = SDKSessionPeerConfig(
**config.model_dump(exclude_none=True)
@ -203,7 +203,7 @@ class UnifiedTestExecutor:
peer = await self.client.peer(id=step.observer_peer_id)
response = await peer.chat(
step.input, session_id=step.session_id, target=step.observed_peer_id
step.input, session=step.session_id, target=step.observed_peer_id
)
return response

View File

@ -826,7 +826,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "honcho-core", specifier = ">=1.6.0" },
{ name = "honcho-core", specifier = ">=1.6.1" },
{ 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" },
@ -837,7 +837,7 @@ dev = [{ name = "ruff", specifier = ">=0.11.13" }]
[[package]]
name = "honcho-core"
version = "1.6.0"
version = "1.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -847,9 +847,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/9a/19d48268e9cb3b7968141ac6f7ec9d6e3163d3402838ad3de58e0e77a769/honcho_core-1.6.0.tar.gz", hash = "sha256:1b394c7f9d611892685e815c918b5f0e8313126763c5936609c61e237d7c039b", size = 141235, upload-time = "2025-12-03T18:31:35.646Z" }
sdist = { url = "https://files.pythonhosted.org/packages/75/ca/5d0229382771d489b838805eb45829817d22b5c7c05d4838cd0a04f59081/honcho_core-1.6.1.tar.gz", hash = "sha256:e2baba3eaf2dfa59c2ecee164f1fb6cca121177167c194d4b861898cbfb5df2e", size = 142082, upload-time = "2025-12-04T16:37:32.725Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/25/7d9d48b6d8a1c9a1f578c76dd4354ef26c4f0704bfcc1d85ed0b2eae32b1/honcho_core-1.6.0-py3-none-any.whl", hash = "sha256:887c04dbff479a529fa4f4b5f774d5498e4dc2860c6438bea63b9964cb5bf6d8", size = 138075, upload-time = "2025-12-03T18:31:34.291Z" },
{ url = "https://files.pythonhosted.org/packages/35/a6/8108dcedfcfa9c2eb1e9fdbcea4bd183e6f89b04b9acb8c9d1c71cf3981b/honcho_core-1.6.1-py3-none-any.whl", hash = "sha256:68ac553ea32c0f91ab47fce1be6637ccc0991d0a5a360155ea91a5ae9b7859b3", size = 139798, upload-time = "2025-12-04T16:37:31.692Z" },
]
[[package]]