agent-ecosystem/src/main/services/error/ErrorTriggerChecker.ts

461 lines
14 KiB
TypeScript

/**
* ErrorTriggerChecker service - Checks different trigger types against messages.
*
* Provides utilities for:
* - Checking tool_result triggers
* - Checking tool_use triggers
* - Checking token threshold triggers
* - Validating project scope
*/
import { type ParsedMessage } from '@main/types';
import { extractProjectName } from '@main/utils/pathDecoder';
import {
estimateTokens,
extractToolResults,
type ToolResultInfo,
type ToolUseInfo,
} from '../analysis/ToolResultExtractor';
import { formatTokens, getToolSummary } from '../analysis/ToolSummaryFormatter';
import { projectPathResolver } from '../discovery/ProjectPathResolver';
import { type NotificationTrigger } from '../infrastructure/ConfigManager';
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
import {
createDetectedError,
type DetectedError,
extractErrorMessage,
findToolNameByToolUseId,
} from './ErrorMessageBuilder';
import {
extractToolUseField,
getContentBlocks,
matchesIgnorePatterns,
matchesPattern,
} from './TriggerMatcher';
// =============================================================================
// Repository Scope Checking
// =============================================================================
// Cache for projectId -> repositoryId mapping to avoid repeated resolution
const repositoryIdCache = new Map<string, string | null>();
interface RepositoryScopeTarget {
projectId: string;
cwdHint?: string;
}
/**
* Resolves a projectId to its repositoryId using GitIdentityResolver.
* Results are cached for performance.
* @param projectId - The encoded project ID (e.g., "-Users-username-myproject")
* @returns Repository ID or null if not resolvable
*/
async function resolveRepositoryId(target: string | RepositoryScopeTarget): Promise<string | null> {
const projectId = typeof target === 'string' ? target : target.projectId;
const cwdHint = typeof target === 'string' ? undefined : target.cwdHint;
// Check cache first
if (repositoryIdCache.has(projectId)) {
return repositoryIdCache.get(projectId) ?? null;
}
const projectPath = await projectPathResolver.resolveProjectPath(projectId, { cwdHint });
// Resolve repository identity
const identity = await gitIdentityResolver.resolveIdentity(projectPath);
const repositoryId = identity?.id ?? null;
// Cache the result
repositoryIdCache.set(projectId, repositoryId);
return repositoryId;
}
/**
* Synchronous version of resolveRepositoryId using cached values only.
* If not cached, attempts synchronous resolution via path heuristics.
*/
function resolveRepositoryIdSync(projectId: string): string | null {
// Check cache first
if (repositoryIdCache.has(projectId)) {
return repositoryIdCache.get(projectId) ?? null;
}
// For sync context, we can't do async resolution
// The async version should be called during initialization
return null;
}
/**
* Checks if the project matches the trigger's repository scope.
* @param projectId - The encoded project ID (e.g., "-Users-username-myproject")
* @param repositoryIds - Optional list of repository group IDs to scope the trigger to
* @returns true if trigger should apply, false if it should be skipped
*/
export function matchesRepositoryScope(projectId: string, repositoryIds?: string[]): boolean {
// If no repository IDs specified, trigger applies to all repositories
if (!repositoryIds || repositoryIds.length === 0) {
return true;
}
// Get the repository ID for this project (from cache)
const repositoryId = resolveRepositoryIdSync(projectId);
// If we can't resolve the repository ID, don't match
if (!repositoryId) {
return false;
}
// Check if the repository ID matches any of the configured IDs
return repositoryIds.includes(repositoryId);
}
/**
* Pre-resolves repository IDs for a list of project IDs.
* Call this before checking triggers to populate the cache.
*/
export async function preResolveRepositoryIds(
targets: (string | RepositoryScopeTarget)[]
): Promise<void> {
const uniqueTargets = new Map<string, RepositoryScopeTarget>();
for (const target of targets) {
if (typeof target === 'string') {
if (!uniqueTargets.has(target)) {
uniqueTargets.set(target, { projectId: target });
}
continue;
}
const existing = uniqueTargets.get(target.projectId);
if (!existing) {
uniqueTargets.set(target.projectId, target);
continue;
}
// Prefer a target with cwd hint if one was provided.
if (!existing.cwdHint && target.cwdHint) {
uniqueTargets.set(target.projectId, target);
}
}
await Promise.all(
Array.from(uniqueTargets.values()).map((target) => resolveRepositoryId(target))
);
}
// =============================================================================
// Tool Result Trigger Checking
// =============================================================================
/**
* Checks if a tool_result matches a trigger.
*/
export function checkToolResultTrigger(
message: ParsedMessage,
trigger: NotificationTrigger,
toolUseMap: Map<string, ToolUseInfo>,
sessionId: string,
projectId: string,
filePath: string,
lineNumber: number
): DetectedError | null {
const toolResults = extractToolResults(message, findToolNameByToolUseId);
for (const result of toolResults) {
// If requireError is true, only match when is_error is true
if (trigger.requireError) {
if (!result.isError) {
continue;
}
// Extract error message for ignore pattern checking
const errorMessage = extractErrorMessage(result);
// Check ignore patterns - if any match, skip this error
if (matchesIgnorePatterns(errorMessage, trigger.ignorePatterns)) {
continue;
}
// Create detected error
return createDetectedError({
sessionId,
projectId,
filePath,
projectName: extractProjectName(projectId),
lineNumber,
source: result.toolName ?? 'tool_result',
message: errorMessage,
timestamp: message.timestamp,
cwd: message.cwd,
toolUseId: result.toolUseId,
triggerColor: trigger.color,
triggerId: trigger.id,
triggerName: trigger.name,
});
}
// Non-error tool_result triggers (if toolName is specified)
if (trigger.toolName) {
const toolUse = toolUseMap.get(result.toolUseId);
if (toolUse?.name !== trigger.toolName) {
continue;
}
// Match against content if matchField is 'content'
if (trigger.matchField === 'content' && trigger.matchPattern) {
const content =
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
if (!matchesPattern(content, trigger.matchPattern)) {
continue;
}
if (matchesIgnorePatterns(content, trigger.ignorePatterns)) {
continue;
}
return createDetectedError({
sessionId,
projectId,
filePath,
projectName: extractProjectName(projectId),
lineNumber,
source: trigger.toolName,
message: `Tool result matched: ${content.slice(0, 200)}`,
timestamp: message.timestamp,
cwd: message.cwd,
toolUseId: result.toolUseId,
triggerColor: trigger.color,
triggerId: trigger.id,
triggerName: trigger.name,
});
}
}
}
return null;
}
// =============================================================================
// Tool Use Trigger Checking
// =============================================================================
/**
* Checks if a tool_use matches a trigger.
*/
export function checkToolUseTrigger(
message: ParsedMessage,
trigger: NotificationTrigger,
sessionId: string,
projectId: string,
filePath: string,
lineNumber: number
): DetectedError | null {
if (message.type !== 'assistant') return null;
const contentBlocks = getContentBlocks(message);
for (const block of contentBlocks) {
if (block.type !== 'tool_use') continue;
const toolUse = block as {
type: 'tool_use';
id: string;
name: string;
input?: Record<string, unknown>;
};
// Check tool name if specified
if (trigger.toolName && toolUse.name !== trigger.toolName) {
continue;
}
// Extract the field to match based on matchField
// If no matchField specified (e.g., "Any Tool"), match against entire input JSON
const fieldValue = trigger.matchField
? extractToolUseField(toolUse, trigger.matchField)
: toolUse.input
? JSON.stringify(toolUse.input)
: null;
if (!fieldValue) continue;
// Check match pattern
if (trigger.matchPattern && !matchesPattern(fieldValue, trigger.matchPattern)) {
continue;
}
// Check ignore patterns
if (matchesIgnorePatterns(fieldValue, trigger.ignorePatterns)) {
continue;
}
// Match found!
return createDetectedError({
sessionId,
projectId,
filePath,
projectName: extractProjectName(projectId),
lineNumber,
source: toolUse.name,
message: `${trigger.matchField ?? 'tool_use'}: ${fieldValue.slice(0, 200)}`,
timestamp: message.timestamp,
cwd: message.cwd,
toolUseId: toolUse.id,
triggerColor: trigger.color,
triggerId: trigger.id,
triggerName: trigger.name,
});
}
return null;
}
// =============================================================================
// Token Threshold Trigger Checking
// =============================================================================
/**
* Check if individual tool_use blocks exceed the token threshold.
* Returns an array of DetectedError for each tool_use that exceeds the threshold.
*
* Token calculation (matches context window impact):
* - Tool call tokens: estimated from name + JSON.stringify(input) (what enters context)
* - Tool result tokens: estimated from tool_result.content (what Claude reads)
* - Total = call + result
*/
export function checkTokenThresholdTrigger(
message: ParsedMessage,
trigger: NotificationTrigger,
toolResultMap: Map<string, ToolResultInfo>,
sessionId: string,
projectId: string,
filePath: string,
lineNumber: number
): DetectedError[] {
const errors: DetectedError[] = [];
// Only check for token_threshold mode
if (trigger.mode !== 'token_threshold' || !trigger.tokenThreshold) {
return errors;
}
// Only check assistant messages that contain tool_use blocks
if (message.type !== 'assistant') {
return errors;
}
const tokenType = trigger.tokenType ?? 'total';
const threshold = trigger.tokenThreshold;
// Collect all tool_use blocks from message
const toolUseBlocks: { id: string; name: string; input: Record<string, unknown> }[] = [];
// Check content array for tool_use blocks
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === 'tool_use') {
const toolUse = block;
toolUseBlocks.push({
id: toolUse.id,
name: toolUse.name,
input: toolUse.input || {},
});
}
}
}
// Also check toolCalls array if present
if (message.toolCalls) {
for (const toolCall of message.toolCalls) {
// Avoid duplicates
if (!toolUseBlocks.some((t) => t.id === toolCall.id)) {
toolUseBlocks.push({
id: toolCall.id,
name: toolCall.name,
input: toolCall.input || {},
});
}
}
}
if (toolUseBlocks.length === 0) {
return errors;
}
// Check each tool_use block individually
for (const toolUse of toolUseBlocks) {
// Check tool name filter if specified
if (trigger.toolName && toolUse.name !== trigger.toolName) {
continue;
}
// Calculate tool call tokens directly from name + input
// This reflects what actually enters the context window
const toolCallTokens = estimateTokens(toolUse.name + JSON.stringify(toolUse.input));
// Calculate tool result tokens (what Claude reads back)
let toolResultTokens = 0;
const toolResult = toolResultMap.get(toolUse.id);
if (toolResult) {
toolResultTokens = estimateTokens(toolResult.content);
}
// Calculate token count based on tokenType
// Note: 'input' here means tool CALL tokens (what enters context)
// 'output' here means tool RESULT tokens (what Claude reads)
let tokenCount = 0;
switch (tokenType) {
case 'input':
// Tool call tokens (name + input that enters context)
tokenCount = toolCallTokens;
break;
case 'output':
// Tool result tokens (what Claude reads - success message, file content for Read, etc.)
tokenCount = toolResultTokens;
break;
case 'total':
// Both: full context impact of the tool operation
tokenCount = toolCallTokens + toolResultTokens;
break;
}
// Check threshold
if (tokenCount <= threshold) {
continue;
}
// Build summary for the tool
const toolSummary = getToolSummary(toolUse.name, toolUse.input);
// Build message with tool info and token type for clarity
const tokenTypeLabel = tokenType === 'total' ? '' : ` ${tokenType}`;
const tokenMessage = `${toolUse.name} - ${toolSummary} : ~${formatTokens(tokenCount)}${tokenTypeLabel} tokens`;
// Check ignore patterns
if (matchesIgnorePatterns(tokenMessage, trigger.ignorePatterns)) {
continue;
}
errors.push(
createDetectedError({
sessionId,
projectId,
filePath,
projectName: extractProjectName(projectId),
lineNumber,
source: toolUse.name,
message: tokenMessage,
timestamp: message.timestamp,
cwd: message.cwd,
toolUseId: toolUse.id,
triggerColor: trigger.color,
triggerId: trigger.id,
triggerName: trigger.name,
})
);
}
return errors;
}