agent-ecosystem/src/main/services/discovery/SubagentResolver.ts
iliya 16f3fa51a3 refactor: clean up code and improve readability across multiple files
- Removed unnecessary eslint disable comments related to intentional mutations for clarity.
- Updated import statements for consistency and organization.
- Refactored regex patterns and utility functions in various components to enhance performance and maintainability.
- Improved error handling and notifications for API errors in the teams IPC module.
- Streamlined the handling of DOM mutations in the ChatHistory and other components for better readability.
- Enhanced type definitions and added utility functions to improve code structure and type safety.
2026-03-19 14:07:14 +02:00

569 lines
19 KiB
TypeScript

/**
* SubagentResolver service - Links Task calls to subagent files and detects parallelism.
*
* Responsibilities:
* - Find subagent JSONL files in {sessionId}/subagents/ directory
* - Parse each subagent file
* - Calculate start/end times and metrics
* - Detect parallel execution (100ms overlap threshold)
* - Link subagents to parent Task tool calls
*/
import { type ParsedMessage, type Process, type SessionMetrics, type ToolCall } from '@main/types';
import { calculateMetrics, checkMessagesOngoing, parseJsonlFile } from '@main/utils/jsonl';
import { createLogger } from '@shared/utils/logger';
import * as path from 'path';
import { type ProjectScanner } from './ProjectScanner';
const logger = createLogger('Discovery:SubagentResolver');
/** Parallel detection window in milliseconds */
const PARALLEL_WINDOW_MS = 100;
export class SubagentResolver {
private projectScanner: ProjectScanner;
constructor(projectScanner: ProjectScanner) {
this.projectScanner = projectScanner;
}
// ===========================================================================
// Main Resolution
// ===========================================================================
/**
* Resolve all subagents for a session.
*/
async resolveSubagents(
projectId: string,
sessionId: string,
taskCalls: ToolCall[],
messages?: ParsedMessage[]
): Promise<Process[]> {
// Get subagent files
const subagentFiles = await this.projectScanner.listSubagentFiles(projectId, sessionId);
if (subagentFiles.length === 0) {
return [];
}
// Parse subagent files with bounded concurrency to avoid overwhelming SFTP.
const parseConcurrency = this.projectScanner.getFileSystemProvider().type === 'ssh' ? 4 : 24;
const subagents = await this.collectInBatches(
subagentFiles,
parseConcurrency,
async (filePath) => this.parseSubagentFile(filePath)
);
// Filter out failed parses
const validSubagents = subagents.filter((s): s is Process => s !== null);
// Link to Task calls using tool result data from parent session messages
this.linkToTaskCalls(validSubagents, taskCalls, messages ?? []);
// Propagate team metadata to continuation files via parentUuid chain
this.propagateTeamMetadata(validSubagents);
// Detect parallel execution
this.detectParallelExecution(validSubagents);
// Enrich team metadata colors from messages
if (messages) {
this.enrichTeamColors(validSubagents, messages);
}
// Sort by start time
validSubagents.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
return validSubagents;
}
// ===========================================================================
// Subagent Parsing
// ===========================================================================
/**
* Parse a single subagent file.
*/
private async parseSubagentFile(filePath: string): Promise<Process | null> {
try {
const messages = await parseJsonlFile(filePath, this.projectScanner.getFileSystemProvider());
if (messages.length === 0) {
return null;
}
// Filter out warmup subagents - these are pre-warming agents spawned by Claude Code
// that have "Warmup" as the first user message and should not be displayed
if (this.isWarmupSubagent(messages)) {
return null;
}
// Extract agent ID from filename (agent-{id}.jsonl)
const filename = path.basename(filePath);
const agentId = filename.replace(/^agent-/, '').replace(/\.jsonl$/, '');
// Filter out compact files (context compaction artifacts, not real subagents)
if (agentId.startsWith('acompact')) {
return null;
}
// Calculate timing
const { startTime, endTime, durationMs } = this.calculateTiming(messages);
// Calculate metrics
const metrics = calculateMetrics(messages);
// Check if subagent is still in progress
const isOngoing = checkMessagesOngoing(messages);
return {
id: agentId,
filePath,
messages,
startTime,
endTime,
durationMs,
metrics,
isParallel: false, // Will be set by detectParallelExecution
isOngoing,
};
} catch (error) {
logger.error(`Error parsing subagent file ${filePath}:`, error);
return null;
}
}
/**
* Check if this is a warmup subagent that should be filtered out.
* Warmup subagents are pre-warming agents spawned by Claude Code that have:
* - First user message with content exactly "Warmup"
* - isSidechain: true (all subagents have this)
*/
private isWarmupSubagent(messages: ParsedMessage[]): boolean {
// Find the first user message
const firstUserMessage = messages.find((m) => m.type === 'user');
if (!firstUserMessage) {
return false;
}
// Check if content is exactly "Warmup" (string, not array)
return firstUserMessage.content === 'Warmup';
}
/**
* Extract the teammate_id attribute from the first <teammate-message> tag in a subagent's messages.
* Returns the teammate_id string if found, undefined otherwise.
* Used for deterministic matching of team member files to their spawning Task calls.
*/
private extractTeammateId(messages: ParsedMessage[]): string | undefined {
const firstUserMessage = messages.find((m) => m.type === 'user');
if (!firstUserMessage) return undefined;
const text = typeof firstUserMessage.content === 'string' ? firstUserMessage.content : '';
const match = /<teammate-message\s[^>]*?\bteammate_id="([^"]+)"/.exec(text);
return match?.[1];
}
/**
* Calculate timing from messages.
*/
private calculateTiming(messages: ParsedMessage[]): {
startTime: Date;
endTime: Date;
durationMs: number;
} {
const timestamps = messages.map((m) => m.timestamp.getTime()).filter((t) => !isNaN(t));
if (timestamps.length === 0) {
const now = new Date();
return { startTime: now, endTime: now, durationMs: 0 };
}
const minTime = Math.min(...timestamps);
const maxTime = Math.max(...timestamps);
return {
startTime: new Date(minTime),
endTime: new Date(maxTime),
durationMs: maxTime - minTime,
};
}
// ===========================================================================
// Task Call Linking
// ===========================================================================
/**
* Link subagents to their parent Task tool calls.
*
* Uses result-based matching: reads tool_result messages from the parent session
* to find agentId values, then matches subagent files by their ID. Falls back to
* positional matching (without wrap-around) for any remaining unmatched subagents.
*
* After matching, enriches subagents with Task call metadata (description, subagentType).
*/
private linkToTaskCalls(
subagents: Process[],
taskCalls: ToolCall[],
messages: ParsedMessage[]
): void {
// Filter to only Task calls
const taskCallsOnly = taskCalls.filter((tc) => tc.isTask);
if (taskCallsOnly.length === 0 || subagents.length === 0) {
return;
}
// Build a map: agentId → taskCallId from tool result messages
// Tool results for Task calls contain an agentId field linking to the subagent file
const agentIdToTaskId = new Map<string, string>();
for (const msg of messages) {
if (!msg.toolUseResult) continue;
const result = msg.toolUseResult;
// Check both camelCase (regular subagents) and snake_case (team spawns) field names
const agentId = (result.agentId ?? result.agent_id) as string | undefined;
if (!agentId) continue;
// Find the Task call ID from sourceToolUseID or toolResults[0].toolUseId
const taskCallId = msg.sourceToolUseID ?? msg.toolResults[0]?.toolUseId;
if (taskCallId) {
agentIdToTaskId.set(agentId, taskCallId);
}
}
// Build a lookup from task call ID → ToolCall for enrichment
const taskCallById = new Map(taskCallsOnly.map((tc) => [tc.id, tc]));
// Track which subagents and tasks got matched
const matchedSubagentIds = new Set<string>();
const matchedTaskIds = new Set<string>();
// Phase 1: Result-based matching (agentId from tool results)
// Works for regular subagents (Explore, etc.) where agentId = file UUID
for (const subagent of subagents) {
const taskCallId = agentIdToTaskId.get(subagent.id);
if (!taskCallId) continue;
const taskCall = taskCallById.get(taskCallId);
if (!taskCall) continue;
this.enrichSubagentFromTask(subagent, taskCall);
subagent.linkType = 'agent-id';
matchedSubagentIds.add(subagent.id);
matchedTaskIds.add(taskCallId);
}
// Phase 2: Deterministic teammate_id matching for team members
// Team spawns use agent_id = "name@team_name" (not a file UUID), so Phase 1 can't match them.
// Instead, match by comparing Task call input.name to the teammate_id XML attribute
// in the subagent file's first <teammate-message> tag.
const teamTaskCalls = taskCallsOnly.filter(
(tc) =>
!matchedTaskIds.has(tc.id) &&
typeof tc.input?.team_name === 'string' &&
typeof tc.input?.name === 'string'
);
if (teamTaskCalls.length > 0) {
// Pre-extract teammate_ids from unmatched subagent files
const subagentTeammateIds = new Map<string, string>();
for (const subagent of subagents) {
if (matchedSubagentIds.has(subagent.id)) continue;
const teammateId = this.extractTeammateId(subagent.messages);
if (teammateId) {
subagentTeammateIds.set(subagent.id, teammateId);
}
}
// Match each team Task call to the earliest subagent file with matching teammate_id
for (const taskCall of teamTaskCalls) {
const inputName = taskCall.input?.name as string;
let bestMatch: Process | undefined;
for (const subagent of subagents) {
if (matchedSubagentIds.has(subagent.id)) continue;
if (subagentTeammateIds.get(subagent.id) !== inputName) continue;
if (!bestMatch || subagent.startTime < bestMatch.startTime) {
bestMatch = subagent;
}
}
if (bestMatch) {
this.enrichSubagentFromTask(bestMatch, taskCall);
bestMatch.linkType = 'team-member-id';
matchedSubagentIds.add(bestMatch.id);
matchedTaskIds.add(taskCall.id);
}
}
}
// Mark remaining unmatched subagents as unlinked (no Phase 3 positional fallback)
for (const subagent of subagents) {
if (!matchedSubagentIds.has(subagent.id) && !subagent.linkType) {
subagent.linkType = 'unlinked';
}
}
}
/**
* Enrich a subagent with metadata from its parent Task call.
* Intentionally mutates the subagent in place for consistency with other resolution methods.
*/
private enrichSubagentFromTask(subagent: Process, taskCall: ToolCall): void {
subagent.parentTaskId = taskCall.id;
subagent.description = taskCall.taskDescription;
subagent.subagentType = taskCall.taskSubagentType;
// Extract team metadata from Task call input
const teamName = taskCall.input?.team_name as string | undefined;
const memberName = taskCall.input?.name as string | undefined;
if (teamName && memberName) {
subagent.team = { teamName, memberName, memberColor: '' };
}
}
/**
* Enrich team member subagents with color information from tool results.
* Teammate spawned results contain color information.
*/
private enrichTeamColors(subagents: Process[], messages: ParsedMessage[]): void {
for (const msg of messages) {
if (!msg.toolUseResult) continue;
// sourceToolUseID may be absent on teammate_spawned results;
// fall back to toolResults[0].toolUseId
const sourceId = msg.sourceToolUseID ?? msg.toolResults[0]?.toolUseId;
if (!sourceId) continue;
const result = msg.toolUseResult;
if (result.status === 'teammate_spawned' && result.color) {
// Set color on ALL subagents sharing this parentTaskId
// (primary file + continuation files from parentUuid chain propagation)
for (const subagent of subagents) {
if (subagent.parentTaskId === sourceId && subagent.team) {
subagent.team.memberColor = result.color as string;
}
}
}
}
}
/**
* Propagate team metadata to continuation files via parentUuid chain.
*
* Team members generate multiple JSONL files (one per activation/turn).
* Only the primary file is matched by linkToTaskCalls (Phase 2 description match).
* Continuation files (task assignments, shutdown responses) are linked to the
* same teammate by following the parentUuid chain: a continuation file's first
* message.parentUuid matches the last message.uuid of the previous file.
*/
private propagateTeamMetadata(subagents: Process[]): void {
// Build map: last message uuid → subagent (for chain lookups)
const lastUuidToSubagent = new Map<string, Process>();
for (const subagent of subagents) {
if (subagent.messages.length === 0) continue;
const lastMsg = subagent.messages[subagent.messages.length - 1];
if (lastMsg.uuid) {
lastUuidToSubagent.set(lastMsg.uuid, subagent);
}
}
// For each subagent without team metadata, follow parentUuid chain
// to find an ancestor with team metadata and propagate it
const maxDepth = 10;
for (const subagent of subagents) {
if (subagent.team) continue; // Already has team metadata
if (subagent.messages.length === 0) continue;
const firstMsg = subagent.messages[0];
if (!firstMsg.parentUuid) continue;
// Walk the chain upward
let ancestor: Process | undefined = lastUuidToSubagent.get(firstMsg.parentUuid);
let depth = 0;
while (ancestor && !ancestor.team && depth < maxDepth) {
if (ancestor.messages.length === 0) break;
const parentUuid = ancestor.messages[0].parentUuid;
if (!parentUuid) break;
ancestor = lastUuidToSubagent.get(parentUuid);
depth++;
}
if (ancestor?.team) {
subagent.team = { ...ancestor.team };
subagent.parentTaskId = subagent.parentTaskId ?? ancestor.parentTaskId;
subagent.description = subagent.description ?? ancestor.description;
subagent.subagentType = subagent.subagentType ?? ancestor.subagentType;
subagent.linkType = subagent.linkType ?? (ancestor.linkType ? 'parent-chain' : undefined);
}
}
}
// ===========================================================================
// Parallel Detection
// ===========================================================================
/**
* Detect parallel execution among subagents.
* Subagents with start times within PARALLEL_WINDOW_MS are marked as parallel.
*/
private detectParallelExecution(subagents: Process[]): void {
if (subagents.length < 2) return;
// Sort by start time
const sorted = [...subagents].sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
// Group by start time buckets
const groups: Process[][] = [];
let currentGroup: Process[] = [];
let groupStartTime = 0;
for (const agent of sorted) {
const startMs = agent.startTime.getTime();
if (currentGroup.length === 0) {
// Start new group
currentGroup.push(agent);
groupStartTime = startMs;
} else if (startMs - groupStartTime <= PARALLEL_WINDOW_MS) {
// Add to current group
currentGroup.push(agent);
} else {
// Finalize current group and start new one
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
currentGroup = [agent];
groupStartTime = startMs;
}
}
// Don't forget the last group
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
// Mark agents in groups with multiple members as parallel
for (const group of groups) {
if (group.length > 1) {
for (const agent of group) {
agent.isParallel = true;
}
}
}
}
// ===========================================================================
// Utility Methods
// ===========================================================================
/**
* Get subagent by ID.
*/
findSubagentById(subagents: Process[], id: string): Process | undefined {
return subagents.find((s) => s.id === id);
}
/**
* Get parallel subagent groups.
*/
getParallelGroups(subagents: Process[]): Process[][] {
const parallelAgents = subagents.filter((s) => s.isParallel);
if (parallelAgents.length === 0) return [];
// Group by start time
const sorted = [...parallelAgents].sort(
(a, b) => a.startTime.getTime() - b.startTime.getTime()
);
const groups: Process[][] = [];
let currentGroup: Process[] = [];
let groupStartTime = 0;
for (const agent of sorted) {
const startMs = agent.startTime.getTime();
if (currentGroup.length === 0) {
currentGroup.push(agent);
groupStartTime = startMs;
} else if (startMs - groupStartTime <= PARALLEL_WINDOW_MS) {
currentGroup.push(agent);
} else {
groups.push(currentGroup);
currentGroup = [agent];
groupStartTime = startMs;
}
}
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
return groups.filter((g) => g.length > 1);
}
/**
* Calculate total metrics for all subagents.
*/
getTotalSubagentMetrics(subagents: Process[]): SessionMetrics {
if (subagents.length === 0) {
return {
durationMs: 0,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
messageCount: 0,
};
}
let totalDuration = 0;
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheCreationTokens = 0;
let messageCount = 0;
for (const agent of subagents) {
totalDuration += agent.durationMs;
inputTokens += agent.metrics.inputTokens;
outputTokens += agent.metrics.outputTokens;
cacheReadTokens += agent.metrics.cacheReadTokens;
cacheCreationTokens += agent.metrics.cacheCreationTokens;
messageCount += agent.metrics.messageCount;
}
return {
durationMs: totalDuration,
totalTokens: inputTokens + outputTokens,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
messageCount,
};
}
private async collectInBatches<T, R>(
items: T[],
batchSize: number,
mapper: (item: T) => Promise<R>
): Promise<R[]> {
const safeBatchSize = Math.max(1, batchSize);
const results: R[] = [];
for (let i = 0; i < items.length; i += safeBatchSize) {
const batch = items.slice(i, i + safeBatchSize);
const settled = await Promise.allSettled(batch.map((item) => mapper(item)));
for (const result of settled) {
if (result.status === 'fulfilled') {
results.push(result.value);
}
}
}
return results;
}
}