回答总时长与交互次数

默认聊天回答不设置工具交互次数上限,也不设置总处理时长上限。持续产生数据的 HTTP 模型流不因运行时间较长而中断。OPENPROGRAM_SSE_TOTAL_TIMEOUT_S 默认 0,表示无总时长限制;正数仍表示部署显式指定的单流期限。实现以正无穷 deadline 表示未设置上限,HTTP URL、重定向、响应大小检查保持原有规则。

连接建立、写入、连接池等待、无数据和无有效数据检查仍然区分于回答总时长。用户取消始终有效。runtime.exec 的显式期限与程序调用轮数参数、GUI/browser 独立工具的声明预算不由聊天默认设置覆盖。

验收使用实际 AgentSession 超过旧 200 次交互、managed HTTP 流经过旧 7200 秒且持续返回内容、显式期限仍终止等公共边界;不进行真实长时间等待或修改真实会话。

实现状态

聊天交互循环和 HTTP 单流已默认不限次数与总时长。回归测试覆盖超过 200 次工具交互、持续响应超过原总时长、Google 同步与异步传输,以及显式总时长配置仍然生效。

OpenProgram · 设计文档

报错与超时机制(现状梳理)

范围:一次 runtime.exec() 从发起到失败/超时的完整路径——重试、退避、连接/流超时、错误分类、外层 watchdog。 目的:先把现状写清楚再优化。优化项在文末(§8)单列并标注实现状态与对应 commit。配套:docs/design/llm-fault-tolerance.md(跨框架对比)、error-retry.md。

一句话结论。 provider 层的连接/流超时基础设施是成熟的——mid-stream stall(连上后不再吐 token)已被 SSE idle/data-stall/total 预算主动限住。真正会导致「卡很久没有新行」的是 三层重试循环相乘 + deadline 不穿透 + 外层 watchdog 被 except 吞掉。三者叠加,一次持续的 ConnectError 能跑数百秒到上千秒。

实现状态 — 已落地(2026-06-01)。 §8 的 #1–#4(框架)与 #5(GUI harness)已实施并通过验证;#6 评估后暂缓(见该节)。下文 §2–§7 描述的是修复前的机制(保留以解释问题根因);§5/§6 标了「已修复」,§8 标了每项状态与对应符号 / env。
框架 OpenProgram 4e64ac95 — deadline 穿透 + ExecInterrupt + 不跨层重试 + OPENPROGRAM_EXEC_TIMEOUT_S
harness GUI-Agent-Harness 74bfd06 — SampleTimeoutError(ExecInterrupt) + --exec-timeout-s + 按 LLMError.reason 分类 + auth 中止 + reraise_if_fatal
文档更新提交于本仓后续 commit;上面两个是实现 commit。行号仍为撰写时快照。

1 三层超时职责

系统里有三种语义不同的「超时」,分属不同层、由不同代码负责。把它们混在一起是大多数困惑的来源。

① 调用级 已覆盖

责任方:provider transport

一次 HTTP 请求 / 一条 SSE 流最多等多久;chunk 之间最大间隔。由 httpx 超时 + TCP keepalive + codex SSE 解析预算共同保证。

② 执行级 部分

责任方:runtime.exec(timeout_s=)

整个 exec(含全部 retry sleep)的 wall-clock 上限。当前是「attempt 之间的软 deadline」,不向下穿透到内层 stream 重试,且默认 None=无界。

③ 任务级 不可靠

责任方:调用方(runner / dispatcher)

一条 benchmark 样本 / 一个 agent turn 的总预算(OCR + 多轮 crop + gate + review + 重试)。只有调用方知道。ScreenSpot 现用 signal.alarm,会被框架吞掉。

2 完整调用栈

从 benchmark 一路到网络 I/O。每层右下角标注它自己持有的超时/重试控制。注意有 三层独立的重试循环(locator、exec、stream_retry),彼此不共享 wall-clock。

