From 3a0e06031ac6dfde148f64906a06cfe820ad9c63 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 29 May 2026 14:43:17 +0800 Subject: [PATCH] fix: project persisted context messages (#195) --- .changeset/project-context-messages.md | 6 + docs/en/customization/hooks.md | 6 +- docs/zh/customization/hooks.md | 6 +- .../agent-core/src/agent/context/projector.ts | 134 +++--------------- .../agent-core/test/agent/context.test.ts | 121 +++++++++++----- packages/agent-core/test/agent/turn.test.ts | 8 +- .../test/session/subagent-host.test.ts | 3 +- 7 files changed, 119 insertions(+), 165 deletions(-) create mode 100644 .changeset/project-context-messages.md diff --git a/.changeset/project-context-messages.md b/.changeset/project-context-messages.md new file mode 100644 index 000000000..45692444e --- /dev/null +++ b/.changeset/project-context-messages.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Project persisted hook and blocked prompt messages into model context. diff --git a/docs/en/customization/hooks.md b/docs/en/customization/hooks.md index 404261b3a..b3fbffe24 100644 --- a/docs/en/customization/hooks.md +++ b/docs/en/customization/hooks.md @@ -84,7 +84,7 @@ The following events are triggered automatically today: | Event | Matcher | Main payload | Behavior | | --- | --- | --- | --- | -| `UserPromptSubmit` | Text content submitted by the user | `prompt` (`ContentPart[]` array) | Fires only for real user messages. Text returned by the hook is wrapped as a hook result, written into session history for transcript/replay, shown to the user, and the current LLM turn continues without sending the hook result to the model; if the hook blocks, the block reason is returned to the user as an assistant message and no model call is made; if all hooks produce no output, the normal LLM turn continues | +| `UserPromptSubmit` | Text content submitted by the user | `prompt` (`ContentPart[]` array) | Fires only for real user messages. Text returned by the hook is wrapped as a hook result, written into session history for transcript/replay, shown to the user, and included in model context before the current LLM turn continues; if the hook blocks, the block reason is returned to the user as an assistant message and no model call is made for that turn; if all hooks produce no output, the normal LLM turn continues | | `PreToolUse` | Tool name | `tool_name`, `tool_input`, `tool_call_id` | Fires before permission checks. If blocked, the tool does not run | | `PostToolUse` | Tool name | `tool_name`, `tool_input`, `tool_call_id`, `tool_output` | Fires after a successful tool call. `tool_output` is truncated to the first 2000 characters | | `PostToolUseFailure` | Tool name | `tool_name`, `tool_input`, `tool_call_id`, `error` | Fires after a tool call fails or is blocked by a hook | @@ -106,9 +106,9 @@ hook response ``` -If multiple `UserPromptSubmit` hooks return text, each result gets its own `` tag. This message keeps its hook-result origin for transcript/replay, but is not sent to the model. The model sees the original user prompt and the current turn continues. +If multiple `UserPromptSubmit` hooks return text, each result gets its own `` tag. This message keeps its hook-result origin for transcript/replay and is sent to the model after the original user prompt before the current turn continues. -If a `UserPromptSubmit` hook blocks the request, the block reason uses the same format and is returned to the user, but the turn does not continue to a model call. +If a `UserPromptSubmit` hook blocks the request, the block reason uses the same format and is returned to the user, but that blocked turn does not continue to a model call. The blocked prompt and block reason remain in session history and are included in later model context. `Stop` block reasons are appended directly as system-triggered user messages so the current turn can continue: diff --git a/docs/zh/customization/hooks.md b/docs/zh/customization/hooks.md index f55c36e52..8a15c1122 100644 --- a/docs/zh/customization/hooks.md +++ b/docs/zh/customization/hooks.md @@ -84,7 +84,7 @@ Hook 命令的退出码和 stdout 会被解释为以下结果: | 事件 | Matcher | 主要 payload | 行为 | | --- | --- | --- | --- | -| `UserPromptSubmit` | 用户提交的文本内容 | `prompt`(`ContentPart[]` 数组) | 仅对真实 User 消息触发。hook 返回的文本会包裹为 hook 结果,写入会话历史用于 transcript/replay,并展示给用户;当前 LLM 轮次会继续,但不会把 hook 结果发给模型;若 hook 阻断,阻断原因会作为 Assistant 消息返回给用户,且不再调用模型;若所有 hook 均无输出,正常 LLM 轮次继续 | +| `UserPromptSubmit` | 用户提交的文本内容 | `prompt`(`ContentPart[]` 数组) | 仅对真实 User 消息触发。hook 返回的文本会包裹为 hook 结果,写入会话历史用于 transcript/replay,展示给用户,并在当前 LLM 轮次继续前加入模型上下文;若 hook 阻断,阻断原因会作为 Assistant 消息返回给用户,且该轮次不再调用模型;若所有 hook 均无输出,正常 LLM 轮次继续 | | `PreToolUse` | 工具名 | `tool_name`、`tool_input`、`tool_call_id` | 在权限检查前触发;阻断后工具不会执行 | | `PostToolUse` | 工具名 | `tool_name`、`tool_input`、`tool_call_id`、`tool_output` | 工具成功后触发;`tool_output` 被截断至前 2000 个字符 | | `PostToolUseFailure` | 工具名 | `tool_name`、`tool_input`、`tool_call_id`、`error` | 工具失败或被 hook 阻断后触发 | @@ -106,9 +106,9 @@ hook response ``` -如果多个 `UserPromptSubmit` hook 返回文本,每个结果都会拥有独立的 `` 标签。这条消息会带有 hook 结果来源,用于 transcript/replay,但不会发给模型。模型只看到原始用户输入,当前轮次继续。 +如果多个 `UserPromptSubmit` hook 返回文本,每个结果都会拥有独立的 `` 标签。这条消息会带有 hook 结果来源,用于 transcript/replay,并会在原始用户提示词之后发给模型,然后当前轮次继续。 -如果 `UserPromptSubmit` hook 阻断请求,阻断原因会使用同样格式返回给用户,但本轮不会继续请求模型。 +如果 `UserPromptSubmit` hook 阻断请求,阻断原因会使用同样格式返回给用户,但被阻断的轮次不会继续请求模型。被阻断的提示词和阻断原因会保留在会话历史中,并包含在后续模型上下文里。 `Stop` 的阻断原因会直接作为系统触发的 User 消息写入上下文,让当前轮次继续: diff --git a/packages/agent-core/src/agent/context/projector.ts b/packages/agent-core/src/agent/context/projector.ts index d085a791f..fc0ba2e5b 100644 --- a/packages/agent-core/src/agent/context/projector.ts +++ b/packages/agent-core/src/agent/context/projector.ts @@ -1,145 +1,42 @@ import type { ContentPart, Message, TextPart } from '@moonshot-ai/kosong'; -import { renderNotificationXml } from './notification-xml'; +import type { ContextMessage } from './types'; -type ProjectableMessage = Message & { - readonly origin?: - | { - readonly kind: string; - readonly event?: string | undefined; - readonly blockedByHook?: string | undefined; - } - | undefined; -}; - -const TRANSCRIPT_ONLY_HOOK_RESULT_EVENTS = new Set(['UserPromptSubmit']); - -export interface EphemeralInjection { - kind: 'memory_recall' | 'system_reminder' | 'pending_notification'; - content: string | Record; - position?: 'before_user' | 'after_system'; -} - -export function project( - history: readonly ProjectableMessage[], - ephemeralInjections?: readonly EphemeralInjection[], -): Message[] { +export function project(history: readonly ContextMessage[]): Message[] { // Keep partial or empty assistant placeholders away from providers. // They can appear when a turn is aborted or errors before any content // or tool call is appended. const usable = history.filter((message) => { - if (isBlockedUserPrompt(message)) return false; return ( - !isTranscriptOnlyHookResult(message) && message.partial !== true && !(message.role === 'assistant' && message.content.length === 0 && message.toolCalls.length === 0) ); }); - const merged = mergeAdjacentUserMessages(usable); - - const injectionMessages = ephemeralInjections?.map((injection) => renderInjection(injection)); - - // Ephemeral injections sit before the first history message - // (before_user) so things like system_reminder land right before the - // user turn they contextualise. - return injectionMessages ? [...injectionMessages, ...merged] : merged; + return mergeAdjacentUserMessages(usable); } -function isTranscriptOnlyHookResult(message: ProjectableMessage): boolean { - return ( - message.origin?.kind === 'hook_result' && - TRANSCRIPT_ONLY_HOOK_RESULT_EVENTS.has(message.origin.event ?? '') - ); -} - -function isBlockedUserPrompt(message: ProjectableMessage): boolean { - return message.role === 'user' && message.origin?.blockedByHook === 'UserPromptSubmit'; -} - -/** - * Render an EphemeralInjection into a synthetic user message. System - * reminders and pending notifications use XML wrappers so the model can - * distinguish host annotations from genuine user text. `memory_recall` - * stays as free text. - * - * The merge-guard logic downstream (`mergeAdjacentUserMessages`) uses - * the `` opening tag to detect - * these messages, so the exact tag names are load-bearing for - * projector correctness — do not rename without also updating - * `isInjectionUserMessage` below. - */ -function renderInjection(injection: EphemeralInjection): Message { - const text = renderInjectionText(injection); - return { - role: 'user', - content: [{ type: 'text', text }], - toolCalls: [], - }; -} - -function renderInjectionText(injection: EphemeralInjection): string { - const { kind, content } = injection; - if (kind === 'pending_notification') { - // Production callers pass notification metadata, but accepting a - // string keeps older embedders from crashing on replay/projection. - if (typeof content === 'string') { - return `\n${content}\n`; - } - return renderNotificationXml(content); - } - if (kind === 'system_reminder') { - const body = typeof content === 'string' ? content : JSON.stringify(content); - return `\n${body}\n`; - } - const body = typeof content === 'string' ? content : JSON.stringify(content); - return body; -} - -/** - * Detect whether a user message was produced by the ephemeral injection - * pipeline (system_reminder or notification XML tag). Such messages - * must never be merged with an adjacent real user turn — doing so would - * smear the injection's XML wrapper into the user's actual prompt and - * confuse the LLM about where the system annotation ends. - * - */ -function isInjectionUserMessage(message: Message): boolean { - if (message.role !== 'user') return false; - const text = extractTextOnly(message); - // Cheap leading-fragment check — injections always have the opening - // tag at the start. We use `trimStart()` so leading whitespace - // doesn't defeat the check, and require `'` tag (no attributes) is not misidentified. - const trimmed = text.trimStart(); - if (trimmed.startsWith('')) return true; - if (trimmed.startsWith(' { ]); }); - it('keeps hook result transcript messages out of LLM projection', async () => { + it('projects hook result messages into LLM projection', async () => { const ctx = testAgent(); ctx.configure(); @@ -117,14 +118,39 @@ describe('Agent context', () => { expect(ctx.agent.context.messages).toEqual([ { role: 'user', - content: [{ type: 'text', text: 'hooked input\n\ncontinue from stop hook' }], + content: [{ type: 'text', text: 'hooked input' }], + toolCalls: [], + }, + { + role: 'user', + content: [ + { + type: 'text', + text: '\nhook response\n', + }, + ], + toolCalls: [], + }, + { + role: 'assistant', + content: [ + { + type: 'text', + text: '\nblocked reason\n', + }, + ], + toolCalls: [], + }, + { + role: 'user', + content: [{ type: 'text', text: 'continue from stop hook' }], toolCalls: [], }, ]); await ctx.expectResumeMatches(); }); - it('keeps blocked UserPromptSubmit prompts out of LLM projection', async () => { + it('projects blocked UserPromptSubmit prompts into LLM projection', async () => { const ctx = testAgent(); ctx.configure(); @@ -145,6 +171,21 @@ describe('Agent context', () => { expect(ctx.agent.context.history).toHaveLength(3); expect(ctx.agent.context.messages).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'blocked prompt' }], + toolCalls: [], + }, + { + role: 'assistant', + content: [ + { + type: 'text', + text: '\nblocked reason\n', + }, + ], + toolCalls: [], + }, { role: 'user', content: [{ type: 'text', text: 'safe followup' }], @@ -509,56 +550,60 @@ describe('Agent context notification projection', () => { expect(text).not.toContain('should stay out of the XML'); }); - it('keeps pending notification injections separate from real user prompts', () => { - const messages = project( - [userMessage('Actual user prompt')], - [ - { - kind: 'pending_notification', - content: { - id: 'n_1', - category: 'task', - type: 'task.done', - source_kind: 'background_task', - source_id: 'bg_1', - title: 'Task done', - severity: 'info', - body: 'Background task finished.', - }, - }, - ], - ); - - expect(messages).toHaveLength(2); - expect(textOf(messages[0]!)).toMatch(/^ { - // Cron fires arrive as user-role messages whose text starts with - // `// and keep them in - // separate messages — otherwise the envelope XML smears into a - // real user turn and confuses the LLM about where the system - // annotation ends. const cronEnvelope = '\n\ncheck the deploy\n\n'; const messages = project([ - userMessage(cronEnvelope), - userMessage('Actual follow-up from the user'), + userMessage(cronEnvelope, { + kind: 'cron_job', + jobId: 'deadbeef', + cron: '*/5 * * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }), + userMessage('Actual follow-up from the user', { kind: 'user' }), ]); expect(messages).toHaveLength(2); expect(textOf(messages[0]!)).toBe(cronEnvelope); expect(textOf(messages[1]!)).toBe('Actual follow-up from the user'); }); + + it('uses message origin to keep non-user-origin messages separate', () => { + const messages = project([ + userMessage('Host reminder without an XML prefix', { + kind: 'injection', + variant: 'host', + }), + userMessage('Actual follow-up from the user', { kind: 'user' }), + ]); + + expect(messages).toHaveLength(2); + expect(textOf(messages[0]!)).toBe('Host reminder without an XML prefix'); + expect(textOf(messages[1]!)).toBe('Actual follow-up from the user'); + }); + + it('only merges user-role messages with user origin', () => { + const messages = project([ + userMessage('First real prompt', { kind: 'user' }), + userMessage('Second real prompt', { kind: 'user' }), + userMessage('No origin prompt'), + userMessage('Third real prompt', { kind: 'user' }), + ]); + + expect(messages).toHaveLength(3); + expect(textOf(messages[0]!)).toBe('First real prompt\n\nSecond real prompt'); + expect(textOf(messages[1]!)).toBe('No origin prompt'); + expect(textOf(messages[2]!)).toBe('Third real prompt'); + }); }); -function userMessage(text: string): Message { +function userMessage(text: string, origin?: ContextMessage['origin']): ContextMessage { return { role: 'user', content: [{ type: 'text', text }], toolCalls: [], + origin, }; } diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 2c8328bb6..24ffa366e 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -264,7 +264,7 @@ describe('Agent turn flow', () => { ); }); - it('continues the turn after showing UserPromptSubmit hook output without injecting it', async () => { + it('continues the turn after projecting UserPromptSubmit hook output', async () => { const hookEngine = new HookEngine([ { event: 'UserPromptSubmit', @@ -293,6 +293,7 @@ describe('Agent turn flow', () => { tools: [] messages: user: text "hooked input" + user: text "\\nhook response 1\\n\\n\\nhook response 2\\n" `); expect(events).toContainEqual( expect.objectContaining({ @@ -330,7 +331,7 @@ describe('Agent turn flow', () => { ]); }); - it('shows structured UserPromptSubmit stdout without injecting it', async () => { + it('projects structured UserPromptSubmit stdout', async () => { const hookEngine = new HookEngine([ { event: 'UserPromptSubmit', @@ -356,6 +357,7 @@ describe('Agent turn flow', () => { tools: [] messages: user: text "hooked input" + user: text "\\n{}\\n\\n\\n{\\"hookSpecificOutput\\":{}}\\n" `); expect(events).toContainEqual( expect.objectContaining({ @@ -442,6 +444,8 @@ describe('Agent turn flow', () => { system: tools: [] messages: + user: text "bad words here" + assistant: text "\\nno profanity\\n" user: text "safe followup" `); }); diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index d08b6634c..faf620526 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -617,7 +617,8 @@ describe('SessionSubagentHost', () => { system: "explore prompt" tools: Read messages: - user: text "Earlier context\\n\\nContinue from context" + user: text "Earlier context" + user: text "Continue from context" `); expect(parent.allEvents).toContainEqual( expect.objectContaining({