agent-ecosystem/src/main/services/analysis/SemanticStepExtractor.ts

218 lines
7.8 KiB
TypeScript

/**
* SemanticStepExtractor - Extracts semantic steps from AI chunks.
*
* Semantic steps represent logical units of work within AI responses:
* - thinking: Claude's reasoning process
* - tool_call: Tool invocation
* - tool_result: Tool execution result
* - output: Text output from Claude
* - subagent: Nested agent execution
* - interruption: User interruption
*/
import { countContentTokens } from '@main/utils/tokenizer';
import type { AIChunk, ContentBlock, EnhancedAIChunk, SemanticStep } from '@main/types';
function normalizeAssistantContent(content: ContentBlock[] | string): ContentBlock[] {
if (typeof content === 'string') {
return content ? [{ type: 'text', text: content }] : [];
}
return Array.isArray(content) ? content : [];
}
/**
* Extract semantic steps from AI chunk responses.
* Semantic steps represent logical units of work within responses.
*
* Note: ALL tool calls are included, including Task tools with subagents.
* Task tools are filtered in the renderer's buildDisplayItems,
* but they are kept here for accurate context token tracking in aggregateToolOutputs.
*/
export function extractSemanticStepsFromAIChunk(chunk: AIChunk | EnhancedAIChunk): SemanticStep[] {
const steps: SemanticStep[] = [];
let stepIdCounter = 0;
// Note: Task tool calls are included in semantic steps for context token tracking.
// The renderer's buildDisplayItems filters Task tools with subagents.
// Process only AI responses (no user message in AIChunk)
for (const msg of chunk.responses) {
if (msg.type === 'assistant') {
// Extract from content blocks
const content = normalizeAssistantContent(msg.content);
for (const block of content) {
if (block.type === 'thinking' && block.thinking) {
// Calculate tokens for thinking content (output from Claude)
const thinkingTokens = countContentTokens(block.thinking);
steps.push({
id: `${msg.uuid}-thinking-${stepIdCounter++}`,
type: 'thinking',
startTime: new Date(msg.timestamp),
durationMs: 0, // Estimated from token count
content: {
thinkingText: block.thinking,
tokenCount: thinkingTokens, // Pre-computed token count
},
tokens: {
input: 0,
output: thinkingTokens, // Thinking is output from Claude
},
context: msg.agentId ? 'subagent' : 'main',
agentId: msg.agentId,
sourceMessageId: msg.uuid,
});
}
if (block.type === 'tool_use' && block.id && block.name) {
// Include ALL tool calls in semantic steps, including Task tools with processes.
// Task tools with processes are filtered from DISPLAY in the renderer's buildDisplayItems,
// but they should be included here for accurate context token tracking.
// The renderer's aggregateToolOutputs will correctly count Task tool tokens
// as part of the main session's context consumption.
// Calculate tool call tokens directly from name + input
// This reflects what actually enters the context window
const callTokens = countContentTokens(block.name + JSON.stringify(block.input));
steps.push({
id: block.id,
type: 'tool_call',
startTime: new Date(msg.timestamp),
durationMs: 0,
content: {
toolName: block.name,
toolInput: block.input,
sourceModel: msg.model,
},
tokens: {
input: callTokens,
output: 0,
},
context: msg.agentId ? 'subagent' : 'main',
agentId: msg.agentId,
sourceMessageId: msg.uuid,
});
}
if (block.type === 'text' && block.text) {
// Calculate tokens for text output (Claude's generated text)
const textTokens = countContentTokens(block.text);
steps.push({
id: `${msg.uuid}-output-${stepIdCounter++}`,
type: 'output',
startTime: new Date(msg.timestamp),
durationMs: 0,
content: {
outputText: block.text,
tokenCount: textTokens, // Pre-computed token count for consistency
},
tokens: {
input: 0, // Text output is generated by Claude, not input
output: textTokens,
},
context: msg.agentId ? 'subagent' : 'main',
agentId: msg.agentId,
sourceMessageId: msg.uuid,
});
}
}
}
// Tool results from internal user messages
// Note: isMeta can be true or null in JSONL, so check for toolResults presence directly
if (msg.type === 'user' && msg.toolResults && msg.toolResults.length > 0) {
for (const result of msg.toolResults) {
steps.push({
id: result.toolUseId,
type: 'tool_result',
startTime: new Date(msg.timestamp),
durationMs: 0,
content: {
toolResultContent:
typeof result.content === 'string' ? result.content : JSON.stringify(result.content),
isError: result.isError,
toolUseResult: msg.toolUseResult, // Enriched data from message
tokenCount: countContentTokens(result.content), // Pre-computed token count
},
context: msg.agentId ? 'subagent' : 'main',
agentId: msg.agentId,
});
}
}
// User interruption messages
// These are user messages with array content containing text like "[Request interrupted by user]"
if (msg.type === 'user' && Array.isArray(msg.content)) {
let foundInterruption = false;
for (const block of msg.content) {
if (block.type === 'text' && block.text) {
const textContent = block.text;
// Check for interruption patterns
if (
textContent.includes('[Request interrupted by user]') ||
textContent.includes('[Request interrupted by user for tool use]')
) {
steps.push({
id: `${msg.uuid}-interruption-${stepIdCounter++}`,
type: 'interruption',
startTime: new Date(msg.timestamp),
durationMs: 0,
content: {
interruptionText: textContent,
},
context: msg.agentId ? 'subagent' : 'main',
agentId: msg.agentId,
});
foundInterruption = true;
}
}
}
// User-rejected tool use (toolUseResult field is "User rejected tool use")
if (!foundInterruption && (msg.toolUseResult as unknown) === 'User rejected tool use') {
steps.push({
id: `${msg.uuid}-interruption-${stepIdCounter++}`,
type: 'interruption',
startTime: new Date(msg.timestamp),
durationMs: 0,
content: {
interruptionText: 'Request interrupted by user',
},
context: msg.agentId ? 'subagent' : 'main',
agentId: msg.agentId,
});
}
}
}
// Link processes as steps
for (const process of chunk.processes) {
steps.push({
id: process.id,
type: 'subagent',
startTime: process.startTime,
endTime: process.endTime,
durationMs: process.durationMs,
content: {
subagentId: process.id,
subagentDescription: process.description,
},
tokens: {
input: process.metrics.inputTokens,
output: process.metrics.outputTokens,
cached: process.metrics.cacheReadTokens,
},
isParallel: process.isParallel,
context: 'subagent',
agentId: process.id,
});
}
// Sort by startTime
return steps.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
}