ScreenSpot 执行器 · run_one()
外层 signal.alarm(sample_timeout_s) 软中断;locator 内部还有自己的多轮重试。benchmark 侧(不在本仓)
retry loop #1(locator 轮次)signal.alarm 看门狗
Runtime.exec(content, …, timeout_s=None)
for attempt in range(max_retries) 包 except Exception;deadline 只在 attempt 之间检查,不打断正在跑的 _call。runtime.py:750 / :780 / :754
重试循环 #2(max_retries=6)软 deadline(要调用方传 timeout_s)
Runtime._call(content, model, response_format)
同步方法;不接收任何 timeout 参数。委派给 _call_via_providers → _run_async()。runtime.py:771 / :981 / :1007
无 timeout 入参
_run_async(coro) · async→sync 桥
无运行中的 loop → asyncio.run(coro) 跑在当前线程(CLI/benchmark 即主线程);已有 loop(web/worker)→ offload 到 线程池 worker 线程。runtime.py:1248–1261
主线程:signal.alarm 能落进来worker 线程:信号根本落不到
openai_codex · _run() → retry_stream(_attempt)
codex provider 的一次完整调用,内部走 stream 重试编排。openai_codex.py:134 / :210
retry_stream(attempt_fn, max_attempts=3)
for attempt in range(max_attempts) 包 except Exception;ConnectError 判 retryable=True。纯按次数,完全不知道任何 deadline。stream_retry.py:230 / :238 / :51
重试循环 #3(max_attempts=3)无 wall-clock 感知
httpx.AsyncClient.stream(...) · get_shared_async_client("openai-codex")
硬化过的共享 client:connect/write/pool=30s、TCP keepalive ~60s 探死连接、force-IPv4 逃生、proxy。http_client.py:107 / :48–104 / openai_codex.py:223
连接 30秒保活约60秒
_parse_sse_stream(response) · SSE 逐行
await asyncio.wait_for(line_iter.__anext__(), timeout=wait),wait = min(idle_left, stall_left, total−now)。mid-stream stall 在这里被主动限住。openai_codex.py:407 / :447 / :449 / :424
空闲 1800秒数据停滞 900秒总计 7200秒

3 已有的超时防御(已 armed)

这些都已经在生效,默认值全部可经 env 覆盖。集中定义在 providers/utils/timeouts.py 与 http_client.py。

防御默认值env 覆盖生效点状态
connect 超时30sOPENPROGRAM_HTTPX_CONNECT_TIMEOUT_Stimeouts.py:39开启
write / pool 超时30s…_WRITE_/_POOL_…timeouts.py:40–41开启
TCP keepalive 探死连接~60s
(30+10×3)
OPENPROGRAM_TCP_KEEPALIVEhttp_client.py:48–79开启
SSE idle(无任何字节)1800sOPENPROGRAM_SSE_IDLE_TIMEOUT_Stimeouts.py:45 / codex:449开启
SSE data-stall(无真实 data)900sOPENPROGRAM_SSE_DATA_STALL_TIMEOUT_Stimeouts.py:47 / codex:447开启
SSE 单流 total 上限7200sOPENPROGRAM_SSE_TOTAL_TIMEOUT_Stimeouts.py:49 / codex:424开启
httpx read backstop1860s
(idle+60)
OPENPROGRAM_HTTPX_READ_TIMEOUT_Stimeouts.py:52–60开启
大 prompt 预算缩放×1.25–2.0按 context_tokens 分档timeouts.py:65–89开启
force-IPv4 / proxy 逃生off / 自动OPENPROGRAM_FORCE_IPV4, HTTP(S)_PROXYhttp_client.py:92–101开启
含义。「连上后服务器不再吐 token」这种典型卡死,已被 codex 的 _parse_sse_stream 用 asyncio.wait_for 主动限在 idle/data-stall/total 三个预算内——不是裸奔。所以「provider 缺 read-timeout」的判断对 codex 不成立。

4 exec 重试状态机

一次 exec() 内部的重试逻辑。关键:deadline 检查只发生在两次 _call 之间(虚线框),无法打断正在执行的 _call。

