Skip to content

[Agent Core] 让子 Agent 继承父 Run 的截止时间与终止语义 - #168

Merged
rojim666 merged 1 commit into
rojim666:mainfrom
z2Ace0107:codex/issue-69-pr07-subagent-run-deadline
Sep 23, 2026
Merged

rojim666 merged 1 commit into
rojim666:mainfrom
z2Ace0107:codex/issue-69-pr07-subagent-run-deadline

Conversation

@z2Ace0107

Copy link
Copy Markdown
Contributor

Summary

关联 Issue #69。PR-01 至 PR-06 已分别覆盖 Run Deadline 基础、LLM hard cutoff、
Provider timeout/retry、工具调用、权限等待和上下文压缩;本 PR 处理剩下的一跳——
Parent → Subagent。

此前有两个问题:

  1. 子 Agent 从配置里拿一份全新的完整预算,与父 Run 剩余时间无关。
    subagent/tool.py 用 self._budget.max_wall_clock_s 构造子 context,因此子的
    deadline = spawn 时刻 + 完整预算,必然越过父的 deadline,越过量等于 spawn 时父
    已耗掉的时间。
  2. AgentLoop._wait_for_background 是无界 asyncio.gather。

实测(探针,在未改动的基线上):父预算 2 秒、1 秒后派生后台 child 再 end_turn,
elapsed=3.359s —— 超预算 68%,且一次本应 success 的收尾被拖成了
interrupted。

本 PR 是 Issue #69 分阶段实现中的 Subagent 阶段,只使用 Relates to #69,
不在本 PR 合并时关闭父 Issue。

Behavior

  • ExecutionContext 新增 inherited_deadline_at 入口字段;start() 在继承 deadline
    与自身 max_wall_clock_s 之间取更早者——父 Run 的截止时刻与子自己的预算互相都
    不能放宽对方(与 provider / 工具 / 权限各处 min(自身超时, remaining) 同一口径)。
  • SpawnAgentTool 派生子 Agent 时传入父的 deadline_at 与 clock。两者必须同时
    传:只传绝对 deadline 不传 clock,会因时钟源不一致而算出无意义的值。
  • _wait_for_background 的 gather 由父 Run 剩余时间界定;deadline 在等待期间到达时
    取消后台子 Agent 并等其收尾,不把无界等待留给父 Run。
  • 子 Agent 撞上 Run deadline 时终态归 CANCELLED 并带具体原因,不再误判为 FAILED。
    取舍:status 只表达"不是错误失败","为什么"由 reason 区分
    (max_wall_clock_exceeded / parent_cancelled 等)。
  • 前台派生路径的 subagent.finished status 归一化进协议词汇
    ("success" | "failed"),不再吐出协议外的 "interrupted"。
  • 无 run 上下文的路径(如手动 /compact)行为不变。

Validation

  • 目标 Python 测试:218 passed(test_subagent_cancellation.py /
    test_subagent_registry.py / test_subagent_prompts.py / test_loop.py /
    test_runner.py / test_compactor.py / test_session_manager.py /
    test_budget_admission.py / test_long_compaction.py /
    tests/integration/test_deadline_foundation.py)。
  • 新增 8 个测试:5 个红→绿确认;3 个落地即绿——其中
    test_deadline_shrinks_monotonically_across_nesting 已核验在未改动的基线上失败
    (assert 9.921 <= 3.0),另两个分别是锁定"自有预算耗尽也归 cancelled"这一取舍、
    以及补充"迟到终态不改变父唯一终态"的覆盖(该机制本就存在,缺的只是覆盖)。
  • 严格 mypy(context.py + loop.py + subagent/tool.py):
    Success: no issues found in 3 source files。
  • Ruff(4 个改动文件):All checks passed!。
  • compileall:通过。
  • git -c core.whitespace=cr-at-eol diff --check:通过。
  • 未运行 TypeScript typecheck / 全量 TS 测试 / build:本 PR 未修改 TypeScript、协议、
    桌面端或构建配置。

Baseline failures fixed by a fixture change

tests/unit/test_subagent_cancellation.py 有两个测试在未改动的 origin/main 上就
稳定失败
(3/3 复现,非抖动):

  • test_max_steps_interrupt_cancels_blocking_child
  • test_interrupt_event_order_and_uniqueness

