Skip to content

feat: improve same-turn steer interactions - #4762

Open
sailist wants to merge 7 commits into
release/2026-11-09from
feat-344-11-09-steer-step-interrupt
Open

sailist wants to merge 7 commits into
release/2026-11-09from
feat-344-11-09-steer-step-interrupt

Conversation

@sailist

@sailist sailist commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

Requirement or Bug

使用者在 LLM 流式请求期间 steer 时,此前必须等待当前 step 自然结束;客户端直接 steer 新消息也需要先 submit,再按 prompt id 发起第二次请求。这个 PR 让有效 steer 只中断当前 LLM 请求,并在原 turn 的下一 step 消费消息,同时提供单请求 direct steer REST 接口。工具调用的流式生成与正式执行阶段仍保持原来的 drain 边界,不取消工具。

Bug Reproduction Steps

N/A

Root Cause

N/A

Code Changes

核心思路是把“是否打断”留在 turn 状态机内部:只有 agent 处于 running.active 且 steer 命中有效排队 prompt 时,才向当前 turn 发送内部 turn.steer。LLM 文本输出或重试阶段进入 draining,而不是结束整个 turn;一旦 accumulator 出现 tool-call,或已经进入正式 acting,则不中断当前请求或工具。

flowchart TD
    A[有效 steer 命中排队 prompt] --> B{当前 step 阶段}
    B -->|文本 thinking / retrying| C[结束 LLM 请求并进入 draining]
    B -->|tool-call 流式生成 / acting| D[不取消请求或工具,等待 step 自然结束]
    C --> E[保留已流出的 thinking / text]
    E --> F[Loop 以 steered 原因关闭当前 step]
    D --> G[按原 drain 边界物化 steer]
    F --> H[原 turn 的下一 step 消费 steer]
    G --> H
Loading

LoopService 用独立的 steerInterruptStep 标记这次由 steer 触发的中断,并发出独立的 steered step 结束原因,避免影响 retry、recovery 和用户 cancel。异步 onWillBeginStep 尚未结算时,gate 同时监听 turn 与 steer signal;旧 hook 失败后不会把已经推进的 step 写成 stale failedStep。TUI 对 steered 只完成流式 UI 收尾,不再显示“用户取消”或清理活动 Agent/Swarm;transcript 将 step 记为 interrupted 并保留 steered endReason,最终 turn 仍正常 completed。steer 消息仍带 inTurn,turn.steer 仍在原 turn 确认,prompt 最终按正常结果结算。

tracked prompt、goal 和后台任务等 untracked message 启动的活动 turn 都采用同一 steer 准入规则:是否可 steer 由当前是否存在活动 turn 决定,不再要求 prompt tracked;普通 queued 输入的归属与结算逻辑不变。

kap-server 将普通 prompt 提交处理器抽成共享的 submit(directSteer):原 POST /sessions/{session_id}/prompts 使用普通提交,新增 POST /sessions/{session_id}/steer 启用 direct steer。两条路径共享请求 schema、媒体、Skill、profile、环境、幂等和错误处理;原按 id steer 接口不变。

阅读顺序:

  • packages/agent-core-v2/src/human/agent/machine.ts:有效 steer 的队列搬移与中断闸门。
  • packages/agent-core-v2/src/human/agent/turn.ts 与 human/llm/message.ts:文本、tool-call 流式生成和正式工具阶段的边界。
  • packages/agent-core-v2/src/agent/loop/loopService.ts:steered step 收尾、异步 gate 竞态处理和 untracked 活动 turn 准入。
  • packages/agent-core-v2/src/agent/loop/projection.ts:inTurn 消息保持独立,避免改写已发送请求的 prompt-cache 前缀。
  • apps/kimi-code/src/tui/controllers/session-event-handler.ts:steer 与真实用户取消的不同展示。
  • packages/kap-server/src/routes/prompts.ts:direct steer 与普通提交共享的完整链路。

其余改动是对应行为测试、API surface 快照、双语文档和一条 patch changeset。

Behavior Changes and Affected Users