# runtime.py:746–818(精简)
_deadline = start + timeout_s   # timeout_s=None ⇒ _deadline=None ⇒ 整段失效
for attempt in range(max_retries):          # 默认 6(_default_max_retries, :58)

    ┌── 软 deadline 检查(仅 attempt 之间)─────────────┐
    if _deadline and now() >= _deadline:           # :754
        raise LLMError(reason=TIMEOUT, retryable=False)
    └──────────────────────────────────────────────────┘

    try:
        reply = self._call(...)      # :771 同步阻塞,无 timeout 入参;卡这里时上面的检查到不了
        break
    except (TypeError, NotImplementedError):
        raise                       # 编程错误,不重试
    except Exception as e:           # :780 ← 这里吞掉一切,包括外来的 TimeoutError
        permanent = _is_permanent_error(e)   # :782 只匹配图片/auth marker
        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% 抖动;attempt5 ≈ 48s(:81)
        if _deadline and now()+sleep_s >= _deadline:  # :801 退避会越界就提前判 TIMEOUT
            raise LLMError(reason=TIMEOUT, retryable=False)
        time.sleep(sleep_s)          # :818

_is_permanent_error 只认两类 runtime.py:104–120

marker 列表只有:not a valid image / invalid image / image data is not / login expired / login failed / re-auth / unauthorized / invalid api key。任何超时类异常都不命中 → permanent=False → 会被重试。这正是外层 SampleTimeoutError 被当 transient 吃掉的直接原因。

5 核心缺陷:三层重试相乘,无人封顶 wall-clock

持续 ConnectError(死路由 / VPN 黑洞 / proxy 不通,日志即 openai-codex stream retry … ConnectError)时,三层循环逐层相乘,没有任何一层用 wall-clock 封顶:

循环位置次数单次代价退避deadline 感知
#3 内层 streamstream_retry.py:230max_attempts=3connect ≤30s1→2s(封顶30s)无
#2 中层 execruntime.py:750max_retries=6= 一整个内层循环1.5→48s软 / 需传 timeout_s
#1 外层 locatorbenchmark 侧多轮= 一整个中层循环—无
connect 为超时(丢包、非 refused,每次吃满 30s)时的最坏 wall-clock: 内层 ≈ 3 × 30s(connect) + (1+2)s(退避) ≈ 93s 中层 ≈ 6 × 93s + (1.5+3+6+12+24)s(退避) ≈ 605s ← 单次 exec() 外层 ≈ locator 再乘 N 轮 ≈ >900s 传输层 connect 尝试总数 = 3 × 6 (× locator 轮) = 18+ 次
双层重复重试(设计味道)。 ConnectError 在内层已经被判 retryable=True 并重试满 3 次、抛出 ProviderStreamError(retryable=True);交到 exec 后,_is_permanent_error 不命中 → exec 又用全新的 6× 预算重试同一个传输错误。同一个传输错误被两层各自重试,这才是把 ~90s 的问题放大成 600s+ 的根本。
已修复 4e64ac95。 exec 把单一 deadline 穿透进 stream_retry(每 attempt 前 + 退避前都检查),且 stream_retry 耗尽预算时给异常打 transport_exhausted 标志,exec 见到就不再用新预算重试 —— 18 次(3×6)塌回内层 3 次。_parse_sse_stream 的 SSE 等待也 clamp 到 min(budget, 剩余 deadline),单条流不再越过预算。

6 watchdog 被吞掉的时序

ScreenSpot 用 signal.alarm 想在 sample 总预算到点时硬停,但它抛的异常正好撞进上面两层的 except Exception。

