From be8e017597b83142282d7e6640076368bf244eae Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 19 Aug 2026 14:58:55 +0800 Subject: [PATCH] fix(agent-core-v2): emit subagent.spawned after task registration (#3005) * fix(agent-core-v2): emit subagent.spawned after task registration The spawned signal previously fired at launch, before the run's task registration, so clients learned the agent id with no task id to bind cancel/status actions to; a failed registration also left a spawned row behind for a run that never registered. Emit it only after registerTask succeeds and carry the task id on the event. * fix(agent-core-v2): keep spawned ahead of started for Agent-tool runs The TUI drops subagent.started until spawned has established the row, and a failed registration must not leave a started row behind with no terminal event. Defer the mirrored started dispatch so the Agent tool can emit it itself after registration and spawned. * fix(agent-core-v2): void the deferred started dispatch * fix(kap-server): key Agent-tool transcript rows by the registered task id Transcript-protocol clients suppress the raw task.*/subagent.* session events, so they only saw a subagent row keyed by agent id that cannot address /tasks/{id}, plus a second row once task.started landed. Key the spawned row by the task id it now carries, fold task.started and the subagent lifecycle back into it, and keep the agent-id path for spawns without a registration (swarm/session-init/tower). Statement-level ordering notes move to the file headers per package convention. * test(agent-core-v2): split the spawned/started ordering contract into its own test * fix(kap-server): keep subagent result details across task termination and drop stale task mappings on taskless respawns * fix(kap-server): recover the agent-to-task association from a backfilled task.started * fix(kap-server): seed pre-attach Agent task mappings on the transcript binding * fix(kap-server): seed the full in-flight task row on transcript bind, not only its id * docs(agent-core-v2): name the state-domain event dispatcher in the Agent tool header * style(kap-server): drop comments in transcript services per the no-comments lint rule --- .changeset/subagent-spawned-task-id.md | 5 + .../src/agent/tools/agent/agentTool.ts | 28 +-- .../src/session/subagent/mirrorAgentRun.ts | 8 +- packages/agent-core-v2/test/tool/tool.test.ts | 35 ++++ .../kap-server/src/protocol/events-zod.ts | 1 + .../src/services/transcript/coreBinding.ts | 20 +++ .../src/services/transcript/coreEventMap.ts | 57 +++++- .../test/services/transcript.test.ts | 164 +++++++++++++++++- packages/protocol/src/events.ts | 6 + 9 files changed, 308 insertions(+), 16 deletions(-) create mode 100644 .changeset/subagent-spawned-task-id.md diff --git a/.changeset/subagent-spawned-task-id.md b/.changeset/subagent-spawned-task-id.md new file mode 100644 index 000000000..10e230a19 --- /dev/null +++ b/.changeset/subagent-spawned-task-id.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Emit subagent.spawned after the run's task registration so the signal carries the task id clients bind cancel/status actions to. diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index eae0f49b5..e05269465 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -51,7 +51,8 @@ import type { Runtime } from '#/runtime/runtime'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; +import { emitAgentRunSpawned, mirrorAgentRun, SubagentStarted } from '#/session/subagent/mirrorAgentRun'; +import { IEventDispatcher } from '#/state/eventDispatcher'; import { ISessionSubagentService } from '#/session/subagent/subagent'; import { buildSubagentModelDescriptions, @@ -310,15 +311,6 @@ export class SubagentTool implements ISubagentTool { }); } - const runInBackground = args.run_in_background === true; - emitAgentRunSpawned(requester, agentId, { - profileName, - parentToolCallId: toolCallId, - description: args.description, - runInBackground, - model: displayModel, - }); - const run = await this.subagents.run( agentId, { kind: 'prompt', prompt: promptText }, @@ -328,6 +320,7 @@ export class SubagentTool implements ISubagentTool { profileName, prompt: promptText, signal: controller.signal, + deferStarted: true, cancel: (reason) => { controller.abort(reason); }, @@ -451,6 +444,21 @@ export class SubagentTool implements ISubagentTool { }; } + const requester = this.lifecycle.get(this.callerAgentId); + if (requester !== undefined) { + emitAgentRunSpawned(requester, handle.agentId, { + profileName: handle.profileName, + parentToolCallId: toolCallId, + description: args.description, + runInBackground, + model: handle.model, + taskId, + }); + void requester.accessor + .get(IEventDispatcher) + ?.dispatch(new SubagentStarted({ subagentId: handle.agentId })); + } + if (runInBackground) { return { output: formatBackgroundAgentResult(taskId, handle, args.description, allowBackground), diff --git a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts index aaa443cf5..5d59f6c31 100644 --- a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts @@ -25,6 +25,7 @@ export interface SubagentSpawnedPayload { readonly runInBackground: boolean; readonly model?: string; readonly thinkingEffort?: string; + readonly taskId?: string; } export class SubagentSpawned extends Event2 { @@ -75,6 +76,7 @@ export interface AgentRunSpawnedMeta { readonly swarmIndex?: number; readonly runInBackground?: boolean; readonly model?: string; + readonly taskId?: string; } export interface MirrorAgentRunOptions { @@ -83,6 +85,7 @@ export interface MirrorAgentRunOptions { readonly suppressRateLimitFailureEvent?: boolean; readonly signal: AbortSignal; readonly cancel?: (reason?: unknown) => void; + readonly deferStarted?: boolean; } export function emitAgentRunSpawned( @@ -107,6 +110,7 @@ export function emitAgentRunSpawned( runInBackground: meta.runInBackground ?? false, model: meta.model, thinkingEffort: childProfile?.getEffectiveThinkingLevel(), + taskId: meta.taskId, }), ); childProfile?.republishStatus(); @@ -127,7 +131,9 @@ export async function mirrorAgentRun( const dispatcher = requester.accessor.get(IEventDispatcher); const subagents = requester.accessor.get(ISessionSubagentService); const agentLifecycle = requester.accessor.get(IAgentLifecycleService); - void dispatcher?.dispatch(new SubagentStarted({ subagentId: run.agentId })); + if (options.deferStarted !== true) { + void dispatcher?.dispatch(new SubagentStarted({ subagentId: run.agentId })); + } if (options.prompt !== undefined) { const cancelAndRethrow = (reason: unknown): never => { options.cancel?.(reason); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 70c058178..a89c4343a 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -1778,6 +1778,36 @@ describe('Agent tool execution contract', () => { completion.resolve({ summary: 'finished later' }); }); + it('emits spawned with the registered task id ahead of started', async () => { + const completion = deferred<{ readonly summary: string }>(); + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: () => completion.promise, + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + run_in_background: true, + }); + + if (typeof result.output !== 'string') throw new TypeError('expected string output'); + const taskId = result.output.match(/task_id: (agent-[0-9a-z]{8})/)?.[1]; + expect(taskId).toBeDefined(); + expect(lifecycle.publishedEvents).toContainEqual( + expect.objectContaining({ + type: 'subagent.spawned', + subagentId: 'agent-child', + taskId, + }), + ); + const eventOrder = lifecycle.publishedEvents.map((event) => event.type); + expect(eventOrder.indexOf('subagent.spawned')).toBeGreaterThanOrEqual(0); + expect(eventOrder.indexOf('subagent.started')).toBeGreaterThan(eventOrder.indexOf('subagent.spawned')); + completion.resolve({ summary: 'finished later' }); + }); + it('rejects background subagents when background execution is disabled', async () => { const lifecycle = createAgentLifecycleStub(); const context = createAgentToolContext(lifecycle); @@ -1878,6 +1908,11 @@ describe('Agent tool execution contract', () => { output: 'Too many background tasks are already running.', }); expect(lifecycle.create).toHaveBeenCalledTimes(2); + expect( + lifecycle.publishedEvents.filter( + (event) => (event as { subagentId?: string }).subagentId === 'agent-second', + ), + ).toEqual([]); completions[0]?.resolve({ summary: 'finished later' }); }); diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index 81cdda1c6..4090490d4 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -845,6 +845,7 @@ export const subagentSpawnedEventSchema = z.object({ runInBackground: z.boolean(), model: z.string().optional(), thinkingEffort: z.string().optional(), + taskId: z.string().optional(), }) satisfies z.ZodType; export const subagentStartedEventSchema = z.object({ diff --git a/packages/kap-server/src/services/transcript/coreBinding.ts b/packages/kap-server/src/services/transcript/coreBinding.ts index 6cbb18669..980c3ebe4 100644 --- a/packages/kap-server/src/services/transcript/coreBinding.ts +++ b/packages/kap-server/src/services/transcript/coreBinding.ts @@ -1,6 +1,7 @@ import { IAgentLifecycleService, IAgentActivityView, + IAgentTaskService, IEventBus, ISessionMetadata, ISessionInteractionService, @@ -103,6 +104,25 @@ export function bindSessionTranscript( }, turn: (turnId) => store.getAgent(agentId)?.getTurn(turnId), }); + for (const agent of agents.list()) { + if (agent.id !== agentId) continue; + const tasks = agent.accessor.get(IAgentTaskService)?.list() ?? []; + for (const info of tasks) { + if (info.kind === 'agent' && typeof info.agentId === 'string' && info.agentId.length > 0) { + applyOps( + agentId, + projector.seedSubagentTask({ + taskId: info.taskId, + agentId: info.agentId, + description: info.description, + status: info.status, + detached: info.detached ?? false, + startedAt: info.startedAt, + }), + ); + } + } + } projectors.set(agentId, projector); } return projector; diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index ed740453f..04867cf3e 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -211,6 +211,40 @@ export class AgentTranscriptProjector { private readonly tasks = new Map(); /** shell `commandId` → transcript `taskId` (`shell.output` is keyed by command id only). */ private readonly shellTasks = new Map(); + /** subagent agent id → registered task id, for Agent-tool runs whose spawned + carried the registration (`taskId`): the task row keys by the task id so + `/tasks/{id}` actions resolve, and lifecycle events fold back to it. */ + private readonly subagentTaskIds = new Map(); + + /** Pre-seed the association and the row for a task registered before + attach: a foreground Agent run emits no `task.started` at all, so + without this a late-bound projector never learns the mapping, shows no + cancellable row, and lets the terminal event invent foreground-wrong + defaults. Only in-flight tasks seed (a terminal one has no lifecycle + left to fold). */ + seedSubagentTask(info: { + readonly taskId: string; + readonly agentId: string; + readonly description: string; + readonly status: string; + readonly detached: boolean; + readonly startedAt: number; + }): TranscriptOperation[] { + if (info.status !== 'running') return []; + this.subagentTaskIds.set(info.agentId, info.taskId); + const task = this.upsertTask(info.taskId, (prev) => ({ + taskId: info.taskId, + kind: 'subagent', + state: 'running', + detached: info.detached, + description: info.description, + agentId: info.agentId, + outputTail: prev?.outputTail ?? '', + startedAt: prev?.startedAt ?? epochMsToIso(info.startedAt), + endedAt: prev?.endedAt, + })); + return [{ op: 'task.upsert', task }]; + } /** interaction id → the pending entity as last emitted (resolve spreads it). */ private readonly interactions = new Map(); /** promptId → the prompt queue entity as last emitted (`prompt.upsert` replaces). */ @@ -871,9 +905,16 @@ export class AgentTranscriptProjector { outputTail: prev?.outputTail ?? '', startedAt: prev?.startedAt ?? epochMsToIso(info.startedAt), endedAt: info.endedAt === null ? prev?.endedAt : epochMsToIso(info.endedAt), + resultSummary: prev?.resultSummary, + usage: prev?.usage, + error: prev?.error, + stateReason: prev?.stateReason, })); const ops: TranscriptOperation[] = [{ op: 'task.upsert', task }]; if (event.type === 'task.started') { + if (info.kind === 'agent' && typeof info.agentId === 'string' && info.agentId.length > 0) { + this.subagentTaskIds.set(info.agentId, info.taskId); + } ops.push({ op: 'taskref.upsert', item: { kind: 'taskref', refId: `ref-${info.taskId}`, taskId: info.taskId, at: nowIso() }, @@ -1004,9 +1045,16 @@ export class AgentTranscriptProjector { description?: string; swarmIndex?: number; runInBackground: boolean; + taskId?: string; }): TranscriptOperation[] { - const task = this.upsertTask(event.subagentId, (prev) => ({ - taskId: event.subagentId, + const taskKey = event.taskId ?? event.subagentId; + if (event.taskId !== undefined) { + this.subagentTaskIds.set(event.subagentId, event.taskId); + } else { + this.subagentTaskIds.delete(event.subagentId); + } + const task = this.upsertTask(taskKey, (prev) => ({ + taskId: taskKey, kind: 'subagent', state: 'running', detached: event.runInBackground, @@ -1048,8 +1096,9 @@ export class AgentTranscriptProjector { : event.type === 'subagent.failed' ? 'failed' : 'running'; - const task = this.upsertTask(event.subagentId, (prev) => ({ - taskId: event.subagentId, + const taskKey = this.subagentTaskIds.get(event.subagentId) ?? event.subagentId; + const task = this.upsertTask(taskKey, (prev) => ({ + taskId: taskKey, kind: 'subagent', state, detached: prev?.detached ?? true, diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 81ed9a8be..298a259b4 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { IAgentLifecycleService, IAgentLoopService, + IAgentTaskService, IEventBus, ISessionIndex, ISessionInteractionService, @@ -970,6 +971,124 @@ describe('AgentTranscriptProjector', () => { }); }); + it('keys an Agent-tool subagent row by its registered task id and folds the lifecycle', () => { + const projector = new AgentTranscriptProjector('main'); + const tx = new AgentTranscript('main'); + const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); + + feed( + ev({ + type: 'subagent.spawned', + subagentId: 'agent-1', + subagentName: 'explore', + parentToolCallId: 'call-1', + description: 'Inspect files', + runInBackground: true, + taskId: 'task-9', + }), + ); + feed( + ev({ + type: 'task.started', + info: { + taskId: 'task-9', + kind: 'agent', + description: 'Inspect files', + status: 'running', + detached: true, + agentId: 'agent-1', + startedAt: 1_700_000_000_000, + endedAt: null, + }, + }), + ); + feed(ev({ type: 'subagent.completed', subagentId: 'agent-1', resultSummary: 'done' })); + feed( + ev({ + type: 'task.terminated', + info: { + taskId: 'task-9', + kind: 'agent', + description: 'Inspect files', + status: 'completed', + detached: true, + agentId: 'agent-1', + startedAt: 1_700_000_000_000, + endedAt: 1_700_000_001_000, + }, + }), + ); + + expect(tx.getTask('task-9')).toMatchObject({ + kind: 'subagent', + state: 'completed', + agentId: 'agent-1', + description: 'Inspect files', + detached: true, + resultSummary: 'done', + }); + expect(tx.getTask('agent-1')).toBeUndefined(); + }); + + it('drops the stale task mapping when a child respawns without a task id', () => { + const projector = new AgentTranscriptProjector('main'); + const tx = new AgentTranscript('main'); + const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); + + feed( + ev({ + type: 'subagent.spawned', + subagentId: 'agent-1', + subagentName: 'explore', + parentToolCallId: 'call-1', + description: 'Inspect files', + runInBackground: true, + taskId: 'task-9', + }), + ); + feed(ev({ type: 'subagent.completed', subagentId: 'agent-1', resultSummary: 'done' })); + feed( + ev({ + type: 'subagent.spawned', + subagentId: 'agent-1', + subagentName: 'worker', + parentToolCallId: 'call-2', + description: 'scan again', + runInBackground: false, + }), + ); + feed(ev({ type: 'subagent.started', subagentId: 'agent-1' })); + + expect(tx.getTask('task-9')).toMatchObject({ state: 'completed', resultSummary: 'done' }); + expect(tx.getTask('agent-1')).toMatchObject({ kind: 'subagent', state: 'running' }); + }); + + it('recovers the agent → task association from a backfilled task.started', () => { + const projector = new AgentTranscriptProjector('main'); + const tx = new AgentTranscript('main'); + const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); + + feed( + ev({ + type: 'task.started', + info: { + taskId: 'task-9', + kind: 'agent', + description: 'Inspect files', + status: 'running', + detached: true, + agentId: 'agent-1', + startedAt: 1_700_000_000_000, + endedAt: null, + }, + }), + ); + feed(ev({ type: 'subagent.completed', subagentId: 'agent-1', resultSummary: 'done' })); + + expect(tx.getTask('task-9')).toMatchObject({ state: 'completed', resultSummary: 'done' }); + expect(tx.getTask('agent-1')).toBeUndefined(); + }); + it('projects goal updates into meta.goal plus an inline marker', () => { const projector = new AgentTranscriptProjector('main'); const tx = new AgentTranscript('main'); @@ -1811,7 +1930,7 @@ describe('bindSessionTranscript', () => { this.disposeHandlers.add(cb); return { dispose: () => this.disposeHandlers.delete(cb) }; } - add(id: string, opts?: { loopStatus?: unknown }): FakeAgentHandle { + add(id: string, opts?: { loopStatus?: unknown; tasks?: readonly unknown[] }): FakeAgentHandle { const bus = new FakeBus(); const handle: FakeAgentHandle = { id, @@ -1822,6 +1941,9 @@ describe('bindSessionTranscript', () => { if (token === IAgentLoopService) { return { status: () => opts?.loopStatus ?? { state: 'idle' } }; } + if (token === IAgentTaskService) { + return { list: () => opts?.tasks ?? [] }; + } return undefined; }, }, @@ -1911,6 +2033,46 @@ describe('bindSessionTranscript', () => { binding.dispose(); }); + it('seeds pre-attach Agent task mappings so a late-bound projector folds the lifecycle', () => { + const agents = new FakeAgents(); + agents.add('main', { + tasks: [ + { + taskId: 'task-9', + kind: 'agent', + agentId: 'agent-1', + status: 'running', + description: 'Inspect', + detached: false, + startedAt: 1_700_000_000_000, + }, + ], + }); + const store = new TranscriptStore('s1'); + const binding = bindSessionTranscript( + store, + fakeSession(new SessionInteractionService(new TestSessionStateService()), agents), + ); + + expect(store.getAgent('main')?.getTask('task-9')).toMatchObject({ + kind: 'subagent', + state: 'running', + detached: false, + description: 'Inspect', + agentId: 'agent-1', + }); + + agents.get('main')!.bus.emit(ev({ type: 'subagent.completed', subagentId: 'agent-1', resultSummary: 'done' })); + + expect(store.getAgent('main')?.getTask('task-9')).toMatchObject({ + state: 'completed', + resultSummary: 'done', + detached: false, + }); + expect(store.getAgent('main')?.getTask('agent-1')).toBeUndefined(); + binding.dispose(); + }); + const SHOT_PNG_UPLOAD = { type: 'file', file_id: 'file_1', diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index d319066fa..7f4d4edd2 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -864,6 +864,11 @@ export interface SubagentSpawnedEvent { /** The child's effective thinking effort at spawn (same vocabulary as * `agent.status.updated`). Optional for cross-version tolerance. */ readonly thinkingEffort?: string; + /** Background-task id the run registered under in the caller's task store. + * Emitted after task registration, so cancel/status actions can bind to + * the task store without waiting for `task.started`. Optional for + * cross-version tolerance (older producers never send it). */ + readonly taskId?: string; } export interface SubagentStartedEvent { @@ -1800,6 +1805,7 @@ export const subagentSpawnedEventSchema = z.object({ runInBackground: z.boolean(), model: z.string().optional(), thinkingEffort: z.string().optional(), + taskId: z.string().optional(), }) satisfies z.ZodType; export const subagentStartedEventSchema = z.object({