diff --git a/packages/kap-server/src/services/history/coldFold.ts b/packages/kap-server/src/services/history/coldFold.ts index c639c3d8ee..be6a9361f9 100644 --- a/packages/kap-server/src/services/history/coldFold.ts +++ b/packages/kap-server/src/services/history/coldFold.ts @@ -1430,6 +1430,105 @@ export function foldWireHistory( }; synthesizeSubagentTasks(); + const synthesizeSwarmMemberTasks = (): void => { + for (const tool of tools.values()) { + if (tool.name !== 'AgentSwarm') continue; + const args = (tool.input ?? {}) as Record; + const resumeIds = + args['resume_agent_ids'] !== null && typeof args['resume_agent_ids'] === 'object' + ? Object.keys(args['resume_agent_ids'] as Record) + : []; + const items = Array.isArray(args['items']) + ? (args['items'] as unknown[]).filter((item): item is string => typeof item === 'string') + : []; + const outputText = typeof tool.output === 'string' ? tool.output : undefined; + const members = outputText === undefined ? [] : parseSwarmMembers(outputText); + for (const agentId of resumeIds) { + if (!tool.agentRefs.some((ref) => ref.agent_id === agentId)) { + tool.agentRefs = [...tool.agentRefs, { agent_id: agentId, role: 'member' }]; + } + } + for (const member of members) { + if ( + member.agentId !== undefined && + !tool.agentRefs.some((ref) => ref.agent_id === member.agentId) + ) { + tool.agentRefs = [...tool.agentRefs, { agent_id: member.agentId, role: 'member' }]; + } + } + const model = typeof args['model'] === 'string' ? args['model'] : undefined; + const thinkingEffort = typeof args['thinking'] === 'string' ? args['thinking'] : undefined; + const swarmDescription = + typeof args['description'] === 'string' ? args['description'] : undefined; + let insertOffset = 1; + const pushMemberTask = ( + agentId: string, + index: number, + member: SwarmMemberResult | undefined, + ): void => { + if (tasks.has(agentId)) return; + const outcome = member?.outcome; + const status = + outcome === 'completed' + ? 'completed' + : outcome === undefined + ? tool.status === 'done' + ? 'completed' + : 'failed' + : 'failed'; + tasks.set(agentId, { + taskId: agentId, + kind: 'subagent', + status, + detached: false, + description: + swarmDescription === undefined ? undefined : `${swarmDescription} #${String(index)}`, + childAgentId: agentId, + outputTail: '', + startedAt: new Date(tool.at).toISOString(), + endedAt: tool.status === 'running' ? undefined : new Date(tool.at).toISOString(), + resultSummary: + outcome === 'completed' && member !== undefined && member.body.length > 0 + ? member.body + : undefined, + error: + outcome !== undefined && outcome !== 'completed' && member !== undefined + ? member.body + : undefined, + stateReason: + member?.stopReason ?? + (outcome === 'aborted' + ? 'aborted' + : status === 'failed' && tool.status === 'running' + ? 'interrupted' + : undefined), + usage: undefined, + model, + thinkingEffort, + at: tool.at, + }); + const toolIndex = order.indexOf(`tool:${tool.toolCallId}`); + if (toolIndex >= 0) order.splice(toolIndex + insertOffset, 0, `task:${agentId}`); + else order.push(`task:${agentId}`); + insertOffset += 1; + }; + for (const [position, agentId] of resumeIds.entries()) { + pushMemberTask( + agentId, + position + 1, + members.find((member) => member.agentId === agentId), + ); + } + for (const member of members) { + if (member.agentId === undefined || resumeIds.includes(member.agentId)) continue; + const itemPosition = + member.item === undefined ? -1 : items.findIndex((item) => item.trim() === member.item); + pushMemberTask(member.agentId, resumeIds.length + itemPosition + 1, member); + } + } + }; + synthesizeSwarmMemberTasks(); + const messages: HistoryMessage[] = []; for (const key of order) { const [kind, id] = splitKey(key); @@ -1610,6 +1709,43 @@ export function foldWireHistory( return messages; } +interface SwarmMemberResult { + readonly agentId?: string; + readonly item?: string; + readonly outcome?: string; + readonly stopReason?: string; + readonly body: string; +} + +function parseSwarmMembers(output: string): SwarmMemberResult[] { + if (!output.includes('')) return []; + const members: SwarmMemberResult[] = []; + for (const match of output.matchAll(/]*)>([\s\S]*?)<\/subagent>/g)) { + const attrs = match[1]!; + const attr = (name: string): string | undefined => { + const value = attrs.match(new RegExp(`${name}="([^"]*)"`))?.[1]; + return value === undefined ? undefined : unescapeXmlAttr(value); + }; + const agentId = attr('agent_id')?.trim(); + members.push({ + agentId: agentId !== undefined && agentId.length > 0 ? agentId : undefined, + item: attr('item'), + outcome: attr('outcome'), + stopReason: attr('stop_reason'), + body: (match[2] ?? '').trim(), + }); + } + return members; +} + +function unescapeXmlAttr(value: string): string { + return value + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); +} + function splitKey(key: string): [string, string] { const index = key.indexOf(':'); return [key.slice(0, index), key.slice(index + 1)]; diff --git a/packages/kap-server/src/services/projection/agentProjector.ts b/packages/kap-server/src/services/projection/agentProjector.ts index 6060f600d9..9aeaba4ffa 100644 --- a/packages/kap-server/src/services/projection/agentProjector.ts +++ b/packages/kap-server/src/services/projection/agentProjector.ts @@ -1241,10 +1241,11 @@ export class AgentMessageProjector { tool.agentRefs = [...tool.agentRefs, ref]; ops.push(this.toolOp(tool)); } - const taskId = event.taskId; + const taskId = + event.taskId ?? (event.swarmIndex !== undefined ? event.subagentId : undefined); if (taskId === undefined) return ops; this.subagentTaskIds.set(event.subagentId, taskId); - if (tool !== undefined && tool.taskId !== taskId) { + if (event.taskId !== undefined && tool !== undefined && tool.taskId !== taskId) { tool.taskId = taskId; ops.push(this.toolOp(tool)); } diff --git a/packages/kap-server/src/services/projection/sessionProjection.ts b/packages/kap-server/src/services/projection/sessionProjection.ts index 7768944958..533f0e799e 100644 --- a/packages/kap-server/src/services/projection/sessionProjection.ts +++ b/packages/kap-server/src/services/projection/sessionProjection.ts @@ -16,6 +16,7 @@ import { INTERACTION_TAG_SESSION_ID, ISessionActivityView, ISessionIndex, + ISessionMetadata, IWireService, MAIN_AGENT_ID, interactions, @@ -575,6 +576,72 @@ export class SessionProjection { if (records === undefined) return; if (this.disposed || this.projectors.get(agentId) !== projector) return; projector.applyTimelineSeed(foldTimelineSeed(records)); + await this.resolveSwarmOriginsFromWire(agentId, records); + } + + private async resolveSwarmOriginsFromWire( + agentId: string, + records: readonly ContextRecord[], + ): Promise { + if (agentId !== MAIN_AGENT_ID) return; + let toolCallId: string | undefined; + let args: Record | undefined; + for (const record of records) { + const event = record['event'] as + | { type?: string; toolCallId?: string; name?: string; args?: unknown } + | undefined; + if (event?.type === 'tool.call' && event.name === 'AgentSwarm') { + if (typeof event.toolCallId !== 'string') continue; + toolCallId = event.toolCallId; + const raw = event.args; + const parsed = typeof raw === 'string' ? safeParseObject(raw) : raw; + args = + parsed !== null && typeof parsed === 'object' + ? (parsed as Record) + : undefined; + } else if (event?.type === 'tool.result' && event.toolCallId === toolCallId) { + toolCallId = undefined; + args = undefined; + } + } + if (toolCallId === undefined || args === undefined) return; + const resumeIds = + args['resume_agent_ids'] !== null && typeof args['resume_agent_ids'] === 'object' + ? Object.keys(args['resume_agent_ids'] as Record) + : []; + const items = Array.isArray(args['items']) + ? (args['items'] as unknown[]).filter((item): item is string => typeof item === 'string') + : []; + const metadata = this.session.accessor.get(ISessionMetadata) as ISessionMetadata | undefined; + const agents = metadata === undefined ? undefined : (await metadata.read()).agents; + if (this.disposed) return; + for (const [memberId, tracker] of this.agentStates) { + if (memberId === MAIN_AGENT_ID || tracker.hasOrigin) continue; + let swarmIndex: number | undefined; + const resumePosition = resumeIds.indexOf(memberId); + if (resumePosition >= 0) { + swarmIndex = resumePosition + 1; + } else { + const meta = agents?.[memberId]; + const item = meta?.labels?.['swarmItem'] ?? meta?.swarmItem; + if (item !== undefined) { + const itemPosition = items.findIndex((candidate) => candidate.trim() === item); + if (itemPosition >= 0) swarmIndex = resumeIds.length + itemPosition + 1; + } + } + if (swarmIndex === undefined) continue; + const profile = this.agentHandle(memberId)?.accessor.get(IAgentProfileService) as + | IAgentProfileService + | undefined; + const seeded = tracker.seedToolSpawned({ + subagentId: memberId, + subagentName: profile?.data().profileName ?? '', + parentToolCallId: toolCallId, + parentAgentId: 'main', + swarmIndex, + }); + if (seeded) this.emitAgentState(memberId); + } } private async healTurns(agentId: string, ordinals: ReadonlySet): Promise { @@ -700,3 +767,11 @@ function interactionAgentId(interaction: Interaction): string { MAIN_AGENT_ID ); } + +function safeParseObject(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} diff --git a/packages/kap-server/test/services/history.test.ts b/packages/kap-server/test/services/history.test.ts index dee4e6bd96..0a2ea58294 100644 --- a/packages/kap-server/test/services/history.test.ts +++ b/packages/kap-server/test/services/history.test.ts @@ -731,7 +731,7 @@ describe('foldWireHistory interactions, facts and modes', () => { }); }); - it('links subagent tasks to their parent tool call with agent refs', () => { + it('links subagent and swarm member tasks to their parent tool call with agent refs', () => { const messages = fold([ rec('turn.prompt', { input: [{ type: 'text', text: 'go' }], origin: { kind: 'user' } }), loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 1), @@ -768,6 +768,75 @@ describe('foldWireHistory interactions, facts and modes', () => { task_id: 'task-2', agent_refs: [{ agent_id: 'sub-1', role: 'child' }], }); + + const swarmOutput = [ + '', + 'completed: 2, failed: 1', + 'resume report', + 'alpha report', + 'beta blew up', + '', + ].join('\n'); + const swarmMessages = fold([ + rec('turn.prompt', { input: [{ type: 'text', text: 'go' }], origin: { kind: 'user' } }), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 1), + loopEvent( + { + type: 'tool.call', + stepUuid: 'u1', + toolCallId: 'call_s', + name: 'AgentSwarm', + args: JSON.stringify({ + description: 'team', + items: ['alpha', 'beta'], + prompt_template: 'do {{item}}', + model: 'k2', + thinking: 'high', + resume_agent_ids: { 'agent-9': 'resume work' }, + }), + }, + T0 + 2, + ), + loopEvent( + { + type: 'tool.result', + stepUuid: 'u1', + toolCallId: 'call_s', + result: { output: swarmOutput }, + }, + T0 + 3, + ), + ]); + const swarmTool = ofType(swarmMessages, 'tool_call')[0]!; + expect(swarmTool.task_id).toBeUndefined(); + expect(swarmTool.agent_refs).toEqual([ + { agent_id: 'agent-9', role: 'member' }, + { agent_id: 'agent-11', role: 'member' }, + { agent_id: 'agent-12', role: 'member' }, + ]); + const memberTasks = ofType(swarmMessages, 'task'); + expect(memberTasks.map((t) => t.task_id)).toEqual(['agent-9', 'agent-11', 'agent-12']); + expect(memberTasks[0]).toMatchObject({ + kind: 'subagent', + status: 'completed', + detached: false, + child_agent_id: 'agent-9', + description: 'team #1', + result_summary: 'resume report', + model: 'k2', + thinking_effort: 'high', + }); + expect(memberTasks[1]).toMatchObject({ + status: 'completed', + description: 'team #2', + result_summary: 'alpha report', + }); + expect(memberTasks[2]).toMatchObject({ + status: 'failed', + description: 'team #3', + error: 'beta blew up', + state_reason: 'rate_limit', + }); }); }); diff --git a/packages/kap-server/test/services/projection.test.ts b/packages/kap-server/test/services/projection.test.ts index c29729bc46..8f6031547d 100644 --- a/packages/kap-server/test/services/projection.test.ts +++ b/packages/kap-server/test/services/projection.test.ts @@ -1,3 +1,7 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { IAgentGoalService, IAgentLifecycleService, @@ -11,6 +15,7 @@ import { IAgentTodoService, IEventBus, ISessionActivityView, + ISessionIndex, ISessionTokenCountingService, ISessionUsageService, interactions, @@ -628,6 +633,78 @@ describe('AgentMessageProjector', () => { }); const toolC = ofType(sink, 'tool_call').at(-1)!; expect(toolC.task_id).toBe('task-c'); + + feed( + projector, + ev({ + type: 'tool.call.started', + turnId: 1, + toolCallId: 'call_d', + name: 'AgentSwarm', + args: '{"items":["a","b"],"prompt_template":"do {{item}}"}', + }), + sink, + ); + feed( + projector, + ev({ + type: 'subagent.spawned', + subagentId: 'sub-d1', + parentToolCallId: 'call_d', + swarmIndex: 1, + runInBackground: false, + description: 'team #1 (coder)', + model: 'k2', + thinkingEffort: 'high', + }), + sink, + ); + feed( + projector, + ev({ + type: 'subagent.spawned', + subagentId: 'sub-d2', + parentToolCallId: 'call_d', + swarmIndex: 2, + runInBackground: false, + }), + sink, + ); + const toolD = ofType(sink, 'tool_call').at(-1)!; + expect(toolD.agent_refs).toEqual([ + { agent_id: 'sub-d1', role: 'member' }, + { agent_id: 'sub-d2', role: 'member' }, + ]); + expect(toolD.task_id).toBeUndefined(); + const memberTasks = ofType(sink, 'task').filter((t) => t.task_id.startsWith('sub-d')); + expect(memberTasks).toHaveLength(2); + expect(memberTasks[0]).toMatchObject({ + task_id: 'sub-d1', + kind: 'subagent', + status: 'running', + detached: false, + child_agent_id: 'sub-d1', + description: 'team #1 (coder)', + model: 'k2', + thinking_effort: 'high', + }); + expect(memberTasks[1]).toMatchObject({ task_id: 'sub-d2', child_agent_id: 'sub-d2' }); + feed( + projector, + ev({ + type: 'subagent.completed', + subagentId: 'sub-d1', + resultSummary: 'member report', + usage: { inputOther: 3, output: 1, inputCacheRead: 0, inputCacheCreation: 0 }, + }), + sink, + ); + expect(ofType(sink, 'task').at(-1)).toMatchObject({ + task_id: 'sub-d1', + status: 'completed', + result_summary: 'member report', + usage: { input_other: 3, output: 1, input_cache_read: 0, input_cache_creation: 0 }, + }); }); it('drives todo entities from the todo emitter and links TodoList tool calls', () => { @@ -686,7 +763,7 @@ describe('AgentMessageProjector', () => { expect(projector.healTurn(1, fold).length).toBeGreaterThan(0); }); - it('settles a full-cut splice as system(clear) unless a context.undone follows', () => { + it('settles a full-cut splice as system(clear) on new events, on timeout, or as undo', () => { const projector = makeProjector(); const before = feedAll(projector, [ ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'one' }), @@ -711,6 +788,30 @@ describe('AgentMessageProjector', () => { ]); expect(ofType(undoOnly, 'system').some((m) => m.subtype === 'clear')).toBe(false); expect(ofType(undoOnly, 'system').some((m) => m.subtype === 'undo')).toBe(true); + + vi.useFakeTimers(); + try { + const deferred: ServerMessage[] = []; + const projector3 = new AgentMessageProjector('main', SESSION, new Map(), undefined, { + onDeferred: (messages) => deferred.push(...messages), + }); + const pending = feedAll(projector3, [ + ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'one' }), + ev({ type: 'turn.ended', turnId: 1, reason: 'completed' }), + ev({ type: 'context.spliced', start: 0, deleteCount: 4, messages: [] }), + ]); + expect(ofType(pending, 'system')).toHaveLength(0); + expect(deferred).toHaveLength(0); + vi.advanceTimersByTime(150); + const timedClear = ofType(deferred, 'system').find((m) => m.subtype === 'clear'); + expect(serverMessageSchema.parse(timedClear)).toMatchObject({ + subtype: 'clear', + payload: { removed_ids: ['t1'] }, + }); + projector3.dispose(); + } finally { + vi.useRealTimers(); + } }); it('replays in-flight entities plus state entities as recovery payload', () => { @@ -871,32 +972,6 @@ describe('AgentMessageProjector', () => { expect(users[0]).toMatchObject({ message_id: 't1.u0', text: [{ type: 'text', text: 'hello', meta: {} }] }); }); - it('settles a full-cut splice as system(clear) after a bounded wait when no undo follows', () => { - vi.useFakeTimers(); - try { - const deferred: ServerMessage[] = []; - const projector = new AgentMessageProjector('main', SESSION, new Map(), undefined, { - onDeferred: (messages) => deferred.push(...messages), - }); - const before = feedAll(projector, [ - ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'one' }), - ev({ type: 'turn.ended', turnId: 1, reason: 'completed' }), - ev({ type: 'context.spliced', start: 0, deleteCount: 4, messages: [] }), - ]); - expect(ofType(before, 'system')).toHaveLength(0); - expect(deferred).toHaveLength(0); - vi.advanceTimersByTime(150); - const clear = ofType(deferred, 'system').find((m) => m.subtype === 'clear'); - expect(serverMessageSchema.parse(clear)).toMatchObject({ - subtype: 'clear', - payload: { removed_ids: ['t1'] }, - }); - projector.dispose(); - } finally { - vi.useRealTimers(); - } - }); - it('counts undo anchors instead of timeline turns when fromTurnId is missing', () => { const projector = makeProjector(); const messages = feedAll(projector, [ @@ -1124,7 +1199,10 @@ describe('SessionProjection', () => { return agent; } - function makeSession(...agents: FakeAgent[]): { + function makeSession( + agents: readonly FakeAgent[], + opts?: { sessionIndex?: unknown }, + ): { session: ISessionScopeHandle; core: Scope; activityEmitter: Emitter<{ state: { busy: boolean; mainTurnActive: boolean; pendingInteraction: 'none' | 'approval' | 'question' }; cause: string }>; @@ -1166,6 +1244,7 @@ describe('SessionProjection', () => { if (token === IAgentLifecycleService) { throw new Error('strict DI: IAgentLifecycleService is not registered at app scope'); } + if (token === ISessionIndex) return opts?.sessionIndex; return undefined; }, }, @@ -1173,17 +1252,20 @@ describe('SessionProjection', () => { return { session, core, activityEmitter }; } - function makeProjection(...agents: FakeAgent[]): { + function makeProjection( + agents: readonly FakeAgent[], + opts?: { homeDir?: string; sessionIndex?: unknown }, + ): { projection: SessionProjection; received: ServerMessage[]; logger: { warn: ReturnType }; activityEmitter: Emitter<{ state: { busy: boolean; mainTurnActive: boolean; pendingInteraction: 'none' | 'approval' | 'question' }; cause: string }>; } { - const { session, core, activityEmitter } = makeSession(...agents); + const { session, core, activityEmitter } = makeSession(agents, opts); const received: ServerMessage[] = []; const logger = { warn: vi.fn() }; const projection = new SessionProjection(SESSION, session, { - homeDir: '/nonexistent', + homeDir: opts?.homeDir ?? '/nonexistent', core, logger, }); @@ -1195,7 +1277,7 @@ describe('SessionProjection', () => { const agent = makeAgent('main'); const child = makeAgent('agent-1'); const swarmChild = makeAgent('agent-2'); - const { projection, received, logger } = makeProjection(agent, child, swarmChild); + const { projection, received, logger } = makeProjection([agent, child, swarmChild]); const bindMainState = ofType(projection.recoveryMessages(), 'agent.state').find( (m) => m.agent_id === 'main', )!; @@ -1348,7 +1430,7 @@ describe('SessionProjection', () => { it('emits the interaction lifecycle and drops outbound messages that fail schema validation', () => { const agent = makeAgent('main'); - const { projection, received, logger } = makeProjection(agent); + const { projection, received, logger } = makeProjection([agent]); interactions.enqueue({ id: 'q-1', kind: 'question', @@ -1388,7 +1470,7 @@ describe('SessionProjection', () => { const agent = makeAgent('main'); agent.planActive = true; agent.swarmTrigger = 'tool'; - const { projection, received } = makeProjection(agent); + const { projection, received } = makeProjection([agent]); const recovery = projection.recoveryMessages(); const state = ofType(recovery, 'session.state')[0]!; expect(state.modes).toEqual({ plan: {}, swarm: {} }); @@ -1431,4 +1513,50 @@ describe('SessionProjection', () => { expect(ofType(received, 'system').map((m) => m.subtype)).toContain('plan.exit'); projection.dispose(); }); + + it('seeds tool-swarm origins for pre-existing members from the wire at bind', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'projection-swarm-bind-')); + try { + const wireDir = join(homeDir, 'sessions', 'ws1', SESSION, 'agents', 'main'); + await mkdir(wireDir, { recursive: true }); + await writeFile( + join(wireDir, 'wire.jsonl'), + `${JSON.stringify({ + type: 'loop.event', + time: T0, + event: { + type: 'tool.call', + stepUuid: 'u1', + toolCallId: 'call_swarm', + name: 'AgentSwarm', + args: JSON.stringify({ resume_agent_ids: { 'agent-2': 'continue the fix' } }), + }, + })}\n`, + ); + const agent = makeAgent('main'); + const member = makeAgent('agent-2'); + const { projection, received } = makeProjection([agent, member], { + homeDir, + sessionIndex: { get: async () => ({ workspaceId: 'ws1' }) }, + }); + expect(ofType(received, 'agent.state').some((m) => m.agent_id === 'agent-2')).toBe(false); + await vi.waitFor(() => { + const state = ofType(projection.recoveryMessages(), 'agent.state').find( + (m) => m.agent_id === 'agent-2', + ); + expect(state).toMatchObject({ + profile: { kind: 'coder' }, + origin: { + kind: 'tool-swarm', + tool_call_id: 'call_swarm', + swarm_index: 1, + parent_agent_id: 'main', + }, + }); + }); + projection.dispose(); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); });