mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix: project persisted context messages (#195)
This commit is contained in:
parent
8c77cfab62
commit
3a0e06031a
7 changed files with 119 additions and 165 deletions
6
.changeset/project-context-messages.md
Normal file
6
.changeset/project-context-messages.md
Normal 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.
|
||||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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 消息写入上下文,让当前轮次继续:
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue