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
5 changes: 5 additions & 0 deletions .changeset/bright-hooks-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Include the final assistant message in Stop hook payloads.
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 the final assistant text as `last_assistant_message`; 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 通过 `last_assistant_message` 提供最后一条助手文本;阻断后可追加一条消息让模型继续 |
| `PostToolUse` | 工具名 | — | 工具成功执行后触发(观察用) |
| `PostToolUseFailure` | 工具名 | — | 工具失败或被阻断后触发(观察用) |
| `PermissionRequest` | 工具名 | — | 即将等待用户审批前触发(观察用) |
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1004,7 +1004,7 @@ export interface AgentStateSnapshot {
'llmRequester.lastConfigLogSignature': string | undefined;
'llmRequester.mediaDegradedTurns': Set<number>;
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
readonly "__@mediaStripSnapshotBrand@2376": undefined;
readonly "__@mediaStripSnapshotBrand@2378": undefined;
}>;
'llmRequester.turnConfigs': Map<number, /* TurnRequestConfig — packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts */ {
readonly resolved: /* ProfileModelContext — packages/agent-core-v2/src/agent/profile/profile.ts */ {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import type { ResolvedToolExecutionHookContext, ToolDidExecuteContext } from '#/
import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { toKimiErrorPayload } from '#/errors';
import { extractText } from '#/kosong/contract/message';
import { ISessionContext } from '#/session/sessionContext/sessionContext';

import { IAgentExternalHooksService } from './externalHooks';
Expand Down Expand Up @@ -352,11 +353,15 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
private async runStop(ctx: AfterStepContext): Promise<string | undefined> {
ctx.signal.throwIfAborted();
if (this.stopHookContinuationUsed) return undefined;
const lastAssistant = this.context.get().findLast((entry) => entry.role === 'assistant');

const block = await this.runner.triggerBlock('Stop', {
signal: ctx.signal,
sessionId: this.sessionContext.sessionId,
inputData: { stopHookActive: false },
inputData: {
stopHookActive: false,
last_assistant_message: lastAssistant === undefined ? '' : extractText(lastAssistant),
},
});
ctx.signal.throwIfAborted();
return block?.reason;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,12 @@ describe('IExternalHooksRunnerService integration', () => {
ix.set(IAgentExternalHooksService, new SyncDescriptor(AgentExternalHooksService));
ix.get(IAgentExternalHooksService);
const eventBus = ix.get(IEventBus);
const finalAssistantMessage = 'release is ready';
context.append({
role: 'assistant',
content: [{ type: 'text', text: finalAssistantMessage }],
toolCalls: [],
});

const signal = new AbortController().signal;
const filtered: AfterStepContext = {
Expand All @@ -290,7 +296,7 @@ describe('IExternalHooksRunnerService integration', () => {
await loop.hooks.onDidFinishStep.run(filtered);
expect(loop.hasPendingRequests()).toBe(false);
expect(stopInputs).toEqual([]);
expect(context.messages).toEqual([]);
expect(context.messages).toHaveLength(1);

const first = makeAfterStep(signal);
await loop.hooks.onDidFinishStep.run(first);
Expand All @@ -307,7 +313,12 @@ describe('IExternalHooksRunnerService integration', () => {
const second = makeAfterStep(signal);
await loop.hooks.onDidFinishStep.run(second);
expect(loop.hasPendingRequests()).toBe(false);
expect(stopInputs).toEqual([{ stopHookActive: false }]);
expect(stopInputs).toEqual([
{
stopHookActive: false,
last_assistant_message: finalAssistantMessage,
},
]);

eventBus.publish({
type: 'turn.ended',
Expand All @@ -327,7 +338,16 @@ describe('IExternalHooksRunnerService integration', () => {
}),
);
expect(loop.drainNextBatch(context)).toBeDefined();
expect(stopInputs).toEqual([{ stopHookActive: false }, { stopHookActive: false }]);
expect(stopInputs).toEqual([
{
stopHookActive: false,
last_assistant_message: finalAssistantMessage,
},
{
stopHookActive: false,
last_assistant_message: finalAssistantMessage,
},
]);
} finally {
ix?.dispose();
disposables.dispose();
Expand Down
10 changes: 9 additions & 1 deletion packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
APIProviderQuotaExhaustedError,
APIStatusError,
APITimeoutError,
extractText,
inputTotal,
isContextOverflowStatusError,
type ContentPart,
Expand Down Expand Up @@ -942,9 +943,16 @@ export class TurnFlow {
// 3. The external Stop hook gets exactly one continuation; the cap
// is intentionally separate from (and does not cap) goal mode.
if (!stopHookContinuationUsed) {
const lastAssistant = this.agent.context.history.findLast(
(entry) => entry.role === 'assistant',
);
const stopBlock = await this.agent.hooks?.triggerBlock('Stop', {
signal,
inputData: { stopHookActive: stopHookContinuationUsed },
inputData: {
stopHookActive: stopHookContinuationUsed,
last_assistant_message:
Comment thread
ousamabenyounes marked this conversation as resolved.
lastAssistant === undefined ? '' : extractText(lastAssistant),
},
});
signal.throwIfAborted();
if (stopBlock !== undefined) {
Expand Down
31 changes: 31 additions & 0 deletions packages/agent-core/test/agent/turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1466,6 +1466,37 @@ describe('Agent turn flow', () => {
expect(JSON.stringify(ctx.agent.context.data().history)).toContain('Second answer.');
});

it('includes the final assistant message in the Stop hook payload', async () => {
const script = [
"let input='';",
"process.stdin.on('data',chunk=>input+=chunk);",
"process.stdin.on('end',()=>{",
'const payload=JSON.parse(input);',
"process.stdout.write(JSON.stringify({hookSpecificOutput:{permissionDecision:'deny',permissionDecisionReason:payload.last_assistant_message}}));",
'});',
].join('');
const hookEngine = new HookEngine([
{
event: 'Stop',
command: `node -e ${JSON.stringify(script)}`,
},
]);
const ctx = testAgent({ hookEngine });
ctx.configure();
ctx.mockNextResponse({ type: 'text', text: 'Final answer for the hook.' });
ctx.mockNextResponse({ type: 'text', text: 'Continued after the hook.' });

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

expect(ctx.agent.context.data().history).toContainEqual({
role: 'user',
content: [{ type: 'text', text: 'Final answer for the hook.' }],
toolCalls: [],
origin: { kind: 'system_trigger', name: 'stop_hook' },
});
});

it('cancels while waiting for a Stop hook', async () => {
const dir = mkdtempSync(join(tmpdir(), 'kimi-stop-hook-'));
const marker = join(dir, 'started');
Expand Down