diff --git a/.changeset/add-vercel-plugin.md b/.changeset/add-vercel-plugin.md deleted file mode 100644 index 8fd208266..000000000 --- a/.changeset/add-vercel-plugin.md +++ /dev/null @@ -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. diff --git a/.changeset/count-created-goal-turn.md b/.changeset/count-created-goal-turn.md deleted file mode 100644 index 8e9ba8ea2..000000000 --- a/.changeset/count-created-goal-turn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Count the turn that starts an autonomous goal toward its goal turn usage. diff --git a/.changeset/fix-headless-exit-code.md b/.changeset/fix-headless-exit-code.md deleted file mode 100644 index f2de03dd8..000000000 --- a/.changeset/fix-headless-exit-code.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix `kimi -p` runs exiting with code 0 when a turn fails. diff --git a/.changeset/fix-hooks-windows-console-popup.md b/.changeset/fix-hooks-windows-console-popup.md deleted file mode 100644 index 46f8b18c7..000000000 --- a/.changeset/fix-hooks-windows-console-popup.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix console windows flashing on Windows each time a hook runs. diff --git a/.changeset/fix-web-ws-reconnect-toast.md b/.changeset/fix-web-ws-reconnect-toast.md deleted file mode 100644 index dbf190b92..000000000 --- a/.changeset/fix-web-ws-reconnect-toast.md +++ /dev/null @@ -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. diff --git a/.changeset/forbid-model-goal-pauses.md b/.changeset/forbid-model-goal-pauses.md deleted file mode 100644 index 3f56a747b..000000000 --- a/.changeset/forbid-model-goal-pauses.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Prevent autonomous goals from being paused by model-reported status updates. diff --git a/.changeset/goal-blocked-audit-prompt.md b/.changeset/goal-blocked-audit-prompt.md deleted file mode 100644 index 4f498ddb1..000000000 --- a/.changeset/goal-blocked-audit-prompt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Tighten goal-mode guidance for blocked and complete status updates. diff --git a/.changeset/image-compression-improvements.md b/.changeset/image-compression-improvements.md deleted file mode 100644 index 70e762223..000000000 --- a/.changeset/image-compression-improvements.md +++ /dev/null @@ -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. diff --git a/.changeset/migrate-web-icons-to-unplugin-icons.md b/.changeset/migrate-web-icons-to-unplugin-icons.md deleted file mode 100644 index e15b783bd..000000000 --- a/.changeset/migrate-web-icons-to-unplugin-icons.md +++ /dev/null @@ -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. diff --git a/.changeset/select-tools-discard-on-compaction.md b/.changeset/select-tools-discard-on-compaction.md deleted file mode 100644 index 70bda32d5..000000000 --- a/.changeset/select-tools-discard-on-compaction.md +++ /dev/null @@ -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. diff --git a/.changeset/structured-output-provider-format.md b/.changeset/structured-output-provider-format.md deleted file mode 100644 index 625fb130a..000000000 --- a/.changeset/structured-output-provider-format.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kosong": patch ---- - -Add provider-level structured response format support. diff --git a/.changeset/web-composer-default-model-switch.md b/.changeset/web-composer-default-model-switch.md deleted file mode 100644 index e35a51784..000000000 --- a/.changeset/web-composer-default-model-switch.md +++ /dev/null @@ -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. diff --git a/.changeset/web-confirm-dialog-enter-key.md b/.changeset/web-confirm-dialog-enter-key.md deleted file mode 100644 index c74cc4e05..000000000 --- a/.changeset/web-confirm-dialog-enter-key.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: Press Enter to confirm in archive and other confirmation dialogs. diff --git a/.changeset/web-cron-redesign.md b/.changeset/web-cron-redesign.md deleted file mode 100644 index d338b601c..000000000 --- a/.changeset/web-cron-redesign.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: Redesign the scheduled reminder UI. diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 44e5829a8..4cb493e84 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -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:` so they are distinguishable from built-in commands; typing the bare skill name still works. + ## 0.23.1 ### Patch Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 68137acf1..0c165a97a 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -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", diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 22f645ef2..eb9131b48 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -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 `/` from the menu, or typed `/ 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:` from the menu, or typed + // `/ 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) { diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index 4f591349a..e8069b0ec 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -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 `, // `/swarm `, `/btw `, 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 = ''; diff --git a/apps/kimi-web/src/lib/slashCommands.ts b/apps/kimi-web/src/lib/slashCommands.ts index 8eb24ed75..8977f3e24 100644 --- a/apps/kimi-web/src/lib/slashCommands.ts +++ b/apps/kimi-web/src/lib/slashCommands.ts @@ -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:`). */ +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 `/`). Skills carry their raw description and + * skills. Non-builtin skills are shown as `/skill:` so the user can + * tell them apart from built-in commands (mirroring the TUI); builtin-sourced + * skills keep the bare `/`. 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. diff --git a/apps/kimi-web/test/slash-menu.test.ts b/apps/kimi-web/test/slash-menu.test.ts index 9b1d8657b..c270eafb4 100644 --- a/apps/kimi-web/test/slash-menu.test.ts +++ b/apps/kimi-web/test/slash-menu.test.ts @@ -74,11 +74,25 @@ describe('useSlashMenu — update', () => { expect(slash.open.value).toBe(false); }); - it('includes session skills as /', () => { - const { slash } = setup('/', [{ name: 'deploy', description: 'deploy stuff' } as AppSkill]); + it('includes session skills as /skill:', () => { + 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'); }); }); diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index a751feccf..7b98cd3f5 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -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:` 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 diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 89124bdac..bce292cfc 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -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:` 显示会话技能,便于与内置命令区分;直接输入技能名称仍然可用。 +- web: 输入框的模型切换器在切换当前会话模型的同时,也会更新全局默认模型,使新会话继承该选择。 +- web: 归档等确认对话框支持按 Enter 确认。 +- 优化目标模式对阻塞与完成状态更新的指引。 +- 渐进式工具加载(`select_tools`,实验功能):压缩后丢弃已加载的工具 schema,由模型重新选择仍需要的工具,使压缩后上下文保持精简;凭记忆调用未再加载的工具会被拒绝,并提示先选择。仅在启用 `tool-select` 实验标志且模型支持 `select_tools` 时生效。 + +### 重构 + +- web: 在构建时编译图标,使打包后的 web UI 仅包含实际渲染的图标。 + ## 0.23.1(2026-07-07) ### 修复 diff --git a/packages/agent-core-v2/src/_base/tools/support/image-compress.ts b/packages/agent-core-v2/src/_base/tools/support/image-compress.ts index fb98431de..62d1d6578 100644 --- a/packages/agent-core-v2/src/_base/tools/support/image-compress.ts +++ b/packages/agent-core-v2/src/_base/tools/support/image-compress.ts @@ -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>): 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 { + 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 { +): 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; } +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. */ diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index 7ce9ae4dd..ebe14bf52 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -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('ERROR:')) return output; - return `${TOOL_ERROR_STATUS}\n${output}`; + if (output.length === 0) base = TOOL_EMPTY_ERROR_STATUS; + else if (output.trimStart().startsWith('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]; } diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 3b6a46921..db059b7cd 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -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; } diff --git a/packages/agent-core-v2/src/agent/mcp/connection-manager.ts b/packages/agent-core-v2/src/agent/mcp/connection-manager.ts index 48cbf04c1..b51e95f16 100644 --- a/packages/agent-core-v2/src/agent/mcp/connection-manager.ts +++ b/packages/agent-core-v2/src/agent/mcp/connection-manager.ts @@ -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; error?: string; client?: RuntimeMcpClient; @@ -158,12 +159,18 @@ export class McpConnectionManager { resolved( name: string, ): - | { client: MCPClient; tools: readonly Tool[]; enabledNames: ReadonlySet } + | { + client: MCPClient; + tools: readonly Tool[]; + rawTools: readonly MCPToolDefinition[]; + enabledNames: ReadonlySet; + } | 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 { + 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 { diff --git a/packages/agent-core-v2/src/agent/mcp/mcp.ts b/packages/agent-core-v2/src/agent/mcp/mcp.ts index 3668a9146..a35ca3b5a 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcp.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcp.ts @@ -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; } diff --git a/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts b/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts new file mode 100644 index 000000000..c0cd0ce0a --- /dev/null +++ b/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts @@ -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('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; + } +} diff --git a/packages/agent-core-v2/src/agent/mcp/mcpService.ts b/packages/agent-core-v2/src/agent/mcp/mcpService.ts index b05cac09a..7fb1c5d81 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpService.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpService.ts @@ -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(); private readonly mcpToolsByServer = new Map(); + 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, + 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[], diff --git a/packages/agent-core-v2/src/agent/mcp/output.ts b/packages/agent-core-v2/src/agent/mcp/output.ts index 990297e69..a377d0946 100644 --- a/packages/agent-core-v2/src/agent/mcp/output.ts +++ b/packages/agent-core-v2/src/agent/mcp/output.ts @@ -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, + }; } /** diff --git a/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts b/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts index fe28cc984..b8d918889 100644 --- a/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts +++ b/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts @@ -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 }; } diff --git a/packages/agent-core-v2/src/agent/tool/toolContract.ts b/packages/agent-core-v2/src/agent/tool/toolContract.ts index fd1f1d536..10eb6cdac 100644 --- a/packages/agent-core-v2/src/agent/tool/toolContract.ts +++ b/packages/agent-core-v2/src/agent/tool/toolContract.ts @@ -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; } diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index b93e72045..817168716 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -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' { diff --git a/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts b/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts index 4cabec2ec..a83f9eb1d 100644 --- a/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts +++ b/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts @@ -61,7 +61,7 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { declare readonly _serviceBrand: undefined; private readonly dir: string; - private dbPromise: Promise | undefined; + private dbPromise: Promise | undefined; private readonly ensuredIndexes = new Set(); constructor( @@ -75,7 +75,7 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { })); } - private openDb(): Promise { + private openDb(): Promise { 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 + // `/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(collection: string, key: string, value: T): Promise { const db = await this.openDb(); + if (db === undefined) return; await db.set(physicalKey(collection, key), value); } async batch(ops: readonly WriteOp[]): Promise { 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 { const db = await this.openDb(); + if (db === undefined) return; await db.del(physicalKey(collection, key)); } async get(collection: string, key: string): Promise { 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 { if (this.dbPromise === undefined) return; const db = await this.dbPromise; - await db.close(); + await db?.close(); } } @@ -175,7 +193,7 @@ class MiniDbQuery implements IQuery { private skip = 0; constructor( - private readonly openDb: () => Promise, + private readonly openDb: () => Promise, private readonly collection: string, ) {} @@ -202,6 +220,7 @@ class MiniDbQuery implements IQuery { async execute(): Promise> { 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; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index ac71cd53a..23c3b946f 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -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; diff --git a/packages/agent-core-v2/test/contextMemory/loop-event-fold-parity.test.ts b/packages/agent-core-v2/test/contextMemory/loop-event-fold-parity.test.ts index 0f3705316..fea6de6fd 100644 --- a/packages/agent-core-v2/test/contextMemory/loop-event-fold-parity.test.ts +++ b/packages/agent-core-v2/test/contextMemory/loop-event-fold-parity.test.ts @@ -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\nImage compressed.' }], + 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: 'Image compressed.', + }, + }); + context.appendLoopEvent({ type: 'step.end', uuid: 's3' }); + const folded = comparable(context.get()); + + expect(folded).toEqual(baseline); + }); }); diff --git a/packages/agent-core-v2/test/mcp/mcp.test.ts b/packages/agent-core-v2/test/mcp/mcp.test.ts index 73021ecd8..563e1ce53 100644 --- a/packages/agent-core-v2/test/mcp/mcp.test.ts +++ b/packages/agent-core-v2/test/mcp/mcp.test.ts @@ -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; } @@ -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', () => { diff --git a/packages/agent-core-v2/test/mcp/output.test.ts b/packages/agent-core-v2/test/mcp/output.test.ts index 41eba2b1b..5c532e8fa 100644 --- a/packages/agent-core-v2/test/mcp/output.test.ts +++ b/packages/agent-core-v2/test/mcp/output.test.ts @@ -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(block: T): T { return block; } +interface TelemetryRecord { + readonly event: string; + readonly properties: Readonly> | 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(/^Image compressed/); - expect(caption.text).toMatch(/<\/system>$/); - expect(caption.text).toContain('saved at'); + expect(out.note).toMatch(/^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 }); diff --git a/packages/agent-core-v2/test/persistence/miniDbQueryStore.test.ts b/packages/agent-core-v2/test/persistence/miniDbQueryStore.test.ts index 2990bd54c..b767a18db 100644 --- a/packages/agent-core-v2/test/persistence/miniDbQueryStore.test.ts +++ b/packages/agent-core-v2/test/persistence/miniDbQueryStore.test.ts @@ -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 }); diff --git a/packages/agent-core-v2/test/toolExecutor/tool-executor.test.ts b/packages/agent-core-v2/test/toolExecutor/tool-executor.test.ts index 557c0f17d..e9349cfbe 100644 --- a/packages/agent-core-v2/test/toolExecutor/tool-executor.test.ts +++ b/packages/agent-core-v2/test/toolExecutor/tool-executor.test.ts @@ -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: 'Image compressed.', + }, + }); + registry.register(tool); + + const results = await execute([toolCall('call_captioned', 'captioned', {})]); + + expect(results[0]).toMatchObject({ + output: 'image sent', + note: 'Image compressed.', + }); + const protocolResult = protocolEvents.find( + (event): event is Extract => + event.type === 'tool.result', + ); + expect(protocolResult).toMatchObject({ + type: 'tool.result', + toolCallId: 'call_captioned', + output: 'image sent', + }); + expect(protocolResult as unknown as Record).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' })]); diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index b9499acd2..a2e74dbcf 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -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 | 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; + /** Connections subscribed to this session, each with its optional agent allowlist. */ + readonly targets: Map; /** Per-session dispatch queue — serializes stamp / journal / fan-out. */ queue: Promise; /** 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 { + async subscribe( + sessionId: string, + target: BroadcastTarget, + filter?: AgentFilter, + ): Promise { 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 { + async getBufferedSince( + sessionId: string, + cursor: SessionCursor, + filter?: AgentFilter, + ): Promise { 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 { 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 diff --git a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts index 11ca0fe23..ba4b5ebd2 100644 --- a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts +++ b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts @@ -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(); + /** Per-session subscription state, with each session's optional agent allowlist. */ + readonly subscriptions = new Map(); private pingTimer?: ReturnType; private pongTimer?: ReturnType; @@ -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 | undefined; + const agentFilter = parseAgentFilter(payload['agent_filter']); const accepted: string[] = []; const resyncRequired: string[] = []; const serverCursors: Record = {}; 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 | 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 = {}; 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, ): Promise { - 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, ): Promise { - 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`) 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 | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const out: Record = {}; + 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'); diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index c83ef7b75..7e88b9727 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -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); + }); }); diff --git a/packages/kap-server/test/wsV1Resync.test.ts b/packages/kap-server/test/wsV1Resync.test.ts index f49ada8de..d1a26097a 100644 --- a/packages/kap-server/test/wsV1Resync.test.ts +++ b/packages/kap-server/test/wsV1Resync.test.ts @@ -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; + }); }); diff --git a/packages/kosong/CHANGELOG.md b/packages/kosong/CHANGELOG.md index 3ea8fce13..9d71fb022 100644 --- a/packages/kosong/CHANGELOG.md +++ b/packages/kosong/CHANGELOG.md @@ -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 diff --git a/packages/kosong/package.json b/packages/kosong/package.json index 16d09e9db..1c7e19223 100644 --- a/packages/kosong/package.json +++ b/packages/kosong/package.json @@ -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", diff --git a/packages/node-sdk/CHANGELOG.md b/packages/node-sdk/CHANGELOG.md index 08bf9779a..0ad65961c 100644 --- a/packages/node-sdk/CHANGELOG.md +++ b/packages/node-sdk/CHANGELOG.md @@ -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 diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index e9343b292..ac9877d24 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -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", diff --git a/packages/protocol/src/__tests__/ws-control.test.ts b/packages/protocol/src/__tests__/ws-control.test.ts index 942752251..c201a4be3 100644 --- a/packages/protocol/src/__tests__/ws-control.test.ts +++ b/packages/protocol/src/__tests__/ws-control.test.ts @@ -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', diff --git a/packages/protocol/src/ws-control.ts b/packages/protocol/src/ws-control.ts index 8e98e5266..2098276c1 100644 --- a/packages/protocol/src/ws-control.ts +++ b/packages/protocol/src/ws-control.ts @@ -90,10 +90,22 @@ export const serverHelloMessageSchema = z.object({ export type ServerHelloMessage = z.infer; +/** + * 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; + 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({