diff --git a/.changeset/ACP-subagent-events.md b/.changeset/ACP-subagent-events.md new file mode 100644 index 0000000000..b51799209b --- /dev/null +++ b/.changeset/ACP-subagent-events.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/kimi-code': patch +--- + +ACP: forward subagent lifecycle and streams. `subagent.spawned/started/suspended/completed/failed` events are now emitted as ordinary `tool_call`/`tool_call_update` session updates carrying a `_meta.kimiCode.subagent` payload (subagentId, parentToolCallId, summary/usage on completion), and the subagent's own assistant/thinking/tool frames are forwarded with `_meta.kimiCode.subagentId` instead of being dropped at the main-agent guard. Clients that ignore `_meta` see a flat but complete tool stream; the capability is advertised as `agentCapabilities._meta.kimiCode.subagentEvents`. diff --git a/docs/en/reference/kimi-acp.md b/docs/en/reference/kimi-acp.md index 30f0787651..84b836bdef 100644 --- a/docs/en/reference/kimi-acp.md +++ b/docs/en/reference/kimi-acp.md @@ -68,6 +68,29 @@ The spec divides methods into a **stable** surface and an evolving **unstable** All methods not listed above return `methodNotFound`. +## Extension frames (`_meta.kimiCode`) + +Beyond the stable surface, the adapter attaches vendor metadata under the +`_meta.kimiCode` key (per the ACP extensibility rules; clients that ignore +`_meta` see ordinary standard frames). Support is advertised during +`initialize` as `agentCapabilities._meta.kimiCode.subagentEvents`. + +### Subagent lifecycle + +`subagent.spawned/started/suspended/completed/failed` engine events are +forwarded as ordinary `tool_call` / `tool_call_update` updates: + +- `spawned` creates one card per subagent with `toolCallId: + subagent:` and `_meta.kimiCode.subagent: {event, subagentId, + subagentName, parentToolCallId, description?, swarmIndex?, + runInBackground}`. +- `started` / `suspended` / `completed` / `failed` update that card; + completion carries `resultSummary` / `usage` / `contextTokens`, suspension + carries `reason`, failure carries `error`. +- The subagent's own `assistant.delta` / `thinking.delta` / `tool.call.*` + frames are forwarded with `_meta.kimiCode.subagentId` so clients can nest + them under the subagent's card instead of the main stream. + ## MCP Forwarding When an ACP client provides `mcpServers` in `session/new` or `session/load`, the adapter layer performs the following conversions: diff --git a/docs/zh/reference/kimi-acp.md b/docs/zh/reference/kimi-acp.md index d58c4460ab..2238a822cb 100644 --- a/docs/zh/reference/kimi-acp.md +++ b/docs/zh/reference/kimi-acp.md @@ -68,6 +68,27 @@ kimi acp 上述未列出的方法一律返回 `methodNotFound`。 +## 扩展帧(`_meta.kimiCode`) + +在稳定面之外,适配器遵循 ACP 可扩展性规则,将厂商元数据挂在 `_meta.kimiCode` +键下(忽略 `_meta` 的客户端只会看到普通标准帧)。该能力在 `initialize` 时通过 +`agentCapabilities._meta.kimiCode.subagentEvents` 公告。 + +### 子代理生命周期 + +引擎的 `subagent.spawned/started/suspended/completed/failed` 事件以普通 +`tool_call` / `tool_call_update` 更新转发: + +- `spawned` 为每个子代理创建一张卡片,`toolCallId: subagent:`, + 并携带 `_meta.kimiCode.subagent: {event, subagentId, subagentName, + parentToolCallId, description?, swarmIndex?, runInBackground}`。 +- `started` / `suspended` / `completed` / `failed` 更新该卡片;完成时携带 + `resultSummary` / `usage` / `contextTokens`,暂停时携带 `reason`,失败时携带 + `error`。 +- 子代理自己的 `assistant.delta` / `thinking.delta` / `tool.call.*` 帧带 + `_meta.kimiCode.subagentId` 转发,客户端可将其嵌套到子代理卡片下,而不是混进 + 主流。 + ## MCP 转发 ACP 客户端在 `session/new` 或 `session/load` 中提供 `mcpServers` 时,适配层做如下转换: diff --git a/packages/acp-adapter/src/events-map.ts b/packages/acp-adapter/src/events-map.ts index 0448f2eb9c..64d1375cc5 100644 --- a/packages/acp-adapter/src/events-map.ts +++ b/packages/acp-adapter/src/events-map.ts @@ -9,6 +9,11 @@ import type { } from '@agentclientprotocol/sdk'; import type { AssistantDeltaEvent, + SubagentCompletedEvent, + SubagentFailedEvent, + SubagentSpawnedEvent, + SubagentStartedEvent, + SubagentSuspendedEvent, ThinkingDeltaEvent, ToolCallDeltaEvent, ToolCallStartedEvent, @@ -525,3 +530,144 @@ export function configOptionUpdateNotification( }, }; } + +/** + * Wire id for a subagent's tool card. + * + * Prefixed so it can never collide with the `${turnId}:${toolCallId}` space + * of real tool calls (see {@link acpToolCallId}), and stable for the whole + * subagent life: `spawned` creates the card once, every later lifecycle event + * is a `tool_call_update` against this id. + */ +export function acpSubagentToolCallId(subagentId: string): string { + return `subagent:${subagentId}`; +} + +/** `_meta` payload carried by every subagent lifecycle frame. */ +interface SubagentMeta { + event: 'spawned' | 'started' | 'suspended' | 'completed' | 'failed'; + subagentId: string; + subagentName?: string; + /** + * The **raw** (unprefixed) id of the `Agent` tool call that spawned this + * subagent. The wire id of the parent's card is `${turnId}:${rawId}`, so + * clients match by suffix — see acpToolCallId. + */ + parentToolCallId?: string; + description?: string; + swarmIndex?: number; + runInBackground?: boolean; + reason?: string; + resultSummary?: string; + usage?: unknown; + contextTokens?: number; + error?: string; +} + +function subagentMeta(meta: SubagentMeta): { kimiCode: { subagent: SubagentMeta } } { + // Absent optional fields stay `undefined` — JSON.stringify drops those + // keys, so nothing ever serializes as an explicit `null`. + return { kimiCode: { subagent: meta } }; +} + +/** + * Map `subagent.spawned` to a `tool_call` CREATE: one card per subagent, + * nested under the spawning `Agent` call via `_meta.kimiCode.subagent + * .parentToolCallId`. Emitted as an ordinary frame (not a custom update + * kind) because v1 SDKs drop unknown `sessionUpdate` kinds — see + * docs/subagent-frames-spec.md in the ACP UI repo. + */ +export function subagentSpawnedToSessionUpdate( + sessionId: string, + event: SubagentSpawnedEvent, +): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId: acpSubagentToolCallId(event.subagentId), + title: `Subagent · ${event.subagentName}`, + kind: 'other', + status: 'pending', + _meta: subagentMeta({ + event: 'spawned', + subagentId: event.subagentId, + subagentName: event.subagentName, + parentToolCallId: event.parentToolCallId, + description: event.description, + swarmIndex: event.swarmIndex, + runInBackground: event.runInBackground, + }), + }, + }; +} + +/** + * Map `subagent.started` / `.suspended` / `.completed` / `.failed` to a + * `tool_call_update` against the card `spawned` created. Status mirrors the + * ACP tool-call lifecycle; suspension stays `in_progress` with the reason in + * `_meta` (ACP has no paused status). + */ +export function subagentLifecycleToSessionUpdate( + sessionId: string, + event: SubagentStartedEvent | SubagentSuspendedEvent | SubagentCompletedEvent | SubagentFailedEvent, +): SessionNotification { + let status: 'in_progress' | 'completed' | 'failed'; + const meta: SubagentMeta = { + event: 'started', + subagentId: event.subagentId, + }; + switch (event.type) { + case 'subagent.started': + status = 'in_progress'; + break; + case 'subagent.suspended': + status = 'in_progress'; + meta.event = 'suspended'; + meta.reason = event.reason; + break; + case 'subagent.completed': + status = 'completed'; + meta.event = 'completed'; + meta.resultSummary = event.resultSummary; + meta.usage = event.usage; + meta.contextTokens = event.contextTokens; + break; + case 'subagent.failed': + status = 'failed'; + meta.event = 'failed'; + meta.error = event.error; + break; + } + return { + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: acpSubagentToolCallId(event.subagentId), + status, + _meta: subagentMeta(meta), + }, + }; +} + +/** + * Attach the nesting marker to a frame that belongs to a subagent's own + * stream (its `assistant.delta`, `tool.call.*`, … events). The client nests + * anything carrying `_meta.kimiCode.subagentId` under the subagent's card; + * frames from the main agent are returned untouched. + */ +export function withSubagentMeta( + notification: T, + event: { agentId?: string }, +): T { + // 'main' is MAIN_AGENT_ID in session.ts; inlined here because session.ts + // already imports this module — the reverse edge would be a cycle. + if (event.agentId === undefined || event.agentId === 'main') return notification; + return { + ...notification, + update: { + ...notification.update, + _meta: { kimiCode: { subagentId: event.agentId } }, + }, + }; +} diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index 6707fd4cae..4e348747b4 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -321,6 +321,10 @@ export class AcpServer implements Agent { list: {}, resume: {}, }, + // This build forwards subagent lifecycle + streams (see events-map.ts): + // advertised per the spec's SHOULD so clients can gate their subagent UI + // on it instead of sniffing frames. + _meta: { kimiCode: { subagentEvents: true } }, }; return { diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 747b44ea9c..a3524648df 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -46,6 +46,8 @@ import { configOptionUpdateNotification, planFromDisplayBlock, stringifyArgs, + subagentLifecycleToSessionUpdate, + subagentSpawnedToSessionUpdate, thinkingDeltaToSessionUpdate, toolCallDeltaToSessionUpdate, toolCallLazyCreateToSessionUpdate, @@ -54,6 +56,7 @@ import { toolProgressToSessionUpdate, toolResultToSessionUpdate, turnEndReasonToStopReason, + withSubagentMeta, } from './events-map'; import { acpModeToToggles, DEFAULT_MODE_ID, isAcpModeId, type AcpModeId } from './modes'; import { outcomeToQuestionAnswer, questionItemToPermissionOptions } from './question'; @@ -1045,6 +1048,40 @@ export class AcpSession { ) { this.currentTurnId = event.turnId; } + // Subagent lifecycle: forwarded as ordinary `tool_call` / + // `tool_call_update` frames with `_meta.kimiCode.subagent` (v1 SDKs + // drop unknown update kinds, so a custom kind is not an option). + // These events carry no `agentId`, so the main-agent guard below + // would not catch them — they need their own branches. + if (event.type === 'subagent.spawned') { + conn + .sessionUpdate(subagentSpawnedToSessionUpdate(sessionId, event)) + .catch((err) => { + log.warn('acp: failed to push subagent tool_call', { + sessionId, + subagentId: event.subagentId, + error: err instanceof Error ? err.message : String(err), + }); + }); + return; + } + if ( + event.type === 'subagent.started' || + event.type === 'subagent.suspended' || + event.type === 'subagent.completed' || + event.type === 'subagent.failed' + ) { + conn + .sessionUpdate(subagentLifecycleToSessionUpdate(sessionId, event)) + .catch((err) => { + log.warn('acp: failed to push subagent tool_call_update', { + sessionId, + subagentId: event.subagentId, + error: err instanceof Error ? err.message : String(err), + }); + }); + return; + } if (event.type === 'error') { if (settled) return; if (!isFromMainAgent(event)) return; @@ -1068,7 +1105,9 @@ export class AcpSession { return; } if (event.type === 'assistant.delta') { - if (!isFromMainAgent(event)) return; + // Subagent deltas are forwarded too, tagged with + // `_meta.kimiCode.subagentId` so clients can nest them; everything + // else about the frame is unchanged. // `sessionUpdate` is itself async (it serializes onto the // ndjson stream). The text deltas form a strictly ordered // single-producer/single-consumer pipeline, so each await @@ -1076,7 +1115,7 @@ export class AcpSession { // Fire-and-forget keeps the stream pumping; we log push // failures rather than dropping them silently. conn - .sessionUpdate(assistantDeltaToSessionUpdate(sessionId, event)) + .sessionUpdate(withSubagentMeta(assistantDeltaToSessionUpdate(sessionId, event), event)) .catch((err) => { log.warn('acp: failed to push agent_message_chunk', { sessionId, @@ -1086,9 +1125,8 @@ export class AcpSession { return; } if (event.type === 'thinking.delta') { - if (!isFromMainAgent(event)) return; conn - .sessionUpdate(thinkingDeltaToSessionUpdate(sessionId, event)) + .sessionUpdate(withSubagentMeta(thinkingDeltaToSessionUpdate(sessionId, event), event)) .catch((err) => { log.warn('acp: failed to push agent_thought_chunk', { sessionId, @@ -1098,7 +1136,6 @@ export class AcpSession { return; } if (event.type === 'tool.call.started') { - if (!isFromMainAgent(event)) return; // Seed the accumulator with the **stringified initial args**. // The wire-level `tool_call_update` is REPLACE-content (not // append) so each subsequent delta emits the cumulative args @@ -1116,7 +1153,9 @@ export class AcpSession { const startedWireId = acpToolCallId(event.turnId, event.toolCallId); if (startedToolCalls.has(startedWireId)) { conn - .sessionUpdate(toolCallStartedUpgradeToSessionUpdate(sessionId, event)) + .sessionUpdate( + withSubagentMeta(toolCallStartedUpgradeToSessionUpdate(sessionId, event), event), + ) .catch((err) => { log.warn('acp: failed to push tool_call_update (start upgrade)', { sessionId, @@ -1127,7 +1166,9 @@ export class AcpSession { } else { startedToolCalls.add(startedWireId); conn - .sessionUpdate(toolCallStartToSessionUpdate(sessionId, event)) + .sessionUpdate( + withSubagentMeta(toolCallStartToSessionUpdate(sessionId, event), event), + ) .catch((err) => { log.warn('acp: failed to push tool_call', { sessionId, @@ -1143,7 +1184,9 @@ export class AcpSession { // into the tool_call card; only `todo_list` becomes a plan. // The emission is fire-and-forget under the same idle-stream // discipline as the assistant deltas above. - if (event.display) { + // Main-agent only: a subagent's TodoList is its own business — + // folding it into the session plan would mix two agents' work. + if (event.display && isFromMainAgent(event)) { const planNote = planFromDisplayBlock(sessionId, event.turnId, event.display); if (planNote !== null) { conn.sessionUpdate(planNote).catch((err) => { @@ -1157,7 +1200,9 @@ export class AcpSession { return; } if (event.type === 'tool.call.delta') { - if (!isFromMainAgent(event)) return; + // Subagent tool streams flow through the same branches, tagged by + // withSubagentMeta — ids are unique per agent, so the shared + // accumulators below cannot cross-contaminate. // The agent-core emits these args-stream deltas BEFORE the // `tool.call.started` event (deltas come from the provider's // streaming phase; started is dispatched afterwards). If we @@ -1171,7 +1216,9 @@ export class AcpSession { argsByToolCall.set(event.toolCallId, { args: initial }); startedToolCalls.add(deltaWireId); conn - .sessionUpdate(toolCallLazyCreateToSessionUpdate(sessionId, event)) + .sessionUpdate( + withSubagentMeta(toolCallLazyCreateToSessionUpdate(sessionId, event), event), + ) .catch((err) => { log.warn('acp: failed to push tool_call (lazy create from delta)', { sessionId, @@ -1189,7 +1236,9 @@ export class AcpSession { argsByToolCall.set(event.toolCallId, acc); } conn - .sessionUpdate(toolCallDeltaToSessionUpdate(sessionId, event, acc)) + .sessionUpdate( + withSubagentMeta(toolCallDeltaToSessionUpdate(sessionId, event, acc), event), + ) .catch((err) => { log.warn('acp: failed to push tool_call_update (delta)', { sessionId, @@ -1200,10 +1249,9 @@ export class AcpSession { return; } if (event.type === 'tool.progress') { - if (!isFromMainAgent(event)) return; const note = toolProgressToSessionUpdate(sessionId, event); if (note === null) return; - conn.sessionUpdate(note).catch((err) => { + conn.sessionUpdate(withSubagentMeta(note, event)).catch((err) => { log.warn('acp: failed to push tool_call_update (progress)', { sessionId, toolCallId: event.toolCallId, @@ -1213,9 +1261,8 @@ export class AcpSession { return; } if (event.type === 'tool.result') { - if (!isFromMainAgent(event)) return; conn - .sessionUpdate(toolResultToSessionUpdate(sessionId, event)) + .sessionUpdate(withSubagentMeta(toolResultToSessionUpdate(sessionId, event), event)) .catch((err) => { log.warn('acp: failed to push tool_call_update (result)', { sessionId, diff --git a/packages/acp-adapter/test/session-prompt.test.ts b/packages/acp-adapter/test/session-prompt.test.ts index 048fd57f06..57cef03bab 100644 --- a/packages/acp-adapter/test/session-prompt.test.ts +++ b/packages/acp-adapter/test/session-prompt.test.ts @@ -349,8 +349,8 @@ describe('AcpServer session/prompt', () => { const sessionId = 'sess-subagent'; const { session, unsubscribeCount } = makeScriptedSession(sessionId, [ { type: 'assistant.delta', sessionId, agentId: 'main', turnId: 1, delta: 'a' } as Event, - { type: 'assistant.delta', sessionId, agentId: 'sub-1', turnId: 99, delta: 'leak' } as Event, - { type: 'thinking.delta', sessionId, agentId: 'sub-1', turnId: 99, delta: 'leak' } as Event, + { type: 'assistant.delta', sessionId, agentId: 'sub-1', turnId: 99, delta: 'work' } as Event, + { type: 'thinking.delta', sessionId, agentId: 'sub-1', turnId: 99, delta: 'work' } as Event, { type: 'tool.call.started', sessionId, @@ -358,7 +358,7 @@ describe('AcpServer session/prompt', () => { turnId: 99, toolCallId: 'sub-tool', name: 'Shell', - args: { command: 'echo leak' }, + args: { command: 'echo work' }, } as Event, { type: 'tool.result', @@ -366,11 +366,13 @@ describe('AcpServer session/prompt', () => { agentId: 'sub-1', turnId: 99, toolCallId: 'sub-tool', - output: 'leak', + output: 'work', } as Event, // A subagent finishes its own turn while the main turn is still // running. Pre-fix this would resolve the parent prompt with // `end_turn` and leak the listener; post-fix it must be ignored. + // The subagent's own frames are NOT dropped, though: they are + // forwarded with `_meta.kimiCode.subagentId` so clients can nest them. { type: 'turn.ended', sessionId, @@ -400,7 +402,20 @@ describe('AcpServer session/prompt', () => { expect(response.stopReason).toBe('end_turn'); await new Promise((resolve) => setTimeout(resolve, 20)); - expect(collecting.promptUpdates).toHaveLength(2); + // 6 frames: 2 main deltas + 4 subagent frames (delta, thinking, + // tool_call, tool_call_update). The subagent's turn.ended contributed + // nothing and did not settle the parent turn. + expect(collecting.promptUpdates).toHaveLength(6); + const tagged = collecting.promptUpdates.filter( + (n) => + (n.update as { _meta?: { kimiCode?: { subagentId?: string } } })._meta?.kimiCode + ?.subagentId === 'sub-1', + ); + expect(tagged).toHaveLength(4); + const untagged = collecting.promptUpdates.filter( + (n) => (n.update as { _meta?: unknown })._meta === undefined, + ); + expect(untagged).toHaveLength(2); expect(unsubscribeCount()).toBe(1); }); }); diff --git a/packages/acp-adapter/test/subagent-events.test.ts b/packages/acp-adapter/test/subagent-events.test.ts new file mode 100644 index 0000000000..37fddbca03 --- /dev/null +++ b/packages/acp-adapter/test/subagent-events.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import type { + SubagentCompletedEvent, + SubagentSpawnedEvent, + SubagentSuspendedEvent, +} from '@moonshot-ai/kimi-code-sdk'; + +import { + acpSubagentToolCallId, + assistantDeltaToSessionUpdate, + subagentLifecycleToSessionUpdate, + subagentSpawnedToSessionUpdate, + withSubagentMeta, +} from '../src/events-map'; + +interface SubagentMetaCarrier { + _meta?: { kimiCode?: { subagent?: Record; subagentId?: string } }; +} + +describe('subagent ACP frames', () => { + it('maps spawned to a tool_call create with the lifecycle meta', () => { + const event: SubagentSpawnedEvent = { + type: 'subagent.spawned', + subagentId: 'agent-3', + subagentName: 'explore', + parentToolCallId: 'call_1', + description: 'look around', + swarmIndex: 0, + runInBackground: false, + }; + const note = subagentSpawnedToSessionUpdate('sess', event); + expect(note.update).toMatchObject({ + sessionUpdate: 'tool_call', + toolCallId: 'subagent:agent-3', + status: 'pending', + }); + const meta = (note.update as SubagentMetaCarrier)._meta?.kimiCode?.subagent; + expect(meta).toMatchObject({ + event: 'spawned', + subagentId: 'agent-3', + subagentName: 'explore', + parentToolCallId: 'call_1', + }); + }); + + it('maps completed to a terminal update with the summary payload', () => { + const event: SubagentCompletedEvent = { + type: 'subagent.completed', + subagentId: 'agent-3', + resultSummary: 'done', + contextTokens: 1234, + }; + const note = subagentLifecycleToSessionUpdate('sess', event); + expect(note.update).toMatchObject({ + sessionUpdate: 'tool_call_update', + toolCallId: 'subagent:agent-3', + status: 'completed', + }); + const meta = (note.update as SubagentMetaCarrier)._meta?.kimiCode?.subagent; + expect(meta).toMatchObject({ event: 'completed', resultSummary: 'done', contextTokens: 1234 }); + }); + + it('keeps suspended in_progress and carries the reason', () => { + const event: SubagentSuspendedEvent = { + type: 'subagent.suspended', + subagentId: 'agent-3', + reason: 'awaiting approval', + }; + const note = subagentLifecycleToSessionUpdate('sess', event); + expect(note.update).toMatchObject({ status: 'in_progress' }); + const meta = (note.update as SubagentMetaCarrier)._meta?.kimiCode?.subagent; + expect(meta).toMatchObject({ event: 'suspended', reason: 'awaiting approval' }); + }); + + it('omits absent optional fields from the wire form (no explicit nulls)', () => { + const event: SubagentSpawnedEvent = { + type: 'subagent.spawned', + subagentId: 'agent-4', + subagentName: 'coder', + parentToolCallId: 'call_9', + runInBackground: true, + }; + const note = subagentSpawnedToSessionUpdate('sess', event); + // What actually travels: JSON.stringify drops undefined properties. + const wire = JSON.parse(JSON.stringify(note)) as { update: SubagentMetaCarrier }; + const meta = wire.update._meta?.kimiCode?.subagent ?? {}; + expect(meta).not.toHaveProperty('description'); + expect(meta).not.toHaveProperty('swarmIndex'); + }); + + it('tags subagent stream frames and leaves main-agent frames untouched', () => { + const base = assistantDeltaToSessionUpdate('sess', { + type: 'assistant.delta', + delta: 'hi', + } as Parameters[1]); + + const main = withSubagentMeta(base, { agentId: 'main' }); + expect((main.update as SubagentMetaCarrier)._meta).toBeUndefined(); + + const sub = withSubagentMeta(base, { agentId: 'agent-3' }); + expect((sub.update as SubagentMetaCarrier)._meta?.kimiCode?.subagentId).toBe('agent-3'); + }); + + it('keeps subagent card ids out of the tool-call id space', () => { + expect(acpSubagentToolCallId('agent-3')).toBe('subagent:agent-3'); + }); +});