agent-ecosystem/src/main/services/error/TriggerMatcher.ts
iliya 4946a65a7b feat: cherry-pick upstream CollapsibleOutputSection + TriggerMatcher regex cache
Cherry-picked from upstream:
- 516d0f6b: CollapsibleOutputSection with markdown preview toggle
- e51c1fd1: cache compiled regexes in TriggerMatcher (perf)
2026-03-25 13:58:38 +02:00

121 lines
3.6 KiB
TypeScript

/**
* TriggerMatcher service - Pattern matching utilities for trigger checking.
*
* Provides utilities for:
* - Regex pattern matching (with ReDoS protection)
* - Ignore pattern checking
* - Extracting fields from tool_use blocks
* - Getting content blocks from messages
*/
import { type ContentBlock, type ParsedMessage } from '@main/types';
import { createSafeRegExp } from '@main/utils/regexValidation';
// =============================================================================
// Regex Cache
// =============================================================================
const MAX_CACHE_SIZE = 500;
/**
* Module-level cache for compiled RegExp objects.
* Key: `${pattern}\0${flags}` (null byte separator avoids collisions).
* Value: compiled RegExp, or null if the pattern is invalid/dangerous.
*/
const regexCache = new Map<string, RegExp | null>();
/**
* Returns a cached RegExp for the given pattern and flags.
* Compiles and caches on first access; returns null for invalid patterns.
* Cache is bounded to MAX_CACHE_SIZE entries (oldest evicted first via Map insertion order).
*/
function getCachedRegex(pattern: string, flags: string): RegExp | null {
const key = `${pattern}\0${flags}`;
if (regexCache.has(key)) {
return regexCache.get(key) ?? null;
}
// Evict oldest entries when cache is full
if (regexCache.size >= MAX_CACHE_SIZE) {
const firstKey = regexCache.keys().next().value;
if (firstKey !== undefined) {
regexCache.delete(firstKey);
}
}
const regex = createSafeRegExp(pattern, flags);
regexCache.set(key, regex);
return regex;
}
// =============================================================================
// Pattern Matching
// =============================================================================
/**
* Checks if content matches a pattern.
* Uses validated regex to prevent ReDoS attacks.
* Regex objects are cached to avoid recompilation on repeated calls.
*/
export function matchesPattern(content: string, pattern: string): boolean {
const regex = getCachedRegex(pattern, 'i');
if (!regex) {
// Pattern is invalid or potentially dangerous, reject match
return false;
}
return regex.test(content);
}
/**
* Checks if content matches any of the ignore patterns.
* Uses validated regex to prevent ReDoS attacks.
* Regex objects are cached to avoid recompilation on repeated calls.
*/
export function matchesIgnorePatterns(content: string, ignorePatterns?: string[]): boolean {
if (!ignorePatterns || ignorePatterns.length === 0) {
return false;
}
for (const pattern of ignorePatterns) {
const regex = getCachedRegex(pattern, 'i');
if (regex?.test(content)) {
return true;
}
// Invalid or potentially dangerous patterns are skipped
}
return false;
}
// =============================================================================
// Field Extraction
// =============================================================================
/**
* Extracts the specified field from a tool_use block.
*/
export function extractToolUseField(
toolUse: { name: string; input?: Record<string, unknown> },
matchField?: string
): string | null {
if (!matchField || !toolUse.input) return null;
const value = toolUse.input[matchField];
if (typeof value === 'string') {
return value;
}
if (value !== undefined) {
return JSON.stringify(value);
}
return null;
}
/**
* Gets content blocks from a message, handling both array and object formats.
*/
export function getContentBlocks(message: ParsedMessage): ContentBlock[] {
if (Array.isArray(message.content)) {
return message.content;
}
return [];
}