1
signal.alarm(900) 起一个一次性闹钟。benchmark run_one() 外层
2
进入 exec → _call → _run_async → asyncio.run(主线程),卡在持续 ConnectError 的嵌套重试里。
3
900s 到点,SIGALRM 触发,handler 在主线程下一个字节码边界 raise SampleTimeoutError(继承 TimeoutError → Exception)。
4
异常冒泡到 stream_retry 的 except Exception(:238)或 exec 的 except Exception(:780),被当成「一次普通调用失败」。
5
_is_permanent_error 不命中 → permanent=False → 继续 retry。sample 语义(「整条样本已超时、必须停」)丢失。
6
signal.alarm 是一次性的,已消耗;外层 finally: alarm(0) 要等 run_one() 返回才执行——但它正卡着。此后没有任何硬保护,继续跑。
web / worker 语境更糟。 当存在运行中的 event loop 时,_run_async 把 asyncio.run offload 到线程池 worker 线程(runtime.py:1260)。Python 只向主线程投递信号,所以 signal.alarm 在这种语境里根本落不到正在跑的 _call 上——软 watchdog 完全无效。
已修复 框架 4e64ac95 + harness 74bfd06。 SampleTimeoutError 改继承 ExecInterrupt(BaseException),两层 except Exception 都吞不住它 —— 它穿过 OpenProgram 重试层、由 run_one() 在 sample 边界接住记 sample_timeout(步骤 4/5 不再发生)。worker 线程语境里信号本就落不到,改由 --exec-timeout-s → OPENPROGRAM_EXEC_TIMEOUT_S 的 call-level deadline 兜底(不依赖信号),且 _run_async 现在把 deadline contextvar 带进 worker 线程。

7 错误分类与 retryable 判定

exec 放弃时抛的是结构化 LLMError(errors.py:62),已经带够了上层精确计分所需的字段——上层不该一律记成 wrong_format。

ErrorReason 枚举 errors.py:31

TRANSPORT · RATE_LIMIT · PROVIDER_INTERNAL · AUTHENTICATION · AUTHORIZATION · INVALID_REQUEST · CONTEXT_LENGTH · CONTENT_POLICY · TIMEOUT · UNKNOWN

LLMError 字段 errors.py:62–85

reason · retryable · http_status · retry_after_s · attempts · elapsed_s · had_image · provider · model · last_error_type · cause

classify_error 判定顺序 errors.py:191–258

优先级依据判定retryable
1HTTP 429RATE_LIMIT是
1HTTP 500/502/503/504PROVIDER_INTERNAL是
1HTTP 401 / 403AUTHENTICATION / AUTHORIZATION否
1HTTP 400(含上下文/策略标记)INVALID_REQUEST / CONTEXT_LENGTH / CONTENT_POLICY否
2异常类型名 ∈ 传输集合
ConnectError / ReadTimeout / …(stream_retry.py:72)
TRANSPORT是
3消息子串兜底(auth / invalid / context / policy / rate-limit / 传输名)对应 reason视类
—都不命中UNKNOWN否

注意:override_reason=TIMEOUT 的 deadline 命中一律 retryable=False(runtime.py:170–176);其余「预算耗尽但类型本可重试」保持 retryable=True,含义是「换个新预算可再试」,不是「立刻重试」。

8 优化项(实现状态)

两条线并行、职责不同:框架侧 #1–#4 让任何调用方(channels worker、web turn、benchmark)都不被嵌套重试拖死;harness 侧 #5 负责 sample-level 计分与可靠 watchdog。子进程不替代框架修复,框架修复也不免除 runner 的 watchdog。#1–#4 已在框架 4e64ac95 落地,#5 在 harness 74bfd06 落地(子进程隔离暂缓);#6 暂缓。

