Skip to content

fix(runtime): prevent a step-capped turn from completing past a finish-hook veto #6221

Closed

Description

@Colafornia

What happened

When a turn has a finite maxSteps and the model's last step returns text, AiSdkTurn skips beforeTurnFinish and emits complete(end_turn). The run is recorded as completed, even if a finish hook (Bot Inbox/Sleep, proactive-matters settlement, plugin hooks) would have vetoed it.

Expected: a turn that a finish hook rejects never ends as a success. If no budget is left, it ends as step_limit.

Two latent defects have the same cause. No production caller triggers them today, because nothing passes mode: 'after_step':

  • An after_step stop with maxSteps set ends as step_limit (failed / tool_step_cap_reached) after only 1 of 100 steps.
  • An after_step stop that arrives during the veto handoffBoundary or the provider retry sleep still sends another provider request.

Why now: #6004 introduced the finish-hook bypass one week ago by copying the gating of neighboring branches, and open PRs #4138 and #5594 add more exits to the same section.

Root cause

After each step, the loop must answer three separate questions: is work left (demand), did a tool end the turn (yield), and may the turn continue (constraint). The code does not represent this decision as a value:

step settled
  │
  ├─ mayTakeAnotherStep = budget left && !stop        ← constraint computed first
  │
  ├─ tool calls  && may ─► handoff ─► recheck stop ─► continue
  ├─ prereq miss        ─► !may ? throw : handoff ─► recheck ─► continue
  ├─ steering    && may ─► handoff ─► recheck stop ─► continue
  ├─ text        && may ─► beforeTurnFinish ─veto─► handoff ─► continue    ✗ no recheck
  │      (!may ⇒ hook never runs)                                          ✗
  └─ break agentLoop        (carries no reason)
        │
        ▼
  stopReason = loopStopReason
            ?? (maxSteps !== undefined && finishReason === 'tool-calls')   ✗ "configured" ≠ "spent"
                 ? 'step_limit'
                 : mapFinishReason(finishReason)                           ✗ veto + text ⇒ end_turn
  1. The constraint check decides whether the demand is evaluated at all (ai-sdk-turn.ts:2455). This causes the hook skip.
  2. The terminal reason is inferred afterwards from proxies (ai-sdk-turn.ts:2608). External stops and tool yields share loopStopRequested/loopStopReason, and an after_step stop sets the flag without a reason (:667).
  3. "Send no request after a stop" is copied onto four continue branches (:2369, :2401, :2442, :2469). It is not enforced at the single request seam, startStream (:1680). The retry sleep (:2156) and overflow recovery have no check.

Adding more rechecks or proxy conditions keeps this structure. Each new branch or await reopens the same class of defect.

Proposed direction

Replace only the step-boundary decision (:2336–2482) and the terminal derivation (:2608):

step settled ─► facts { yield?, demand?, constraint? }
                  (a finish candidate always consults beforeTurnFinish, whatever the budget)
                    │
                    ▼
             decideStep(facts)   ← pure, finite domain
          ┌─────────┼──────────────┐
  terminate(reason) continue      fail
                    │
            crossStepBoundary()   ← one copy, handles pause only
                    │
     startStream guard: stop latched? ─► terminate(user_stop)
  • Separate StopRequest {mode, reason} from TurnYield {reason}.
  • Emit complete with the decided reason. Delete the proxy inference.
  • Leave out of scope: provider streaming, tool settlement, T1/T2, and the Host Run/Turn/Session layering.

Open questions: whether a missing loadTurnRuntimeEvents should count as a constraint instead of a reason to skip the hook, and whether the Bot beforeFinish writes stay correct when the hook also runs at the step cap.

How to reproduce

Use AiSdkBackend with a controlled model:

  1. Set maxSteps: 1.
  2. Make beforeTurnFinish always return { allow: false, feedback }.
  3. Make the model return text with finishReason: 'stop'.

Result: 1 request, 0 hook calls, complete(end_turn), mapped to status=completed. With maxSteps 2 or 3, the hook runs only 1 or 2 times, and the turn still ends as end_turn.

This path is reachable through turn.start maxSteps, the CLI --max-steps flag, and continuation after a handoff. A full assistant-workflow E2E run was not done.

Environment

  • Commit: dc4515cd6
  • OS: macOS 26.3.1
  • Surface: Runtime (packages/runtime)
  • Node.js: v24.21.0

Additional context

Verification plan: a table test over all decideStep inputs, plus three AiSdkBackend wiring regressions (one per defect above) and the verify step-limit / stop / steering journeys.

Related: #3802 (provider finish-reason semantics, one layer below), #5594 (proactive-matters finish hook).

简体中文

发生了什么

当 Turn 设置了有限的 maxSteps,且模型最后一步返回文本时,AiSdkTurn 会跳过 beforeTurnFinish,直接发出 complete(end_turn)。即使结束检查(Bot 的 Inbox/Sleep、proactive-matters 的结算检查、插件检查)本应拒绝结束,这次运行也会被记为 completed。

