-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix(kap-server): project swarm member tasks and agent refs in v3 protocol #3716
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string, unknown>; | ||
| const resumeIds = | ||
| args['resume_agent_ids'] !== null && typeof args['resume_agent_ids'] === 'object' | ||
| ? Object.keys(args['resume_agent_ids'] as Record<string, unknown>) | ||
| : []; | ||
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the same agent is resumed by two sequential Useful? React with 👍 / 👎. |
||
| const outcome = member?.outcome; | ||
| const status = | ||
| outcome === 'completed' | ||
| ? 'completed' | ||
| : outcome === undefined | ||
| ? tool.status === 'done' | ||
| ? 'completed' | ||
| : 'failed' | ||
|
Comment on lines
+1474
to
+1477
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| : '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('<agent_swarm_result>')) return []; | ||
| const members: SwarmMemberResult[] = []; | ||
| for (const match of output.matchAll(/<subagent\b([^>]*)>([\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)]; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<void> { | ||
| if (agentId !== MAIN_AGENT_ID) return; | ||
| let toolCallId: string | undefined; | ||
| let args: Record<string, unknown> | 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<string, unknown>) | ||
| : 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<string, unknown>) | ||
| : []; | ||
| 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; | ||
|
Comment on lines
+625
to
+629
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When projection first binds during a later swarm whose item text was used in an earlier swarm, historical swarm agents are still origin-less and carry the same persisted Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
| 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<number>): Promise<void> { | ||
|
|
@@ -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; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These values do not represent the models that actually executed:
AgentSwarmToolInputSchemahas nothinkingproperty, and itsmodelproperty applies only to item-spawned agents while resumed agents explicitly keep their existing model. Thus valid cold history always losesthinking_effortand can label resumed members with an ignored model override; the fold needs durable execution/profile metadata and must distinguish resumed members from new item members.Useful? React with 👍 / 👎.