#修复点为什么价值状态
1把单一 deadline 从 exec 穿透到 _call → codex _run → stream_retry(SSE wait 取 min(budget, 剩余))现在 exec(timeout_s) 只管中层、且要调用方传;内层 stream_retry + SSE 预算都不知道它。穿透后才是真 end-to-end,溢出 ≤ 一次 connect。最高已实施 4e64ac95
新 providers/utils/deadline.py(ContextVar);exec/async_exec set+reset;stream_retry & codex 读取
2传输层错误不跨层重复重试stream_retry 已耗尽 3 次的 ProviderStreamError 到 exec 又被重试 6 次 = 18 次。exec 应识别「provider 自己的传输预算已耗尽」,几乎不再重试。高已实施 4e64ac95
stream_retry 打 transport_exhausted 标志;exec 见到即不再重试(reason 仍诚实 TRANSPORT/retryable)
3引入 ExecInterrupt(BaseException) 硬中断契约exec 与 stream_retry 都有 except Exception;BaseException 同时穿透两层,外部 watchdog 一抛即退。高已实施 4e64ac95
errors.ExecInterrupt;两层显式 except ExecInterrupt: raise
4exec(timeout_s) 给默认值 / 入口必传现默认 None=无界。dispatcher / worker / benchmark 任一未 arm 的调用方都裸奔。中已实施 4e64ac95
OPENPROGRAM_EXEC_TIMEOUT_S 进程级默认;默认 0=无界(opt-in,不改既有行为)
5ScreenSpot:可靠 watchdog + 按 LLMError.reason 计分(+ 子进程隔离)sample-level 总预算只有 runner 知道。auth 直接停 run、timeout 单列、传输失败单列,不污染 wrong_format。harness 侧部分 74bfd06
SampleTimeoutError(ExecInterrupt)、--exec-timeout-s、reason 分类 + provider_auth、auth 中止 run、reraise_if_fatal 已做;子进程硬隔离暂缓(watchdog 已可靠 + 有 call-level deadline,优先级降低)
6同步 _call 线程 future 硬上限(兜底)仅防「单次 _call 真无限挂」。SSE 预算已限住 stall,故降级为 belt-and-suspenders。兜底暂缓
副作用见下;#1 已让 stall/connect 有界,单独 _call 无限挂的窗口已很小
关于线程 future 兜底的副作用(#6)。 Python 不能安全 kill 线程:超时后 caller 返回,但那个 _call 线程仍在后台跑,直到 socket 自己报错才回收;其间它可能仍去 append model-call node / 写 store/DAG,造成状态污染。所以 #6 只能当兜底,且必须依赖 #1 的 read-timeout 让泄漏窗口有界。理想路径是推动 provider 暴露 async,exec 主走 asyncio.wait_for(能真正取消到 await 点,无泄漏线程),同步线程方案仅作无 async 路径时的退路。
现状基于对以下文件的只读核查: 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。 行号为撰写时快照,优化落地后需同步更新。配套对比见 llm-fault-tolerance.md。

内部 Codex 认证与终止错误契约

Codex Runtime 默认使用该 provider 的活动账户,与聊天凭据一致;显式 profile 固定所选账户。CredentialProvider 继续负责凭据刷新。同步和异步 Runtime 调用经过 AgentSession 后保留 provider 的终止原因、重试判定和 Retry-After。OAuth 拒绝及显式不可重试错误只产生一次 provider 请求;暂时错误保留有界恢复。公开入口的假 provider 验收覆盖活动与固定账户、HTTP 状态分类和终止错误传递,不访问外部凭据或服务。

没有显式重试判定的普通异常可以在展示分类中成为 UNKNOWN/不可重试。AssistantMessage 保留该分类,并标记 error_retryable_inferred。仅在 UNKNOWN/不可重试且带此推断标记时,Runtime 保留既有的有界重试;显式 provider 判定(true 和 false)、已知分类或耗尽的传输预算仍保留其原有契约。taxonomy_fields 保留原始的布尔 retryable 值;只有缺失判定时才使用推断标记。该标记在既有生产者和序列化消息中默认为 false,并随消息与事件序列化保留。

策略传输层中的 DNS 故障

由 socket.gaierror 导致的 DNS_ERROR 属于连接故障。共用 HTTP 传输层保留策略诊断记录,并抛出 httpx.ConnectError,使已有有次数限制的提供商恢复机制在响应提交前处理此故障。每次尝试仍解析并检查全部地址,连接只使用已批准的地址。空或无效解析结果、来源拒绝和私有地址仍属于策略错误。代理解析遵循相同规则,不增加 DNS 替代方案、私有地址例外或无限重试。

实现状态:本次进行静态审查和发布构建;遵照用户要求,不运行自动回归测试。