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]]