Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/subagent-spawned-task-id.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 18 additions & 10 deletions packages/agent-core-v2/src/agent/tools/agent/agentTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
wbxl2000 marked this conversation as resolved.
import { ISessionSubagentService } from '#/session/subagent/subagent';
import {
buildSubagentModelDescriptions,
Expand Down Expand Up @@ -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 },
Expand All @@ -328,6 +320,7 @@ export class SubagentTool implements ISubagentTool {
profileName,
prompt: promptText,
signal: controller.signal,
deferStarted: true,
cancel: (reason) => {
controller.abort(reason);
},
Expand Down Expand Up @@ -451,6 +444,21 @@ export class SubagentTool implements ISubagentTool {
};
}

const requester = this.lifecycle.get(this.callerAgentId);
if (requester !== undefined) {
emitAgentRunSpawned(requester, handle.agentId, {
Comment thread
wbxl2000 marked this conversation as resolved.
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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface SubagentSpawnedPayload {
readonly runInBackground: boolean;
readonly model?: string;
readonly thinkingEffort?: string;
readonly taskId?: string;
}

export class SubagentSpawned extends Event2<SubagentSpawnedPayload> {
Expand Down Expand Up @@ -75,6 +76,7 @@ export interface AgentRunSpawnedMeta {
readonly swarmIndex?: number;
readonly runInBackground?: boolean;
readonly model?: string;
readonly taskId?: string;
}

export interface MirrorAgentRunOptions {
Expand All @@ -83,6 +85,7 @@ export interface MirrorAgentRunOptions {
readonly suppressRateLimitFailureEvent?: boolean;
readonly signal: AbortSignal;
readonly cancel?: (reason?: unknown) => void;
readonly deferStarted?: boolean;
}

export function emitAgentRunSpawned(
Expand All @@ -107,6 +110,7 @@ export function emitAgentRunSpawned(
runInBackground: meta.runInBackground ?? false,
model: meta.model,
thinkingEffort: childProfile?.getEffectiveThinkingLevel(),
taskId: meta.taskId,
}),
);
childProfile?.republishStatus();
Expand All @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions packages/agent-core-v2/test/tool/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Comment thread
wbxl2000 marked this conversation as resolved.
completion.resolve({ summary: 'finished later' });
});

it('rejects background subagents when background execution is disabled', async () => {
const lifecycle = createAgentLifecycleStub();
const context = createAgentToolContext(lifecycle);
Expand Down Expand Up @@ -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' });
});

Expand Down
1 change: 1 addition & 0 deletions packages/kap-server/src/protocol/events-zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,7 @@ export const subagentSpawnedEventSchema = z.object({
runInBackground: z.boolean(),
model: z.string().optional(),
thinkingEffort: z.string().optional(),
taskId: z.string().optional(),
Comment thread
wbxl2000 marked this conversation as resolved.
}) satisfies z.ZodType<SubagentSpawnedPayload>;

export const subagentStartedEventSchema = z.object({
Expand Down
20 changes: 20 additions & 0 deletions packages/kap-server/src/services/transcript/coreBinding.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
IAgentLifecycleService,
IAgentActivityView,
IAgentTaskService,
IEventBus,
ISessionMetadata,
ISessionInteractionService,
Expand Down Expand Up @@ -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;
Expand Down
57 changes: 53 additions & 4 deletions packages/kap-server/src/services/transcript/coreEventMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,40 @@ export class AgentTranscriptProjector {
private readonly tasks = new Map<string, TranscriptTask>();
/** shell `commandId` → transcript `taskId` (`shell.output` is keyed by command id only). */
private readonly shellTasks = new Map<string, string>();
/** 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<string, string>();

/** 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<string, TranscriptInteraction>();
/** promptId → the prompt queue entity as last emitted (`prompt.upsert` replaces). */
Expand Down Expand Up @@ -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);
Comment thread
wbxl2000 marked this conversation as resolved.
}
ops.push({
op: 'taskref.upsert',
item: { kind: 'taskref', refId: `ref-${info.taskId}`, taskId: info.taskId, at: nowIso() },
Expand Down Expand Up @@ -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);
}
Comment thread
wbxl2000 marked this conversation as resolved.
const task = this.upsertTask(taskKey, (prev) => ({
Comment thread
wbxl2000 marked this conversation as resolved.
taskId: taskKey,
kind: 'subagent',
state: 'running',
detached: event.runInBackground,
Expand Down Expand Up @@ -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;
Comment thread
wbxl2000 marked this conversation as resolved.
const task = this.upsertTask(taskKey, (prev) => ({
taskId: taskKey,
kind: 'subagent',
state,
detached: prev?.detached ?? true,
Expand Down
Loading
Loading