Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/project-context-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---

Project persisted hook and blocked prompt messages into model context.
6 changes: 3 additions & 3 deletions docs/en/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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:

Expand Down
6 changes: 3 additions & 3 deletions docs/zh/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 阻断后触发 |
Expand All @@ -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 消息写入上下文,让当前轮次继续:

Expand Down
134 changes: 16 additions & 118 deletions packages/agent-core/src/agent/context/projector.ts
Original file line number Diff line number Diff line change
@@ -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 canMergeUserMessage(message: ContextMessage): boolean {
return message.role === 'user' && message.origin?.kind === 'user';
}

function mergeTwoUserMessages(a: Message, b: Message): Message {
function mergeTwoUserMessages(a: ContextMessage, b: ContextMessage): ContextMessage {
const aText = extractTextOnly(a);
const bText = extractTextOnly(b);
const nonTextParts = [
Expand All @@ -152,6 +49,7 @@ function mergeTwoUserMessages(a: Message, b: Message): Message {
role: 'user',
content,
toolCalls: [],
origin: a.origin,
};
}

Expand All @@ -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,
Expand Down
Loading
Loading