Visitar URL original
fix(agent-core-v2): fire external Stop hooks on completed turns by kimi-agent-bot · Pull Request #4902 · MoonshotAI/kimi-code · GitHub
Skip to content

fix(agent-core-v2): fire external Stop hooks on completed turns - #4902

Open
kimi-agent-bot wants to merge 3 commits into
mainfrom
fix/stop-hook-pending-guard
Open

kimi-agent-bot wants to merge 3 commits into
mainfrom
fix/stop-hook-pending-guard

Conversation

@kimi-agent-bot

@kimi-agent-bot kimi-agent-bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

Requirement or Bug

Resolve #4884

@moonshot-ai/kimi-code 2.6.0 在模型正常结束轮次时从不执行用户配置的外部 Stop hook(涵盖 kimi -p print 模式、TUI 交互模式及基于 kap-server 的服务端会话),同时导致 Stop hook 的阻断续跑机制(退出码 2 或 JSON permissionDecision: "deny")完全失效。本 PR 恢复 Stop hook 在轮次正常收尾且无后续排队请求时的单次触发,并保证阻断续跑在新轮次模型下最多续跑一次、不会跨轮死循环。

Bug Reproduction Steps

See linked issue (#4884).

Root Cause

根本修复。

#4630 迁移至 @moonshot-ai/core Loop 运行时后,在 Stop hook 路径上同时引入了两处耦合回归:

  1. snapshot().hasPendingRequests 计入了当前运行中的轮次,导致 Stop 守卫恒跳过:
    AgentExternalHooksService.registerLoopHooks 在 onDidFinishStep 中以 loop.snapshot().hasPendingRequests 判断是否还有后续排队请求;而 #4630 在 AgentLoopService.hasPendingRequests() 中新增了 this.loop !== undefined && this.loop.state.status !== 'idle'。由于 onDidFinishStep 运行于 AgentTurnProjection.stepStopping 内部,此时当前轮次尚未回到 idle(loop.state.status === 'running'),即便提交队列与通知队列均为空,hasPendingRequests 也恒为 true,使 runStop() 永远无法执行。
  2. 阻断续跑改为开启新轮次后,源轮次 TurnEnded 提前清空了单次续跑标记:
    2.5.3 中 Stop 阻断续跑在同一 turnId 内直接追加下一步,TurnEnded 在续跑步骤完成后才发出;#4630 之后 loop.notify(...) 会先结束当前轮次并发出源轮次的 TurnEnded(turnId = N),随后再调度新的 notify 续跑轮次(turnId = N + 1)。AgentExternalHooksService.notifyTurnEnded 无条件将 stopHookContinuationUsed 重置为 false,导致源轮次刚结束就清除了防重入标记;一旦修复第一处守卫,持续返回阻断结果的 Stop 脚本会在续跑轮次结束时再次触发并陷入无限续跑循环。

既有单测 test/features/externalHooks/integration.test.ts 使用 stubLoopWithHooks() 手动调用 onDidFinishStep.run(...),其桩实现的 hasPendingRequests 不包含 status !== 'idle',且未模拟真实 Loop 先发出源轮次 TurnEnded 再进入续跑轮次的时序,因此未能捕获上述两处问题。

sequenceDiagram
    participant Loop as Core Loop / TurnProjection
    participant Hooks as AgentExternalHooksService
    participant Script as External Stop Hook

    Loop->>Hooks: onDidFinishStep (turnId=N, finishReason="completed", tools=[])
    Hooks->>Loop: snapshot().hasPendingRequests (excludes running status -> false)
    Hooks->>Script: triggerBlock("Stop", { stop_hook_active: false })
    Script-->>Hooks: block(reason)
    Hooks->>Loop: notify(origin={ kind: "system_trigger", name: "stop_hook" })
    Note over Hooks: stopHookContinuationUsed = true<br/>pendingStopContinuationTurnId = N
    Loop->>Hooks: TurnEnded (turnId=N, reason="completed")
    Note over Hooks: Matches source turn N -> keep stopHookContinuationUsed = true
    Loop->>Hooks: onDidFinishStep (turnId=N+1, continuation turn)
    Note over Hooks: stopHookContinuationUsed === true -> skip Second Stop
    Loop->>Hooks: TurnEnded (turnId=N+1, reason="completed")
    Note over Hooks: Continuation turn settled -> reset stopHookContinuationUsed = false
Loading

因为在源轮次 TurnEnded 时保留了 stopHookContinuationUsed,所以紧随其后的 stop_hook 续跑轮次不会重复触发阻断,而在续跑轮次结束(或取消、丢弃)后会归零,不影响下一轮用户输入。

Code Changes

  1. packages/agent-core-v2/src/agent/loop/loopService.ts:将「存在除当前运行轮次外的待处理请求」(hasPendingRequests)与「循环存在活跃轮次或待处理工作」(hasActiveOrPendingWork)拆开。
 AgentLoopService
   hasPendingRequests()
     pendingSubmissions / pendingNotifications
     loop.state.inbox.normal / notify
     loop.state.completionPending / resetting
-    loop.state.status !== 'idle'
+  hasActiveOrPendingWork()
+    hasPendingRequests() || (loop !== undefined && loop.state.status !== 'idle')
   snapshot() -> uses hasPendingRequests()
   initializeHistory / tryAcquireQuiescence / settled / maybeSettle -> use hasActiveOrPendingWork()

这样 LoopSnapshot.state 继续表达当前是否处于 running,LoopSnapshot.hasPendingRequests 恢复 2.5.3 的队列待处理语义,而内部静默锁(tryAcquireQuiescence)、历史初始化守卫与 settled() 的等待条件保持不变。

  1. packages/agent-core-v2/src/features/externalHooks/agent/agentExternalHooksService.ts:排除携带工具调用的中间步骤(兼容 Google GenAI finishReason: "completed" + 函数调用),让 stopHookContinuationUsed 跨越源轮次的 TurnEnded 延续到紧随其后的续跑轮次,并维护 Stop 与 Interrupt 的终态互斥契约。
 onDidFinishStep(ctx)
-  if tool_calls || filtered || loop.snapshot().hasPendingRequests -> return
+  if tool_calls || filtered || ctx.tools.length > 0 || loop.snapshot().hasPendingRequests -> return
   reason = await runStop(ctx)  // marks stopCompletedTurnId = ctx.turnId upon completion
-  if reason !== undefined
+  if reason === undefined || loop.snapshot().hasPendingRequests -> return
   stopHookContinuationUsed = true
+  pendingStopContinuationTurnId = ctx.turnId
+  stopContinuationUnconsumed = true
   loop.notify({ message: stop_hook, onConsume, onDrop })

 notifyTurnEnded(event)
-  stopHookContinuationUsed = false
+  if event.reason === 'cancelled' -> drop unconsumed notify & reset state
+  else if event.turnId === pendingStopContinuationTurnId -> clear pendingStopContinuationTurnId only
+  else if !stopContinuationUnconsumed || event.reason !== 'completed' -> reset stopHookContinuationUsed = false
+  if event.reason === 'cancelled' && !stopCompletedForTurn -> fire Interrupt

这样 Google GenAI 返回 STOP 的工具步骤不会提前触发 Stop;如果在外部 Stop 脚本异步执行期间已有新的用户提交或通知入队,返回后复核 hasPendingRequests 会直接放弃过期的 stop_hook 续跑;若轮次在 Stop 已完成后才收到取消,会主动 drop() 未消费续跑通知且不再重复发出 Interrupt。

Behavior Changes and Affected Users

行为 / 场景 修改前(2.6.0) 修改后 依赖旧行为的人群 退路
配置了外部 Stop hook,轮次正常完成(finishReason 非 tool_calls / filtered 且 ctx.tools 为空)且无排队请求 因 snapshot().hasPendingRequests 在 running 态恒为 true,Stop hook 从不触发 在轮次收尾时触发 1 次 Stop hook(stop_hook_active: false),恢复 2.5.3 行为 全端配置了 [[hooks]] event = "Stop" 或插件 Stop hook 的用户(TUI、kimi -p print 模式、desktop/web、VS Code、ACP、SDK);无合理依赖永不触发的旧缺陷行为 无需(修复 2.6.0 回归;如不需触发可移除对应 Stop hook 配置)
外部 Stop hook 返回阻断(退出码 2 或 JSON permissionDecision: "deny") 因 Stop 不触发而完全不执行续跑(若仅修守卫则会因源轮次 TurnEnded 重置标记而无限续跑) 注入 origin: { kind: "system_trigger", name: "stop_hook" } 消息并启动 1 次续跑轮次;续跑轮次收尾不再重复触发 Stop,完成后重置标记供下一轮用户输入使用 使用阻断型 Stop hook 做自动自检/续跑的外部脚本与全端用户 无需(恢复单次阻断续跑契约)
Google GenAI / VertexAI 等在返回工具调用时携带 finishReason: "completed"(rawFinishReason: "STOP")的中间步骤 被 hasPendingRequests === true 掩盖而未触发 Stop 通过 ctx.tools.length > 0 显式排除,与 OpenAI / Anthropic 的 tool_calls 步骤保持一致,仅在最终无工具调用的完成步骤触发 Stop 使用 google-genai / vertexai provider 并配置了 Stop hook 的全端用户 无需
同一轮次在 Stop hook 已执行完毕后的收尾窗口内被取消 Stop 未执行,取消时触发 Interrupt 丢弃尚未消费的 stop_hook 续跑通知,且不再对已完成 Stop 的同一轮次重复触发 Interrupt;在 Stop 完成前发生的取消仍正常触发 Interrupt 同时配置了 Stop 与 Interrupt hook 的外部自动化脚本用户(保持文档声明的互斥语义) 无需
轮次运行中且无额外排队提交/通知时的 LoopSnapshot.hasPendingRequests true(把当前 running 轮次自身计入 pending) false(仅在存在排队提交、排队通知、completionPending 或 resetting 时为 true;当前运行态仍由 snapshot.state === "running" 与 activeTurnId 表达) 内部调用方:SubagentScopeCacheService、SessionLifecycleService、GoalService、PromptPredictionService 原本均已显式同时判断 state / activeTurnId,行为不变;tryAcquireQuiescence / settled 仍通过 hasActiveOrPendingWork 门控 无需
正常完成轮次收尾与 token_counting.measured wire 记录的相对时序(loop.test.ts 快照) runStop 被同步短路跳过,turn.ended 早于异步 token_counting.measured 落盘 stepStopping 正常执行异步 runStop 检查,token_counting.measured 在 turn.ended 之前落盘(与同文件 filtered 轮次快照一致) 会话持久化与客户端消费均不依赖 turn.ended 事件前后的单个微任务差;wire 载荷结构不变 无需
  • 受影响模块:
    • packages/agent-core-v2/src/agent/loop/loopService.ts
    • packages/agent-core-v2/src/features/externalHooks/agent/agentExternalHooksService.ts
  • 测试覆盖:
    • packages/agent-core-v2/test/agent/loop/coreLoopService.test.ts:验证真实 AgentLoopService 在运行中无排队项时 snapshot().hasPendingRequests === false 且 tryAcquireQuiescence() 仍保持门控,在有排队 submit / notify 时 hasPendingRequests === true,排空后回到 idle + false。
    • packages/agent-core-v2/test/features/externalHooks/integration.test.ts:基于真实 AgentLoopService + ExternalHooksRunnerService 新增三组端到端集成测试,覆盖正常完成触发 1 次、tool_calls 与 Google GenAI STOP 工具步骤及 filtered 结束不触发(filtered 仍触发 StopFailure)、排队用户提交/通知延后触发、异步 Stop 执行期新用户消息入队竞态、无条件退出码 2 阻断续跑单次上限与跨轮恢复,以及 Stop 后取消与 Stop 前取消下 Interrupt 的互斥行为。
    • packages/agent-core-v2/test/agent/loop/loop.test.ts:对齐纯文本完成轮次下 token_counting.measured 在 turn.ended 前落盘的内联快照。
验证明细
  • 真实 CLI kimi -p 端到端前后对比(复刻 Issue Stop hook never fires in 2.6.0 (regression from 2.5.3) #4884 配置):
    • 修复前(main 基线):
      • 普通 Stop hook:stdout = "• OK",hooks.log = ["SESSION_START_FIRED"](Stop 0 次),llmRequests = 1
      • 退出码 2 阻断 Stop hook:stdout = "• OK",hooks.log = ["SESSION_START_FIRED"](Stop 0 次),llmRequests = 1
    • 修复后(本分支构建 apps/kimi-code/dist/main.mjs):
      • 普通 Stop hook:stdout = "• OK",hooks.log = ["SESSION_START_FIRED", "STOP_HOOK_FIRED"](恰好 1 次),llmRequests = 1
      • 退出码 2 阻断 Stop hook:stdout = "• OK\n\n• FOLLOWUP_2",hooks.log = ["SESSION_START_FIRED", "STOP_BLOCK_FIRED"](恰好 1 次、不死循环),llmRequests = 2,第 2 次请求末条消息为 {"role":"user","content":"Run follow-up verification"}
  • pnpm vitest run packages/agent-core-v2/test/agent/loop/loop.test.ts packages/agent-core-v2/test/features/externalHooks/ packages/agent-core-v2/test/agent/loop/coreLoopService.test.ts packages/agent-core-v2/test/agent/task/rpc-events.test.ts packages/agent-core-v2/test/agent/task/idle-notification-repro.test.ts packages/agent-core-v2/test/features/goal/goal.test.ts packages/agent-core-v2/test/session/subagent/subagentScopeCache.test.ts packages/agent-core-v2/test/wire/resume.test.ts apps/kimi-code/test/cli/v2-run-print.test.ts:全部通过。
  • pnpm vitest run packages/agent-core-v2/test/lint/ packages/agent-core-v2/test/app/config/configManifest.test.ts packages/agent-core-v2/test/wire/wireService.test.ts packages/kap-server/test/apiSurface.snapshot.test.ts:全部通过。
  • pnpm run typecheck(全仓 15 包 + CLI + VS Code + vis):0 errors。
  • pnpm run lint --quiet(全仓 3364 文件 oxlint --type-aware):0 errors;pnpm run sherif:No issues found;pnpm -C apps/kimi-code run build && pnpm -C apps/kimi-code run smoke:通过。

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue (external PRs: issue must have a maintainer's /approve).
  • I have added tests that prove my feature works.
  • The behavior-change table above is complete, and every removed behavior or flipped default is named in the changeset and either has an escape hatch or was explicitly approved by a maintainer in this PR.
  • Ran gen-changesets skill, or this PR needs no changeset.
  • Ran gen-docs skill, or this PR needs no doc update.

@changeset-bot

changeset-bot Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 050be07

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-13T03:55:45.224755Z 050be07 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@moonshot-ai/kimi-code@050be07
npx https://pkg.pr.new/@moonshot-ai/kimi-code@050be07

commit: 050be07

@kimi-agent-bot

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 12affaac02

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@kimi-agent-bot

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 050be07cb1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

IvanWng97 added a commit to pixtuoid/pixtuoid that referenced this pull request Nov 13, 2026
…2296)

## Summary

Until now, no Esc-cancelled kimi turn reached the office as a turn end. On a cancel, kimi fires `Interrupt` instead of `Stop`, and `KIMI_EVENTS` never installed `Interrupt`.

- **What a cancel does in kimi:** it ends the turn as `cancelled`. That fires `Interrupt` with `{ turn_id, reason: "cancelled" }` (`packages/agent-core-v2/src/features/externalHooks/agent/agentExternalHooksService.ts:422-424` at `@moonshot-ai/kimi-code@2.5.3`). The aborted step skips the after-step hooks that fire `Stop` (`agent/loop/loopService.ts:1922-1938`, same ref). The docs say the same: "fires in place of `Stop`" (`docs/en/customization/hooks.md:130`).
- **What the user saw:** an Esc while a tool runs leaves the sprite Running on that call until the stale sweep.
  - Each tool runs on its own scope's signal (`human/agent/machine.ts:415-421`), and `PostToolUse` fires with the call's signal (`agentExternalHooksService.ts:358`).
  - Leaving the turn aborts every tool scope in it (`human/agent/machine.ts:750-755` → `:446-457`).
  - `runHook` kills a hook whose signal has already aborted (`features/externalHooks/internal/runHook.ts:120-128`).
  - So that tool's `PostToolUse` never lands. `Interrupt` carries no signal, so it survives the abort: its call passes none (`agentExternalHooksService.ts:423`) to `fireAndForget`'s optional `signal` (`:126`), same ref.
- **Decoding:** `Interrupt` is now in `KIMI_EVENTS`, and `decode_kimi_hook_custom` decodes it the way it decodes `Stop` and `StopFailure`. It emits the session's `Identity`, since it is a per-turn hook like those two, then the shared `Stop` arm's `TurnEnd`.
- **Existing installs:** a kimi install connected before this change has no `Interrupt` entry. Until kimi is reconnected in the Sources panel, it reports `missing hook entries for: Interrupt … reconnect via the Sources panel` at launch and in `doctor` (`install/verify.rs:136-141`), and Esc-cancelled turns still don't end. Launch never merges config on its own: a config merge needs the consent that only `connect` asks for (`install/mod.rs:326-328`). That existing warning already names the reconnect, so this PR adds no `NOTICES` entry.
- **One rule across CLIs:** an interrupted turn end is the same `TurnEnd` a completed one is. codex's `turn_aborted` already shares `EM_TURN_END` with `task_complete` (`source/codex.rs:287`). opencode's `session.execution.interrupted` shares the succeeded/failed arm (`source/opencode.rs:177-181`).
- **New recorded scenario:** kimi's TUI profile now cancels with Esc. In 2.5.3, Esc cancels a streaming turn (`apps/kimi-code/src/tui/controllers/editor-keyboard.ts:259-262`). A `cancel` scenario, recorded at the 2.5.3 pin, pins the wire.
  - The recorded `Interrupt` payload is `hook_event_name`, `session_id`, `cwd`, `client_type`, `session_title`, `turn_id`, `reason`. The strip blanked the last four, which no decoder reads.
  - `screens/cancel.txt` is the run's own `tui_drive` dump of the screen it cancelled at: `HERMETIC-STREAM` painted mid-stream.

## Related issue

None. This does NOT close #2285 (kimi 2.6.x never fires `Stop`). That issue stays open until a kimi release carries upstream MoonshotAI/kimi-code#4902. `Interrupt` is not affected by that regression: it rides the `TurnEnded` subscription (`agentExternalHooksService.ts:247`, `:414-422` at `@moonshot-ai/kimi-code@2.6.1`), not the guarded `onDidFinishStep` path. Tracked on the Roadmap as the draft "kimi: an Esc-cancelled turn ends (Interrupt hook)".

## Type of change

- [x] Bug fix

## How I tested it

- **TDD:** adding `Interrupt` to `KIMI_EVENTS` and its roster pin (`kimi_events_pins_the_exact_registered_set`) turned `every_registered_kimi_event_decodes` red (unsupported event). Adding `Interrupt` rows to the core decoder tests' own tables turned `a_failure_or_cancel_decodes_as_its_shared_arm` and `a_claimed_event_without_session_id_errs_and_breadcrumbs` red. Those two read their own tables, not `KIMI_EVENTS`. The decoder arm turned all three green.
- **Byte proof:** `sources::kimi::a_turn_end_alone_registers_its_session` gains a `("cancel-hermetic", "Interrupt")` row. The recorded `Interrupt` alone registers its session.
- **Replay hermetically (REVIEW.md must-run, hook integration):** ran `just hermetic --record kimi` at the pin (kimi 2.5.3, aimock 1.47.0, macOS, unbilled):
  ```
  == kimi/tool-run      Pass
  == kimi/permission    Pass
  == kimi/cancel        Pass
  == kimi/model-error   Pass
  == kimi (every scenario)  Pass
  kept kimi/tool-run: the run renamed only its ids
  kept kimi/permission: the run renamed only its ids
  recorded kimi/cancel into .../tests/sources/kimi/fixtures/cancel-hermetic
  kept kimi/model-error: the run renamed only its ids
  ```
- **Suites:** `just test -p pixtuoid-core` passed 1841/1841 and `just test -p pixtuoid --lib` passed 1608/1608. The pre-push preflight passed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01FH67SzKfVsCVcrL4qWuycg

This branch has not been deployed

No deployments
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.

Stop hook never fires in 2.6.0 (regression from 2.5.3)

1 participant