fix: project persisted context messages (#195)

This commit is contained in:
_Kerman 2026-05-29 14:43:17 +08:00 committed by GitHub
parent 8c77cfab62
commit 3a0e06031a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 119 additions and 165 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---
Project persisted hook and blocked prompt messages into model context.

View file

@ -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
</hook_result>
```
If multiple `UserPromptSubmit` hooks return text, each result gets its own `<hook_result>` 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 `<hook_result>` 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:

View file

@ -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
</hook_result>
```
如果多个 `UserPromptSubmit` hook 返回文本,每个结果都会拥有独立的 `<hook_result>` 标签。这条消息会带有 hook 结果来源,用于 transcript/replay但不会发给模型。模型只看到原始用户输入,当前轮次继续。
如果多个 `UserPromptSubmit` hook 返回文本,每个结果都会拥有独立的 `<hook_result>` 标签。这条消息会带有 hook 结果来源,用于 transcript/replay并会在原始用户提示词之后发给模型,然后当前轮次继续。
如果 `UserPromptSubmit` hook 阻断请求,阻断原因会使用同样格式返回给用户,但本轮不会继续请求模型
如果 `UserPromptSubmit` hook 阻断请求,阻断原因会使用同样格式返回给用户,但被阻断的轮次不会继续请求模型。被阻断的提示词和阻断原因会保留在会话历史中,并包含在后续模型上下文里
`Stop` 的阻断原因会直接作为系统触发的 User 消息写入上下文,让当前轮次继续:

View file

@ -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<string, unknown>;
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 `<notification ` / `<system-reminder>` 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 `<notification>\n${content}\n</notification>`;
}
return renderNotificationXml(content);
}
if (kind === 'system_reminder') {
const body = typeof content === 'string' ? content : JSON.stringify(content);
return `<system-reminder>\n${body}\n</system-reminder>`;
}
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 `'<notification '` (with
// trailing space) so user text like `<notificationally` or the
// bare `<notification>` tag (no attributes) is not misidentified.
const trimmed = text.trimStart();
if (trimmed.startsWith('<notification ')) return true;
if (trimmed.startsWith('<system-reminder>')) return true;
if (trimmed.startsWith('<hook_result ')) return true;
if (trimmed.startsWith('<cron-fire ')) return true;
return false;
}
function mergeAdjacentUserMessages(history: readonly Message[]): Message[] {
const out: Message[] = [];
function mergeAdjacentUserMessages(history: readonly ContextMessage[]): Message[] {
const out: ContextMessage[] = [];
for (const message of history) {
const previous = out.at(-1);
if (
message.role === 'user' &&
canMergeUserMessage(message) &&
previous !== undefined &&
previous.role === 'user' &&
!isInjectionUserMessage(message) &&
!isInjectionUserMessage(previous)
canMergeUserMessage(previous)
) {
out[out.length - 1] = mergeTwoUserMessages(previous, message);
continue;
}
// Clone into a fresh Message so we never mutate input arrays.
out.push(cloneMessage(message));
out.push(message);
}
return out;
return out.map(stripContextMetadata);
}
function mergeTwoUserMessages(a: Message, b: Message): Message {
function canMergeUserMessage(message: ContextMessage): boolean {
return message.role === 'user' && message.origin?.kind === 'user';
}
function mergeTwoUserMessages(a: ContextMessage, b: ContextMessage): ContextMessage {
const aText = extractTextOnly(a);
const bText = extractTextOnly(b);
const nonTextParts = [
@ -152,6 +49,7 @@ function mergeTwoUserMessages(a: Message, b: Message): Message {
role: 'user',
content,
toolCalls: [],
origin: a.origin,
};
}
@ -162,7 +60,7 @@ function extractTextOnly(message: Message): string {
.join('');
}
function cloneMessage(message: Message): Message {
function stripContextMetadata(message: ContextMessage): Message {
return {
role: message.role,
name: message.name,

View file

@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import { renderNotificationXml } from '../../src/agent/context/notification-xml';
import { project } from '../../src/agent/context/projector';
import type { ContextMessage } from '../../src/agent/context/types';
import { estimateTokensForMessages } from '../../src/utils/tokens';
import { testAgent } from './harness/agent';
@ -79,7 +80,7 @@ describe('Agent context', () => {
]);
});
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: '<hook_result hook_event="UserPromptSubmit">\nhook response\n</hook_result>',
},
],
toolCalls: [],
},
{
role: 'assistant',
content: [
{
type: 'text',
text: '<hook_result hook_event="UserPromptSubmit">\nblocked reason\n</hook_result>',
},
],
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: '<hook_result hook_event="UserPromptSubmit">\nblocked reason\n</hook_result>',
},
],
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(/^<notification /);
expect(textOf(messages[0]!)).toContain('Task done');
expect(textOf(messages[1]!)).toBe('Actual user prompt');
});
it('does not merge a cron-fire envelope into an adjacent user message', () => {
// Cron fires arrive as user-role messages whose text starts with
// `<cron-fire `. `mergeAdjacentUserMessages` must treat them like
// <notification>/<system-reminder>/<hook_result> 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 =
'<cron-fire jobId="deadbeef" cron="*/5 * * * *" recurring="true" coalescedCount="1" stale="false">\n<prompt>\ncheck the deploy\n</prompt>\n</cron-fire>';
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,
};
}

View file

@ -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 "<hook_result hook_event=\\"UserPromptSubmit\\">\\nhook response 1\\n</hook_result>\\n<hook_result hook_event=\\"UserPromptSubmit\\">\\nhook response 2\\n</hook_result>"
`);
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 "<hook_result hook_event=\\"UserPromptSubmit\\">\\n{}\\n</hook_result>\\n<hook_result hook_event=\\"UserPromptSubmit\\">\\n{\\"hookSpecificOutput\\":{}}\\n</hook_result>"
`);
expect(events).toContainEqual(
expect.objectContaining({
@ -442,6 +444,8 @@ describe('Agent turn flow', () => {
system: <system-prompt>
tools: []
messages:
user: text "bad words here"
assistant: text "<hook_result hook_event=\\"UserPromptSubmit\\">\\nno profanity\\n</hook_result>"
user: text "safe followup"
`);
});

View file

@ -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({