Behavior Before After Who relies on the old behavior Escape hatch
LLM 文本输出或重试期间的有效 steer 等待当前 step 自然结束后再消费 中断当前 LLM 请求,当前 step 以 steered 收尾,并在原 turn 的下一 step 消费;已流出的 thinking / text 按现有规则保留 通过 TUI、desktop、web、ACP 或 SDK 在模型输出期间 steer 的使用者 无;这是本 PR 明确调整的交互时机
同 turn steer 后的请求历史 相邻 user 消息投影可能把 steer 合并进已发送的首条 prompt,改写 prompt-cache 前缀 inTurn 消息保持独立,下一请求只在原历史后追加,不改写已发送内容 使用 prompt cache 的 Kimi 及兼容 provider 请求 无;这是缓存正确性修复,消息内容与 turn 归属不变
tool-call 流式生成与正式工具执行阶段的 steer 等待当前 step 结束 保持不变;accumulator 已出现 tool-call 时也不中断 LLM 请求 在工具调用期间 steer 的客户端及后台任务工作流 不需要;原行为保留
steer 截断后的 UI 与 step 记录 复用 aborted,TUI 显示用户取消 使用独立 steered;TUI 不显示用户取消,transcript 保留该 endReason,最终 turn 正常 completed TUI、desktop、web 及读取 transcript 的客户端 无;这是取消误报修复,真实用户取消仍使用 aborted
异步 step hook 与 steer 并发 旧 gate 的 hook 失败可能写入 stale failedStep steer 推进 gate 后,旧 hook 的失败不会错误终止新 step 注册 onWillBeginStep 的 hooks 与使用 steer 的客户端 无;这是竞态修复,真实当前 step 失败仍按原路径处理
goal、后台任务等 untracked 活动 turn 的 steer 因 prompt 未 tracked 而排队或拒绝按 id steer 与 tracked 活动 turn 一样,可按当前阶段立即中断或等待 drain 使用 goal、后台任务并通过客户端 steer 的使用者 无;扩展到此前不接受 steer 的活动 turn
未命中有效排队 prompt 的 steer 不中断当前 LLM 请求 保持不变;running.active 与有效匹配项双重闸门避免误中断 使用过期或未知 prompt id 的客户端 不需要;原行为保留
直接 steer 新消息内容 客户端先 submit,再按 prompt id 调用 steer 可调用 POST /api/v1/sessions/{session_id}/steer 一次完成,复用普通提交字段与返回结构 desktop、web 和其他 REST 客户端 原 submit + 按 id steer 流程保留
普通 prompt 与既有 steer REST 接口 使用原有提交和按 id steer 契约 保持不变;仅复用共享处理器 现有 REST 客户端与外部脚本 不需要;原路径和 schema 保留

受影响模块与覆盖:

  • agent machine / turn:覆盖 LLM signal 取消、部分内容保留、只启动一个 turn、无 turn.aborted、tool-call 流式阶段不断流,以及正式工具 signal 不取消。
  • Loop / prompt:覆盖当前 step 以 steered 收尾一次、同 turn turn.steer、下一请求消费 steer、prompt 正常 completed、连续 steer 的 FIFO 和身份归属、untracked 活动 turn、stale gate 失败后继续执行,以及 release prompt-cache guard 的 append-only 前缀检查。
  • TUI / transcript:覆盖 steered 与真实 aborted 的区别;TUI 不显示用户取消、不清理活动 Agent/Swarm,transcript 记录 interrupted step 的 steered endReason。
  • Skill 与 WaitFor:覆盖 steer 后的真实调用次数、turn 编号和任务结算,不增加测试声明总数。
  • kap-server direct API:覆盖 direct 单请求提交、steerIfActive=true、消息进入历史,以及 API surface 快照。
  • 构建与静态检查:相关 core / kap-server / CLI typecheck、正式 build、oxlint 和 comment-free 检查均已通过。
验证记录
  • 最终 release head:machine、prompt、activateSkill、kap-server prompts、TUI message-flow 五份文件 598/598 通过,无未处理异常;WaitFor 51/51 通过;core、kap-server、CLI typecheck 全部通过,包含 release prompt-cache guard。
  • agent machine + prompt:67/67 通过;untracked 活动 turn 相关 machine、prompt、Skill 回归:83/83 通过。
  • kap-server prompts:80/80 通过;API surface:1/1 通过。
  • 两份 Skill 测试:29/29 通过;WaitFor:51/51 通过。
  • 确定性 TUI play 覆盖文本流立即截断、tool-call 流式阶段继续、正式工具继续运行和真实用户取消;native 二进制复跑通过。
  • 此前 main 基线全量回归:867 个测试文件通过、2 个失败、5 个跳过;17409 个用例通过、2 个失败、28 个跳过。所有 steer、direct API 及相关回归均通过;失败仅为不在本 PR diff 中的既有 remote/process 两项进程组清理时序问题,全量结果不宣称全绿。
  • 中英文 server API 文档已同步,VitePress 文档构建通过。

Checklist

  • I have read the CONTRIBUTING document.
  • Requirement context is included above; no external issue applies.
  • 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.
  • Updated the English and Chinese server API reference; VitePress build passed.

@changeset-bot

changeset-bot Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 60981bd

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 9, 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-09T13:54:34.248679Z 60981bd New commits
ℹ️ 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 9, 2026 •

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

commit: 60981bd

@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: acba0bcead

ℹ️ 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".

Comment on lines +1048 to +1056
if (turn.steerInterruptStep !== undefined) {
const current = turn.current;
if (current?.number === turn.steerInterruptStep) {
this.closeFailedMachineStep(turn, current, 'interrupted');
turn.current = undefined;
turn.interruptStep = undefined;
this.emitStepInterrupted(turn.id, current.number, 'aborted');
}
turn.steerInterruptStep = undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve steers when the step cap is exhausted

When max_steps_per_turn equals the current step number (for example, it is 1 during the first request), this new interruption path proceeds to another gate, but the existing stepOrdinal > maxSteps check rejects that gate because the steer nudge has bypassMaxSteps: false. Before this change, steering during the request left the prompt queued to seed a new turn; now the same configured users get a max_steps failure after their input has been moved into the active turn, with no model response. Either defer the steer to a new turn when the cap is exhausted or explicitly allow its continuation step.

AGENTS.md reference: AGENTS.md:L109-L111

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Leaving the step-cap behavior unchanged in this PR. max_steps_per_turn remains a hard cap: steering does not grant an extra step or reroute the input into a new turn. This is an intentional scope decision, not an attempt to bypass the configured limit.

Comment thread .changeset/calm-pandas-steer.md Outdated
"@moonshot-ai/kimi-code": patch
---

Make steering interrupt active model requests and continue in the same turn.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include the direct-steer API in the changeset

The release note only describes interrupting an active model request, so it omits the other user-facing feature added by this commit: clients can now use POST /api/v1/sessions/{session_id}/steer instead of the two-request submit-and-steer flow. Mention the new endpoint and how to use it so the published changelog states the complete perceptible change.

AGENTS.md reference: AGENTS.md:L88-L89

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Updated .changeset/calm-pandas-steer.md to include POST /api/v1/sessions/{session_id}/steer and the one-request message-steering behavior.

Comment on lines +699 to +705
'turn.steer': {
target: 'draining',
actions: [
({ context }) => {
context.llmScope.abort();
},
'salvageSteered',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wait for the interrupted gate before starting the next step

When a steer arrives while the request is still awaiting an asynchronous onWillBeginStep hook, this transition immediately enters draining and starts the continuation gate, but aborting llmScope does not abort the hook because gate() passes the turn-level step.signal. The old and new gates can therefore run concurrently; if the old hook later throws, failMachineGate() records a stale failedStep and the continued turn fails even though that step was already interrupted. This is reachable during asynchronous pre-step work such as automatic compaction, so wait for the old gate to settle or give it a steer-abortable signal before starting the continuation.

AGENTS.md reference: AGENTS.md:L109-L111

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in d7893f7e14. onWillBeginStep now receives a signal composed from turn cancellation and steering. A steer aborts the pending gate before advancing, and late failures from an obsolete gate no longer populate failedStep. The regression test drives two overlapping gates and verifies that the stale failure does not fail the continued turn. promptService is 33/33 and core typecheck passes.

tags: ['prompts'],
operationId: 'steerPromptContent',
},
submit(true),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Separate direct-steer idempotency from prompt submission

When a client first sends a body with a fixed prompt_id to /prompts and then sends the identical body to the new /steer endpoint, both requests are fingerprinted as { route: 'prompts', body } by the shared handler. beginPromptAttempt() therefore returns the first cached response and this submit(true) path never runs, so the second request reports success while leaving the prompt queued rather than steering it. Give the direct endpoint a distinct route discriminator so cross-endpoint prompt-id reuse is rejected as a conflict instead of silently replaying a semantically different operation.

AGENTS.md reference: AGENTS.md:L121-L123

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Leaving the shared idempotency behavior unchanged in this PR. The direct Ctrl-S client creates a fresh prompt ID and sends only /steer, so it never reuses an ID across /prompts and /steer. Cross-endpoint ID reuse is out of scope for this change.

- interrupt only the current llm step and consume steer in the same turn
- add a direct session steer endpoint using the prompt submission flow
- update API documentation and regression coverage
- distinguish steered steps from user cancellation
- preserve streaming tool calls until the drain boundary
- allow steer delivery to active untracked turns
- abort pending step hooks when steer advances the gate
- prevent stale hook failures from failing the active step
- document the direct steer endpoint in the changeset
@sailist
sailist force-pushed the feat-344-11-09-steer-step-interrupt branch from acba0bc to d7893f7 Compare November 9, 2026 13:36
@sailist
sailist changed the base branch from main to release/2026-11-09 November 9, 2026 13:36
- keep in-turn user messages separate during request projection
- avoid rewriting the previous request prompt-cache prefix
- drop a test that depends on skill variants unavailable in this release
- preserve the accumulator part returned to the loop engine
- retain tool-call detection without changing the streaming contract

@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: 60981bd617

ℹ️ 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".

if (turn.controller.signal.aborted || isAbortError(error) || step.signal.aborted) {
turn.abortReason = turn.controller.signal.aborted ? turn.controller.signal.reason : error;
if (turn.controller.signal.aborted || isAbortError(error) || gateSignal.aborted) {
if (turn.controller.signal.aborted) turn.abortReason = turn.controller.signal.reason;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve hook-originated abort reasons

When an onWillBeginStep hook rejects with an AbortError without the turn or steer signal being aborted—for example, because the hook cancels its own asynchronous work—this branch returns fail but no longer records error as turn.abortReason. The requester consequently converts the decision into the generic GATE_FAILURE, so the turn is reported as failed instead of retaining the previous cancellation behavior and original reason. Suppress the reason only for an obsolete steer-aborted gate, while preserving independent hook aborts.

AGENTS.md reference: AGENTS.md:L110-L112

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Acknowledged. This is reachable in the TUI when automatic compaction blocks a step and the user cancels only that compaction with Esc/Ctrl+C, without cancelling the turn. We did not find an equivalent desktop/web flow. We’re treating this as a corner case and leaving it out of scope for this PR rather than changing the gate behavior again.

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.

1 participant