mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
Merge branch 'kimi-code-v2' of https://github.com/MoonshotAI/kimi-code into kimi-code-v2
This commit is contained in:
commit
e3fb6d249e
50 changed files with 1194 additions and 221 deletions
|
|
@ -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.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Count the turn that starts an autonomous goal toward its goal turn usage.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix `kimi -p` runs exiting with code 0 when a turn fails.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix console windows flashing on Windows each time a hook runs.
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Prevent autonomous goals from being paused by model-reported status updates.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Tighten goal-mode guidance for blocked and complete status updates.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/kosong": patch
|
||||
---
|
||||
|
||||
Add provider-level structured response format support.
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
web: Press Enter to confirm in archive and other confirmation dialogs.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
web: Redesign the scheduled reminder UI.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,34 @@ outline: 2
|
|||
|
||||
本页记录 Kimi Code CLI 每个版本的变更内容。
|
||||
|
||||
## 0.23.2(2026-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.1(2026-07-07)
|
||||
|
||||
### 修复
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
48
packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts
Normal file
48
packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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' })]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue