Merge branch 'kimi-code-v2' of https://github.com/MoonshotAI/kimi-code into kimi-code-v2

This commit is contained in:
_Kerman 2026-07-08 17:54:50 +08:00
commit e3fb6d249e
50 changed files with 1194 additions and 221 deletions

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
Add the Vercel plugin to the bundled plugin marketplace. Run /plugins and select Vercel Plugin to install it.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
Count the turn that starts an autonomous goal toward its goal turn usage.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix `kimi -p` runs exiting with code 0 when a turn fails.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix console windows flashing on Windows each time a hook runs.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Fix the connection error toast lingering after the WebSocket reconnects when returning from the background.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
Prevent autonomous goals from being paused by model-reported status updates.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
Tighten goal-mode guidance for blocked and complete status updates.

View file

@ -1,6 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
"@moonshot-ai/kimi-code-sdk": patch
---
Raise the image downscale cap from 2000px to 3000px, and fix swapped width/height for EXIF-rotated (portrait) photos in compression captions and media read notes so region readback coordinates map correctly.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Compile icons at build time so the bundled web UI only carries the icons it renders.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
Progressive tool disclosure (`select_tools`, experimental): compaction now discards the loaded tool schemas instead of re-injecting them. After a compaction the boundary announcement re-lists every loadable tool name and the model re-selects what it still needs; a from-memory call to a no-longer-loaded tool is rejected with guidance to select it first. This keeps the post-compaction context at its minimal users+summary floor and removes the schema-rebuild budget heuristics. No effect unless the `tool-select` experimental flag and a `select_tools`-capable model are active.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kosong": patch
---
Add provider-level structured response format support.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
web: The composer model switcher switches the active session's model as before and additionally bumps the global default model, so new sessions inherit the choice.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Press Enter to confirm in archive and other confirmation dialogs.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Redesign the scheduled reminder UI.

View file