期望:被结束检查拒绝的 Turn 不能以成功结束;没有剩余步数时应以 step_limit 结束。

同一根因还导致两个潜伏缺陷。目前没有生产调用方传 mode: 'after_step',所以生产上触发不到:

  • 设置了 maxSteps 时,after_step 停止只跑了 100 步中的 1 步,却以 step_limit(failed / tool_step_cap_reached)结束。
  • 如果 after_step 停止在结束检查拒绝后的 handoffBoundary 期间,或在 provider 重试退避期间到达,循环仍会再发一次 provider 请求。

为什么现在做:#6004 一周前照搬相邻分支的前提条件,引入了结束检查被绕过的问题;开着的 #4138、#5594 还在往同一段代码增加出口。

根因

每一步结束后,循环需要回答三个独立的问题:还有没有工作(demand),工具有没有要求结束(yield),是否允许继续(constraint)。现有代码没有把这个决策表示成一个值:

step settled
  │
  ├─ mayTakeAnotherStep = budget left && !stop        ← 先算约束
  │
  ├─ tool calls  && may ─► handoff ─► recheck stop ─► continue
  ├─ prereq miss        ─► !may ? throw : handoff ─► recheck ─► continue
  ├─ steering    && may ─► handoff ─► recheck stop ─► continue
  ├─ text        && may ─► beforeTurnFinish ─veto─► handoff ─► continue    ✗ 未重验
  │      (!may ⇒ 检查不运行)                                               ✗
  └─ break agentLoop        (不携带原因)
        │
        ▼
  stopReason = loopStopReason
            ?? (maxSteps !== undefined && finishReason === 'tool-calls')   ✗ 配置了 ≠ 用完了
                 ? 'step_limit'
                 : mapFinishReason(finishReason)                           ✗ 拒绝 + 文本 ⇒ end_turn
  1. 约束决定了需求是否被求值(ai-sdk-turn.ts:2455),因此结束检查被跳过。
  2. 终止原因在事后通过代理条件反推(ai-sdk-turn.ts:2608)。外部停止和工具 yield 共用 loopStopRequested/loopStopReason,而 after_step 只设置标志、不设置原因(:667)。
  3. "停止后不再发请求"这条规则被复制到四个 continue 分支(:2369、:2401、:2442、:2469),而不是放在唯一的发请求出口 startStream(:1680)。重试退避(:2156)和溢出恢复都没有检查。

继续补重验或补代理条件只会保留这种结构,以后每增加一个分支或一个 await,都会重新打开同类缺陷。

建议方向

只替换步边界决策(:2336–2482)和终态推导(:2608):

step settled ─► facts { yield?, demand?, constraint? }
                  (出现结束候选时总是调用 beforeTurnFinish,与预算无关)
                    │
                    ▼
             decideStep(facts)   ← 纯函数,有限域
          ┌─────────┼──────────────┐
  terminate(reason) continue      fail
                    │
            crossStepBoundary()   ← 只保留一份,只处理 pause
                    │
     startStream guard: stop latched? ─► terminate(user_stop)
  • 把 StopRequest {mode, reason} 和 TurnYield {reason} 分开。
  • complete 直接使用决策出的原因,删除代理推断。
  • 不在范围内:provider 流、工具结算、T1/T2,以及 Host 的 Run/Turn/Session 分层。

待确认:缺少 loadTurnRuntimeEvents 时,应该算作约束,而不是跳过结束检查的理由;结束检查在步数上限处也会运行后,Bot beforeFinish 的写操作是否仍然正确。

如何复现

使用 AiSdkBackend 和受控模型:

  1. 设置 maxSteps: 1。
  2. 让 beforeTurnFinish 总是返回 { allow: false, feedback }。
  3. 让模型返回文本,finishReason: 'stop'。

结果:1 次请求,结束检查调用 0 次,发出 complete(end_turn),映射为 status=completed。maxSteps 为 2、3 时,结束检查分别只运行 1、2 次,最终同样是 end_turn。

生产上可以经由 turn.start 的 maxSteps、CLI 的 --max-steps 参数,以及 handoff 之后的继续执行走到这条路径。尚未运行完整的助手工作流端到端验证。

环境

  • Commit:dc4515cd6
  • 操作系统:macOS 26.3.1
  • 范围:Runtime(packages/runtime)
  • Node.js:v24.21.0

其他信息

验证计划:对 decideStep 的全部输入做表驱动测试,加上三个 AiSdkBackend 接线回归测试(上面三个缺陷各一个),以及 verify 的 step-limit / stop / steering 路线。

相关:#3802(provider finish reason 语义,属于下一层),#5594(proactive-matters 结束检查)。

Activity

  1. self-assigned this
    on Oct 21, 2026
  2. added a commit that references this issue on Oct 22, 2026
    2701647
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions