Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/stop-hook-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@moonshot-ai/kimi-code": patch
---

Stop hook now includes recent `messages` in its payload, enabling external memory systems like Mimir to learn from the current turn.

- Added `messages` field to the `Stop` hook input data, containing the last user/assistant text messages.
- Updated the English and Chinese hook documentation.
- Added a test to verify the payload format.
2 changes: 1 addition & 1 deletion docs/en/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ Only **blockable events** (`PreToolUse`, `Stop`, `UserPromptSubmit`) have return
| --- | --- | --- | --- |
| `UserPromptSubmit` | The text submitted by the user | ✓ | Triggered when the user sends a message; returned text is appended to context; if blocked, the model is not called for this turn |
| `PreToolUse` | Tool name | ✓ | Triggered before a tool call (before permission checks); the tool will not execute if blocked |
| `Stop` | Empty string | ✓ | Triggered when the model is about to end the current turn; if blocked, a message can be appended to let the model continue |
| `Stop` | Empty string | ✓ | Triggered when the model is about to end the current turn; the payload includes `messages` (recent user/assistant text) so memory hooks can learn from the turn. If blocked, a message can be appended to let the model continue. |
| `PostToolUse` | Tool name | — | Triggered after a tool executes successfully (observation only) |
| `PostToolUseFailure` | Tool name | — | Triggered after a tool fails or is blocked (observation only) |
| `PermissionRequest` | Tool name | — | Triggered just before waiting for user approval (observation only) |
Expand Down
2 changes: 1 addition & 1 deletion docs/zh/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ Hook 命令的工作目录是当前会话的项目目录。非 Windows 平台上
| --- | --- | --- | --- |
| `UserPromptSubmit` | 用户提交的文本内容 | ✓ | 用户发送消息时触发;返回文本会附加到上下文;若阻断,本轮不调用模型 |
| `PreToolUse` | 工具名 | ✓ | 工具调用前触发(权限检查前);阻断后工具不会执行 |
| `Stop` | 空字符串 | ✓ | 模型准备结束本轮时触发;阻断后可追加一条消息让模型继续 |
| `Stop` | 空字符串 | ✓ | 模型准备结束本轮时触发;payload 包含 `messages`(最近的用户/助手对话文本),外部记忆系统可借此学习本轮内容。若阻断,可追加一条消息让模型继续 |
| `PostToolUse` | 工具名 | — | 工具成功执行后触发(观察用) |
| `PostToolUseFailure` | 工具名 | — | 工具失败或被阻断后触发(观察用) |
| `PermissionRequest` | 工具名 | — | 即将等待用户审批前触发(观察用) |
Expand Down
19 changes: 16 additions & 3 deletions packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -738,12 +738,25 @@ export class TurnFlow {
return { continue: true };
}

// 3. The external Stop hook gets exactly one continuation; the cap
// is intentionally separate from (and does not cap) goal mode.
// 3. The external Stop hook gets exactly one continuation; the
// cap is intentionally separate from (and does not cap) goal mode.
if (!stopHookContinuationUsed) {
const stopMessages = this.agent.context.messages
.slice(-10)
.map((msg) => ({
role: msg.role,
content: msg.content
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join(''),
}))
.filter((msg) => msg.role === 'user' || msg.role === 'assistant');
Comment on lines +744 to +753

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 Filter Stop payload before applying the 10-message cap

For tool-heavy turns, context.messages alternates assistant tool-call messages and tool results before the final answer, but this takes the last 10 raw messages before removing tool roles. After about five tool exchanges, the actual user prompt is outside that window, and the hook receives mostly empty assistant tool-call entries plus the final answer instead of the documented recent user/assistant text, so memory hooks can store a turn without the request that produced it. Filter to user/assistant text messages before applying the limit.

Useful? React with 👍 / 👎.

const stopBlock = await this.agent.hooks?.triggerBlock('Stop', {
signal,
inputData: { stopHookActive: stopHookContinuationUsed },
inputData: {
stopHookActive: stopHookContinuationUsed,
messages: stopMessages,
},
});
signal.throwIfAborted();
if (stopBlock !== undefined) {
Expand Down
42 changes: 41 additions & 1 deletion packages/agent-core/test/agent/turn.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, mkdtempSync } from 'node:fs';
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { setTimeout as delay } from 'node:timers/promises';
Expand Down Expand Up @@ -737,6 +737,46 @@ describe('Agent turn flow', () => {
]);
});

it('includes recent messages in the Stop hook payload', async () => {
const dir = mkdtempSync(join(tmpdir(), 'kimi-stop-hook-messages-'));
const payloadPath = join(dir, 'stop-payload.json');
const scriptPath = join(dir, 'record-stop-payload.cjs');
writeFileSync(
scriptPath,
[
"const fs = require('node:fs');",
"let input = '';",
"process.stdin.on('data', (d) => { input += d; });",
"process.stdin.on('end', () => { fs.writeFileSync(process.argv[2], input); });",
'',
].join('\n'),
'utf-8',
);
const hookEngine = new HookEngine([
{
event: 'Stop',
command: `node ${JSON.stringify(scriptPath)} ${JSON.stringify(payloadPath)}`,
},
]);
const ctx = testAgent({ hookEngine });
ctx.configure();
ctx.mockNextResponse({ type: 'text', text: 'Assistant reply.' });

await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] });
await ctx.untilTurnEnd();

const payload = JSON.parse(
existsSync(payloadPath) ? readFileSync(payloadPath, 'utf-8') : '{}',
);
expect(payload.hook_event_name).toBe('Stop');
expect(payload.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({ role: 'user', content: 'hello' }),
expect.objectContaining({ role: 'assistant', content: 'Assistant reply.' }),
]),
);
});

it('uses a Stop hook block reason as a one-shot turn continuation', async () => {
const hookEngine = new HookEngine([
{
Expand Down