From d8535cf84ed4aae275e8dd0b163ff09621f14c12 Mon Sep 17 00:00:00 2001 From: Abhishek Gahlot Date: Sun, 18 Oct 2026 20:34:55 +0530 Subject: [PATCH 1/9] fix: stop on incomplete openai responses and raise their output cap --- deepgym/src/deepgym/agents/computer.py | 20 +++++++------- deepgym/tests/agents/test_computer.py | 38 +++++++++++++++++++------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/deepgym/src/deepgym/agents/computer.py b/deepgym/src/deepgym/agents/computer.py index e849db7..159c056 100644 --- a/deepgym/src/deepgym/agents/computer.py +++ b/deepgym/src/deepgym/agents/computer.py @@ -103,6 +103,8 @@ def _openai_usage(data: dict[str, Any]) -> Usage: # The SDK retries connection drops, 429s and 5xx with backoff; one blip shouldn't end an # hours-long episode. A retried request may be billed twice; usage counts only answers. _SDK_RETRIES = 4 +ANTHROPIC_MAX_TOKENS = 16000 +OPENAI_MAX_OUTPUT_TOKENS = 16000 # covers reasoning plus output on the Responses API def _api_error(exc: Exception) -> str: @@ -183,7 +185,7 @@ def run( ) response = client.messages.create( model=self.model, - max_tokens=16000, + max_tokens=ANTHROPIC_MAX_TOKENS, cache_control={'type': 'ephemeral'}, **thinking, system=_SYSTEM, @@ -316,7 +318,7 @@ def run( tools=[{'type': 'computer'}], input=items, previous_response_id=previous, - max_output_tokens=4096, + max_output_tokens=OPENAI_MAX_OUTPUT_TOKENS, timeout=tracker.remaining_seconds, ).model_dump(exclude_none=True) _account(tracker, response.get('usage'), self.prices, 'openai') @@ -339,17 +341,15 @@ def run( 'safety_check', error=('Provider safety checks require approval; no actions executed'), ) - if not calls: - if response.get('status') == 'incomplete': - items = [{'role': 'user', 'content': 'Continue from the output limit.'}] - continue - if response.get('status') != 'completed': - return tracker.result(session, 'error', error='Response did not complete') - return tracker.result(session, 'completed', text) if response.get('status') != 'completed': + # Never act on, or blindly continue, a partial response; keep its reason. + details = response.get('incomplete_details') or {} + reason = details.get('reason') or response.get('status') return tracker.result( - session, 'error', error='Computer response did not complete' + session, 'error', error=f'Response did not complete: {reason}' ) + if not calls: + return tracker.result(session, 'completed', text) items = [] notes = [] for call in calls: diff --git a/deepgym/tests/agents/test_computer.py b/deepgym/tests/agents/test_computer.py index 22dd9bc..a0bfd9c 100644 --- a/deepgym/tests/agents/test_computer.py +++ b/deepgym/tests/agents/test_computer.py @@ -296,23 +296,41 @@ def test_openai_safety_checks_block_all_actions(sdk): assert result.trajectory.steps == [] -@pytest.mark.parametrize( - 'agent,provider,response', - [ - (AnthropicComputerAgent, 'anthropic', anthropic_response([], stop_reason='max_tokens')), - (OpenAIComputerAgent, 'openai', openai_response([], status='incomplete')), - ], -) -def test_incomplete_responses_consume_turn_budget(sdk, agent, provider, response): - client = sdk(provider, [response]) +def test_anthropic_output_limit_continues_within_turn_budget(sdk): + client = sdk('anthropic', [anthropic_response([], stop_reason='max_tokens')]) session = FakeSession() - result = agent('test').run( + result = AnthropicComputerAgent('test').run( session, session.observation, task='Task', budget=Budget(max_turns=1) ) assert result.termination_reason == 'turn_budget' assert len(client.requests) == 1 +CLICK = {'type': 'computer_call', 'call_id': 'call', 'action': {'type': 'click', 'x': 1, 'y': 2}} + + +@pytest.mark.parametrize('output', [[], [CLICK]]) +@pytest.mark.parametrize( + 'details', [{'reason': 'max_output_tokens'}, {'reason': 'content_filter'}, None] +) +def test_openai_incomplete_response_stops_without_acting(sdk, output, details): + response = openai_response(output, status='incomplete') + if details: + response['incomplete_details'] = details + client = sdk('openai', [response]) + session = FakeSession() + result = OpenAIComputerAgent('test').run( + session, session.observation, task='Task', budget=Budget(max_turns=5) + ) + assert result.termination_reason == 'error' + reason = (details or {}).get('reason', 'incomplete') + assert result.error == f'Response did not complete: {reason}' + assert len(client.requests) == 1 + assert client.requests[0]['max_output_tokens'] == 16000 + assert not result.trajectory.steps + assert result.usage.output_tokens == 10 + + @pytest.mark.parametrize('agent', [AnthropicComputerAgent, OpenAIComputerAgent]) def test_unknown_prices_reject_usd_budget_before_sdk_import(agent): session = FakeSession() From 8c0b10a22bb96a96f5b291cae45088ae2592fc98 Mon Sep 17 00:00:00 2001 From: Abhishek Gahlot Date: Sun, 18 Oct 2026 23:25:29 +0530 Subject: [PATCH 2/9] feat: model-max output and max effort for computer agents --- deepgym/src/deepgym/agents/computer.py | 30 +++++++++++++++++++------- deepgym/tests/agents/test_computer.py | 21 +++++++++++++++++- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/deepgym/src/deepgym/agents/computer.py b/deepgym/src/deepgym/agents/computer.py index 159c056..6522177 100644 --- a/deepgym/src/deepgym/agents/computer.py +++ b/deepgym/src/deepgym/agents/computer.py @@ -103,8 +103,9 @@ def _openai_usage(data: dict[str, Any]) -> Usage: # The SDK retries connection drops, 429s and 5xx with backoff; one blip shouldn't end an # hours-long episode. A retried request may be billed twice; usage counts only answers. _SDK_RETRIES = 4 -ANTHROPIC_MAX_TOKENS = 16000 -OPENAI_MAX_OUTPUT_TOKENS = 16000 # covers reasoning plus output on the Responses API +ANTHROPIC_MAX_TOKENS = ( + 128_000 # Opus 5.5's maximum output (the API states it); streamed, as the SDK requires +) def _api_error(exc: Exception) -> str: @@ -129,17 +130,21 @@ class AnthropicComputerAgent: model: Provider model supporting computer_toolset_20260801. prices: Explicit model-specific rates, required for a USD budget. effort: Adaptive-thinking effort; None sends no thinking or effort settings. + max_tokens: Output limit per response; defaults to Opus 5.5's maximum so the + harness never caps the model below what it can produce. """ def __init__( self, model: str, prices: TokenPrices | None = None, - effort: Literal['low', 'medium', 'high'] | None = 'medium', + effort: Literal['low', 'medium', 'high', 'xhigh', 'max'] | None = 'max', + max_tokens: int = ANTHROPIC_MAX_TOKENS, ) -> None: self.model = model self.prices = prices self.effort = effort + self.max_tokens = max_tokens def run( self, session: Session, initial: Observation, *, task: str, budget: Budget @@ -183,9 +188,9 @@ def run( if self.effort else {} ) - response = client.messages.create( + with client.messages.stream( model=self.model, - max_tokens=ANTHROPIC_MAX_TOKENS, + max_tokens=self.max_tokens, cache_control={'type': 'ephemeral'}, **thinking, system=_SYSTEM, @@ -197,7 +202,8 @@ def run( ], messages=messages, timeout=tracker.remaining_seconds, - ).model_dump(exclude_none=True) + ) as stream: + response = stream.get_final_message().model_dump(exclude_none=True) _account(tracker, response.get('usage'), self.prices, 'anthropic') content = response['content'] messages.append({'role': 'assistant', 'content': content}) @@ -276,11 +282,19 @@ class OpenAIComputerAgent: Args: model: Provider model supporting the Responses computer tool. prices: Explicit model-specific rates, required for a USD budget. + effort: Reasoning effort; None sends no reasoning setting. No output limit is sent, + so the model may use its full output allowance. """ - def __init__(self, model: str, prices: TokenPrices | None = None) -> None: + def __init__( + self, + model: str, + prices: TokenPrices | None = None, + effort: Literal['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] | None = 'max', + ) -> None: self.model = model self.prices = prices + self.effort = effort def run( self, session: Session, initial: Observation, *, task: str, budget: Budget @@ -318,7 +332,7 @@ def run( tools=[{'type': 'computer'}], input=items, previous_response_id=previous, - max_output_tokens=OPENAI_MAX_OUTPUT_TOKENS, + **({'reasoning': {'effort': self.effort}} if self.effort else {}), timeout=tracker.remaining_seconds, ).model_dump(exclude_none=True) _account(tracker, response.get('usage'), self.prices, 'openai') diff --git a/deepgym/tests/agents/test_computer.py b/deepgym/tests/agents/test_computer.py index a0bfd9c..cc821cd 100644 --- a/deepgym/tests/agents/test_computer.py +++ b/deepgym/tests/agents/test_computer.py @@ -131,10 +131,27 @@ def create(self, **kwargs): raise response return SimpleNamespace(model_dump=lambda **_: response) + def stream(self, **kwargs): + return _Stream(self.create(**kwargs)) # raises like a failed request + def close(self): self.closed = True +class _Stream: + def __init__(self, response): + self.response = response + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def get_final_message(self): + return self.response + + @pytest.fixture def sdk(monkeypatch): def install(provider, responses): @@ -326,7 +343,9 @@ def test_openai_incomplete_response_stops_without_acting(sdk, output, details): reason = (details or {}).get('reason', 'incomplete') assert result.error == f'Response did not complete: {reason}' assert len(client.requests) == 1 - assert client.requests[0]['max_output_tokens'] == 16000 + assert 'max_output_tokens' not in client.requests[0] and client.requests[0]['reasoning'] == { + 'effort': 'max' + } assert not result.trajectory.steps assert result.usage.output_tokens == 10 From 1eab97c2ece83246de96753aa06bea604f87855c Mon Sep 17 00:00:00 2001 From: Abhishek Gahlot Date: Sun, 18 Oct 2026 23:36:21 +0530 Subject: [PATCH 3/9] fix: never act on an unfinished anthropic stream --- deepgym/src/deepgym/agents/computer.py | 23 +++++- deepgym/tests/agents/test_computer.py | 109 +++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/deepgym/src/deepgym/agents/computer.py b/deepgym/src/deepgym/agents/computer.py index 6522177..ef1c175 100644 --- a/deepgym/src/deepgym/agents/computer.py +++ b/deepgym/src/deepgym/agents/computer.py @@ -48,6 +48,14 @@ def _step(session: Session, action: Action, tracker: BudgetTracker) -> Observati return observation +def _incomplete(tracker: BudgetTracker, snapshot: Any, prices: TokenPrices | None) -> None: + """Keep the usage an unfinished stream reported, with the total bill marked unresolved.""" + data = snapshot.model_dump(exclude_none=True).get('usage') if snapshot is not None else None + usage = _anthropic_usage(data) if isinstance(data, dict) else Usage() + unresolved = 'incomplete streamed response: partial usage, billing unresolved' + tracker.add_usage(usage, Cost(provenance=unresolved)) + + def _account( tracker: BudgetTracker, data: dict[str, Any] | None, @@ -203,7 +211,20 @@ def run( messages=messages, timeout=tracker.remaining_seconds, ) as stream: - response = stream.get_final_message().model_dump(exclude_none=True) + try: + response = stream.get_final_message().model_dump(exclude_none=True) + # Anything raised while reading the stream (transport errors come from httpx or + # httpx2 depending on the SDK version, or an APIError event) ends this response. + except Exception as exc: # noqa: BLE001 + _incomplete(tracker, stream.current_message_snapshot, self.prices) + return tracker.result( + session, 'error', error=f'Response stream failed: {type(exc).__name__}' + ) + if response.get('stop_reason') is None: # stream ended without message_stop + _incomplete(tracker, stream.current_message_snapshot, self.prices) + return tracker.result( + session, 'error', error='Response stream ended before completion' + ) _account(tracker, response.get('usage'), self.prices, 'anthropic') content = response['content'] messages.append({'role': 'assistant', 'content': content}) diff --git a/deepgym/tests/agents/test_computer.py b/deepgym/tests/agents/test_computer.py index cc821cd..5efc401 100644 --- a/deepgym/tests/agents/test_computer.py +++ b/deepgym/tests/agents/test_computer.py @@ -1,5 +1,7 @@ """Test actual provider loops with in-memory SDK responses and Session stepping.""" +import importlib +import json import sys from types import SimpleNamespace @@ -784,3 +786,110 @@ def test_anthropic_requests_cache_the_prefix_and_set_thinking(sdk, effort): assert request['output_config'] == {'effort': 'high'} else: assert 'thinking' not in request and 'output_config' not in request + + +def _sdk_transport(): + """The HTTP package the installed anthropic SDK uses (httpx, or httpx2 from 1.15 on).""" + base = importlib.import_module('anthropic._base_client') + return getattr(base, 'httpx2', None) or base.httpx + + +def _sse(*events: dict) -> bytes: + return b''.join(f'event: {e["type"]}\ndata: {json.dumps(e)}\n\n'.encode() for e in events) + + +START = { + 'type': 'message_start', + 'message': { + 'id': 'msg', + 'type': 'message', + 'role': 'assistant', + 'model': 'test', + 'content': [], + 'stop_reason': None, + 'stop_sequence': None, + 'usage': {'input_tokens': 100, 'output_tokens': 1}, + }, +} +OUT = {'output_tokens': 5} +CLICK_EVENTS = ( + { + 'type': 'content_block_start', + 'index': 0, + 'content_block': {'type': 'tool_use', 'id': 'c1', 'name': 'left_click', 'input': {}}, + }, + { + 'type': 'content_block_delta', + 'index': 0, + 'delta': {'type': 'input_json_delta', 'partial_json': '{"coordinate": [10, 20]}'}, + }, + {'type': 'content_block_stop', 'index': 0}, +) + + +@pytest.mark.parametrize('failure', ['eof', 'disconnect']) +def test_anthropic_unfinished_stream_never_acts(monkeypatch, failure): + anthropic = pytest.importorskip('anthropic') + httpx = _sdk_transport() + body = _sse(START, *CLICK_EVENTS) # no message_delta / message_stop + + class Cut(httpx.SyncByteStream): + def __iter__(self): + yield body + if failure == 'disconnect': + raise httpx.RemoteProtocolError('peer closed connection') + + def handler(request): + return httpx.Response(200, headers={'content-type': 'text/event-stream'}, stream=Cut()) + + real = anthropic.Anthropic + + def client(**kwargs): + transport = httpx.MockTransport(handler) + return real(api_key='test', http_client=httpx.Client(transport=transport), max_retries=0) + + monkeypatch.setattr(anthropic, 'Anthropic', client) + session = FakeSession() + result = AnthropicComputerAgent('test').run( + session, session.observation, task='Task', budget=Budget() + ) + assert result.termination_reason == 'error' + assert not result.trajectory.steps # the half-received click was not executed + assert result.usage.input_tokens == 100 # observed usage kept + assert result.cost.usd is None and 'billing unresolved' in result.cost.provenance + + +def test_anthropic_completed_stream_acts(monkeypatch): + anthropic = pytest.importorskip('anthropic') + httpx = _sdk_transport() + done = ( + {'type': 'message_delta', 'delta': {'stop_reason': 'tool_use'}, 'usage': OUT}, + {'type': 'message_stop'}, + ) + text = ( + {'type': 'content_block_start', 'index': 0, 'content_block': {'type': 'text', 'text': ''}}, + {'type': 'content_block_delta', 'index': 0, 'delta': {'type': 'text_delta', 'text': 'Ok'}}, + {'type': 'content_block_stop', 'index': 0}, + {'type': 'message_delta', 'delta': {'stop_reason': 'end_turn'}, 'usage': OUT}, + {'type': 'message_stop'}, + ) + bodies = iter([_sse(START, *CLICK_EVENTS, *done), _sse(START, *text)]) + + def handler(request): + return httpx.Response( + 200, headers={'content-type': 'text/event-stream'}, content=next(bodies) + ) + + real = anthropic.Anthropic + + def client(**kwargs): + transport = httpx.MockTransport(handler) + return real(api_key='test', http_client=httpx.Client(transport=transport), max_retries=0) + + monkeypatch.setattr(anthropic, 'Anthropic', client) + session = FakeSession() + result = AnthropicComputerAgent('test').run( + session, session.observation, task='Task', budget=Budget() + ) + assert result.termination_reason == 'completed' + assert [action.action_type for _, action in result.trajectory.steps] == ['click'] From b7744a29a949e613eefe3761d23dfd160eeed276 Mon Sep 17 00:00:00 2001 From: Abhishek Gahlot Date: Sun, 18 Oct 2026 23:40:51 +0530 Subject: [PATCH 4/9] fix: require message_stop before acting on a stream --- deepgym/src/deepgym/agents/computer.py | 36 +++++++++++++++++--------- deepgym/tests/agents/test_computer.py | 26 ++++++++++++++++--- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/deepgym/src/deepgym/agents/computer.py b/deepgym/src/deepgym/agents/computer.py index ef1c175..88b7f41 100644 --- a/deepgym/src/deepgym/agents/computer.py +++ b/deepgym/src/deepgym/agents/computer.py @@ -48,6 +48,14 @@ def _step(session: Session, action: Action, tracker: BudgetTracker) -> Observati return observation +def _snapshot(stream: Any) -> Any: + """The accumulated message, or None when no message_start arrived (the SDK asserts then).""" + try: + return stream.current_message_snapshot + except AssertionError: + return None + + def _incomplete(tracker: BudgetTracker, snapshot: Any, prices: TokenPrices | None) -> None: """Keep the usage an unfinished stream reported, with the total bill marked unresolved.""" data = snapshot.model_dump(exclude_none=True).get('usage') if snapshot is not None else None @@ -211,20 +219,24 @@ def run( messages=messages, timeout=tracker.remaining_seconds, ) as stream: - try: - response = stream.get_final_message().model_dump(exclude_none=True) - # Anything raised while reading the stream (transport errors come from httpx or - # httpx2 depending on the SDK version, or an APIError event) ends this response. + stopped, failure = False, None + try: # consume every event; only a received message_stop completes a response + for event in stream: + stopped = stopped or event.type == 'message_stop' + # Anything raised while reading (httpx or httpx2 transport errors, by SDK + # version, or an error event) ends this response. except Exception as exc: # noqa: BLE001 - _incomplete(tracker, stream.current_message_snapshot, self.prices) - return tracker.result( - session, 'error', error=f'Response stream failed: {type(exc).__name__}' - ) - if response.get('stop_reason') is None: # stream ended without message_stop - _incomplete(tracker, stream.current_message_snapshot, self.prices) - return tracker.result( - session, 'error', error='Response stream ended before completion' + failure = type(exc).__name__ + snapshot = _snapshot(stream) + if failure or not stopped or snapshot is None: + _incomplete(tracker, snapshot, self.prices) + error = ( + f'Response stream failed: {failure}' + if failure + else 'Response stream ended before completion' ) + return tracker.result(session, 'error', error=error) + response = snapshot.model_dump(exclude_none=True) _account(tracker, response.get('usage'), self.prices, 'anthropic') content = response['content'] messages.append({'role': 'assistant', 'content': content}) diff --git a/deepgym/tests/agents/test_computer.py b/deepgym/tests/agents/test_computer.py index 5efc401..885d7e7 100644 --- a/deepgym/tests/agents/test_computer.py +++ b/deepgym/tests/agents/test_computer.py @@ -150,7 +150,11 @@ def __enter__(self): def __exit__(self, *exc): return False - def get_final_message(self): + def __iter__(self): # a complete fake stream ends with message_stop + yield SimpleNamespace(type='message_stop') + + @property + def current_message_snapshot(self): return self.response @@ -827,11 +831,23 @@ def _sse(*events: dict) -> bytes: ) -@pytest.mark.parametrize('failure', ['eof', 'disconnect']) +DELTA = { + 'type': 'message_delta', + 'delta': {'stop_reason': 'tool_use'}, + 'usage': {'output_tokens': 5}, +} + + +@pytest.mark.parametrize('failure', ['eof', 'disconnect', 'eof_after_delta', 'empty']) def test_anthropic_unfinished_stream_never_acts(monkeypatch, failure): anthropic = pytest.importorskip('anthropic') httpx = _sdk_transport() - body = _sse(START, *CLICK_EVENTS) # no message_delta / message_stop + body = { # none of these streams reaches message_stop + 'eof': _sse(START, *CLICK_EVENTS), + 'disconnect': _sse(START, *CLICK_EVENTS), + 'eof_after_delta': _sse(START, *CLICK_EVENTS, DELTA), + 'empty': b'', + }[failure] class Cut(httpx.SyncByteStream): def __iter__(self): @@ -855,7 +871,9 @@ def client(**kwargs): ) assert result.termination_reason == 'error' assert not result.trajectory.steps # the half-received click was not executed - assert result.usage.input_tokens == 100 # observed usage kept + assert result.usage.input_tokens == ( + 0 if failure == 'empty' else 100 + ) # observed usage kept once assert result.cost.usd is None and 'billing unresolved' in result.cost.provenance From 8e44e48eec30e2e33af1aba17d93518bbeeabda0 Mon Sep 17 00:00:00 2001 From: Abhishek Gahlot Date: Mon, 19 Oct 2026 00:18:50 +0530 Subject: [PATCH 5/9] feat: unlimited turns, wall clock and steps when set to none --- deepgym/src/deepgym/agents/base.py | 16 ++++++++++++---- deepgym/src/deepgym/agents/cli.py | 4 ++++ deepgym/src/deepgym/agents/cli_events.py | 6 +++++- deepgym/src/deepgym/agents/computer.py | 6 +++--- deepgym/src/deepgym/computer_use.py | 2 +- deepgym/src/deepgym/models.py | 4 ++-- deepgym/src/deepgym/qa/agents.py | 5 ++++- deepgym/src/deepgym/session.py | 2 +- deepgym/tests/agents/test_computer.py | 10 ++++++++++ 9 files changed, 42 insertions(+), 13 deletions(-) diff --git a/deepgym/src/deepgym/agents/base.py b/deepgym/src/deepgym/agents/base.py index 168e7e3..574e965 100644 --- a/deepgym/src/deepgym/agents/base.py +++ b/deepgym/src/deepgym/agents/base.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math import time from typing import Literal, Protocol, runtime_checkable @@ -32,8 +33,8 @@ class Budget(BaseModel): """ model_config = ConfigDict(frozen=True, allow_inf_nan=False) - max_turns: int = Field(default=30, ge=1) - max_seconds: float = Field(default=300, gt=0) + max_turns: int | None = Field(default=30, ge=1) # None: no turn limit + max_seconds: float | None = Field(default=300, gt=0) # None: no wall-clock limit max_cost_usd: float | None = Field(default=None, ge=0) @@ -132,9 +133,16 @@ def __init__(self, budget: Budget) -> None: @property def remaining_seconds(self) -> float: - """Return remaining wall time, including retries and tool execution.""" + """Return remaining wall time, including retries and tools; inf without a limit.""" + if self.budget.max_seconds is None: + return math.inf return max(0, self.budget.max_seconds - (time.monotonic() - self.started)) + @property + def request_timeout(self) -> float | None: + """Deadline for one provider call or action; None without a wall-clock limit.""" + return None if self.budget.max_seconds is None else self.remaining_seconds + @property def reason(self) -> TerminationReason | None: """Return an exhausted time or spend bound, without checking turn admission.""" @@ -153,7 +161,7 @@ def check(self) -> None: def before_turn(self) -> None: """Admit one provider call, raising BudgetExceeded at an exhausted bound.""" self.check() - if self.turns >= self.budget.max_turns: + if self.budget.max_turns is not None and self.turns >= self.budget.max_turns: raise BudgetExceeded('turn_budget') self.turns += 1 diff --git a/deepgym/src/deepgym/agents/cli.py b/deepgym/src/deepgym/agents/cli.py index 14c134b..429d4d2 100644 --- a/deepgym/src/deepgym/agents/cli.py +++ b/deepgym/src/deepgym/agents/cli.py @@ -88,6 +88,10 @@ def _run(self, session: Session, task: str, budget: Budget) -> AgentResult: raise ValueError(f'{source} or api_key is required') if tracker.remaining_seconds <= 0: return tracker.result(session, 'time_budget') + if budget.max_seconds is None: + raise ValueError( + 'CLI runs need Budget.max_seconds: the supervisor enforces the CLI deadline' + ) events = CliEvents(self.provider, tracker, self.prices) credential = source if self.provider == 'anthropic' else 'CODEX_API_KEY' request = CliRequest( diff --git a/deepgym/src/deepgym/agents/cli_events.py b/deepgym/src/deepgym/agents/cli_events.py index c5e8d02..41c4092 100644 --- a/deepgym/src/deepgym/agents/cli_events.py +++ b/deepgym/src/deepgym/agents/cli_events.py @@ -147,7 +147,11 @@ def consume(self, event: dict[str, JsonValue]) -> str | None: and self.tracker.cost.usd >= self.tracker.budget.max_cost_usd ): self.stop_reason = 'cost_budget' - elif not self.completed and self.tracker.turns >= self.tracker.budget.max_turns: + elif ( + not self.completed + and self.tracker.budget.max_turns is not None + and self.tracker.turns >= self.tracker.budget.max_turns + ): self.stop_reason = 'turn_budget' return self.stop_reason diff --git a/deepgym/src/deepgym/agents/computer.py b/deepgym/src/deepgym/agents/computer.py index 88b7f41..523204d 100644 --- a/deepgym/src/deepgym/agents/computer.py +++ b/deepgym/src/deepgym/agents/computer.py @@ -43,7 +43,7 @@ def _step(session: Session, action: Action, tracker: BudgetTracker) -> Observati raise BudgetExceeded('session_done') if action.action_type == 'wait' and action.amount > tracker.remaining_seconds: raise BudgetExceeded('time_budget') - observation = session.step(action, timeout=tracker.remaining_seconds).observation + observation = session.step(action, timeout=tracker.request_timeout).observation tracker.check() return observation @@ -217,7 +217,7 @@ def run( } ], messages=messages, - timeout=tracker.remaining_seconds, + timeout=tracker.request_timeout, ) as stream: stopped, failure = False, None try: # consume every event; only a received message_stop completes a response @@ -366,7 +366,7 @@ def run( input=items, previous_response_id=previous, **({'reasoning': {'effort': self.effort}} if self.effort else {}), - timeout=tracker.remaining_seconds, + timeout=tracker.request_timeout, ).model_dump(exclude_none=True) _account(tracker, response.get('usage'), self.prices, 'openai') previous = response['id'] diff --git a/deepgym/src/deepgym/computer_use.py b/deepgym/src/deepgym/computer_use.py index c49d17a..6f16bcb 100644 --- a/deepgym/src/deepgym/computer_use.py +++ b/deepgym/src/deepgym/computer_use.py @@ -26,5 +26,5 @@ class ComputerUseEnvironment(MultiTurnEnvironment): allowed_actions: frozenset[ActionType] = frozenset({*GUI_ACTIONS, 'done'}) """GUI only, so the agent can't skip the interface with a shell.""" - max_steps: int = Field(default=30, ge=1) + max_steps: int | None = Field(default=30, ge=1) # None: no step limit """GUI tasks take more steps than shell tasks.""" diff --git a/deepgym/src/deepgym/models.py b/deepgym/src/deepgym/models.py index c4030fd..d358207 100644 --- a/deepgym/src/deepgym/models.py +++ b/deepgym/src/deepgym/models.py @@ -212,7 +212,7 @@ class EnvMetadata(BaseModel): image: str | None = None snapshot: str | None = None """Multi-turn and computer-use envs: the Daytona snapshot their sessions boot.""" - max_steps: int = Field(default=10, ge=1) + max_steps: int | None = Field(default=10, ge=1) # None: no step limit timeout_per_step: int = Field(default=30, ge=1) repo_dir: str | None = None """Image-based repo patch envs (test_patch.diff): where the repo lives in the image.""" @@ -531,7 +531,7 @@ class MultiTurnEnvironment(BaseModel): final_verifier_code: str = '' """Verifier run at the end to compute final score.""" - max_steps: int = Field(default=10, ge=1) + max_steps: int | None = Field(default=10, ge=1) # None: no step limit """Maximum number of steps before forced termination.""" timeout_per_step: int = Field(default=30, ge=1) diff --git a/deepgym/src/deepgym/qa/agents.py b/deepgym/src/deepgym/qa/agents.py index db2c65c..1d32c59 100644 --- a/deepgym/src/deepgym/qa/agents.py +++ b/deepgym/src/deepgym/qa/agents.py @@ -28,7 +28,10 @@ def do_everything(env: MultiTurnEnvironment) -> list[Action]: """Every allowed action type in turn, with fixed arguments, for the whole step budget.""" kinds = _kinds(env) - return [_action(kinds[step % len(kinds)], step, None) for step in range(env.max_steps)] + steps = ( + env.max_steps if env.max_steps is not None else len(kinds) + ) # no step limit: one action of each kind + return [_action(kinds[step % len(kinds)], step, None) for step in range(steps)] def random_agent(env: MultiTurnEnvironment) -> list[Action]: diff --git a/deepgym/src/deepgym/session.py b/deepgym/src/deepgym/session.py index 3715698..08bb508 100644 --- a/deepgym/src/deepgym/session.py +++ b/deepgym/src/deepgym/session.py @@ -1086,7 +1086,7 @@ def step(self, action: Action, *, timeout: float | None = None) -> StepResult: done = ( action.action_type == 'done' or _signals_done(output) - or self._step >= self._env.max_steps + or (self._env.max_steps is not None and self._step >= self._env.max_steps) ) observation: Observation | None = None try: diff --git a/deepgym/tests/agents/test_computer.py b/deepgym/tests/agents/test_computer.py index 885d7e7..5eccd4e 100644 --- a/deepgym/tests/agents/test_computer.py +++ b/deepgym/tests/agents/test_computer.py @@ -911,3 +911,13 @@ def client(**kwargs): ) assert result.termination_reason == 'completed' assert [action.action_type for _, action in result.trajectory.steps] == ['click'] + + +def test_unlimited_budget_sends_no_deadline(sdk): + client = sdk('anthropic', [anthropic_response([{'type': 'text', 'text': 'Done'}])]) + session = FakeSession() + result = AnthropicComputerAgent('test').run( + session, session.observation, task='Task', budget=Budget(max_turns=None, max_seconds=None) + ) + assert result.termination_reason == 'completed' + assert client.requests[0]['timeout'] is None From 5eea61aa68aae583a72f29fadddaaa89023eeeec Mon Sep 17 00:00:00 2001 From: Abhishek Gahlot Date: Mon, 19 Oct 2026 00:23:58 +0530 Subject: [PATCH 6/9] fix: scripted qa controls end when an env has no step limit --- deepgym/src/deepgym/qa/agents.py | 15 ++++++++++----- deepgym/tests/test_qa_multi_turn.py | 10 ++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/deepgym/src/deepgym/qa/agents.py b/deepgym/src/deepgym/qa/agents.py index 1d32c59..1b524ce 100644 --- a/deepgym/src/deepgym/qa/agents.py +++ b/deepgym/src/deepgym/qa/agents.py @@ -28,17 +28,22 @@ def do_everything(env: MultiTurnEnvironment) -> list[Action]: """Every allowed action type in turn, with fixed arguments, for the whole step budget.""" kinds = _kinds(env) - steps = ( - env.max_steps if env.max_steps is not None else len(kinds) - ) # no step limit: one action of each kind - return [_action(kinds[step % len(kinds)], step, None) for step in range(steps)] + return [ + _action(kinds[step % len(kinds)], step, None) for step in range(_control_steps(env, kinds)) + ] def random_agent(env: MultiTurnEnvironment) -> list[Action]: """Seeded random actions of the allowed types, for the whole step budget.""" rng = random.Random(_SEED) kinds = _kinds(env) - return [_action(rng.choice(kinds), step, rng) for step in range(env.max_steps)] + return [_action(rng.choice(kinds), step, rng) for step in range(_control_steps(env, kinds))] + + +def _control_steps(env: MultiTurnEnvironment, kinds: list[ActionType]) -> int: + """Length of a scripted QA control: the env's step budget, or one round of every allowed + action type when the env sets no step limit (fixed controls must end; never a model run).""" + return env.max_steps if env.max_steps is not None else len(kinds) def _kinds(env: MultiTurnEnvironment) -> list[ActionType]: diff --git a/deepgym/tests/test_qa_multi_turn.py b/deepgym/tests/test_qa_multi_turn.py index 7c10c38..391f2d6 100644 --- a/deepgym/tests/test_qa_multi_turn.py +++ b/deepgym/tests/test_qa_multi_turn.py @@ -129,3 +129,13 @@ def run_episode(env, agent): assert report.status == 'pass', report.checks assert set(report.ran) >= {'reference_passes', 'busy_fails', 'random_fails'} + + +def test_scripted_controls_end_without_a_step_limit(): + from deepgym.qa.agents import _kinds, do_everything, random_agent + + env = MultiTurnEnvironment( + name='no-limit', task='t', max_steps=None, final_verifier_code='print(1)' + ) + kinds = _kinds(env) + assert len(do_everything(env)) == len(kinds) == len(random_agent(env)) From 7febe685c6ece22b9e74c7275936e71f33023ec3 Mon Sep 17 00:00:00 2001 From: Abhishek Gahlot Date: Mon, 19 Oct 2026 00:29:40 +0530 Subject: [PATCH 7/9] feat: mount provider volumes into daytona sessions --- deepgym/src/deepgym/api/schemas.py | 2 ++ deepgym/src/deepgym/models.py | 13 +++++++++++++ deepgym/src/deepgym/sandboxes/daytona/sandbox.py | 5 +++++ deepgym/tests/test_api_schemas.py | 9 +++++++++ deepgym/tests/test_sandbox_images.py | 14 ++++++++++++++ 5 files changed, 43 insertions(+) diff --git a/deepgym/src/deepgym/api/schemas.py b/deepgym/src/deepgym/api/schemas.py index e7524ca..679dad0 100644 --- a/deepgym/src/deepgym/api/schemas.py +++ b/deepgym/src/deepgym/api/schemas.py @@ -219,6 +219,8 @@ def reject_runtime( raise ValueError('root_service_start cannot be set over the API') if not isinstance(v, str) and v.cli_provider_egress: raise ValueError('cli_provider_egress cannot be set over the API') + if not isinstance(v, str) and v.volumes: + raise ValueError('volumes cannot be set over the API') return v diff --git a/deepgym/src/deepgym/models.py b/deepgym/src/deepgym/models.py index d358207..7b1870d 100644 --- a/deepgym/src/deepgym/models.py +++ b/deepgym/src/deepgym/models.py @@ -491,6 +491,15 @@ class Trajectory(BaseModel): without one, so a failed episode has one reward fewer than steps.""" +class VolumeMount(BaseModel): + """One provider volume mounted at an absolute path inside the sandbox.""" + + model_config = ConfigDict(frozen=True, extra='forbid') + volume_id: str = Field(min_length=1) + mount_path: str = Field(pattern=r'^/') + subpath: str | None = None + + class MultiTurnEnvironment(BaseModel): """Define a multi-turn environment where agents take multiple steps. @@ -516,6 +525,10 @@ class MultiTurnEnvironment(BaseModel): The command must start the service itself and exit non-zero if readiness fails. """ + volumes: tuple[VolumeMount, ...] = () + """Provider volumes mounted into the sandbox (Daytona), for evidence too large for the image, + such as attachment files. Trusted environment configuration only; never accept from an agent.""" + cli_provider_egress: bool = Field(default=False, strict=True) """Opt in to sandbox-level egress for the trusted CLI provider proxy (Daytona). Agent commands, root service startup and verification remain in the offline namespace. diff --git a/deepgym/src/deepgym/sandboxes/daytona/sandbox.py b/deepgym/src/deepgym/sandboxes/daytona/sandbox.py index 84ad998..3d572ed 100644 --- a/deepgym/src/deepgym/sandboxes/daytona/sandbox.py +++ b/deepgym/src/deepgym/sandboxes/daytona/sandbox.py @@ -88,6 +88,11 @@ def _session_params( """ sdk = _sdk() options = {'ephemeral': True, 'network_block_all': not env.cli_provider_egress} + if env.volumes: + options['volumes'] = [ + sdk.VolumeMount(volume_id=v.volume_id, mount_path=v.mount_path, subpath=v.subpath) + for v in env.volumes + ] if env.image and not env.snapshot: return sdk.CreateSandboxFromImageParams(image=sdk.Image.base(env.image), **options) return sdk.CreateSandboxFromSnapshotParams(snapshot=env.snapshot, **options) diff --git a/deepgym/tests/test_api_schemas.py b/deepgym/tests/test_api_schemas.py index 05cba12..ddab303 100644 --- a/deepgym/tests/test_api_schemas.py +++ b/deepgym/tests/test_api_schemas.py @@ -59,3 +59,12 @@ def test_inline_api_cannot_open_trusted_root_egress(as_model: bool) -> None: environment = MultiTurnEnvironment(**environment) with pytest.raises(ValidationError, match='cli_provider_egress cannot be set over the API'): CreateSessionRequest(environment=environment) + + +@pytest.mark.parametrize('as_model', [False, True]) +def test_inline_api_cannot_mount_volumes(as_model: bool) -> None: + environment = {'task': 'task', 'volumes': [{'volume_id': 'v', 'mount_path': '/data'}]} + if as_model: + environment = MultiTurnEnvironment(**environment) + with pytest.raises(ValidationError, match='volumes cannot be set over the API'): + CreateSessionRequest(environment=environment) diff --git a/deepgym/tests/test_sandbox_images.py b/deepgym/tests/test_sandbox_images.py index 5da6d15..1667f27 100644 --- a/deepgym/tests/test_sandbox_images.py +++ b/deepgym/tests/test_sandbox_images.py @@ -133,3 +133,17 @@ def test_cli_egress_keeps_agent_root_services_and_verifier_offline(monkeypatch) assert '/usr/bin/nsenter --net=/opt/deepgym-netns --' in command assert '--reuid=997' in agent assert '--reuid=996' in verifier + + +def test_daytona_params_mount_declared_volumes() -> None: + pytest.importorskip('daytona') + from deepgym.models import VolumeMount + from deepgym.sandboxes.daytona import create_params + + mount = VolumeMount(volume_id='vol', mount_path='/srv/a', subpath='x') + env = MultiTurnEnvironment(task='t', snapshot='snap', volumes=(mount,)) + params = create_params(env) + assert [(v.volume_id, v.mount_path, v.subpath) for v in params.volumes] == [ + ('vol', '/srv/a', 'x') + ] + assert not create_params(MultiTurnEnvironment(task='t', snapshot='snap')).volumes From 67e2877a4b9141f8e79947e4f1adeeb03f31025c Mon Sep 17 00:00:00 2001 From: Abhishek Gahlot Date: Mon, 19 Oct 2026 00:30:36 +0530 Subject: [PATCH 8/9] test: volume params on the session sandbox builder --- deepgym/tests/test_sandbox_images.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/deepgym/tests/test_sandbox_images.py b/deepgym/tests/test_sandbox_images.py index 1667f27..03a48b7 100644 --- a/deepgym/tests/test_sandbox_images.py +++ b/deepgym/tests/test_sandbox_images.py @@ -138,12 +138,11 @@ def test_cli_egress_keeps_agent_root_services_and_verifier_offline(monkeypatch) def test_daytona_params_mount_declared_volumes() -> None: pytest.importorskip('daytona') from deepgym.models import VolumeMount - from deepgym.sandboxes.daytona import create_params + from deepgym.sandboxes.daytona.sandbox import _session_params mount = VolumeMount(volume_id='vol', mount_path='/srv/a', subpath='x') - env = MultiTurnEnvironment(task='t', snapshot='snap', volumes=(mount,)) - params = create_params(env) + params = _session_params(MultiTurnEnvironment(task='t', snapshot='snap', volumes=(mount,))) assert [(v.volume_id, v.mount_path, v.subpath) for v in params.volumes] == [ ('vol', '/srv/a', 'x') ] - assert not create_params(MultiTurnEnvironment(task='t', snapshot='snap')).volumes + assert not _session_params(MultiTurnEnvironment(task='t', snapshot='snap')).volumes From 541a0ce68ea18cf2ef530ac98e5492aaabdc8d9f Mon Sep 17 00:00:00 2001 From: Abhishek Gahlot Date: Mon, 19 Oct 2026 00:31:21 +0530 Subject: [PATCH 9/9] docs: volumes are shared across episodes; mount read-only and pin --- deepgym/src/deepgym/models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/deepgym/src/deepgym/models.py b/deepgym/src/deepgym/models.py index 7b1870d..b64c63e 100644 --- a/deepgym/src/deepgym/models.py +++ b/deepgym/src/deepgym/models.py @@ -527,7 +527,10 @@ class MultiTurnEnvironment(BaseModel): volumes: tuple[VolumeMount, ...] = () """Provider volumes mounted into the sandbox (Daytona), for evidence too large for the image, - such as attachment files. Trusted environment configuration only; never accept from an agent.""" + such as attachment files. Trusted environment configuration only; never accept from an agent. + A volume is shared and outlives each sandbox, so whatever one episode writes, the next reads: + mount it where untrusted processes cannot write (e.g. a root-only path re-exposed through a + read-only bind mount in root_service_start) and check its contents against a pinned manifest.""" cli_provider_egress: bool = Field(default=False, strict=True) """Opt in to sandbox-level egress for the trusted CLI provider proxy (Daytona).