根因是测试夹具坏了,不是生产代码:_spawn_then_loop_provider() 的
state["spawned"] 标志在父子间共享(SpawnAgentTool 把同一个 provider 对象传给子
Agent),子的第一次调用就走到 unknown_tool 分支并以 repeated_error 自行失败,
被测的"父中断 → 取消 child"路径从未被执行。诊断实测
child status=failed reason=repeated_error。

本 PR 给该夹具加 parent_run_id 参数、按 run_id 分派,使子真正阻塞到被取消。
换成正确夹具后实测生产代码行为本就是正确的(child status=cancelled、
subagent.finished status=cancelled),因此修的是夹具,不是生产代码;
两个测试的断言一字未改。

Known limitations

  • 两个 CI 检查会失败(Node and TypeScript checks、Build Linux packages),
    已核验为基线失败,非本 PR 引入:仓库只有 desktop-linux.yml、
    typescript-ci.yml、website.yml 三个 workflow,没有 Python CI,本 PR 只改
    Python;两个 workflow 在 main 上近期运行全部失败(含 PR-06 的 merge commit),
    失败 job 日志根因均为 npm cache is not found。
  • SubagentFinishedEvent 只有 status,没有 reason 字段
    (bus/events.py:336-341;协议 packages/protocol/src/index.ts:301 同样如此),
    子的具体终止原因无法在该事件上体现,只能经 agent_result 的 reason 文本观察。
    本 PR 未改协议。
  • 非合作 provider 场景下子的取消原因仍是泛化的 "cancelled":若子阻塞在一个不遵守
    remaining_s 的 provider 调用里,它无法自己算出 deadline,只能被父强制取消。
    遵守 remaining_s 的正常 provider 走的是已修好的路径。
  • runner.py 收尾对非 success 终态仍不取消挂起压缩(PR-06 已记录的同一条遗留项),
    与本 PR 无关,未在本次范围内改动。

Checklist

  • 变更范围与关联 Issue 一致,没有混入无关重构
  • 没有提交凭据、日志、缓存、私有源码或本地评测产物
  • 已添加与风险匹配的单元和集成测试(含两个端到端回归测试)
  • 未修改协议,无需重新生成 wire protocol 文档
  • commit 添加 Signed-off-by(DCO)
  • 已在文案中披露基线红测的真实根因是夹具、以及 CI 基线失败

Relates

#69

子 Agent 此前从配置里拿一份全新的完整墙钟预算,与父 Run 的剩余时间无关:
子的 deadline = spawn 时刻 + 完整预算,必然越过父的 deadline,越过量等于
spawn 时父已耗掉的时间。同时 AgentLoop._wait_for_background 是无界 gather,
一次成功的父收尾会被仍在运行的后台子 Agent 拖出预算。

- ExecutionContext 新增 inherited_deadline_at 入口;start() 在继承 deadline 与
  自身 max_wall_clock_s 之间取更早者,任一方都不能放宽另一方
- SpawnAgentTool 派生子 Agent 时传入父的 deadline_at 与 clock(必须同时传,
  否则绝对 deadline 与时钟源不一致)
- _wait_for_background 的 gather 加 Run 剩余时间边界,deadline 到达时取消
  后台子 Agent 并等其收尾
- 子 Agent 撞上 Run deadline 时终态归 CANCELLED 并带具体原因,不再误判为
  FAILED;reason 负责区分"为什么",status 只表达"不是错误失败"
- 前台派生路径的 subagent.finished status 归一化进协议词汇
  ("success" | "failed"),不再吐出协议外的 "interrupted"
- 修 _spawn_then_loop_provider 夹具:原先父子共享一个 spawned 标志,导致子
  自行 repeated_error 失败,"父中断 → 取消 child"路径从未被执行

Refs rojim666#69

Signed-off-by: Ace <[email protected]>
@z2Ace0107
z2Ace0107 requested a review from rojim666 as a code owner September 23, 2026 06:33
@rojim666
rojim666 merged commit 0743e26 into rojim666:main Sep 23, 2026
0 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants