diff --git a/.changeset/stop-hook-messages.md b/.changeset/stop-hook-messages.md new file mode 100644 index 000000000..a2a8f8d55 --- /dev/null +++ b/.changeset/stop-hook-messages.md @@ -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. diff --git a/docs/en/customization/hooks.md b/docs/en/customization/hooks.md index 1f966e341..0b640dcbd 100644 --- a/docs/en/customization/hooks.md +++ b/docs/en/customization/hooks.md @@ -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) | diff --git a/docs/zh/customization/hooks.md b/docs/zh/customization/hooks.md index a506923b9..5c9c3fd06 100644 --- a/docs/zh/customization/hooks.md +++ b/docs/zh/customization/hooks.md @@ -100,7 +100,7 @@ Hook 命令的工作目录是当前会话的项目目录。非 Windows 平台上 | --- | --- | --- | --- | | `UserPromptSubmit` | 用户提交的文本内容 | ✓ | 用户发送消息时触发;返回文本会附加到上下文;若阻断,本轮不调用模型 | | `PreToolUse` | 工具名 | ✓ | 工具调用前触发(权限检查前);阻断后工具不会执行 | -| `Stop` | 空字符串 | ✓ | 模型准备结束本轮时触发;阻断后可追加一条消息让模型继续 | +| `Stop` | 空字符串 | ✓ | 模型准备结束本轮时触发;payload 包含 `messages`(最近的用户/助手对话文本),外部记忆系统可借此学习本轮内容。若阻断,可追加一条消息让模型继续 | | `PostToolUse` | 工具名 | — | 工具成功执行后触发(观察用) | | `PostToolUseFailure` | 工具名 | — | 工具失败或被阻断后触发(观察用) | | `PermissionRequest` | 工具名 | — | 即将等待用户审批前触发(观察用) | diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 847794aaa..f592059c8 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -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'); const stopBlock = await this.agent.hooks?.triggerBlock('Stop', { signal, - inputData: { stopHookActive: stopHookContinuationUsed }, + inputData: { + stopHookActive: stopHookContinuationUsed, + messages: stopMessages, + }, }); signal.throwIfAborted(); if (stopBlock !== undefined) { diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index e47eefa04..8edc27036 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -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'; @@ -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([ {