diff --git a/docs/docs.json b/docs/docs.json
index bad1a145..09792bfa 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -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"
diff --git a/docs/v2/api-reference/endpoint/observations/create-observations.mdx b/docs/v2/api-reference/endpoint/observations/create-observations.mdx
new file mode 100644
index 00000000..1aafec44
--- /dev/null
+++ b/docs/v2/api-reference/endpoint/observations/create-observations.mdx
@@ -0,0 +1,3 @@
+---
+openapi: post /v2/workspaces/{workspace_id}/observations
+---
diff --git a/docs/v2/documentation/reference/sdk.mdx b/docs/v2/documentation/reference/sdk.mdx
index f16005b6..87c574e4 100644
--- a/docs/v2/documentation/reference/sdk.mdx
+++ b/docs/v2/documentation/reference/sdk.mdx
@@ -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");
```
+#### Creating Observations Manually
+
+You can also create observations directly, which is useful for importing data or adding explicit facts:
+
+
+```python Python
+# Create observations for what alice knows about bob
+bob_obs = alice.observations_of("bob")
+
+# Create a single observation
+created = bob_obs.create([
+ {"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}`);
+}
+```
+
+
+
+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.
+
+
### 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
diff --git a/docs/v2/openapi.json b/docs/v2/openapi.json
index c347ff53..bca1b8e9 100644
--- a/docs/v2/openapi.json
+++ b/docs/v2/openapi.json
@@ -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": {
diff --git a/sdks/python/examples/chat.py b/sdks/python/examples/chat.py
index 107c8ec8..0917de6c 100644
--- a/sdks/python/examples/chat.py
+++ b/sdks/python/examples/chat.py
@@ -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
diff --git a/sdks/python/examples/file_upload.py b/sdks/python/examples/file_upload.py
index a0829488..a7d00db2 100644
--- a/sdks/python/examples/file_upload.py
+++ b/sdks/python/examples/file_upload.py
@@ -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!
diff --git a/sdks/python/examples/get_summaries.py b/sdks/python/examples/get_summaries.py
index 892016d4..f70ee10e 100644
--- a/sdks/python/examples/get_summaries.py
+++ b/sdks/python/examples/get_summaries.py
@@ -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()
diff --git a/sdks/python/examples/multi_user_representations.py b/sdks/python/examples/multi_user_representations.py
index 25db231d..ca44007b 100644
--- a/sdks/python/examples/multi_user_representations.py
+++ b/sdks/python/examples/multi_user_representations.py
@@ -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)
diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml
index cc70c627..b2a4ff44 100644
--- a/sdks/python/pyproject.toml
+++ b/sdks/python/pyproject.toml
@@ -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\"",
diff --git a/sdks/python/src/honcho/__init__.py b/sdks/python/src/honcho/__init__.py
index ae254cc7..8d83738f 100644
--- a/sdks/python/src/honcho/__init__.py
+++ b/sdks/python/src/honcho/__init__.py
@@ -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",
diff --git a/sdks/python/src/honcho/async_client/client.py b/sdks/python/src/honcho/async_client/client.py
index 78201b9c..e85cf9fe 100644
--- a/sdks/python/src/honcho/async_client/client.py
+++ b/sdks/python/src/honcho/async_client/client.py
@@ -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,
diff --git a/sdks/python/src/honcho/async_client/peer.py b/sdks/python/src/honcho/async_client/peer.py
index d52e121e..7b8ff60a 100644
--- a/sdks/python/src/honcho/async_client/peer.py
+++ b/sdks/python/src/honcho/async_client/peer.py
@@ -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
)
diff --git a/sdks/python/src/honcho/async_client/session.py b/sdks/python/src/honcho/async_client/session.py
index 01215a14..de709e8b 100644
--- a/sdks/python/src/honcho/async_client/session.py
+++ b/sdks/python/src/honcho/async_client/session.py
@@ -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
diff --git a/sdks/python/src/honcho/base.py b/sdks/python/src/honcho/base.py
new file mode 100644
index 00000000..a27a64ad
--- /dev/null
+++ b/sdks/python/src/honcho/base.py
@@ -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"
+ )
diff --git a/sdks/python/src/honcho/client.py b/sdks/python/src/honcho/client.py
index 350d98a2..9de68d19 100644
--- a/sdks/python/src/honcho/client.py
+++ b/sdks/python/src/honcho/client.py
@@ -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,
diff --git a/sdks/python/src/honcho/observations.py b/sdks/python/src/honcho/observations.py
index d8bb1894..69f1fd8f 100644
--- a/sdks/python/src/honcho/observations.py
+++ b/sdks/python/src/honcho/observations.py
@@ -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,
diff --git a/sdks/python/src/honcho/peer.py b/sdks/python/src/honcho/peer.py
index fecafa1e..ac7f49ee 100644
--- a/sdks/python/src/honcho/peer.py
+++ b/sdks/python/src/honcho/peer.py
@@ -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:
diff --git a/sdks/python/src/honcho/session.py b/sdks/python/src/honcho/session.py
index e8278c1c..5f774bea 100644
--- a/sdks/python/src/honcho/session.py
+++ b/sdks/python/src/honcho/session.py
@@ -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
diff --git a/sdks/python/src/honcho/types.py b/sdks/python/src/honcho/types.py
index f5910f5c..dc723161 100644
--- a/sdks/python/src/honcho/types.py
+++ b/sdks/python/src/honcho/types.py
@@ -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."""
diff --git a/sdks/typescript/__tests__/client.test.ts b/sdks/typescript/__tests__/client.test.ts
index 9790aa33..f826b08d 100644
--- a/sdks/typescript/__tests__/client.test.ts
+++ b/sdks/typescript/__tests__/client.test.ts
@@ -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'
);
});
diff --git a/sdks/typescript/__tests__/peer.test.ts b/sdks/typescript/__tests__/peer.test.ts
index c513c5c8..8e80125c 100644
--- a/sdks/typescript/__tests__/peer.test.ts
+++ b/sdks/typescript/__tests__/peer.test.ts
@@ -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',
diff --git a/sdks/typescript/__tests__/session.test.ts b/sdks/typescript/__tests__/session.test.ts
index 2be051b1..49647ca6 100644
--- a/sdks/typescript/__tests__/session.test.ts
+++ b/sdks/typescript/__tests__/session.test.ts
@@ -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({
diff --git a/sdks/typescript/bun.lock b/sdks/typescript/bun.lock
index 878148e3..736dfd07 100644
--- a/sdks/typescript/bun.lock
+++ b/sdks/typescript/bun.lock
@@ -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=="],
diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json
index 06a724ec..d3bbdb08 100644
--- a/sdks/typescript/package.json
+++ b/sdks/typescript/package.json
@@ -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"
},
diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts
index 5b0a9557..b185feee 100644
--- a/sdks/typescript/src/client.ts
+++ b/sdks/typescript/src/client.ts
@@ -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
}> {
- 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
}> {
- 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,
- sessionId?: string
+ session?: string | Session
): Promise {
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
diff --git a/sdks/typescript/src/observations.ts b/sdks/typescript/src/observations.ts
index a1e09d58..50e830a4 100644
--- a/sdks/typescript/src/observations.ts
+++ b/sdks/typescript/src/observations.ts
@@ -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 {
+ const resolvedSessionId = session
+ ? typeof session === 'string'
+ ? session
+ : session.id
+ : undefined
const filters: Record = {
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 {
+ // 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)
+ )
+ }
+
/**
* Get the computed representation for this scope.
*
diff --git a/sdks/typescript/src/peer.ts b/sdks/typescript/src/peer.ts
index 6f4745d9..a8701a3e 100644
--- a/sdks/typescript/src/peer.ts
+++ b/sdks/typescript/src/peer.ts
@@ -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 {
+ 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') {
diff --git a/sdks/typescript/src/session.ts b/sdks/typescript/src/session.ts
index b8d2dc30..6e38fe0c 100644
--- a/sdks/typescript/src/session.ts
+++ b/sdks/typescript/src/session.ts
@@ -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
+ 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
}> {
- 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
+ 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
}> {
- 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
configuration?: Record
@@ -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) }
: {}),
diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts
index 3672a35c..6ba97e75 100644
--- a/sdks/typescript/src/types.ts
+++ b/sdks/typescript/src/types.ts
@@ -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.
*/
diff --git a/sdks/typescript/src/validation.ts b/sdks/typescript/src/validation.ts
index f78abe79..4bff7c9b 100644
--- a/sdks/typescript/src/validation.ts
+++ b/sdks/typescript/src/validation.ts
@@ -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(),
diff --git a/src/crud/__init__.py b/src/crud/__init__.py
index 6595120b..489c0b14 100644
--- a/src/crud/__init__.py
+++ b/src/crud/__init__.py
@@ -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",
diff --git a/src/crud/document.py b/src/crud/document.py
index 874e43df..5ad3fdcc 100644
--- a/src/crud/document.py
+++ b/src/crud/document.py
@@ -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,
diff --git a/src/crud/peer.py b/src/crud/peer.py
index 14f7a652..9006f272 100644
--- a/src/crud/peer.py
+++ b/src/crud/peer.py
@@ -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
diff --git a/src/embedding_client.py b/src/embedding_client.py
index 08c61758..796e7bf0 100644
--- a/src/embedding_client.py
+++ b/src/embedding_client.py
@@ -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
)
diff --git a/src/routers/observations.py b/src/routers/observations.py
index 2b9500a5..4739ec49 100644
--- a/src/routers/observations.py
+++ b/src/routers/observations.py
@@ -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],
diff --git a/src/schemas.py b/src/schemas.py
index a83f63f5..a8bac364 100644
--- a/src/schemas.py
+++ b/src/schemas.py
@@ -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(
diff --git a/tests/bench/beam.py b/tests/bench/beam.py
index acc726fe..731c4265 100644
--- a/tests/bench/beam.py
+++ b/tests/bench/beam.py
@@ -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
diff --git a/tests/bench/longmem.py b/tests/bench/longmem.py
index c7182be2..3c388bd4 100644
--- a/tests/bench/longmem.py
+++ b/tests/bench/longmem.py
@@ -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
diff --git a/tests/bench/run_tests.py b/tests/bench/run_tests.py
index 6544a909..fa08ea3f 100644
--- a/tests/bench/run_tests.py
+++ b/tests/bench/run_tests.py
@@ -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,
diff --git a/tests/routes/test_observations.py b/tests/routes/test_observations.py
index acd3fdc9..9f1bba94 100644
--- a/tests/routes/test_observations.py
+++ b/tests/routes/test_observations.py
@@ -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
diff --git a/tests/sdk/test_client.py b/tests/sdk/test_client.py
index 42530e63..6c5d8670 100644
--- a/tests/sdk/test_client.py
+++ b/tests/sdk/test_client.py
@@ -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"})
diff --git a/tests/sdk/test_file_uploads.py b/tests/sdk/test_file_uploads.py
index 4d0b64a1..4c427c49 100644
--- a/tests/sdk/test_file_uploads.py
+++ b/tests/sdk/test_file_uploads.py
@@ -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
diff --git a/tests/sdk/test_observations.py b/tests/sdk/test_observations.py
new file mode 100644
index 00000000..3e6faa33
--- /dev/null
+++ b/tests/sdk/test_observations.py
@@ -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
diff --git a/tests/sdk/test_session.py b/tests/sdk/test_session.py
index 0be49af6..ebf04615 100644
--- a/tests/sdk/test_session.py
+++ b/tests/sdk/test_session.py
@@ -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)
diff --git a/tests/unified/runner.py b/tests/unified/runner.py
index ab79eaaa..e4badf12 100644
--- a/tests/unified/runner.py
+++ b/tests/unified/runner.py
@@ -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
diff --git a/uv.lock b/uv.lock
index 662e37bc..986f095e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -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]]