From 55a0519bd2e9db615bf4ce3d492558ad96b9fc47 Mon Sep 17 00:00:00 2001 From: steven-ji Date: Thu, 3 Sep 2026 05:31:46 +0800 Subject: [PATCH] feat(sdk): add per-call peer chat timeout (#1098) Forward optional timeout overrides through sync and async Peer.chat while retaining client-wide defaults. Refs #734 --- docs/v3/documentation/reference/sdk.mdx | 10 ++++++++ sdks/python/CHANGELOG.md | 6 +++++ sdks/python/src/honcho/aio.py | 10 +++++++- sdks/python/src/honcho/peer.py | 9 +++++++ tests/sdk/test_peer.py | 34 +++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 1 deletion(-) diff --git a/docs/v3/documentation/reference/sdk.mdx b/docs/v3/documentation/reference/sdk.mdx index 432a4aab..66e08e89 100644 --- a/docs/v3/documentation/reference/sdk.mdx +++ b/docs/v3/documentation/reference/sdk.mdx @@ -276,6 +276,9 @@ response = alice.chat("What do I know about Bob?", target="bob") response = alice.chat("What happened in session-1?", session="session-1") response = alice.chat("Summarize what matters most to me.", reasoning_level="high") +# Override the timeout for one non-streaming dialectic request +response = alice.chat("Give me a quick summary.", timeout=5.0) + # Add content to a session with a peer session = honcho.session("session-1") session.add_messages([ @@ -378,6 +381,13 @@ const bobConclusions = await alice.conclusionsOf("bob").list(); // Conclusions ``` +For Python, `peer.chat(timeout=...)` and `await peer.aio.chat(timeout=...)` +accept a timeout in seconds for each HTTP attempt made by one non-streaming +request. Omit it or pass `None` to use the client-wide timeout configured on +`Honcho`. Retries still follow the client's `max_retries` setting and can extend +total elapsed time; use `max_retries=0` when a host shutdown budget permits only +one attempt. + ### Peer Context The `context()` method on peers retrieves both the working representation and peer card in a single API call: diff --git a/sdks/python/CHANGELOG.md b/sdks/python/CHANGELOG.md index 7fe5d8f7..2bea7442 100644 --- a/sdks/python/CHANGELOG.md +++ b/sdks/python/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] + +### Added + +- Optional per-call `timeout` on synchronous and asynchronous `Peer.chat()`. It overrides the timeout for each HTTP attempt; when omitted or set to `None`, the client-wide timeout configured on `Honcho` remains in effect. + ## [2.4.0] - 2026-08-25 ### Added diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py index f5148ee6..3629551f 100644 --- a/sdks/python/src/honcho/aio.py +++ b/sdks/python/src/honcho/aio.py @@ -777,6 +777,7 @@ class PeerAio(AsyncMetadataConfigMixin): reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[TResponseFormat], + timeout: float | None = None, ) -> TResponseFormat | None: ... @overload @@ -791,6 +792,7 @@ class PeerAio(AsyncMetadataConfigMixin): reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: dict[str, Any] | None = None, + timeout: float | None = None, ) -> str | None: ... @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) @@ -805,12 +807,17 @@ class PeerAio(AsyncMetadataConfigMixin): reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[BaseModel] | dict[str, Any] | None = None, + timeout: float | None = Field( + None, gt=0, description="Timeout in seconds for this chat request" + ), ) -> BaseModel | str | None: """Query the peer's representation asynchronously. See Peer.chat for parameter details. When response_format is a Pydantic model class, the answer is parsed into an instance of it; when it is a - JSON Schema dict, the answer is a JSON string. + JSON Schema dict, the answer is a JSON string. When timeout is omitted, + the Honcho client's configured timeout is used; retries can extend total + elapsed time. """ await self._peer._honcho._ensure_workspace_async() target_id = resolve_id(target) @@ -835,6 +842,7 @@ class PeerAio(AsyncMetadataConfigMixin): data = await self._peer._honcho._async_http_client.post( routes.peer_chat(self._peer.workspace_id, self._peer.id), body=body, + timeout=timeout, ) content = data.get("content") if not content: diff --git a/sdks/python/src/honcho/peer.py b/sdks/python/src/honcho/peer.py index 38edf269..bf6cf2d7 100644 --- a/sdks/python/src/honcho/peer.py +++ b/sdks/python/src/honcho/peer.py @@ -246,6 +246,7 @@ class Peer(PeerBase, MetadataConfigMixin): reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[TResponseFormat], + timeout: float | None = None, ) -> TResponseFormat | None: ... @overload @@ -260,6 +261,7 @@ class Peer(PeerBase, MetadataConfigMixin): reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: dict[str, Any] | None = None, + timeout: float | None = None, ) -> str | None: ... @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) @@ -274,6 +276,9 @@ class Peer(PeerBase, MetadataConfigMixin): reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[BaseModel] | dict[str, Any] | None = None, + timeout: float | None = Field( + None, gt=0, description="Timeout in seconds for this chat request" + ), ) -> BaseModel | str | None: """ Query the peer's representation with a natural language question. @@ -310,6 +315,9 @@ class Peer(PeerBase, MetadataConfigMixin): model class to get a parsed instance back, or a raw JSON Schema dict (root type "object") to get the answer as a JSON string. + timeout: Optional timeout in seconds for each HTTP attempt made by + this request. When omitted, the Honcho client's configured + timeout is used. Retries can extend total elapsed time. Returns: Response string containing the answer (a JSON string when a schema @@ -342,6 +350,7 @@ class Peer(PeerBase, MetadataConfigMixin): data = self._honcho._http.post( routes.peer_chat(self.workspace_id, self.id), body=body, + timeout=timeout, ) content = data.get("content") if not content: diff --git a/tests/sdk/test_peer.py b/tests/sdk/test_peer.py index 0c019d9b..d563d034 100644 --- a/tests/sdk/test_peer.py +++ b/tests/sdk/test_peer.py @@ -277,6 +277,40 @@ async def test_peer_chat_non_streaming( assert response is None or isinstance(response, str) +@pytest.mark.asyncio +@pytest.mark.parametrize("timeout", [None, 2.5]) +async def test_peer_chat_forwards_per_call_timeout( + client_fixture: tuple[Honcho, str], + timeout: float | None, +) -> None: + honcho_client, client_type = client_fixture + timeout_label = "default" if timeout is None else "override" + + if client_type == "async": + peer = await honcho_client.aio.peer(id=f"test-timeout-{timeout_label}-async") + + async def mock_post(*args: object, **kwargs: object) -> dict[str, str]: # pyright: ignore[reportUnusedParameter] + return {"content": "ok"} + + with patch.object( + peer._honcho._async_http_client, # pyright: ignore[reportPrivateUsage] + "post", + side_effect=mock_post, + ) as mock: + result = await peer.aio.chat("What do I like?", timeout=timeout) + else: + peer = honcho_client.peer(id=f"test-timeout-{timeout_label}-sync") + with patch.object( + peer._honcho._http, # pyright: ignore[reportPrivateUsage] + "post", + return_value={"content": "ok"}, + ) as mock: + result = peer.chat("What do I like?", timeout=timeout) + + assert result == "ok" + assert mock.call_args.kwargs["timeout"] == timeout + + @pytest.mark.asyncio async def test_peer_representation_no_params( client_fixture: tuple[Honcho, str],