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
136 changes: 136 additions & 0 deletions packages/kap-server/src/services/history/coldFold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +1459 to +1460

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use executed model metadata for swarm members

These values do not represent the models that actually executed: AgentSwarmToolInputSchema has no thinking property, and its model property applies only to item-spawned agents while resumed agents explicitly keep their existing model. Thus valid cold history always loses thinking_effort and 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 👍 / 👎.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh synthesized tasks on repeated resumes

When the same agent is resumed by two sequential AgentSwarm calls, the first call inserts a task keyed by that agent ID and this early return prevents the second call from updating it. Cold history consequently retains the first run's description, status, result, and timestamps, whereas the live projector upserts that task ID with the latest run, so reconnecting can replace current results with stale ones.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep in-flight resumed member tasks running

When /history is queried for a live session while an AgentSwarm resume is still executing, members is empty but resumeIds is populated and the tool remains running, so this branch emits every resumed task as failed with state_reason: "interrupted". The endpoint therefore reports failure during the exact mid-swarm scenario this projection is intended to support; use running when options.live and the tool are still running.

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);
Expand Down Expand Up @@ -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('&quot;', '"')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&');
}

function splitKey(key: string): [string, string] {
const index = key.indexOf(':');
return [key.slice(0, index), key.slice(index + 1)];
Expand Down
5 changes: 3 additions & 2 deletions packages/kap-server/src/services/projection/agentProjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
75 changes: 75 additions & 0 deletions packages/kap-server/src/services/projection/sessionProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
INTERACTION_TAG_SESSION_ID,
ISessionActivityView,
ISessionIndex,
ISessionMetadata,
IWireService,
MAIN_AGENT_ID,
interactions,
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid matching swarm members by item alone

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 swarmItem, so this lookup assigns all of them the current tool call and swarm index. Recovery then exposes unrelated old agents as members of the active swarm; item-based matching must additionally correlate the agent to the current invocation rather than treating the label as unique across the session.

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> {
Expand Down Expand Up @@ -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;
}
}
71 changes: 70 additions & 1 deletion packages/kap-server/test/services/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -768,6 +768,75 @@ describe('foldWireHistory interactions, facts and modes', () => {
task_id: 'task-2',
agent_refs: [{ agent_id: 'sub-1', role: 'child' }],
});

const swarmOutput = [
'<agent_swarm_result>',
'<summary>completed: 2, failed: 1</summary>',
'<subagent mode="resume" agent_id="agent-9" item="old item" outcome="completed">resume report</subagent>',
'<subagent agent_id="agent-11" item="alpha" outcome="completed">alpha report</subagent>',
'<subagent agent_id="agent-12" item="beta" outcome="failed" stop_reason="rate_limit">beta blew up</subagent>',
'</agent_swarm_result>',
].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',
});
});
});

Expand Down
Loading
Loading