Repository navigation
fix(runtime): prevent a step-capped turn from completing past a finish-hook veto #6221
Description
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_stepstop withmaxStepsset ends asstep_limit(failed / tool_step_cap_reached) after only 1 of 100 steps. - An
after_stepstop that arrives during the vetohandoffBoundaryor 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
- The constraint check decides whether the demand is evaluated at all (
ai-sdk-turn.ts:2455). This causes the hook skip. - The terminal reason is inferred afterwards from proxies (
ai-sdk-turn.ts:2608). External stops and tool yields shareloopStopRequested/loopStopReason, and anafter_stepstop sets the flag without a reason (:667). - "Send no request after a stop" is copied onto four
continuebranches (: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}fromTurnYield {reason}. - Emit
completewith 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:
- Set
maxSteps: 1. - Make
beforeTurnFinishalways return{ allow: false, feedback }. - 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
- 约束决定了需求是否被求值(
ai-sdk-turn.ts:2455),因此结束检查被跳过。 - 终止原因在事后通过代理条件反推(
ai-sdk-turn.ts:2608)。外部停止和工具 yield 共用loopStopRequested/loopStopReason,而after_step只设置标志、不设置原因(:667)。 - "停止后不再发请求"这条规则被复制到四个
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 和受控模型:
- 设置
maxSteps: 1。 - 让
beforeTurnFinish总是返回{ allow: false, feedback }。 - 让模型返回文本,
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
fix(runtime): honor finish-hook vetoes at the step cap (apache#6228)