@ -1,5 +1,37 @@
# @moonshot-ai/kimi-code
## 0.23.2
### Patch Changes
- [#1489](https://github.com/MoonshotAI/kimi-code/pull/1489) [`2206d21`](https://github.com/MoonshotAI/kimi-code/commit/2206d21327129aa2331b6b159cfce61110b7f94f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add the Vercel plugin to the bundled plugin marketplace. Run /plugins and select Vercel Plugin to install it.
- [#1477](https://github.com/MoonshotAI/kimi-code/pull/1477) [`150206a`](https://github.com/MoonshotAI/kimi-code/commit/150206a6f7027879df954e26736b4baa5d336235) Thanks [@chengluyu](https://github.com/chengluyu)! - Count the turn that starts an autonomous goal toward its goal turn usage.
- [#1483](https://github.com/MoonshotAI/kimi-code/pull/1483) [`f30781b`](https://github.com/MoonshotAI/kimi-code/commit/f30781bb273321f3e3bbb548a9d0724ab6299fc6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix `kimi -p` runs exiting with code 0 when a turn fails.
- [#1466](https://github.com/MoonshotAI/kimi-code/pull/1466) [`063bce2`](https://github.com/MoonshotAI/kimi-code/commit/063bce2a2f52601abaa0d13173ab88371cbbe9ae) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix console windows flashing on Windows each time a hook runs.
- [#1474](https://github.com/MoonshotAI/kimi-code/pull/1474) [`11c6a37`](https://github.com/MoonshotAI/kimi-code/commit/11c6a37ce030f8e64de5c810da07ec7fab3b0615) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the connection error toast lingering after the WebSocket reconnects when returning from the background.
- [#1476](https://github.com/MoonshotAI/kimi-code/pull/1476) [`d1a964f`](https://github.com/MoonshotAI/kimi-code/commit/d1a964fba9b3dca902ea6f81bacaccc839955c03) Thanks [@chengluyu](https://github.com/chengluyu)! - Prevent autonomous goals from being paused by model-reported status updates.
- [#1481](https://github.com/MoonshotAI/kimi-code/pull/1481) [`1317000`](https://github.com/MoonshotAI/kimi-code/commit/131700097a732b97b3d17c5e2efa1c5a44b013ef) Thanks [@chengluyu](https://github.com/chengluyu)! - Tighten goal-mode guidance for blocked and complete status updates.
- [#1460](https://github.com/MoonshotAI/kimi-code/pull/1460) [`474ce28`](https://github.com/MoonshotAI/kimi-code/commit/474ce289dd39aa42d1a77a9a2e15531aee49aa15) Thanks [@RealKai42](https://github.com/RealKai42)! - Raise the image downscale cap from 2000px to 3000px, and fix swapped width/height for EXIF-rotated (portrait) photos in compression captions and media read notes so region readback coordinates map correctly.
- [#1467](https://github.com/MoonshotAI/kimi-code/pull/1467) [`ee38545`](https://github.com/MoonshotAI/kimi-code/commit/ee385456d0eda380fec067db92c025462db13f5a) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Compile icons at build time so the bundled web UI only carries the icons it renders.
- [#1471](https://github.com/MoonshotAI/kimi-code/pull/1471) [`9b76e5b`](https://github.com/MoonshotAI/kimi-code/commit/9b76e5bff631cceaeecb2b0cbc096533c5fdc8cc) Thanks [@starquakee](https://github.com/starquakee)! - Progressive tool disclosure (`select_tools`, experimental): compaction now discards the loaded tool schemas instead of re-injecting them. After a compaction the boundary announcement re-lists every loadable tool name and the model re-selects what it still needs; a from-memory call to a no-longer-loaded tool is rejected with guidance to select it first. This keeps the post-compaction context at its minimal users+summary floor and removes the schema-rebuild budget heuristics. No effect unless the `tool-select` experimental flag and a `select_tools`-capable model are active.
- [#1491](https://github.com/MoonshotAI/kimi-code/pull/1491) [`0cc9831`](https://github.com/MoonshotAI/kimi-code/commit/0cc9831a2f79d93903259bd3353e746abac01b67) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: The composer model switcher switches the active session's model as before and additionally bumps the global default model, so new sessions inherit the choice.
- [#1490](https://github.com/MoonshotAI/kimi-code/pull/1490) [`b30a45e`](https://github.com/MoonshotAI/kimi-code/commit/b30a45efecfa5ece4f4f10f2c5403ba097e7690b) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Press Enter to confirm in archive and other confirmation dialogs.
- [#1480](https://github.com/MoonshotAI/kimi-code/pull/1480) [`2ad0120`](https://github.com/MoonshotAI/kimi-code/commit/2ad0120c2a5c8383892e4da1ee7c6853926ed365) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Redesign the scheduled reminder UI.
- [#1492](https://github.com/MoonshotAI/kimi-code/pull/1492) [`b0809dd`](https://github.com/MoonshotAI/kimi-code/commit/b0809ddac833d8d920d95187f7ef64f97bafdbc6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show session skills in the slash menu as `/skill:<name>` so they are distinguishable from built-in commands; typing the bare skill name still works.
## 0.23.1
### Patch Changes

View file

@ -1,6 +1,6 @@
{
"name": "@moonshot-ai/kimi-code",
"version": "0.23.1",
"version": "0.23.2",
"description": "The Starting Point for Next-Gen Agents",
"license": "MIT",
"author": "Moonshot AI",

View file

@ -39,6 +39,7 @@ import ServerAuthDialog from './components/ServerAuthDialog.vue';
import { initServerAuth, onAuthRequired } from './api/daemon/serverAuth';
import type { AppConfig, ThinkingLevel } from './api/types';
import { coerceThinkingForModel, commitLevel, segmentsFor } from './lib/modelThinking';
import { stripSkillPrefix } from './lib/slashCommands';
import Button from './components/ui/Button.vue';
import IconButton from './components/ui/IconButton.vue';
import Icon from './components/ui/Icon.vue';
@ -506,13 +507,15 @@ function handleCommand(cmd: string): void {
break;
default: {
// Not a built-in command treat it as a session skill activation
// (the user picked `/<skill>` from the menu, or typed `/<skill> args`).
// The daemon answers an unknown name with skill.not_found, surfaced as a
// warning, so a stray slash is harmless. With no active session, create
// one first (same path as the first prompt) so the activation isn't
// silently dropped on the new-session screen.
// (the user picked `/skill:<skill>` from the menu, or typed
// `/<skill> args`). Strip the `skill:` display prefix the REST API
// takes the bare skill name. The daemon answers an unknown name with
// skill.not_found, surfaced as a warning, so a stray slash is harmless.
// With no active session, create one first (same path as the first
// prompt) so the activation isn't silently dropped on the new-session
// screen.
const space = cmd.indexOf(' ');
const name = (space === -1 ? cmd : cmd.slice(0, space)).slice(1);
const name = stripSkillPrefix((space === -1 ? cmd : cmd.slice(0, space)).slice(1));
const args = space === -1 ? undefined : cmd.slice(space + 1).trim() || undefined;
if (!name) break;
if (!client.activeSessionId.value && client.activeWorkspaceId.value) {

View file

@ -4,7 +4,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import SlashMenu from './SlashMenu.vue';
import MentionMenu from './MentionMenu.vue';
import { buildSlashItems, parseSlash } from '../../lib/slashCommands';
import { buildSlashItems, parseSlash, SKILL_COMMAND_PREFIX } from '../../lib/slashCommands';
import type { FileItem } from './MentionMenu.vue';
import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../../types';
import type { AppModel, AppSkill, ThinkingLevel } from '../../api/types';
@ -304,11 +304,14 @@ function handleSubmit(): void {
// If it's a known slash command, keep the optional tail as command input
// instead of submitting it as normal chat text. This covers `/goal <task>`,
// `/swarm <task>`, `/btw <question>`, slash skills with args, and bare
// commands such as `/model`.
// commands such as `/model`. A hand-typed bare skill name (`/deploy`) also
// resolves to its prefixed menu entry (`/skill:deploy`), mirroring the TUI.
if (trimmed) {
const parsed = parseSlash(trimmed);
const known = parsed
? buildSlashItems(props.skills).some((item) => item.name === parsed.cmd)
? buildSlashItems(props.skills).some(
(item) => item.name === parsed.cmd || item.name === `/${SKILL_COMMAND_PREFIX}${parsed.cmd.slice(1)}`,
)
: false;
if (parsed && known) {
text.value = '';

View file

@ -65,16 +65,30 @@ export function parseSlash(input: string): { cmd: string; arg: string } | null {
};
}
/** The prefix marking a slash item as a skill activation (`/skill:<name>`). */
export const SKILL_COMMAND_PREFIX = 'skill:';
/**
* Strip the `skill:` prefix from a slash-command name (with or without the
* leading `/`), returning the bare skill name. Non-prefixed input is returned
* unchanged.
*/
export function stripSkillPrefix(name: string): string {
return name.startsWith(SKILL_COMMAND_PREFIX) ? name.slice(SKILL_COMMAND_PREFIX.length) : name;
}
/**
* Build the full slash-item list: built-in commands followed by the session's
* skills (each shown as `/<skill-name>`). Skills carry their raw description and
* skills. Non-builtin skills are shown as `/skill:<skill-name>` so the user can
* tell them apart from built-in commands (mirroring the TUI); builtin-sourced
* skills keep the bare `/<skill-name>`. Skills carry their raw description and
* an `isSkill` flag so the caller knows to activate rather than run a command.
*/
export function buildSlashItems(
skills: ReadonlyArray<{ name: string; description: string }> = [],
skills: ReadonlyArray<{ name: string; description: string; source?: string }> = [],
): SlashCommand[] {
const skillItems: SlashCommand[] = skills.map((s) => ({
name: `/${s.name}`,
name: s.source === 'builtin' ? `/${s.name}` : `/${SKILL_COMMAND_PREFIX}${s.name}`,
desc: s.description,
isSkill: true,
// Keep the selected skill in the composer so arguments can be appended.

View file

@ -74,11 +74,25 @@ describe('useSlashMenu — update', () => {
expect(slash.open.value).toBe(false);
});
it('includes session skills as /<skill-name>', () => {
const { slash } = setup('/', [{ name: 'deploy', description: 'deploy stuff' } as AppSkill]);
it('includes session skills as /skill:<skill-name>', () => {
const { slash } = setup('/', [{ name: 'deploy', description: 'deploy stuff', source: 'project' } as AppSkill]);
slash.update();
const names = slash.items.value.map((i) => i.name);
expect(names).toContain('/deploy');
expect(names).toContain('/skill:deploy');
});
it('keeps builtin-sourced skills unprefixed', () => {
const { slash } = setup('/', [{ name: 'update-config', description: 'edit config', source: 'builtin' } as AppSkill]);
slash.update();
const names = slash.items.value.map((i) => i.name);
expect(names).toContain('/update-config');
expect(names).not.toContain('/skill:update-config');
});
it('matches a prefixed skill when filtering by its bare name', () => {
const { slash } = setup('/depl', [{ name: 'deploy', description: 'deploy stuff', source: 'project' } as AppSkill]);
slash.update();
expect(slash.items.value.map((i) => i.name)).toContain('/skill:deploy');
});
});

View file

@ -6,6 +6,34 @@ outline: 2
This page documents the changes in each Kimi Code CLI release.
## 0.23.2 (2026-07-08)
### Features
- Add the Vercel plugin to the bundled plugin marketplace. Run `/plugins` and select Vercel Plugin to install it.
### Bug Fixes
- Fix `kimi -p` runs exiting with code 0 when a turn fails.
- Prevent autonomous goals from being paused by model-reported status updates.
- Count the turn that starts an autonomous goal toward its turn budget.
- Raise the image downscale cap from 2000px to 3000px, and fix swapped width/height for EXIF-rotated (portrait) photos in compression captions and media read notes so region readback coordinates map correctly.
- web: Fix the connection error toast lingering after the WebSocket reconnects when returning from the background.
- Fix console windows flashing on Windows each time a hook runs.
### Polish
- web: Redesign the scheduled reminder UI.
- web: Show session skills in the slash menu as `/skill:<name>` so they are distinguishable from built-in commands; typing the bare skill name still works.
- web: The composer model switcher switches the active session's model as before and additionally bumps the global default model, so new sessions inherit the choice.
- web: Press Enter to confirm in archive and other confirmation dialogs.
- Tighten goal-mode guidance for blocked and complete status updates.
- Progressive tool disclosure (`select_tools`, experimental): compaction now discards the loaded tool schemas instead of re-injecting them, and the model re-selects the tools it still needs afterward. A from-memory call to a no-longer-loaded tool is rejected with guidance to select it first. No effect unless the `tool-select` experimental flag and a `select_tools`-capable model are active.
### Refactors
- web: Compile icons at build time so the bundled web UI only carries the icons it renders.
## 0.23.1 (2026-07-07)
### Bug Fixes

View file

@ -6,6 +6,34 @@ outline: 2
本页记录 Kimi Code CLI 每个版本的变更内容。
## 0.23.22026-07-08
### 新功能
- 内置插件市场新增 Vercel 插件,运行 `/plugins` 并选择 Vercel Plugin 即可安装。
### 修复
- 修复 `kimi -p` 在轮次失败时仍以退出码 0 退出的问题。
- 修复自主目标会被模型上报的状态更新暂停的问题。
- 修复启动自主目标的轮次未计入其轮次预算的问题。
- 将图片降采样上限从 2000px 提高到 3000px并修复 EXIF 旋转(竖拍)照片在压缩说明与媒体读取备注中宽高互换的问题,使区域回读坐标正确对应。
- web: 修复从后台返回后WebSocket 重连完成但连接错误提示仍残留的问题。
- 修复 Windows 上每次运行 hook 时控制台窗口闪烁的问题。
### 优化
- web: 重新设计定时提醒界面。
- web: 在斜杠菜单中以 `/skill:<name>` 显示会话技能,便于与内置命令区分;直接输入技能名称仍然可用。
- web: 输入框的模型切换器在切换当前会话模型的同时,也会更新全局默认模型,使新会话继承该选择。
- web: 归档等确认对话框支持按 Enter 确认。
- 优化目标模式对阻塞与完成状态更新的指引。
- 渐进式工具加载(`select_tools`,实验功能):压缩后丢弃已加载的工具 schema由模型重新选择仍需要的工具使压缩后上下文保持精简凭记忆调用未再加载的工具会被拒绝并提示先选择。仅在启用 `tool-select` 实验标志且模型支持 `select_tools` 时生效。
### 重构
- web: 在构建时编译图标,使打包后的 web UI 仅包含实际渲染的图标。
## 0.23.12026-07-07
### 修复

View file

@ -76,8 +76,26 @@ export interface CompressImageOptions {
readonly byteBudget?: number;
/** Override the raw-byte ceiling above which compression is skipped. */
readonly maxDecodeBytes?: number;
readonly telemetry?: ImageCompressionTelemetry;
}
export interface ImageCompressionTelemetryClient {
track(event: string, properties?: Readonly<Record<string, unknown>>): void;
}
export interface ImageCompressionTelemetry {
readonly client: ImageCompressionTelemetryClient;
readonly source: string;
}
type CompressOutcome =
| 'compressed'
| 'passthrough_fast'
| 'passthrough_guard'
| 'passthrough_unsupported'
| 'passthrough_unhelpful'
| 'passthrough_error';
export interface CompressImageResult {
/** Bytes to send: the re-encoded image, or the original when unchanged. */
readonly data: Uint8Array;
@ -109,6 +127,7 @@ export async function compressImageForModel(
mimeType: string,
options: CompressImageOptions = {},
): Promise<CompressImageResult> {
const startedAt = Date.now();
const maxEdge = options.maxEdge ?? MAX_IMAGE_EDGE_PX;
const byteBudget = options.byteBudget ?? IMAGE_BYTE_BUDGET;
const maxDecodeBytes = options.maxDecodeBytes ?? MAX_DECODE_BYTES;
@ -126,23 +145,36 @@ export async function compressImageForModel(
originalByteLength: bytes.length,
finalByteLength: bytes.length,
});
const finish = (outcome: CompressOutcome, result: CompressImageResult): CompressImageResult => {
reportCompressEvent(options.telemetry, {
outcome,
startedAt,
inputMime: normalizedMime,
result,
});
return result;
};
if (bytes.length === 0) return passthrough();
if (bytes.length === 0) return finish('passthrough_unsupported', passthrough());
// Only re-encode formats the codec handles; everything else passes through.
if (!RECODABLE_MIME.has(normalizedMime)) return passthrough();
if (!RECODABLE_MIME.has(normalizedMime)) return finish('passthrough_unsupported', passthrough());
// Fast path: already within both budgets — no codec load, no allocation.
const longestEdge = dims ? Math.max(dims.width, dims.height) : 0;
const withinBytes = bytes.length <= byteBudget;
const withinEdge = longestEdge > 0 && longestEdge <= maxEdge;
if (withinBytes && (withinEdge || longestEdge === 0)) return passthrough();
if (withinBytes && (withinEdge || longestEdge === 0)) {
return finish('passthrough_fast', passthrough());
}
// Decompression-bomb guard: refuse to decode absurd pixel counts. The sniff
// above gave us the dimensions without decoding, so this costs nothing.
if (dims && dims.width * dims.height > MAX_DECODE_PIXELS) return passthrough();
if (dims && dims.width * dims.height > MAX_DECODE_PIXELS) {
return finish('passthrough_guard', passthrough());
}
// Refuse to decode very large byte payloads (e.g. a huge or invalid image
// from an MCP tool) that would be loaded just to be dropped downstream.
if (bytes.length > maxDecodeBytes) return passthrough();
if (bytes.length > maxDecodeBytes) return finish('passthrough_guard', passthrough());
try {
const { Jimp } = await import('jimp');
@ -166,9 +198,9 @@ export async function compressImageForModel(
const finalPixels = encoded.width * encoded.height;
const shrankBytes = encoded.data.length < bytes.length;
const shrankPixels = originalPixels > 0 && finalPixels < originalPixels;
if (!shrankBytes && !shrankPixels) return passthrough();
if (!shrankBytes && !shrankPixels) return finish('passthrough_unhelpful', passthrough());
return {
return finish('compressed', {
data: encoded.data,
mimeType: encoded.mimeType,
width: encoded.width,
@ -178,10 +210,10 @@ export async function compressImageForModel(
changed: true,
originalByteLength: bytes.length,
finalByteLength: encoded.data.length,
};
});
} catch {
// Decode/encode failure — keep the original bytes.
return passthrough();
return finish('passthrough_error', passthrough());
}
}
@ -214,10 +246,11 @@ export async function compressBase64ForModel(
// Skip very large payloads before allocating: base64 decodes to ~3/4 its
// length, so a payload whose decoded size would exceed the cap is passed
// through without the Buffer.from allocation (and without touching Jimp).
const startedAt = Date.now();
const maxDecodeBytes = options.maxDecodeBytes ?? MAX_DECODE_BYTES;
const approxBytes = Math.floor((base64.length * 3) / 4);
if (approxBytes > maxDecodeBytes) {
return {
const result: CompressBase64Result = {
base64,
mimeType,
width: 0,
@ -228,12 +261,19 @@ export async function compressBase64ForModel(
originalByteLength: approxBytes,
finalByteLength: approxBytes,
};
reportCompressEvent(options.telemetry, {
outcome: 'passthrough_guard',
startedAt,
inputMime: normalizeMime(mimeType),
result,
});
return result;
}
let bytes: Buffer;
try {
bytes = Buffer.from(base64, 'base64');
} catch {
return {
const result: CompressBase64Result = {
base64,
mimeType,
width: 0,
@ -244,6 +284,13 @@ export async function compressBase64ForModel(
originalByteLength: 0,
finalByteLength: 0,
};
reportCompressEvent(options.telemetry, {
outcome: 'passthrough_error',
startedAt,
inputMime: normalizeMime(mimeType),
result,
});
return result;
}
const result = await compressImageForModel(bytes, mimeType, options);
if (!result.changed) {
@ -279,18 +326,21 @@ export async function compressBase64ForModel(
* remote http(s) image) are passed through, as are non-image parts. Best
* effort: a part that fails to compress is left unchanged.
*
* With `annotate` set, every image that was actually re-encoded gains a
* {@link buildImageCompressionCaption} text part immediately before it, so the
* model knows it is looking at a downsampled copy. `annotate.persistOriginal`
* additionally saves the pre-compression bytes and puts the returned path in
* the caption so the model can read the original back; persistence failures
* degrade to a caption without a path.
* With `annotate` set, every image that was actually re-encoded produces a
* {@link buildImageCompressionCaption} string in the returned `captions` array
* (in encounter order), so the caller can place it next to the image e.g. as
* a leading text part for the model, or on a `note` side channel kept out of
* UI-visible output. `annotate.persistOriginal` additionally saves the
* pre-compression bytes and puts the returned path in the caption so the model
* can read the original back; persistence failures degrade to a caption
* without a path.
*/
export async function compressImageContentParts(
parts: readonly ContentPart[],
options: CompressImageOptions & { readonly annotate?: CompressAnnotateOptions } = {},
): Promise<ContentPart[]> {
): Promise<{ parts: ContentPart[]; captions: readonly string[] }> {
const { annotate, ...compressOptions } = options;
const captions: string[] = [];
const out: ContentPart[] = [];
for (const part of parts) {
if (part.type === 'image_url') {
@ -310,9 +360,8 @@ export async function compressImageContentParts(
originalPath = null;
}
}
out.push({
type: 'text',
text: buildImageCompressionCaption({
captions.push(
buildImageCompressionCaption({
original: {
width: result.originalWidth,
height: result.originalHeight,
@ -327,7 +376,7 @@ export async function compressImageContentParts(
},
originalPath,
}),
});
);
}
out.push({
type: 'image_url',
@ -339,7 +388,7 @@ export async function compressImageContentParts(
}
out.push(part);
}
return out;
return { parts: out, captions };
}
export interface CompressAnnotateOptions {
@ -350,6 +399,46 @@ export interface CompressAnnotateOptions {
readonly persistOriginal?: (bytes: Uint8Array, mimeType: string) => Promise<string | null>;
}
interface CompressEventResult {
readonly mimeType: string;
readonly width: number;
readonly height: number;
readonly originalWidth: number;
readonly originalHeight: number;
readonly originalByteLength: number;
readonly finalByteLength: number;
}
function reportCompressEvent(
telemetry: ImageCompressionTelemetry | undefined,
input: {
readonly outcome: CompressOutcome;
readonly startedAt: number;
readonly inputMime: string;
readonly result: CompressEventResult;
},
): void {
if (telemetry === undefined) return;
try {
telemetry.client.track('image_compress', {
source: telemetry.source,
outcome: input.outcome,
input_mime: input.inputMime,
output_mime: normalizeMime(input.result.mimeType),
original_bytes: input.result.originalByteLength,
final_bytes: input.result.finalByteLength,
original_width: input.result.originalWidth,
original_height: input.result.originalHeight,
final_width: input.result.width,
final_height: input.result.height,
exif_transposed: false,
duration_ms: Date.now() - input.startedAt,
});
} catch {
return;
}
}
// ── crop ─────────────────────────────────────────────────────────────
/** Crop rectangle in ORIGINAL-image pixel coordinates. */

View file

@ -93,7 +93,11 @@ export type LoopRecordedEvent =
| {
readonly type: 'tool.result';
readonly toolCallId: string;
readonly result: { readonly output: string | readonly ContentPart[]; readonly isError?: boolean };
readonly result: {
readonly output: string | readonly ContentPart[];
readonly isError?: boolean;
readonly note?: string;
};
readonly parentUuid?: string;
};
@ -250,24 +254,33 @@ function interruptedToolMessage(toolCallId: string): ContextMessage {
function toolResultOutputForModel(result: {
readonly output: string | readonly ContentPart[];
readonly isError?: boolean;
readonly note?: string;
}): string | ContentPart[] {
const { output, isError } = result;
const { output, isError, note } = result;
let base: string | ContentPart[];
if (typeof output === 'string') {
if (isError === true) {
if (output.length === 0) return TOOL_EMPTY_ERROR_STATUS;
if (output.trimStart().startsWith('<system>ERROR:')) return output;
return `${TOOL_ERROR_STATUS}\n${output}`;
if (output.length === 0) base = TOOL_EMPTY_ERROR_STATUS;
else if (output.trimStart().startsWith('<system>ERROR:')) base = output;
else base = `${TOOL_ERROR_STATUS}\n${output}`;
} else if (output.length === 0 || output.trim() === TOOL_OUTPUT_EMPTY_TEXT) {
base = TOOL_EMPTY_STATUS;
} else {
base = output;
}
if (output.length === 0 || output.trim() === TOOL_OUTPUT_EMPTY_TEXT) {
return TOOL_EMPTY_STATUS;
}
return output;
} else if (output.length === 0) {
base = [{ type: 'text', text: isError === true ? TOOL_EMPTY_ERROR_STATUS : TOOL_EMPTY_STATUS }];
} else if (isError === true) {
base = [{ type: 'text', text: TOOL_ERROR_STATUS }, ...output];
} else {
base = [...output];
}
if (output.length === 0) {
return [{ type: 'text', text: isError === true ? TOOL_EMPTY_ERROR_STATUS : TOOL_EMPTY_STATUS }];
if (note === undefined || note.length === 0) return base;
const notePart: ContentPart = { type: 'text', text: note };
if (typeof base === 'string') return `${base}\n${note}`;
const only = base[0];
if (base.length === 1 && only?.type === 'text') {
return [{ type: 'text', text: `${only.text}\n${note}` }];
}
if (isError === true) {
return [{ type: 'text', text: TOOL_ERROR_STATUS }, ...output];
}
return [...output];
return [...base, notePart];
}

View file

@ -234,7 +234,7 @@ export class AgentLoopService implements IAgentLoopService {
type: 'tool.result',
parentUuid: toolCallUuids.get(toolResult.toolCallId) ?? randomUUID(),
toolCallId: toolResult.toolCallId,
result: { output: result.output, isError: result.isError },
result: { output: result.output, isError: result.isError, note: result.note },
});
if (result.stopTurn === true) stopTurn = true;
}

View file

@ -21,7 +21,7 @@ import { SseMcpClient } from './client-sse';
import type { UnexpectedCloseReason } from './client-shared';
import { StdioMcpClient } from './client-stdio';
import type { McpOAuthService } from '#/agent/mcp/oauth/service';
import { assertMcpInputSchema, type MCPClient } from './types';
import { assertMcpInputSchema, type MCPClient, type MCPToolDefinition } from './types';
export type McpServerStatus = 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth';
@ -39,6 +39,7 @@ interface InternalEntry {
attemptId: number;
status: McpServerStatus;
tools?: readonly Tool[];
rawTools?: readonly MCPToolDefinition[];
enabledNames?: ReadonlySet<string>;
error?: string;
client?: RuntimeMcpClient;
@ -158,12 +159,18 @@ export class McpConnectionManager {
resolved(
name: string,
):
| { client: MCPClient; tools: readonly Tool[]; enabledNames: ReadonlySet<string> }
| {
client: MCPClient;
tools: readonly Tool[];
rawTools: readonly MCPToolDefinition[];
enabledNames: ReadonlySet<string>;
}
| undefined {
const entry = this.entries.get(name);
if (
entry?.status !== 'connected' ||
entry.tools === undefined ||
entry.rawTools === undefined ||
entry.client === undefined
) {
return undefined;
@ -171,6 +178,7 @@ export class McpConnectionManager {
return {
client: entry.client,
tools: entry.tools,
rawTools: entry.rawTools,
enabledNames: entry.enabledNames ?? new Set(entry.tools.map((t) => t.name)),
};
}
@ -214,6 +222,7 @@ export class McpConnectionManager {
entry.status = 'disabled';
entry.tools = undefined;
entry.enabledNames = undefined;
entry.rawTools = undefined;
entry.error = undefined;
this.emit(entry);
this.entries.delete(name);
@ -265,6 +274,7 @@ export class McpConnectionManager {
entry.status = 'pending';
entry.tools = undefined;
entry.enabledNames = undefined;
entry.rawTools = undefined;
entry.error = undefined;
this.emit(entry);
await this.connectOne(entry, attemptId);
@ -285,7 +295,7 @@ export class McpConnectionManager {
const startupClient = await this.createClient(entry.config, entry.name);
client = startupClient;
entry.client = startupClient;
const tools = await withTimeout(
const discovered = await withTimeout(
this.connectAndDiscoverTools(startupClient),
timeoutMs,
() => {
@ -297,8 +307,9 @@ export class McpConnectionManager {
await this.closeRuntimeClient(startupClient);
return;
}
entry.tools = tools;
entry.enabledNames = computeEnabledNames(entry.config, tools);
entry.tools = discovered.tools;
entry.rawTools = discovered.rawTools;
entry.enabledNames = computeEnabledNames(entry.config, discovered.tools);
entry.status = 'connected';
this.watchForUnexpectedClose(entry, startupClient, attemptId);
} catch (error) {
@ -317,6 +328,7 @@ export class McpConnectionManager {
}
entry.tools = undefined;
entry.enabledNames = undefined;
entry.rawTools = undefined;
// Drop the client reference so a later reconnect builds a fresh one.
await this.closeClient(entry);
}
@ -338,6 +350,7 @@ export class McpConnectionManager {
entry.error = formatUnexpectedCloseError(entry.name, reason);
entry.tools = undefined;
entry.enabledNames = undefined;
entry.rawTools = undefined;
entry.client = undefined;
// Best-effort close; the transport is already gone, but this lets the
// SDK release timers and pending request handlers.
@ -398,14 +411,19 @@ export class McpConnectionManager {
return isUnauthorizedLikeError(error);
}
private async connectAndDiscoverTools(client: RuntimeMcpClient): Promise<Tool[]> {
private async connectAndDiscoverTools(
client: RuntimeMcpClient,
): Promise<{ tools: Tool[]; rawTools: MCPToolDefinition[] }> {
await client.connect();
const mcpTools = await client.listTools();
return mcpTools.map((mcpTool) => ({
name: mcpTool.name,
description: mcpTool.description,
parameters: assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema),
}));
return {
rawTools: mcpTools,
tools: mcpTools.map((mcpTool) => ({
name: mcpTool.name,
description: mcpTool.description,
parameters: assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema),
})),
};
}
private async closeClient(entry: InternalEntry): Promise<void> {

View file

@ -7,11 +7,12 @@ import type {
McpServerEntry,
} from './connection-manager';
import type { McpOAuthService } from '#/agent/mcp/oauth/service';
import type { MCPClient } from './types';
import type { MCPClient, MCPToolDefinition } from './types';
export interface McpResolvedServer {
readonly client: MCPClient;
readonly tools: readonly KosongTool[];
readonly rawTools: readonly MCPToolDefinition[];
readonly enabledNames: ReadonlySet<string>;
}

View file

@ -0,0 +1,48 @@
/**
* `mcp` domain (L5) MCP tool-discovery wire state.
*
* Restores the per-agent de-dup cursor for durable MCP discovery records,
* keyed by `${serverName}\n${hash}` entries already present in this log.
*/
import { defineModel } from '#/wire/model';
import { defineOp } from '#/wire/op';
import type { MCPToolDefinition } from './types';
export interface McpToolCollision {
readonly qualified: string;
readonly toolName: string;
readonly collidesWith:
| { readonly kind: 'same_server'; readonly toolName: string }
| { readonly kind: 'other_server'; readonly serverName: string };
}
export interface McpDiscoveryState {
readonly seen: readonly string[];
}
export const McpDiscoveryModel = defineModel<McpDiscoveryState>('mcp.discovery', () => ({
seen: [],
}));
export interface McpToolsDiscoveredPayload {
readonly serverName: string;
readonly hash: string;
readonly tools: readonly MCPToolDefinition[];
readonly enabledNames: readonly string[];
readonly collisions?: readonly McpToolCollision[];
}
export const mcpToolsDiscovered = defineOp(McpDiscoveryModel, 'mcp.tools_discovered', {
apply: (s, p: McpToolsDiscoveredPayload): McpDiscoveryState => {
const key = `${p.serverName}\n${p.hash}`;
if (s.seen.includes(key)) return s;
return { seen: [...s.seen, key] };
},
});
declare module '#/agent/wireRecord/wireRecord' {
interface WireRecordMap {
'mcp.tools_discovered': McpToolsDiscoveredPayload;
}
}

View file

@ -1,3 +1,5 @@
import { createHash } from 'node:crypto';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import type { Tool as KosongTool } from '#/app/llmProtocol/tool';
@ -17,7 +19,14 @@ import { createMcpTool } from '#/agent/mcp/tools/mcp';
import type { McpServerEntry } from './connection-manager';
import { IAgentMcpService, type McpServiceOptions } from './mcp';
import { qualifyMcpToolName } from './tool-naming';
import type { MCPClient } from './types';
import type { MCPClient, MCPToolDefinition } from './types';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import {
McpDiscoveryModel,
mcpToolsDiscovered,
type McpToolCollision,
} from './mcpDiscoveryOps';
declare module '#/app/event/eventBus' {
interface DomainEventMap {
@ -34,24 +43,19 @@ interface McpToolRegistration {
readonly serverName: string;
}
interface McpToolCollision {
readonly qualified: string;
readonly toolName: string;
readonly collidesWith:
| { readonly kind: 'same_server'; readonly toolName: string }
| { readonly kind: 'other_server'; readonly serverName: string };
}
export class AgentMcpService extends Disposable implements IAgentMcpService {
declare readonly _serviceBrand: undefined;
private readonly mcpTools = new Map<string, McpToolRegistration>();
private readonly mcpToolsByServer = new Map<string, string[]>();
private readonly pendingDiscoveries: Array<() => void> = [];
private discoveryWritesReady = false;
constructor(
private readonly options: McpServiceOptions = {},
@IAgentToolRegistryService private readonly registry: IAgentToolRegistryService,
@IEventBus private readonly eventBus: IEventBus,
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
@IAgentWireService private readonly wire: IWireService,
) {
super();
this.attachMcpTools();
@ -64,6 +68,8 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
},
),
);
this._register(this.wire.onRestored(() => this.flushPendingDiscoveries()));
this._register(this.wire.onEmission(() => this.flushPendingDiscoveries()));
}
get oauthService() {
@ -164,6 +170,7 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
resolved.enabledNames,
);
this.emitMcpToolCollisions(entry.name, result.collisions);
this.recordDiscovery(entry.name, resolved.rawTools, resolved.enabledNames, result.collisions);
this.eventBus.publish({
type: 'tool.list.updated',
reason: 'mcp.connected',
@ -252,6 +259,44 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
return true;
}
private recordDiscovery(
serverName: string,
rawTools: readonly MCPToolDefinition[],
enabledNames: ReadonlySet<string>,
collisions: readonly McpToolCollision[],
): void {
const enabledNamesSnapshot = [...enabledNames].toSorted((a, b) => a.localeCompare(b));
const work = (): void => {
const hash = createHash('sha256')
.update(JSON.stringify({ tools: rawTools, enabledNames: enabledNamesSnapshot, collisions }))
.digest('hex');
const key = `${serverName}\n${hash}`;
if (this.wire.getModel(McpDiscoveryModel).seen.includes(key)) return;
this.wire.dispatch(
mcpToolsDiscovered({
serverName,
hash,
tools: rawTools,
enabledNames: enabledNamesSnapshot,
collisions: collisions.length > 0 ? collisions : undefined,
}),
);
};
if (!this.discoveryWritesReady) {
this.pendingDiscoveries.push(work);
return;
}
work();
}
private flushPendingDiscoveries(): void {
this.discoveryWritesReady = true;
const pending = this.pendingDiscoveries.splice(0);
for (const work of pending) {
work();
}
}
private emitMcpToolCollisions(
serverName: string,
collisions: readonly McpToolCollision[],

View file

@ -14,7 +14,9 @@
* would silently reintroduce the very degradation the caption reports.
* 4. Compress oversized inline images, announcing each compression with a
* caption (original vs. sent size, readback path to the persisted
* original) so downsampling is never silent.
* original) so downsampling is never silent. The captions ride the
* result's `note` side channel projected to the model at fold time, but
* kept out of `output` so UIs never render them.
* 5. Apply the per-part 10 MB binary cap: oversized binary parts
* (image/audio/video URLs) collapse to a notice, so a single
* screenshot cannot evict every text part.
@ -26,6 +28,7 @@
*/
import type { ContentPart } from '#/app/llmProtocol/message';
import type { ITelemetryService } from '#/app/telemetry/telemetry';
import { compressImageContentParts } from '#/_base/tools/support/image-compress';
import { persistOriginalImage } from '#/_base/tools/support/image-originals';
@ -38,6 +41,7 @@ export interface McpOutputOptions {
* Falls back to the shared temp-dir cache when absent.
*/
readonly originalsDir?: string;
readonly telemetry?: ITelemetryService;
}
// MCP servers can produce arbitrarily large outputs; cap what we feed back to
@ -151,7 +155,12 @@ export async function mcpResultToExecutableOutput(
result: MCPToolResult,
qualifiedToolName: string,
options: McpOutputOptions = {},
): Promise<{ output: string | ContentPart[]; isError: boolean; truncated?: true }> {
): Promise<{
output: string | ContentPart[];
isError: boolean;
note?: string;
truncated?: true;
}> {
const converted: ContentPart[] = [];
for (const block of result.content) {
const part = convertMCPContentBlock(block);
@ -174,6 +183,10 @@ export async function mcpResultToExecutableOutput(
// known) so the model can read detail back via ReadMediaFile + region.
// Parts that cannot be compressed pass through.
const compressed = await compressImageContentParts(budgeted.parts, {
telemetry:
options.telemetry === undefined
? undefined
: { client: options.telemetry, source: 'mcp_tool_result' },
annotate: {
persistOriginal: (bytes, mimeType) =>
persistOriginalImage(
@ -183,12 +196,16 @@ export async function mcpResultToExecutableOutput(
),
},
});
const capped = applyBinaryPartCap(compressed);
const capped = applyBinaryPartCap(compressed.parts);
const truncated = budgeted.truncated || capped.truncated;
const output = collapseSingleText(capped.parts);
return truncated
? { output, isError: result.isError, truncated: true }
: { output, isError: result.isError };
const note = compressed.captions.length > 0 ? compressed.captions.join('\n') : undefined;
return {
output,
isError: result.isError,
note,
truncated: truncated ? true : undefined,
};
}
/**

View file

@ -43,14 +43,15 @@ export function createMcpTool(
function normalizeMcpToolResult(result: {
readonly output: ExecutableToolResult['output'];
readonly isError: boolean;
readonly note?: string;
readonly truncated?: true;
}): ExecutableToolResult {
if (result.isError) {
return result.truncated === true
? { output: result.output, isError: true, truncated: true }
: { output: result.output, isError: true };
? { output: result.output, isError: true, note: result.note, truncated: true }
: { output: result.output, isError: true, note: result.note };
}
return result.truncated === true
? { output: result.output, truncated: true }
: { output: result.output };
? { output: result.output, note: result.note, truncated: true }
: { output: result.output, note: result.note };
}

View file

@ -51,6 +51,7 @@ export interface ExecutableToolSuccessResult {
readonly stopTurn?: boolean | undefined;
readonly message?: string | undefined;
readonly truncated?: boolean | undefined;
readonly note?: string;
readonly delivery?: ToolDelivery | undefined;
}
@ -60,6 +61,7 @@ export interface ExecutableToolErrorResult {
readonly message?: string | undefined;
readonly stopTurn?: boolean | undefined;
readonly truncated?: boolean | undefined;
readonly note?: string;
readonly delivery?: ToolDelivery | undefined;
}

View file

@ -731,9 +731,20 @@ function normalizeToolResult(result: ExecutableToolResult): ToolResult {
}
}
if (result.isError === true) {
return { output, isError: true, stopTurn: result.stopTurn };
return {
output,
isError: true,
stopTurn: result.stopTurn,
truncated: result.truncated,
note: result.note,
};
}
return { output, stopTurn: result.stopTurn };
return {
output,
stopTurn: result.stopTurn,
truncated: result.truncated,
note: result.note,
};
}
function toolTelemetryOutcome(result: ToolResult): 'success' | 'error' | 'cancelled' {

View file

@ -61,7 +61,7 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore {
declare readonly _serviceBrand: undefined;
private readonly dir: string;
private dbPromise: Promise<MiniDb> | undefined;
private dbPromise: Promise<MiniDb | undefined> | undefined;
private readonly ensuredIndexes = new Set<string>();
constructor(
@ -75,7 +75,7 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore {
}));
}
private openDb(): Promise<MiniDb> {
private openDb(): Promise<MiniDb | undefined> {
if (this.dbPromise !== undefined) return this.dbPromise;
this.dbPromise = MiniDb.openOrRebuild(
{
@ -92,18 +92,33 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore {
});
},
},
);
).catch((err) => {
// The query store is a rebuildable derived read model; authoritative data
// lives in the append-log / atomic-document stores. If it cannot be opened
// — typically because another kimi process holds the single-writer lock on
// `<cacheDir>/query-store` — degrade to a no-op store instead of crashing
// the host. Consumers then fall back to their non-read-model paths. The
// degraded state is memoized for the process lifetime so the warning is
// emitted once and we do not hammer a contended lock.
this.log.warn('minidb query-store unavailable; disabling read model', {
dir: this.dir,
error: String(err),
});
return undefined;
});
return this.dbPromise;
}
async put<T>(collection: string, key: string, value: T): Promise<void> {
const db = await this.openDb();
if (db === undefined) return;
await db.set(physicalKey(collection, key), value);
}
async batch(ops: readonly WriteOp[]): Promise<void> {
if (ops.length === 0) return;
const db = await this.openDb();
if (db === undefined) return;
await db.batch(
ops.map((op) =>
op.kind === 'put'
@ -115,11 +130,13 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore {
async delete(collection: string, key: string): Promise<void> {
const db = await this.openDb();
if (db === undefined) return;
await db.del(physicalKey(collection, key));
}
async get<T>(collection: string, key: string): Promise<T | undefined> {
const db = await this.openDb();
if (db === undefined) return undefined;
return db.get(physicalKey(collection, key)) as T | undefined;
}
@ -131,6 +148,7 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore {
const guard = `${collection}:${def.kind}:${def.name}`;
if (this.ensuredIndexes.has(guard)) return;
const db = await this.openDb();
if (db === undefined) return;
const name = indexName(collection, def.name);
if (def.kind === 'value') {
if (!db.listIndexes().some((i) => i.name === name)) {
@ -163,7 +181,7 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore {
async close(): Promise<void> {
if (this.dbPromise === undefined) return;
const db = await this.dbPromise;
await db.close();
await db?.close();
}
}
@ -175,7 +193,7 @@ class MiniDbQuery<T> implements IQuery<T> {
private skip = 0;
constructor(
private readonly openDb: () => Promise<MiniDb>,
private readonly openDb: () => Promise<MiniDb | undefined>,
private readonly collection: string,
) {}
@ -202,6 +220,7 @@ class MiniDbQuery<T> implements IQuery<T> {
async execute(): Promise<Page<T>> {
const db = await this.openDb();
if (db === undefined) return { items: [] };
const prefix = `${this.collection}${SEP}`;
const q: QueryOptions = { key: { prefix } };
if (Object.keys(this.filter).length > 0) q.filter = this.filter as Record<string, unknown>;

View file

@ -26,6 +26,8 @@ import {
registerScopedService,
} from '#/_base/di/scope';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IEventBus } from '#/app/event/eventBus';
import { ErrorCodes, makeErrorPayload } from '#/errors';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { ILogService } from '#/_base/log/log';
import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog';
@ -245,6 +247,11 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
const manager = this.getMcpManager();
const initialLoad = this.connectMcpServers(manager).catch((error: unknown) => {
this.log.error('mcp initial load failed', { error });
const message = error instanceof Error ? error.message : String(error);
this.handles.get('main')?.accessor.get(IEventBus)?.publish({
type: 'error',
...makeErrorPayload(ErrorCodes.MCP_STARTUP_FAILED, message),
});
});
this.mcpInitialLoad = initialLoad;
return initialLoad;

View file

@ -106,4 +106,45 @@ describe('loop-event fold parity', () => {
expect(folded).toEqual(baseline);
});
it('folds a tool-result note as trailing model text without splitting text-only output', () => {
context.append(
{
role: 'assistant',
content: [],
toolCalls: [{ type: 'function', id: 'c3', name: 'Screenshot', arguments: '{}' }],
},
{
role: 'tool',
content: [{ type: 'text', text: 'result text\n<system>Image compressed.</system>' }],
toolCalls: [],
toolCallId: 'c3',
isError: false,
},
);
const baseline = comparable(context.get());
context.clear();
context.appendLoopEvent({ type: 'step.begin', uuid: 's3' });
context.appendLoopEvent({
type: 'tool.call',
stepUuid: 's3',
toolCallId: 'c3',
name: 'Screenshot',
args: {},
});
context.appendLoopEvent({
type: 'tool.result',
toolCallId: 'c3',
result: {
output: 'result text',
isError: false,
note: '<system>Image compressed.</system>',
},
});
context.appendLoopEvent({ type: 'step.end', uuid: 's3' });
const folded = comparable(context.get());
expect(folded).toEqual(baseline);
});
});

View file

@ -10,7 +10,12 @@ import type { McpConnectionManager, McpServerEntry } from '#/agent/mcp/connectio
import { IAgentMcpService } from '#/agent/mcp/mcp';
import { AgentMcpService } from '#/agent/mcp/mcpService';
import type { McpOAuthService } from '#/agent/mcp/oauth/service';
import type { MCPClient } from '#/agent/mcp/types';
import type { MCPClient, MCPToolDefinition } from '#/agent/mcp/types';
import { IAgentWireService } from '#/wire/tokens';
import { WireService } from '#/wire/wireServiceImpl';
import { McpDiscoveryModel } from '#/agent/mcp/mcpDiscoveryOps';
import { AGENT_WIRE_PROTOCOL_VERSION } from '#/agent/wireRecord/wireRecord';
import { wireMetadata } from '#/agent/wireRecord/metadataOps';
import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorService';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
@ -29,6 +34,7 @@ const MCP_OUTPUT_TRUNCATED_TEXT =
interface ResolvedServer {
readonly client: MCPClient;
readonly tools: readonly KosongTool[];
readonly rawTools: readonly MCPToolDefinition[];
readonly enabledNames: ReadonlySet<string>;
}
@ -74,8 +80,21 @@ class FakeMcpManager {
client: MCPClient,
tools: readonly KosongTool[],
enabledNames = new Set(tools.map((tool) => tool.name)),
rawTools?: readonly MCPToolDefinition[],
): void {
this.resolvedEntries.set(name, { client, tools, enabledNames });
const resolvedRawTools =
rawTools ??
tools.map((tool) => ({
name: tool.name,
description: tool.description ?? '',
inputSchema: (tool.parameters ?? {}) as MCPToolDefinition['inputSchema'],
}));
this.resolvedEntries.set(name, {
client,
tools,
rawTools: resolvedRawTools,
enabledNames,
});
}
connect(name: string, options: { readonly transport?: 'stdio' | 'http' | 'sse' } = {}): void {
@ -128,6 +147,7 @@ describe('AgentMcpService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
let events: DomainEvent[];
let wire: WireService;
beforeEach(() => {
disposables = new DisposableStore();
@ -142,6 +162,8 @@ describe('AgentMcpService', () => {
ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService));
ix.set(IAgentToolExecutorService, new SyncDescriptor(AgentToolExecutorService));
ix.stub(IAgentTurnService, stubTurnWithHooks());
wire = disposables.add(new WireService({ logScope: 'mcp-test', logKey: 'wire.jsonl' }));
ix.stub(IAgentWireService, wire);
});
afterEach(() => {
disposables.dispose();
@ -469,6 +491,200 @@ describe('AgentMcpService', () => {
}),
);
});
const RAW_QUERY: MCPToolDefinition = {
name: 'query_range',
description: 'Query a metrics range',
inputSchema: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
};
function collectDiscoveries(): {
records: { type: string; [key: string]: unknown }[];
off: { dispose(): void };
} {
const records: { type: string; [key: string]: unknown }[] = [];
const off = wire.onEmission((e) => {
if (e.record.type === 'mcp.tools_discovered') {
records.push(e.record as { type: string; [key: string]: unknown });
}
});
return { records, off };
}
it('records tools/list once after restore and dedups unchanged reconnects', async () => {
const manager = new FakeMcpManager();
const client = fakeMcpClient([RAW_QUERY]);
const rawTools = await client.listTools();
manager.setResolved(
'grafana',
client,
await discoverTools(client),
new Set(['query_range']),
rawTools,
);
createService(manager);
const { records, off } = collectDiscoveries();
try {
manager.connect('grafana');
expect(records).toHaveLength(0); // parked until restore
await wire.replay();
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({
type: 'mcp.tools_discovered',
serverName: 'grafana',
tools: rawTools,
enabledNames: ['query_range'],
});
expect(records[0]!['collisions']).toBeUndefined();
// identical content -> no second record
manager.connect('grafana');
expect(records).toHaveLength(1);
// allow-list change is a different gating decision -> record again
manager.setResolved('grafana', client, await discoverTools(client), new Set(), rawTools);
manager.connect('grafana');
expect(records).toHaveLength(2);
} finally {
off.dispose();
}
});
it('parks a discovery observed before restore and flushes it after replay', async () => {
const manager = new FakeMcpManager();
const client = fakeMcpClient([RAW_QUERY]);
const rawTools = await client.listTools();
manager.setResolved(
'grafana',
client,
await discoverTools(client),
new Set(['query_range']),
rawTools,
);
createService(manager);
const { records, off } = collectDiscoveries();
try {
manager.connect('grafana');
expect(records).toHaveLength(0); // parked, not yet durable
await wire.replay();
expect(records).toHaveLength(1);
} finally {
off.dispose();
}
});
it('snapshots enabledNames when parking a discovery before restore', async () => {
const manager = new FakeMcpManager();
const client = fakeMcpClient([RAW_QUERY]);
const rawTools = await client.listTools();
const enabledNames = new Set(['query_range']);
manager.setResolved(
'grafana',
client,
await discoverTools(client),
enabledNames,
rawTools,
);
createService(manager);
const { records, off } = collectDiscoveries();
try {
manager.connect('grafana');
enabledNames.clear();
enabledNames.add('mutated_after_observation');
await wire.replay();
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({
type: 'mcp.tools_discovered',
serverName: 'grafana',
tools: rawTools,
enabledNames: ['query_range'],
});
} finally {
off.dispose();
}
});
it('flushes a parked discovery after the first live wire record on a fresh session', async () => {
const manager = new FakeMcpManager();
const client = fakeMcpClient([RAW_QUERY]);
const rawTools = await client.listTools();
manager.setResolved(
'grafana',
client,
await discoverTools(client),
new Set(['query_range']),
rawTools,
);
createService(manager);
const { records, off } = collectDiscoveries();
try {
manager.connect('grafana');
expect(records).toHaveLength(0);
wire.dispatch(
wireMetadata({
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
created_at: 1,
}),
);
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({
type: 'mcp.tools_discovered',
serverName: 'grafana',
tools: rawTools,
enabledNames: ['query_range'],
});
} finally {
off.dispose();
}
});
it('re-records when only the collision outcome changes', async () => {
const manager = new FakeMcpManager();
const occupant = fakeMcpClient([RAW_QUERY]);
const occupantRaw = await occupant.listTools();
manager.setResolved(
'graf.ana',
occupant,
await discoverTools(occupant),
new Set(['query_range']),
occupantRaw,
);
createService(manager);
manager.connect('graf.ana');
await wire.replay(); // restore; occupant discovery recorded (before we subscribe)
const { records, off } = collectDiscoveries();
try {
const client = fakeMcpClient([RAW_QUERY]);
const rawTools = await client.listTools();
manager.setResolved(
'graf_ana',
client,
await discoverTools(client),
new Set(['query_range']),
rawTools,
);
manager.connect('graf_ana'); // collides with the occupant's qualified name
expect(records).toHaveLength(1);
expect(records[0]!['collisions']).toHaveLength(1);
manager.disconnect('graf.ana'); // occupant gone
manager.connect('graf_ana'); // same rawTools/allow-list, collision flipped
expect(records).toHaveLength(2);
expect(records[1]!['collisions']).toBeUndefined();
} finally {
off.dispose();
}
});
});
describe('AgentMcpService + AgentProfileService', () => {

View file

@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, test } from 'vitest';
import type { ITelemetryService } from '#/app/telemetry/telemetry';
import { convertMCPContentBlock, mcpResultToExecutableOutput } from '#/agent/mcp/output';
import { createMcpTool } from '#/agent/mcp/tools/mcp';
import type { MCPClient, MCPContentBlock, MCPToolResult } from '#/agent/mcp/types';
@ -28,6 +29,29 @@ function assertValidMcpBlock<T extends MCPContentBlock>(block: T): T {
return block;
}
interface TelemetryRecord {
readonly event: string;
readonly properties: Readonly<Record<string, unknown>> | undefined;
}
function recordingTelemetry(records: TelemetryRecord[]): ITelemetryService {
const telemetry: ITelemetryService = {
_serviceBrand: undefined,
track(event, properties) {
records.push({ event, properties });
},
withContext: () => telemetry,
setContext: () => {},
addAppender: () => ({ dispose: () => {} }),
removeAppender: () => {},
setAppender: () => {},
setEnabled: () => {},
flush: async () => {},
shutdown: async () => {},
};
return telemetry;
}
describe('convertMCPContentBlock', () => {
test('converts text block to TextPart', () => {
const block: MCPContentBlock = { type: 'text', text: 'hello' };
@ -321,7 +345,7 @@ describe('mcpResultToExecutableOutput', () => {
{ type: 'text', text: 'A'.repeat(100_000) },
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,' + 'B'.repeat(500_000) } },
]);
expect(out).not.toHaveProperty('truncated');
expect(out.truncated).toBeUndefined();
});
test('downsamples an oversized real image instead of leaving it full-size', async () => {
@ -358,15 +382,12 @@ describe('mcpResultToExecutableOutput', () => {
);
const parts = out.output as ContentPart[];
const captionIndex = parts.findIndex(
(p) => p.type === 'text' && p.text.includes('Image compressed'),
);
expect(captionIndex).toBeGreaterThanOrEqual(0);
const caption = (parts[captionIndex] as { text: string }).text;
const caption = out.note;
expect(caption).toContain('Image compressed');
expect(caption).toContain('2600x2600');
expect(parts[captionIndex + 1]?.type).toBe('image_url');
expect(parts.some((p) => p.type === 'image_url')).toBe(true);
const pathMatch = /saved at "([^"]+)"/.exec(caption);
const pathMatch = /saved at "([^"]+)"/.exec(caption!);
expect(pathMatch).not.toBeNull();
const persisted = await readFile(pathMatch![1]!);
expect(persisted.equals(bigBytes)).toBe(true);
@ -383,9 +404,38 @@ describe('mcpResultToExecutableOutput', () => {
'mcp__s__shot',
);
const parts = out.output as ContentPart[];
const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
expect(joined).not.toContain('Image compressed');
expect(out.note).toBeUndefined();
});
test('reports MCP image compression telemetry with the MCP tool-result source', async () => {
const records: TelemetryRecord[] = [];
const big = Buffer.from(
await new Jimp({ width: 2600, height: 2600, color: 0x3366ccff }).getBuffer('image/png'),
).toString('base64');
await mcpResultToExecutableOutput(
result([{ type: 'image', data: big, mimeType: 'image/png' }]),
'mcp__s__shot',
{ telemetry: recordingTelemetry(records) },
);
const events = records.filter((record) => record.event === 'image_compress');
expect(events).toHaveLength(1);
const properties = events[0]!.properties;
expect(properties).toEqual(
expect.objectContaining({
source: 'mcp_tool_result',
outcome: 'compressed',
input_mime: 'image/png',
output_mime: 'image/png',
original_width: 2600,
original_height: 2600,
exif_transposed: false,
}),
);
expect(properties?.['final_width']).toBeLessThanOrEqual(2000);
expect(properties?.['final_height']).toBeLessThanOrEqual(2000);
expect(properties?.['duration_ms']).toEqual(expect.any(Number));
});
test('persists originals into the provided session originals dir', async () => {
@ -400,10 +450,9 @@ describe('mcpResultToExecutableOutput', () => {
{ originalsDir: dir },
);
const parts = out.output as ContentPart[];
const caption = parts.find((p) => p.type === 'text' && p.text.includes('Image compressed'));
if (caption?.type !== 'text') throw new Error('expected a compression caption');
const pathMatch = /saved at "([^"]+)"/.exec(caption.text);
const caption = out.note;
expect(caption).toContain('Image compressed');
const pathMatch = /saved at "([^"]+)"/.exec(caption!);
expect(pathMatch).not.toBeNull();
expect(pathMatch![1]!.startsWith(dir)).toBe(true);
const persisted = await readFile(pathMatch![1]!);
@ -432,10 +481,8 @@ describe('mcpResultToExecutableOutput', () => {
const toolText = parts[0];
if (toolText?.type !== 'text') throw new Error('expected the tool text part first');
expect(toolText.text).toContain('Output truncated');
const caption = parts.find((p) => p.type === 'text' && p.text.includes('Image compressed'));
if (caption?.type !== 'text') throw new Error('expected a compression caption');
expect(caption.text).toMatch(/<\/system>$/);
expect(caption.text).toContain('saved at');
expect(out.note).toMatch(/<\/system>$/);
expect(out.note).toContain('saved at');
await rm(dir, { recursive: true, force: true });
});
@ -454,13 +501,11 @@ describe('mcpResultToExecutableOutput', () => {
{ originalsDir: dir },
);
const parts = out.output as ContentPart[];
expect(out.truncated).toBeUndefined();
const caption = parts.find((p) => p.type === 'text' && p.text.includes('Image compressed'));
if (caption?.type !== 'text') throw new Error('expected a compression caption');
expect(caption.text).toMatch(/^<system>Image compressed/);
expect(caption.text).toMatch(/<\/system>$/);
expect(caption.text).toContain('saved at');
expect(out.note).toMatch(/^<system>Image compressed/);
expect(out.note).toMatch(/<\/system>$/);
expect(out.note).toContain('saved at');
const parts = out.output as ContentPart[];
const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
expect(joined).not.toContain('Output truncated');
await rm(dir, { recursive: true, force: true });

View file

@ -9,6 +9,7 @@ import { LifecycleScope, _clearScopedRegistryForTests, registerScopedService } f
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { ILogService } from '#/_base/log/log';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { MiniDb } from '@moonshot-ai/minidb';
import { MiniDbQueryStore } from '#/persistence/backends/minidb/miniDbQueryStore';
import { IQueryStore } from '#/persistence/interface/queryStore';
import { stubBootstrap } from '../bootstrap/stubs';
@ -131,6 +132,28 @@ describe('MiniDbQueryStore', () => {
expect(await store.getCheckpoint('wire:abc')).toEqual({ seq: 42 });
});
it('degrades gracefully when the database lock is held by another process', async () => {
// Simulate another kimi process holding the single-writer lock on the
// shared query-store directory.
const storeDir = join(homeDir, 'cache', 'query-store');
const lockHolder = await MiniDb.open({ dir: storeDir, valueCodec: 'json' });
try {
const store = build();
// Writes must not throw: the read model is a rebuildable cache, so lock
// contention degrades to a no-op rather than crashing the host.
await expect(store.put(COLLECTION, 'a', { id: 'a' })).resolves.toBeUndefined();
await expect(store.batch([{ kind: 'put', collection: COLLECTION, key: 'b', value: { id: 'b' } }])).resolves.toBeUndefined();
await expect(store.ensureIndex(COLLECTION, { kind: 'value', name: 'byId', field: 'id' })).resolves.toBeUndefined();
// Reads fall back to empty so consumers (e.g. session index) take their
// legacy path.
expect(await store.get(COLLECTION, 'a')).toBeUndefined();
expect(await store.getCheckpoint('wire:abc')).toBeUndefined();
expect(await store.query(COLLECTION).execute()).toEqual({ items: [] });
} finally {
await lockHolder.close();
}
});
it('rebuilds a clean store after on-disk corruption', async () => {
const first = build();
await first.put(COLLECTION, 'a', { v: 1 });

View file

@ -100,6 +100,33 @@ describe('AgentToolExecutorService', () => {
});
});
it('preserves internal result notes without exposing them on protocol tool.result events', async () => {
const tool = new TestTool('captioned', {
result: {
output: 'image sent',
note: '<system>Image compressed.</system>',
},
});
registry.register(tool);
const results = await execute([toolCall('call_captioned', 'captioned', {})]);
expect(results[0]).toMatchObject({
output: 'image sent',
note: '<system>Image compressed.</system>',
});
const protocolResult = protocolEvents.find(
(event): event is Extract<AgentEvent, { type: 'tool.result' }> =>
event.type === 'tool.result',
);
expect(protocolResult).toMatchObject({
type: 'tool.result',
toolCallId: 'call_captioned',
output: 'image sent',
});
expect(protocolResult as unknown as Record<string, unknown>).not.toHaveProperty('note');
});
it('records an error tool.result when the tool name is unknown', async () => {
const results = await execute([toolCall('call_missing', 'missing', { text: 'hi' })]);

View file

@ -92,14 +92,22 @@ export interface BroadcastTarget {
send(envelope: EventEnvelope): void;
}
/**
* Per-subscription agent allowlist for fine-grained v1 event delivery.
* `undefined` (or omitted) means "receive every agent" the legacy
* session-grained behavior. A `ReadonlySet` restricts delivery to the listed
* agent ids; global events ({@link isGlobalEvent}) bypass the filter entirely.
*/
export type AgentFilter = ReadonlySet<string> | undefined;
interface SessionState {
readonly sessionId: string;
readonly journal: SessionEventJournal;
readonly tracker: InFlightTurnTracker;
/** Recent durable envelopes for in-memory replay. */
readonly tail: Array<{ seq: number; envelope: EventEnvelope }>;
/** Connections subscribed to this session. */
readonly targets: Set<BroadcastTarget>;
/** Connections subscribed to this session, each with its optional agent allowlist. */
readonly targets: Map<BroadcastTarget, AgentFilter>;
/** Per-session dispatch queue — serializes stamp / journal / fan-out. */
queue: Promise<void>;
/** agentId → sink subscription. */
@ -132,10 +140,14 @@ export class SessionEventBroadcaster {
}
/** Subscribe a connection to a session's stream (activates the session). */
async subscribe(sessionId: string, target: BroadcastTarget): Promise<boolean> {
async subscribe(
sessionId: string,
target: BroadcastTarget,
filter?: AgentFilter,
): Promise<boolean> {
const state = await this.ensureState(sessionId);
if (state === undefined) return false;
state.targets.add(target);
state.targets.set(target, filter);
return true;
}
@ -143,7 +155,11 @@ export class SessionEventBroadcaster {
this.sessions.get(sessionId)?.targets.delete(target);
}
async getBufferedSince(sessionId: string, cursor: SessionCursor): Promise<BufferedSinceResult> {
async getBufferedSince(
sessionId: string,
cursor: SessionCursor,
filter?: AgentFilter,
): Promise<BufferedSinceResult> {
const state = await this.ensureState(sessionId);
if (state === undefined) {
return { events: [], resyncRequired: 'session_recreated', currentSeq: 0, epoch: '' };
@ -168,14 +184,24 @@ export class SessionEventBroadcaster {
return { events: [], resyncRequired: 'buffer_overflow', currentSeq, epoch };
}
// Filter is a view crop over the session's single durable sequence: the
// watermark and overflow checks above stay global, only the returned
// envelopes are narrowed to the subscriber's agent allowlist.
const applyFilter = (
entries: Array<{ seq: number; envelope: EventEnvelope }>,
): Array<{ seq: number; envelope: EventEnvelope }> =>
filter === undefined
? entries
: entries.filter(({ envelope }) => matchesAgentFilter(envelope, filter));
// Serve from the memory tail when it fully covers the gap; else the journal.
const tailStart = tail[0]?.seq;
if (tailStart !== undefined && tailStart <= cursor.seq + 1) {
const events = tail.filter((e) => e.seq > cursor.seq);
const events = applyFilter(tail.filter((e) => e.seq > cursor.seq));
return { events, resyncRequired: false, currentSeq, epoch };
}
const fromDisk = await journal.readSince(cursor.seq, this.maxBufferSize);
return { events: fromDisk, resyncRequired: false, currentSeq, epoch };
return { events: applyFilter(fromDisk), resyncRequired: false, currentSeq, epoch };
}
async getCursor(sessionId: string): Promise<{ seq: number; epoch: string }> {
@ -252,7 +278,7 @@ export class SessionEventBroadcaster {
journal,
tracker: new InFlightTurnTracker(),
tail: [],
targets: new Set(),
targets: new Map(),
queue: Promise.resolve(),
agentDisposables: new Map(),
lifecycleDisposables: [],
@ -277,7 +303,7 @@ export class SessionEventBroadcaster {
journal,
tracker: new InFlightTurnTracker(),
tail: [],
targets: new Set(),
targets: new Map(),
queue: Promise.resolve(),
agentDisposables: new Map(),
lifecycleDisposables: [],
@ -526,12 +552,24 @@ export class SessionEventBroadcaster {
while (tail.length > this.maxBufferSize) tail.shift();
}
const fanOut = isGlobalEvent(event.type) ? this.allTargets() : targets;
for (const target of fanOut) {
try {
target.send(envelope);
} catch {
// best-effort fan-out; a broken target is dropped, not fatal
if (isGlobalEvent(event.type)) {
// Global events (session/workspace/config/model-catalog) are not agent
// events — fan out to every subscriber regardless of any agent filter.
for (const target of this.allTargets()) {
try {
target.send(envelope);
} catch {
// best-effort fan-out; a broken target is dropped, not fatal
}
}
} else {
for (const [target, filter] of targets) {
if (!matchesAgentFilter(envelope, filter)) continue;
try {
target.send(envelope);
} catch {
// best-effort fan-out; a broken target is dropped, not fatal
}
}
}
}
@ -554,7 +592,7 @@ export class SessionEventBroadcaster {
private *allTargets(): Iterable<BroadcastTarget> {
for (const state of this.sessions.values()) {
for (const target of state.targets) yield target;
for (const target of state.targets.keys()) yield target;
}
}
}
@ -594,6 +632,31 @@ function isGlobalEvent(type: string): boolean {
);
}
/**
* Per-subscription agent allowlist check shared by live fan-out and replay.
* Returns `true` when the envelope should be delivered to a subscriber carrying
* `filter`:
* - `filter === undefined` receive every agent (legacy session-grained
* behavior);
* - global events (session/workspace/config/model-catalog) are not agent
* events and always pass;
* - events without a string `agentId` (should not happen on the v1 wire,
* where the broadcaster stamps every event) pass defensively rather than
* being dropped;
* - otherwise the envelope's `payload.agentId` must be in the allowlist.
*/
function matchesAgentFilter(envelope: EventEnvelope, filter: AgentFilter): boolean {
if (filter === undefined) return true;
if (isGlobalEvent(envelope.type)) return true;
const payload = envelope.payload;
const agentId =
typeof payload === 'object' && payload !== null
? (payload as { agentId?: unknown }).agentId
: undefined;
if (typeof agentId !== 'string') return true;
return filter.has(agentId);
}
// ---------------------------------------------------------------------------
// Interaction → v1 protocol event synthesis. Event names and payload shapes
// mirror v1's question/approval services

View file

@ -29,6 +29,7 @@ import {
buildServerHello,
} from './protocol';
import {
type AgentFilter,
type BroadcastTarget,
type ResyncReason,
type SessionEventBroadcaster,
@ -98,8 +99,8 @@ export class WsConnectionV1 implements BroadcastTarget {
private closed = false;
private gotClientHello = false;
/** Session ids this connection is currently subscribed to. */
readonly subscriptions = new Set<string>();
/** Per-session subscription state, with each session's optional agent allowlist. */
readonly subscriptions = new Map<string, AgentFilter>();
private pingTimer?: ReturnType<typeof setInterval>;
private pongTimer?: ReturnType<typeof setTimeout>;
@ -149,7 +150,7 @@ export class WsConnectionV1 implements BroadcastTarget {
}
get subscriptionSessionIds(): readonly string[] {
return Array.from(this.subscriptions).sort();
return Array.from(this.subscriptions.keys()).sort();
}
/** BroadcastTarget — forward a sequenced envelope to the socket. */
@ -194,13 +195,21 @@ export class WsConnectionV1 implements BroadcastTarget {
const payload = frame.payload ?? {};
const subscriptions = asStringArray(payload['subscriptions']);
const cursors = payload['cursors'] as Record<string, SessionCursor> | undefined;
const agentFilter = parseAgentFilter(payload['agent_filter']);
const accepted: string[] = [];
const resyncRequired: string[] = [];
const serverCursors: Record<string, { seq: number; epoch?: string }> = {};
for (const sid of subscriptions) {
await this.attachSession(sid, cursors?.[sid], accepted, resyncRequired, serverCursors);
await this.attachSession(
sid,
cursors?.[sid],
agentFilter?.[sid],
accepted,
resyncRequired,
serverCursors,
);
}
this.sendFrame(
@ -216,6 +225,7 @@ export class WsConnectionV1 implements BroadcastTarget {
const payload = frame.payload ?? {};
const sessionIds = asStringArray(payload['session_ids']);
const cursors = payload['cursors'] as Record<string, SessionCursor> | undefined;
const agentFilter = parseAgentFilter(payload['agent_filter']);
const accepted: string[] = [];
const notFound: string[] = [];
@ -223,16 +233,17 @@ export class WsConnectionV1 implements BroadcastTarget {
const serverCursors: Record<string, { seq: number; epoch?: string }> = {};
for (const sid of sessionIds) {
const ok = await this.broadcaster.subscribe(sid, this);
const filter = agentFilter?.[sid];
const ok = await this.broadcaster.subscribe(sid, this, filter);
if (!ok) {
notFound.push(sid);
continue;
}
this.subscriptions.add(sid);
this.subscriptions.set(sid, filter);
accepted.push(sid);
const cursor = cursors?.[sid];
if (cursor !== undefined) {
await this.replay(sid, cursor, resyncRequired, serverCursors);
await this.replay(sid, cursor, filter, resyncRequired, serverCursors);
} else {
const cur = await this.broadcaster.getCursor(sid);
serverCursors[sid] = cur;
@ -268,19 +279,20 @@ export class WsConnectionV1 implements BroadcastTarget {
private async attachSession(
sid: string,
cursor: SessionCursor | undefined,
filter: AgentFilter | undefined,
accepted: string[],
resyncRequired: string[],
serverCursors: Record<string, { seq: number; epoch?: string }>,
): Promise<void> {
const ok = await this.broadcaster.subscribe(sid, this);
const ok = await this.broadcaster.subscribe(sid, this, filter);
if (!ok) {
resyncRequired.push(sid);
return;
}
this.subscriptions.add(sid);
this.subscriptions.set(sid, filter);
accepted.push(sid);
if (cursor !== undefined) {
await this.replay(sid, cursor, resyncRequired, serverCursors);
await this.replay(sid, cursor, filter, resyncRequired, serverCursors);
} else {
const cur = await this.broadcaster.getCursor(sid);
serverCursors[sid] = cur;
@ -290,10 +302,11 @@ export class WsConnectionV1 implements BroadcastTarget {
private async replay(
sid: string,
cursor: SessionCursor,
filter: AgentFilter | undefined,
resyncRequired: string[],
serverCursors: Record<string, { seq: number; epoch?: string }>,
): Promise<void> {
const result = await this.broadcaster.getBufferedSince(sid, cursor);
const result = await this.broadcaster.getBufferedSince(sid, cursor, filter);
if (result.resyncRequired !== false) {
this.sendFrame(
buildResyncRequired(sid, result.resyncRequired as ResyncReason, result.currentSeq, result.epoch),
@ -447,7 +460,7 @@ export class WsConnectionV1 implements BroadcastTarget {
if (this.flushTimer !== undefined) clearTimeout(this.flushTimer);
if (this.backpressureRetryTimer !== undefined) clearTimeout(this.backpressureRetryTimer);
this.outbound = [];
for (const sid of this.subscriptions) this.broadcaster.unsubscribe(sid, this);
for (const sid of this.subscriptions.keys()) this.broadcaster.unsubscribe(sid, this);
// registry removal is handled by registerWsV1 on the socket 'close' event.
}
}
@ -457,6 +470,26 @@ function asStringArray(value: unknown): string[] {
return value.filter((v): v is string => typeof v === 'string');
}
/**
* Parse the wire `agent_filter` payload (`Record<session_id, agent_id[]>`) into
* a per-session allowlist map. Sessions missing from the returned map or the
* whole field absent fall back to "every agent" (`undefined`), the legacy
* session-grained behavior. Malformed entries (non-object, empty arrays,
* non-string ids) are dropped per-session rather than failing the whole
* handshake, so a bad entry cannot widen another session's filter.
*/
function parseAgentFilter(value: unknown): Record<string, AgentFilter> | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
const out: Record<string, AgentFilter> = {};
for (const [sid, ids] of Object.entries(value)) {
if (!Array.isArray(ids)) continue;
const set = new Set(ids.filter((v): v is string => typeof v === 'string'));
if (set.size === 0) continue;
out[sid] = set;
}
return out;
}
function rawDataToString(data: RawData): string {
if (typeof data === 'string') return data;
if (Buffer.isBuffer(data)) return data.toString('utf8');

View file

@ -580,4 +580,88 @@ describe('SessionEventBroadcaster', () => {
await bc.getCursor('s1');
expect(envelopes.map((e) => e.type)).toEqual(['event.question.answered']);
});
// -------------------------------------------------------------------------
// Per-agent subscription filter
// -------------------------------------------------------------------------
it('delivers only the allowlisted agent events on live fan-out', async () => {
const lc = new FakeLifecycle();
const main = lc.addAgent('main');
const sub = lc.addAgent('agent-0');
sessions.set('s1', lc);
const { target, envelopes } = collectingTarget();
await bc.subscribe('s1', target, new Set(['main']));
main.bus.emit(agentEvent('turn.ended', { turnId: 1 }));
sub.bus.emit(agentEvent('turn.ended', { turnId: 1 }));
await bc.getCursor('s1');
expect(envelopes).toHaveLength(1);
expect((envelopes[0]!.payload as { agentId: string }).agentId).toBe('main');
});
it('delivers every agent event when no filter is set', async () => {
const lc = new FakeLifecycle();
const main = lc.addAgent('main');
const sub = lc.addAgent('agent-0');
sessions.set('s1', lc);
const { target, envelopes } = collectingTarget();
await bc.subscribe('s1', target); // no filter — legacy behavior
main.bus.emit(agentEvent('turn.ended', { turnId: 1 }));
sub.bus.emit(agentEvent('turn.ended', { turnId: 1 }));
await bc.getCursor('s1');
const agentIds = envelopes.map((e) => (e.payload as { agentId: string }).agentId);
expect(agentIds).toEqual(['main', 'agent-0']);
});
it('bypasses the agent filter for global events', async () => {
const lc = new FakeLifecycle();
lc.addAgent('main');
sessions.set('s1', lc);
const { target, envelopes } = collectingTarget();
// Filter does not include 'main', yet global events must still be delivered.
await bc.subscribe('s1', target, new Set(['agent-0']));
eventBus.emit({
type: 'session.meta.updated',
payload: {
agentId: 'main',
sessionId: 's1',
title: '测试',
patch: { title: '测试' },
},
});
await vi.waitFor(() => expect(envelopes).toHaveLength(1));
expect(envelopes[0]!.type).toBe('session.meta.updated');
});
it('replays only the allowlisted agent events while keeping the global sequence', async () => {
const lc = new FakeLifecycle();
const main = lc.addAgent('main');
const sub = lc.addAgent('agent-0');
sessions.set('s1', lc);
// Activate the session and journal a mixed sequence before replaying.
const warm = collectingTarget();
await bc.subscribe('s1', warm.target);
main.bus.emit(agentEvent('turn.ended', { turnId: 1 })); // seq 1
sub.bus.emit(agentEvent('turn.ended', { turnId: 1 })); // seq 2
main.bus.emit(agentEvent('turn.ended', { turnId: 2 })); // seq 3
await bc.getCursor('s1');
const result = await bc.getBufferedSince('s1', { seq: 0 }, new Set(['main']));
expect(result.resyncRequired).toBe(false);
// Filtered view keeps the session's original seq offsets (with gaps).
expect(result.events.map((e) => e.seq)).toEqual([1, 3]);
expect(
result.events.every((e) => (e.envelope.payload as { agentId: string }).agentId === 'main'),
).toBe(true);
});
});

View file

@ -237,4 +237,46 @@ describe('server-v2 /api/v1/ws resync', () => {
c.ws.close();
await c.closed;
});
it('delivers only the allowlisted agent events via agent_filter', async () => {
const sid = await createSession();
await ensureMainAgent(sid);
// Add a second agent to the same session so we can distinguish sources.
const session = server!.core.accessor.get(ISessionLifecycleService).get(sid);
expect(session).toBeDefined();
const agents = session!.accessor.get(IAgentLifecycleService);
const sub = await agents.create({ agentId: 'agent-0' });
const c = await openConn(wsUrl, server!.authTokenService.getToken());
await c.next((f) => f.type === 'server_hello');
c.send({
type: 'client_hello',
id: 'h1',
payload: withToken({
client_id: 'cli',
subscriptions: [sid],
agent_filter: { [sid]: ['main'] },
}),
});
await c.next((f) => f.type === 'ack' && f.id === 'h1');
// Emit one durable event per agent — only `main` is allowlisted.
agents
.getHandle('main')!
.accessor.get(IEventBus)
.publish({ type: 'turn.ended', turnId: 1 } as unknown as DomainEvent);
sub.accessor
.get(IEventBus)
.publish({ type: 'turn.ended', turnId: 2 } as unknown as DomainEvent);
const ev = await c.next((f) => f.type === 'turn.ended');
expect(ev.payload).toMatchObject({ agentId: 'main' });
// The agent-0 event is filtered out — no second turn.ended arrives.
await expect(c.next((f) => f.type === 'turn.ended', 300)).rejects.toThrow();
c.ws.close();
await c.closed;
});
});

View file

@ -1,5 +1,11 @@
# @moonshot-ai/kosong
## 0.5.3
### Patch Changes
- [#1397](https://github.com/MoonshotAI/kimi-code/pull/1397) [`6c9abe8`](https://github.com/MoonshotAI/kimi-code/commit/6c9abe8cf7765b489ca50a2bb0f9b829fd680a51) Thanks [@kermanx](https://github.com/kermanx)! - Add provider-level structured response format support.
## 0.5.2
### Patch Changes

View file

@ -1,6 +1,6 @@
{
"name": "@moonshot-ai/kosong",
"version": "0.5.2",
"version": "0.5.3",
"private": true,
"description": "The LLM abstraction layer for modern AI agent applications",
"license": "MIT",

View file

@ -1,5 +1,11 @@
# @moonshot-ai/kimi-code-sdk
## 0.13.1
### Patch Changes
- [#1460](https://github.com/MoonshotAI/kimi-code/pull/1460) [`474ce28`](https://github.com/MoonshotAI/kimi-code/commit/474ce289dd39aa42d1a77a9a2e15531aee49aa15) Thanks [@RealKai42](https://github.com/RealKai42)! - Raise the image downscale cap from 2000px to 3000px, and fix swapped width/height for EXIF-rotated (portrait) photos in compression captions and media read notes so region readback coordinates map correctly.
## 0.13.0
### Minor Changes

View file

@ -1,6 +1,6 @@
{
"name": "@moonshot-ai/kimi-code-sdk",
"version": "0.13.0",
"version": "0.13.1",
"private": true,
"description": "TypeScript SDK for the Kimi Code Agent",
"license": "MIT",

View file

@ -226,6 +226,22 @@ describe('ws-control — §3.2 client_hello', () => {
expect(result.success).toBe(true);
});
it('client_hello accepts an agent_filter map', () => {
const result = clientHelloMessageSchema.safeParse({
type: 'client_hello',
id: 'c1',
payload: {
client_id: 'web_abc',
subscriptions: ['sess_1', 'sess_2'],
agent_filter: {
sess_1: ['main'],
sess_2: ['main', 'agent-0'],
},
},
});
expect(result.success).toBe(true);
});
it('client_hello rejects the v1 bare-seq cursor map', () => {
const result = clientHelloMessageSchema.safeParse({
type: 'client_hello',
@ -264,6 +280,42 @@ describe('ws-control — §3.3 subscribe / unsubscribe', () => {
expect(result.success).toBe(true);
});
it('subscribe accepts an agent_filter map', () => {
const result = subscribeMessageSchema.safeParse({
type: 'subscribe',
id: 'c2',
payload: {
session_ids: ['sess_1', 'sess_2'],
agent_filter: {
sess_1: ['main'],
sess_2: ['main', 'agent-0'],
},
},
});
expect(result.success).toBe(true);
});
it('subscribe accepts a missing agent_filter (legacy session-grained behavior)', () => {
const result = subscribeMessageSchema.safeParse({
type: 'subscribe',
id: 'c2',
payload: { session_ids: ['sess_1'] },
});
expect(result.success).toBe(true);
});
it('subscribe rejects an empty agent_filter allowlist', () => {
const result = subscribeMessageSchema.safeParse({
type: 'subscribe',
id: 'c2',
payload: {
session_ids: ['sess_1'],
agent_filter: { sess_1: [] },
},
});
expect(result.success).toBe(false);
});
it('subscribe rejects missing session_ids', () => {
const result = subscribeMessageSchema.safeParse({
type: 'subscribe',

View file

@ -90,10 +90,22 @@ export const serverHelloMessageSchema = z.object({
export type ServerHelloMessage = z.infer<typeof serverHelloMessageSchema>;
/**
* Per-session agent allowlist for fine-grained v1 event subscriptions. Keys are
* session ids, values are the non-empty set of agent ids the client wants to
* receive events for within that session. Sessions absent from the map (or the
* whole field omitted) fall back to receiving every agent the legacy
* session-grained behavior.
*/
export const agentFilterSchema = z.record(z.string(), z.array(z.string()).min(1));
export type AgentFilter = z.infer<typeof agentFilterSchema>;
export const clientHelloPayloadSchema = z.object({
client_id: z.string(),
subscriptions: z.array(z.string()),
cursors: cursorsBySessionSchema.optional(),
agent_filter: agentFilterSchema.optional(),
});
export const clientHelloMessageSchema = z.object({
@ -124,6 +136,7 @@ export const subscribePayloadSchema = z.object({
session_ids: z.array(z.string()),
cursors: cursorsBySessionSchema.optional(),
watch_fs: z.record(z.string(), watchFsConfigSchema).optional(),
agent_filter: agentFilterSchema.optional(),
});
export const subscribeMessageSchema = z.object({