From 89a2d1a41c2b1da949aeb3b4bbc7e87c878f3006 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 30 Aug 2026 06:57:56 +0000 Subject: [PATCH 1/3] feat(navigator): public completion_request() for harness-owned wrap-up turns Returns the actor's exact next Chat Completions request (system prompt, windowed messages, sampling fields, request chaining), optionally with extra chat messages appended, without advancing the loop or mutating the trajectory. Lets a harness implement conventions like a step-cap stop-and-summarize probe on public surface instead of reaching into _prepare_completion_messages/_resolve_completions. --- tests/test_navigator_n2.py | 28 ++++++++++++++++++++++++++++ yutori/navigator/n2.py | 20 ++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/tests/test_navigator_n2.py b/tests/test_navigator_n2.py index d6e486d..1402e8c 100644 --- a/tests/test_navigator_n2.py +++ b/tests/test_navigator_n2.py @@ -4,6 +4,7 @@ import asyncio import base64 +import copy import io import json import time @@ -1260,3 +1261,30 @@ def test_converter_preserves_plain_string_assistant_turns(): ] ) assert {"role": "assistant", "content": "earlier answer"} in messages + + +@pytest.mark.asyncio +async def test_completion_request_is_public_wrapup_surface() -> None: + """completion_request() returns the actor's exact next request — windowed messages, + sampling fields, chaining — with optional harness-owned extra messages appended, + without advancing the loop or mutating the trajectory.""" + agent = N2ComputerAgent( + computer=FakeComputer(), + tool_set=TOOL_SET_COMPUTER_USE_HYBRID_BATCH, + completions=FakeCompletions([_turn({"content": "Done.", "tool_calls": []})]), + screenshot_delay=0, + ) + async for _step in agent.run("task"): + pass + before = copy.deepcopy(agent.trajectory) + nudge = {"role": "user", "content": [{"type": "text", "text": "Stop here. Summarize."}]} + request = agent.completion_request([nudge]) + assert request["model"] == agent.model + assert request["tool_set"] == agent.tool_set + assert request["max_completion_tokens"] == agent.max_completion_tokens + assert request["messages"][-1] == nudge + # the trajectory itself renders just before the nudge, exactly as the loop would send it + assert request["messages"][:-1] == agent._prepare_completion_messages(agent.trajectory) + assert agent.trajectory == before + if agent.last_request_id is not None: + assert request["extra_body"]["prev_request_id"] == agent.last_request_id diff --git a/yutori/navigator/n2.py b/yutori/navigator/n2.py index 5393492..56bf842 100644 --- a/yutori/navigator/n2.py +++ b/yutori/navigator/n2.py @@ -1280,6 +1280,26 @@ def _prepare_completion_messages(self, items: list[dict[str, Any]]) -> list[dict ) return completion_messages + def completion_request(self, extra_messages: "list[dict[str, Any]] | None" = None) -> dict[str, Any]: + """The actor's next Chat Completions request for the current trajectory. + + Returns exactly what the loop itself would send — system prompt, image-windowed + messages, sampling fields, and request chaining — optionally with ``extra_messages`` + (chat-format) appended after the trajectory. Useful for harness-owned turns that + must not advance the loop or execute tools, such as a step-cap "stop and + summarize" wrap-up: send it yourself with the same client, + ``await client.chat.completions.create(**agent.completion_request([nudge]))``. + The call and its response stay the caller's own; the trajectory is not changed. + """ + request = self._completion_request_kwargs() + messages = self._prepare_completion_messages(self.trajectory) + if extra_messages: + messages = messages + copy.deepcopy(list(extra_messages)) + request["messages"] = messages + if self.last_request_id is not None: + request["extra_body"] = {**(request.get("extra_body") or {}), "prev_request_id": self.last_request_id} + return request + def _completion_request_kwargs(self) -> dict[str, Any]: """Return resolved actor call fields other than messages and chaining.""" From beb968e78e5be4f89926af359f9eeb7f1e11f6cf Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 30 Aug 2026 07:48:48 +0000 Subject: [PATCH 2/3] refactor(navigator): the loop's own request assembly rides completion_request() _predict_step now builds its api_kwargs via the public helper (items override), keeping one assembly path for messages, sampling fields, the historical completion_kwargs merge order, and request chaining. --- yutori/navigator/n2.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/yutori/navigator/n2.py b/yutori/navigator/n2.py index 56bf842..c4cc45d 100644 --- a/yutori/navigator/n2.py +++ b/yutori/navigator/n2.py @@ -1280,22 +1280,27 @@ def _prepare_completion_messages(self, items: list[dict[str, Any]]) -> list[dict ) return completion_messages - def completion_request(self, extra_messages: "list[dict[str, Any]] | None" = None) -> dict[str, Any]: + def completion_request( + self, extra_messages: "list[dict[str, Any]] | None" = None, *, items: "list[dict[str, Any]] | None" = None + ) -> dict[str, Any]: """The actor's next Chat Completions request for the current trajectory. - Returns exactly what the loop itself would send — system prompt, image-windowed + Returns exactly what the loop itself sends — system prompt, image-windowed messages, sampling fields, and request chaining — optionally with ``extra_messages`` (chat-format) appended after the trajectory. Useful for harness-owned turns that must not advance the loop or execute tools, such as a step-cap "stop and summarize" wrap-up: send it yourself with the same client, ``await client.chat.completions.create(**agent.completion_request([nudge]))``. The call and its response stay the caller's own; the trajectory is not changed. + ``items`` overrides the rendered trajectory (the loop's own steps pass their + in-flight working set). """ request = self._completion_request_kwargs() - messages = self._prepare_completion_messages(self.trajectory) + # Historical merge order: callers should not pass ``messages`` through + # completion_kwargs, but it previously won if they did. + request.setdefault("messages", self._prepare_completion_messages(self.trajectory if items is None else items)) if extra_messages: - messages = messages + copy.deepcopy(list(extra_messages)) - request["messages"] = messages + request["messages"] = list(request["messages"]) + copy.deepcopy(list(extra_messages)) if self.last_request_id is not None: request["extra_body"] = {**(request.get("extra_body") or {}), "prev_request_id": self.last_request_id} return request @@ -1324,7 +1329,8 @@ async def _await_completion(self, awaitable: Awaitable[Any]) -> Any: return await _await_model_response(self.computer, awaitable) async def _predict_step(self, items: list[dict[str, Any]]) -> dict[str, Any]: - completion_messages = self._prepare_completion_messages(items) + api_kwargs = self.completion_request(items=items) + completion_messages = api_kwargs["messages"] latest_url = latest_image_url(completion_messages) if latest_url is None: @@ -1343,11 +1349,6 @@ async def _predict_step(self, items: list[dict[str, Any]]) -> dict[str, Any]: else: native_width, native_height = image_dimensions(latest_url) - api_kwargs = self._completion_request_kwargs() - # Preserve the historical merge order: callers should not pass - # ``messages`` through completion_kwargs, but it previously won if they did. - api_kwargs.setdefault("messages", completion_messages) - for attempt in (0, 1): if self.last_request_id is not None: # Echo the previous response's request_id so the platform links the From 7a5e49be542a1f9ab847959843a9a36d4e58f140 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 31 Aug 2026 19:31:05 +0000 Subject: [PATCH 3/3] feat(client): configurable model-call retries, deeper by default Navigator model calls go through the bundled OpenAI client, which retries connection errors, timeouts and 429/5xx with exponential backoff. Two things were wrong for agent workloads: the depth was the vendor default of 2 (under ~2s of backoff in total) and it was not reachable from the SDK surface at all. A long-horizon agent run issues hundreds of sequential model calls, so one unretried upstream blip ends the whole run. Measured on 2026-08-31: three ~2-minute bursts of gateway upstream_error 5xx cost an eval 20 of 108 tasks, each dying on its first attempt. Adds max_retries to both clients (default 4, from config.DEFAULT_MAX_RETRIES), threaded into the chat namespace's client. Retried requests are idempotent, so the ceiling is caller patience; unattended batch work can raise it further. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QQn7mTTxR8tBuK7G4SSiKE --- tests/test_client.py | 23 +++++++++++++++++++++++ yutori/_async/chat.py | 18 +++++++++++++----- yutori/_sync/chat.py | 18 +++++++++++++----- yutori/async_client.py | 9 +++++++-- yutori/client.py | 9 +++++++-- yutori/config.py | 11 +++++++++++ 6 files changed, 74 insertions(+), 14 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 3e3154a..fb1a2c3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -510,3 +510,26 @@ def test_close_without_chat_use(self, client): with patch.object(httpx.Client, "close"): client.close() assert client._chat is None + + +class TestModelCallRetries: + """Navigator model calls retry transient failures; the depth is caller-visible. + + The eval that motivated this lost 20 of 108 tasks to three ~2-minute bursts of + gateway `upstream_error` 5xx: the bundled client's own default of 2 retries spans + under ~2s of backoff, so a brief incident ends a long agent run outright. + """ + + def test_default_depth_is_the_sdk_default_not_the_vendor_default(self, client): + from yutori.config import DEFAULT_MAX_RETRIES + + assert client.chat._openai_client.max_retries == DEFAULT_MAX_RETRIES + assert DEFAULT_MAX_RETRIES > 2 # the openai client's own default + + def test_caller_can_raise_the_depth(self): + deep = YutoriClient(api_key="yt-test", max_retries=9) + assert deep.chat._openai_client.max_retries == 9 + + def test_caller_can_disable_retries(self): + none = YutoriClient(api_key="yt-test", max_retries=0) + assert none.chat._openai_client.max_retries == 0 diff --git a/yutori/_async/chat.py b/yutori/_async/chat.py index 37d172a..f577518 100644 --- a/yutori/_async/chat.py +++ b/yutori/_async/chat.py @@ -9,6 +9,7 @@ from openai.types.chat import ChatCompletion, ChatCompletionMessageParam from .._http import apply_chat_extra_body +from ..config import DEFAULT_MAX_RETRIES from ..navigator.models import NAVIGATOR_N1_5_MODEL @@ -65,13 +66,20 @@ class AsyncChatNamespace: """Async namespace for Navigator API operations (pixels-to-actions LLM). Requests go through the bundled OpenAI client, which retries failures - (connection errors, timeouts, 429/5xx) twice by default; this is not - configurable through the SDK surface. The other SDK namespaces never - retry. + (connection errors, timeouts, 429/5xx) with exponential backoff, honoring + a ``Retry-After`` header when the server sends one. The depth is + ``max_retries`` (default :data:`~yutori.config.DEFAULT_MAX_RETRIES`), set + on the client. The other SDK namespaces never retry. """ - def __init__(self, base_url: str, api_key: str, timeout: float) -> None: - self._openai_client = AsyncOpenAI(base_url=base_url, api_key=api_key, timeout=timeout) + def __init__( + self, + base_url: str, + api_key: str, + timeout: float, + max_retries: int = DEFAULT_MAX_RETRIES, + ) -> None: + self._openai_client = AsyncOpenAI(base_url=base_url, api_key=api_key, timeout=timeout, max_retries=max_retries) self.completions = AsyncChatCompletions(self._openai_client) async def close(self) -> None: diff --git a/yutori/_sync/chat.py b/yutori/_sync/chat.py index f9e50ca..2f8fbdd 100644 --- a/yutori/_sync/chat.py +++ b/yutori/_sync/chat.py @@ -9,6 +9,7 @@ from openai.types.chat import ChatCompletion, ChatCompletionMessageParam from .._http import apply_chat_extra_body +from ..config import DEFAULT_MAX_RETRIES from ..navigator.models import NAVIGATOR_N1_5_MODEL @@ -65,13 +66,20 @@ class ChatNamespace: """Namespace for Navigator API operations (pixels-to-actions LLM). Requests go through the bundled OpenAI client, which retries failures - (connection errors, timeouts, 429/5xx) twice by default; this is not - configurable through the SDK surface. The other SDK namespaces never - retry. + (connection errors, timeouts, 429/5xx) with exponential backoff, honoring + a ``Retry-After`` header when the server sends one. The depth is + ``max_retries`` (default :data:`~yutori.config.DEFAULT_MAX_RETRIES`), set + on the client. The other SDK namespaces never retry. """ - def __init__(self, base_url: str, api_key: str, timeout: float) -> None: - self._openai_client = OpenAI(base_url=base_url, api_key=api_key, timeout=timeout) + def __init__( + self, + base_url: str, + api_key: str, + timeout: float, + max_retries: int = DEFAULT_MAX_RETRIES, + ) -> None: + self._openai_client = OpenAI(base_url=base_url, api_key=api_key, timeout=timeout, max_retries=max_retries) self.completions = ChatCompletions(self._openai_client) def close(self) -> None: diff --git a/yutori/async_client.py b/yutori/async_client.py index da990d1..d81475d 100644 --- a/yutori/async_client.py +++ b/yutori/async_client.py @@ -15,7 +15,7 @@ ) from ._http import _AsyncBaseNamespace, build_query_params from .auth.credentials import require_api_key -from .config import DEFAULT_BASE_URL, DEFAULT_TIMEOUT_SECONDS, sanitize_base_url +from .config import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_SECONDS, sanitize_base_url class AsyncYutoriClient(_AsyncBaseNamespace): @@ -45,6 +45,7 @@ def __init__( *, base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT_SECONDS, + max_retries: int = DEFAULT_MAX_RETRIES, ) -> None: """Initialize the async Yutori client. @@ -53,6 +54,9 @@ def __init__( reads from the YUTORI_API_KEY environment variable. base_url: API base URL (default: https://api.yutori.com/v1). timeout: Request timeout in seconds (default: 30). + max_retries: How many times a Navigator (``client.chat``) model call is + retried on a connection error, timeout, 429 or 5xx, with exponential + backoff (default: 4). Other namespaces never retry. Raises: AuthenticationError: If no API key is provided or found in environment. @@ -66,6 +70,7 @@ def __init__( self.browsing = AsyncBrowsingNamespace(self._client, self._base_url, self._api_key) self.research = AsyncResearchNamespace(self._client, self._base_url, self._api_key) self._timeout = timeout + self._max_retries = max_retries self._chat: AsyncChatNamespace | None = None async def get_usage(self, *, period: str | None = None) -> dict[str, Any]: @@ -92,7 +97,7 @@ def chat(self) -> AsyncChatNamespace: AsyncYutoriClient, even for callers that never use chat completions. """ if self._chat is None: - self._chat = AsyncChatNamespace(self._base_url, self._api_key, self._timeout) + self._chat = AsyncChatNamespace(self._base_url, self._api_key, self._timeout, self._max_retries) return self._chat async def close(self) -> None: diff --git a/yutori/client.py b/yutori/client.py index 92a3914..0662949 100644 --- a/yutori/client.py +++ b/yutori/client.py @@ -10,7 +10,7 @@ from ._http import _SyncBaseNamespace, build_query_params from ._sync import BrowsingNamespace, ChatNamespace, ResearchNamespace, ScoutsNamespace from .auth.credentials import require_api_key -from .config import DEFAULT_BASE_URL, DEFAULT_TIMEOUT_SECONDS, sanitize_base_url +from .config import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_SECONDS, sanitize_base_url class YutoriClient(_SyncBaseNamespace): @@ -35,6 +35,7 @@ def __init__( *, base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT_SECONDS, + max_retries: int = DEFAULT_MAX_RETRIES, ) -> None: """Initialize the Yutori client. @@ -43,6 +44,9 @@ def __init__( reads from the YUTORI_API_KEY environment variable. base_url: API base URL (default: https://api.yutori.com/v1). timeout: Request timeout in seconds (default: 30). + max_retries: How many times a Navigator (``client.chat``) model call is + retried on a connection error, timeout, 429 or 5xx, with exponential + backoff (default: 4). Other namespaces never retry. Raises: AuthenticationError: If no API key is provided or found in environment. @@ -56,6 +60,7 @@ def __init__( self.browsing = BrowsingNamespace(self._client, self._base_url, self._api_key) self.research = ResearchNamespace(self._client, self._base_url, self._api_key) self._timeout = timeout + self._max_retries = max_retries self._chat: ChatNamespace | None = None def get_usage(self, *, period: str | None = None) -> dict[str, Any]: @@ -82,7 +87,7 @@ def chat(self) -> ChatNamespace: for callers that never use chat completions. """ if self._chat is None: - self._chat = ChatNamespace(self._base_url, self._api_key, self._timeout) + self._chat = ChatNamespace(self._base_url, self._api_key, self._timeout, self._max_retries) return self._chat def close(self) -> None: diff --git a/yutori/config.py b/yutori/config.py index 013c423..9a86c43 100644 --- a/yutori/config.py +++ b/yutori/config.py @@ -5,6 +5,17 @@ DEFAULT_BASE_URL = "https://api.yutori.com/v1" DEFAULT_TIMEOUT_SECONDS = 30.0 +# Model-call retries for the Navigator (chat) namespace. The bundled OpenAI client retries +# connection errors, timeouts and 429/5xx with exponential backoff, honoring `Retry-After`. +# +# 4 rather than the client's own default of 2: a long-horizon agent run issues hundreds of +# sequential model calls, so a single unretried upstream blip ends the whole run, and two +# retries span under ~2s of backoff. A brief gateway incident (measured: three ~2-minute +# bursts of `upstream_error` on 2026-08-31) outlasts that. Raise `max_retries` further for +# unattended batch work; the ceiling is a caller's patience, not correctness, since every +# retried request is idempotent. +DEFAULT_MAX_RETRIES = 4 + def sanitize_base_url(url: str) -> str: """Ensure the base URL never ends with a trailing slash."""