Default chat responses have neither a tool-interaction limit nor a total processing-duration limit. HTTP model streams that keep producing data are not interrupted merely for running a long time. OPENPROGRAM_SSE_TOTAL_TIMEOUT_S defaults to 0, meaning no total-duration limit; positive values remain explicit deployment deadlines for one stream. An unset limit uses a positive-infinity deadline. Existing HTTP URL, redirect and response-size checks remain unchanged.
Connection establishment, writes, pool waits, no-data and no-effective-data checks remain distinct from total response duration. User cancellation always applies. Explicit runtime.exec deadlines, program call-round parameters and declared budgets of standalone GUI/browser tools are not overridden by chat defaults.
Acceptance uses public boundaries: an actual AgentSession exceeding the former 200 interactions, a managed HTTP stream returning content beyond the former 7200 seconds, and continued termination under explicit deadlines. Tests neither wait for real long periods nor modify real sessions.
Chat interaction loops and individual HTTP streams now default to unlimited counts and total duration. Regressions cover more than 200 tool interactions, continuous responses beyond the former duration, Google synchronous/asynchronous transport, and explicit total-duration configuration.
OpenProgram · Design document
Scope: the complete path of one runtime.exec() from initiation to failure/timeout: retries, backoff, connection/stream timeouts, error classification and outer watchdog.
Purpose: document current behavior before optimizing. Optimization items are listed separately in §8 with implementation state and corresponding commits. Companion documents: docs/design/llm-fault-tolerance.md(cross-framework comparison), error-retry.md.
Summary. The provider-layer connection/stream timeout infrastructure is mature: mid-stream stalls after connection are actively bounded by SSE idle/data-stall/total budgets. Long periods with no new output instead result from
multiplication of three retry loops +
deadlines not propagating +
outer watchdog exceptions being caught. Together, one persistent ConnectError can run for hundreds to over a thousand seconds.
4e64ac95 — deadline propagation + ExecInterrupt + no cross-layer retries + OPENPROGRAM_EXEC_TIMEOUT_S74bfd06 — SampleTimeoutError(ExecInterrupt) + --exec-timeout-s + classification by LLMError.reason + abort on auth + reraise_if_fatalThe system has three semantically different timeouts, owned by different layers and code. Confusing them causes most misunderstandings.
Maximum wait for one HTTP request/SSE stream and maximum interval between chunks, enforced jointly by httpx timeouts, TCP keepalive and codex SSE parsing budgets.
runtime.exec(timeout_s=)Wall-clock limit for an entire exec, including all retry sleeps. Currently a soft deadline between attempts; it does not propagate into inner stream retries, and defaults to None=unbounded.
Total budget for one benchmark sample/Agent turn: OCR, multiple crops, gates, review and retries. Only the caller knows it. ScreenSpot currently uses signal.alarm, which the framework catches.
From benchmark to network I/O. Each layer’s lower-right corner lists its own timeout/retry controls. There are three independent retry loops(locator, exec, stream_retry), without a shared wall-clock budget.
signal.alarm(sample_timeout_s) soft interrupt; locator also has its own multiround retries.Benchmark side (outside this repository)for attempt in range(max_retries) wraps except Exception; the deadline is checked only between attemptsand does not interrupt an executing _call.runtime.py:750 / :780 / :754_call_via_providers → _run_async().runtime.py:771 / :981 / :1007asyncio.run(coro) runs on the current thread(main thread for CLI/benchmark); existing loop (web/worker) → offload to a thread-pool worker.runtime.py:1248–1261for attempt in range(max_attempts) wraps except Exception; ConnectError Checks retryable=True.Count-based only, with no knowledge of any deadline.stream_retry.py:230 / :238 / :51await asyncio.wait_for(line_iter.__anext__(), timeout=wait), wait = min(idle_left, stall_left, total−now).Mid-stream stalls are actively bounded here.openai_codex.py:407 / :447 / :449 / :424These are all already active; environment variables can override every default. Defined centrally in providers/utils/timeouts.py and http_client.py.
| Defense | Default | Environment override | Enforcement point | Status |
|---|---|---|---|---|
| Connect timeout | 30s | OPENPROGRAM_HTTPX_CONNECT_TIMEOUT_S | timeouts.py:39 | on |
| Write / pool timeout | 30s | …_WRITE_/_POOL_… | timeouts.py:40–41 | on |
| TCP keepalive dead-connection detection | ~60s (30+10×3) | OPENPROGRAM_TCP_KEEPALIVE | http_client.py:48–79 | on |
| SSE idle (no bytes) | 1800s | OPENPROGRAM_SSE_IDLE_TIMEOUT_S | timeouts.py:45 / codex:449 | on |
| SSE data-stall (no actual data) | 900s | OPENPROGRAM_SSE_DATA_STALL_TIMEOUT_S | timeouts.py:47 / codex:447 | on |
| SSE single-stream total limit | 7200s | OPENPROGRAM_SSE_TOTAL_TIMEOUT_S | timeouts.py:49 / codex:424 | on |
| httpx read backstop | 1860s (idle+60) | OPENPROGRAM_HTTPX_READ_TIMEOUT_S | timeouts.py:52–60 | on |
| Large-prompt budget scaling | ×1.25–2.0 | Tiered by context_tokens | timeouts.py:65–89 | on |
| Force-IPv4 / proxy fallback | Off / automatic | OPENPROGRAM_FORCE_IPV4, HTTP(S)_PROXY | http_client.py:92–101 | on |
_parse_sse_stream using asyncio.wait_for to idle/data-stall/total budgets:protection is present. Therefore claiming that the codex provider lacks a read timeout is incorrect.Retry logic within one exec() . Key point: the deadline is checked only between two _call between attempts(dashed box), so it cannot interrupt the currently running _call.
# runtime.py:746–818 (abridged) _deadline = start + timeout_s # timeout_s=None ⇒ _deadline=None ⇒ entire block inactive for attempt in range(max_retries): # default 6 (_default_max_retries, :58) ┌── Soft deadline check (between attempts only) ─────────────┐ if _deadline and now() >= _deadline: # :754 raise LLMError(reason=TIMEOUT, retryable=False) └──────────────────────────────────────────────────┘ try: reply = self._call(...) # :771 synchronous blocking, no timeout argument; a stall here prevents the check above break except (TypeError, NotImplementedError): raise # Programming error: do not retry except Exception as e: # :780 catches everything, including an external TimeoutError permanent = _is_permanent_error(e) # :782 matches image/auth markers only if permanent or attempt == max_retries-1: raise _build_llm_error(...) # → LLMError(reason, retryable, cause, …) sleep_s = _retry_sleep_seconds(attempt) # 1.5·2^n, ±25% jitter; attempt5 ≈ 48s (:81) if _deadline and now()+sleep_s >= _deadline: # :801 report TIMEOUT early if backoff would exceed the deadline raise LLMError(reason=TIMEOUT, retryable=False) time.sleep(sleep_s) # :818
_is_permanent_error Recognizes only two classes runtime.py:104–120The marker list contains only: not a valid image / invalid image / image data is not / login expired / login failed / re-auth / unauthorized / invalid api key. Any timeout-class exception misses these → permanent=False → it is retried. This directly explains why the outer SampleTimeoutError is treated as transient.
With persistent ConnectError(dead route, VPN packet loss or unreachable proxy; logs show openai-codex stream retry … ConnectError), the three loops multiply, and no layer caps wall-clock time:
| Loop | Location | Count | Cost per attempt | Backoff | Deadline awareness |
|---|---|---|---|---|---|
| #3 Inner stream | stream_retry.py:230 | max_attempts=3 | connect ≤30s | 1→2s (capped at 30s) | None |
| #2 Middle exec | runtime.py:750 | max_retries=6 | = one complete inner loop | 1.5→48s | Soft / requires timeout_s |
| #1 Outer locator | Benchmark side | Multiple rounds | = one complete middle loop | — | None |
ConnectError The inner layer already classifies retryable=True and retries it three times before raising ProviderStreamError(retryable=True); once it reaches exec, _is_permanent_error does not match → exec again retries the same transport error with a fresh 6× budget.The same transport error is independently retried at two layers, turning a ~90s problem into 600s+.4e64ac95. Exec propagates one deadline into stream_retry(checked before each attempt and backoff), and stream_retry marks budget-exhausted exceptions with transport_exhausted ; exec thendoes not retry with a new budget , reducing 18 attempts (3×6) to the three inner attempts._parse_sse_stream SSE waits in min(budget, remaining deadline) are also clamped, so individual streams cannot exceed the budget.ScreenSpot uses signal.alarm to stop at the sample deadline, but its exception reaches the two layers’ except Exception.
raise SampleTimeoutError(inherits TimeoutError → Exception).except Exception(:238), or exec’s except Exception(:780), and is treated as an ordinary call failure._is_permanent_error Does not match → permanent=False → retry continues. The sample meaning (“this entire sample timed out and must stop”) islost.signal.alarm is one-shot and has been consumed; the outer finally: alarm(0) runs only after run_one() returns, but it is still blocked.No further hard protection remains, so execution continues._run_async takes asyncio.run and offloads it to a thread-pool worker(runtime.py:1260). Python delivers signals only to the main thread, so signal.alarm in this contextcannot reachthe running _call : the soft watchdog is ineffective.4e64ac95 + harness 74bfd06. SampleTimeoutError now inherits ExecInterrupt(BaseException), which neither except Exception can catch. It crosses the OpenProgram retry layers and is caught by run_one() at the sample boundary to record sample_timeout(steps 4/5 no longer occur). Signals never reach worker threads; instead, --exec-timeout-s → OPENPROGRAM_EXEC_TIMEOUT_S provides a signal-independent call-level deadline, and _run_async now propagates the deadline ContextVar into the worker thread.When exec gives up, it raises structured LLMError (errors.py:62), already containing enough fields for precise caller-side scoring; callers should not label everything wrong_format.
ErrorReason Enumeration errors.py:31TRANSPORT · RATE_LIMIT · PROVIDER_INTERNAL · AUTHENTICATION · AUTHORIZATION · INVALID_REQUEST · CONTEXT_LENGTH · CONTENT_POLICY · TIMEOUT · UNKNOWN
LLMError Fields errors.py:62–85reason · retryable · http_status · retry_after_s · attempts · elapsed_s · had_image · provider · model · last_error_type · cause
classify_error Classification order errors.py:191–258| Priority | Basis | Classification | retryable |
|---|---|---|---|
| 1 | HTTP 429 | RATE_LIMIT | Yes |
| 1 | HTTP 500/502/503/504 | PROVIDER_INTERNAL | Yes |
| 1 | HTTP 401 / 403 | AUTHENTICATION / AUTHORIZATION | No |
| 1 | HTTP 400 (+context/policy marker) | INVALID_REQUEST / CONTEXT_LENGTH / CONTENT_POLICY | No |
| 2 | Exception type name belongs to the transport set ConnectError / ReadTimeout / … (stream_retry.py:72) | TRANSPORT | Yes |
| 3 | Message-substring fallback (auth / invalid / context / policy / rate-limit / transport name) | Corresponding reason | Depends on class |
| — | No match | UNKNOWN | No |
Note: override_reason=TIMEOUT deadline hits always use retryable=False (runtime.py:170–176); other exhausted budgets whose error type could be retried retain retryable=True, meaning “retry with a new budget”, not “retry immediately”.
Two parallel areas with different responsibilities: framework #1–#4 preventanycaller (channel worker, web turn, benchmark) from excessive nested retries; harness #5 handles sample-level scoring and a reliable watchdog. Subprocesses do not replace framework fixes, and framework fixes do not remove runner watchdog obligations.#1–#4 delivered in framework 4e64ac95 , #5 in harness 74bfd06 (subprocess isolation deferred); #6 deferred.
| # | Fix | Reason | Value | Status |
|---|---|---|---|---|
| 1 | Propagate a single deadline from exec to _call → codex _run → stream_retry(SSE waits use min(budget, remaining)) | Currently exec(timeout_s) controls only the middle layer and requires caller input; inner stream_retry and SSE budgets do not know it. Propagation makes the deadline end-to-end, with overshoot bounded by one connect attempt. | Highest | Implemented 4e64ac95New providers/utils/deadline.py (ContextVar); exec/async_exec set+reset; stream_retry & Codex reads |
| 2 | Do not repeat transport retries across layers | After stream_retry exhausts three attempts for ProviderStreamError , exec retries six more times, totaling 18. Exec should recognize the provider’s exhausted transport budget and practically avoid retrying. | High | Implemented 4e64ac95stream_retry Set the transport_exhausted flag; exec stops retrying while honestly retaining TRANSPORT/retryable as the reason |
| 3 | Introduce a ExecInterrupt(BaseException) hard-interrupt contract | exec and stream_retry Both include except Exception; BaseException so it passes through both layers and immediately exits on an external watchdog interrupt. | High | Implemented 4e64ac95errors.ExecInterrupt; both layers explicitly except ExecInterrupt: raise |
| 4 | exec(timeout_s) Provide a default / require it at entry | Current default None=unbounded. Any dispatcher/worker/benchmark caller that does not configure it lacks this protection. | Medium | Implemented 4e64ac95OPENPROGRAM_EXEC_TIMEOUT_S Process-level default; 0=unbounded (opt-in, preserving behavior) |
| 5 | ScreenSpot: reliable watchdog + scoring by LLMError.reason (+ subprocess isolation) | Only the runner knows the total sample budget. Authentication stops the run; timeout and transport failures have separate categories and do not contaminate wrong_format. | Harness side | Partial 74bfd06SampleTimeoutError(ExecInterrupt), --exec-timeout-s, reason classification + provider_auth, auth aborts the run, reraise_if_fatal implemented; hard subprocess isolation deferred(watchdog reliability and call-level deadlines reduce its priority) |
| 6 | Synchronous _call Hard thread-future limit (last resort) | Only protects against one _call hanging indefinitely. SSE budgets already bound stalls, making this an additional safeguard. | Last resort | Deferred See side effects below; #1 bounds stalls/connects, leaving little scope for an individual _call to hang indefinitely |
_call thread keeps running in the backgrounduntil the socket itself fails; meanwhile it may append model-call nodes or write store/DAG state, causing contamination. Thus #6 is only a last resort and must rely on #1 read timeouts to bound the leak. Ideally providers expose async and exec uses asyncio.wait_for(cancellation reaches await points with no leaked threads); synchronous threads remain a fallback only when async is unavailable.agentic_programming/runtime.py (exec/retry/bridge),
providers/utils/timeouts.py, providers/utils/http_client.py,
providers/utils/stream_retry.py, providers/utils/errors.py,
providers/openai_codex/openai_codex.py.
Line numbers are snapshots from authoring and must be updated after implementation. Companion comparison: llm-fault-tolerance.md.
Codex Runtime uses the provider active account by default, matching direct chat credentials; an explicit profile pins its own account. CredentialProvider refresh remains authoritative. Both synchronous and asynchronous Runtime calls preserve the provider terminal reason, retry verdict and Retry-After through AgentSession. A rejected OAuth token or any explicit non-retryable provider failure makes exactly one provider request; transient failures retain bounded recovery. Public fake-provider acceptance covers active versus pinned accounts, HTTP status classification and terminal failure propagation without external credentials or requests.
An arbitrary exception without an explicit retry verdict can be classified UNKNOWN/non-retryable for display. AssistantMessage retains that classification and marks error_retryable_inferred. Runtime preserves its existing bounded fallback only for UNKNOWN/non-retryable messages with this inferred marker; an explicit provider verdict (both true and false), known taxonomy or exhausted transport budget remains authoritative. taxonomy_fields retains an original boolean retryable value; only an omitted verdict receives the inferred marker. The marker defaults to false for existing producers and serialized messages and survives message/event serialization.
A DNS_ERROR caused by socket.gaierror is an operational connection failure. The shared HTTP transport records the policy diagnostic and raises httpx.ConnectError, allowing existing bounded provider transport recovery to apply before response commitment. Every attempt still resolves and validates every address and pins the connection to approved addresses. Empty or malformed resolver answers and denied origins or private addresses remain policy failures. Proxy resolution follows the same rule. No DNS fallback, private-address exception or unbounded retry is added.
Implementation status: static review and release build only for this change; automated regression suites are not run at the user's request.