diff --git a/apps/mcp-server/src/keyword/explicit-pattern-matcher.spec.ts b/apps/mcp-server/src/keyword/explicit-pattern-matcher.spec.ts new file mode 100644 index 00000000..a4272826 --- /dev/null +++ b/apps/mcp-server/src/keyword/explicit-pattern-matcher.spec.ts @@ -0,0 +1,179 @@ +/** + * Tests for explicit-pattern-matcher + * + * Validates matching of user prompts against agents' activation.explicit_patterns. + */ + +import { matchExplicitPatterns, type ExplicitPatternsMap } from './explicit-pattern-matcher'; + +describe('matchExplicitPatterns', () => { + const patternsMap: ExplicitPatternsMap = new Map([ + ['agent-architect', ['create agent', 'new agent', 'validate agent', 'agent creation']], + [ + 'parallel-orchestrator', + ['parallel issues', 'parallel execution', 'taskMaestro', 'wave execution'], + ], + ['test-engineer', ['write test code', 'unit test', 'with TDD', 'as test-engineer']], + ['tooling-engineer', ['config file', 'tsconfig', 'eslint', 'prettier', 'webpack']], + [ + 'security-engineer', + ['fix security vulnerability', 'implement JWT', 'implement authentication'], + ], + ]); + + const availableAgents = [ + 'agent-architect', + 'parallel-orchestrator', + 'test-engineer', + 'tooling-engineer', + 'security-engineer', + 'software-engineer', + ]; + + describe('basic matching', () => { + it('returns null when no patterns match', () => { + const result = matchExplicitPatterns('refactor the login page', patternsMap, availableAgents); + expect(result).toBeNull(); + }); + + it('matches a simple pattern case-insensitively', () => { + const result = matchExplicitPatterns( + 'I need to CREATE AGENT for the new workflow', + patternsMap, + availableAgents, + ); + expect(result).not.toBeNull(); + expect(result!.agentName).toBe('agent-architect'); + expect(result!.source).toBe('explicit_patterns'); + expect(result!.confidence).toBeGreaterThanOrEqual(0.9); + }); + + it('matches pattern as substring of prompt', () => { + const result = matchExplicitPatterns( + 'please help me with parallel issues in our project', + patternsMap, + availableAgents, + ); + expect(result).not.toBeNull(); + expect(result!.agentName).toBe('parallel-orchestrator'); + }); + + it('matches TDD-related prompt to test-engineer', () => { + const result = matchExplicitPatterns( + 'let us proceed with TDD for the auth module', + patternsMap, + availableAgents, + ); + expect(result).not.toBeNull(); + expect(result!.agentName).toBe('test-engineer'); + }); + }); + + describe('longest match wins', () => { + it('prefers longer matching pattern when multiple agents match', () => { + // "implement authentication" (23 chars) from security-engineer + // vs shorter matches from other agents + const result = matchExplicitPatterns( + 'we need to implement authentication for the API', + patternsMap, + availableAgents, + ); + expect(result).not.toBeNull(); + expect(result!.agentName).toBe('security-engineer'); + }); + + it('uses longest pattern when same agent has multiple matches', () => { + // Both "create agent" and "agent creation" match + const result = matchExplicitPatterns( + 'agent creation process needs improvement', + patternsMap, + availableAgents, + ); + expect(result).not.toBeNull(); + expect(result!.agentName).toBe('agent-architect'); + }); + }); + + describe('availability filtering', () => { + it('skips agents not in availableAgents list', () => { + const limitedAgents = ['software-engineer', 'tooling-engineer']; + const result = matchExplicitPatterns( + 'create agent for the new workflow', + patternsMap, + limitedAgents, + ); + // agent-architect is not in limitedAgents, so no match + expect(result).toBeNull(); + }); + + it('falls through to next matching agent when first is unavailable', () => { + // Only test-engineer available, not agent-architect + const limitedAgents = ['test-engineer', 'software-engineer']; + const result = matchExplicitPatterns( + 'write test code for the new feature', + patternsMap, + limitedAgents, + ); + expect(result).not.toBeNull(); + expect(result!.agentName).toBe('test-engineer'); + }); + }); + + describe('edge cases', () => { + it('handles empty patterns map', () => { + const result = matchExplicitPatterns('create agent for workflow', new Map(), availableAgents); + expect(result).toBeNull(); + }); + + it('handles empty available agents', () => { + const result = matchExplicitPatterns('create agent for workflow', patternsMap, []); + expect(result).toBeNull(); + }); + + it('handles empty prompt', () => { + const result = matchExplicitPatterns('', patternsMap, availableAgents); + expect(result).toBeNull(); + }); + + it('handles agent with empty patterns array', () => { + const mapWithEmpty = new Map([['some-agent', []]]); + const result = matchExplicitPatterns('anything', mapWithEmpty, ['some-agent']); + expect(result).toBeNull(); + }); + + it('does not match partial words by default', () => { + // "test" should not match "unit test" pattern partially + // But "unit test" as a full phrase should match + const result = matchExplicitPatterns( + 'unit testing is important', + patternsMap, + availableAgents, + ); + // "unit test" is a substring of "unit testing" — this should still match + expect(result).not.toBeNull(); + expect(result!.agentName).toBe('test-engineer'); + }); + }); + + describe('result shape', () => { + it('returns correct result structure', () => { + const result = matchExplicitPatterns( + 'configure eslint for the project', + patternsMap, + availableAgents, + ); + expect(result).toEqual({ + agentName: 'tooling-engineer', + source: 'explicit_patterns', + confidence: expect.any(Number), + reason: expect.stringContaining('eslint'), + }); + }); + + it('includes matched pattern in reason', () => { + const result = matchExplicitPatterns('setup webpack bundler', patternsMap, availableAgents); + expect(result).not.toBeNull(); + expect(result!.reason).toContain('webpack'); + }); + }); +}); diff --git a/apps/mcp-server/src/keyword/explicit-pattern-matcher.ts b/apps/mcp-server/src/keyword/explicit-pattern-matcher.ts new file mode 100644 index 00000000..59b0ef1f --- /dev/null +++ b/apps/mcp-server/src/keyword/explicit-pattern-matcher.ts @@ -0,0 +1,65 @@ +/** + * Explicit Pattern Matcher + * + * Matches user prompts against agents' activation.explicit_patterns fields. + * Used by PrimaryAgentResolver to select agents based on keyword matching. + * + * Priority in resolution chain: explicit request > explicit_patterns > recommended > config > intent > default + */ + +import type { PrimaryAgentResolutionResult } from './keyword.types'; + +/** Map of agent name → explicit pattern strings from activation.explicit_patterns */ +export type ExplicitPatternsMap = Map; + +/** + * Match user prompt against agents' explicit_patterns. + * + * Performs case-insensitive substring matching. When multiple agents match, + * the agent with the longest matching pattern wins (most specific match). + * + * @param prompt - User's prompt text + * @param patternsMap - Map of agent name to their explicit_patterns arrays + * @param availableAgents - List of agents available for selection + * @returns Resolution result if a match is found, null otherwise + */ +export function matchExplicitPatterns( + prompt: string, + patternsMap: ExplicitPatternsMap, + availableAgents: string[], +): PrimaryAgentResolutionResult | null { + if (!prompt || patternsMap.size === 0 || availableAgents.length === 0) { + return null; + } + + const lowerPrompt = prompt.toLowerCase(); + const availableSet = new Set(availableAgents); + + let bestMatch: { agentName: string; pattern: string; length: number } | null = null; + + for (const [agentName, patterns] of patternsMap) { + if (!availableSet.has(agentName)) { + continue; + } + + for (const pattern of patterns) { + const lowerPattern = pattern.toLowerCase(); + if (lowerPrompt.includes(lowerPattern)) { + if (!bestMatch || lowerPattern.length > bestMatch.length) { + bestMatch = { agentName, pattern, length: lowerPattern.length }; + } + } + } + } + + if (!bestMatch) { + return null; + } + + return { + agentName: bestMatch.agentName, + source: 'explicit_patterns', + confidence: 0.95, + reason: `Matched explicit pattern: "${bestMatch.pattern}"`, + }; +} diff --git a/apps/mcp-server/src/keyword/keyword.module.ts b/apps/mcp-server/src/keyword/keyword.module.ts index b7dba996..c9528fc4 100644 --- a/apps/mcp-server/src/keyword/keyword.module.ts +++ b/apps/mcp-server/src/keyword/keyword.module.ts @@ -9,6 +9,7 @@ import { AgentModule } from '../agent/agent.module'; import { AgentService } from '../agent/agent.service'; import { normalizeAgentName } from '../shared/agent.utils'; import { resolveClientType } from '../shared/client-type'; +import type { ExplicitPatternsMap } from './explicit-pattern-matcher'; import { KeywordService, type KeywordServiceOptions, @@ -85,7 +86,30 @@ export const KEYWORD_SERVICE = 'KEYWORD_SERVICE'; return primaryAgents; }; - const primaryAgentResolver = new PrimaryAgentResolver(getProjectConfig, listPrimaryAgents); + const loadExplicitPatterns = async (): Promise => { + const patternsMap: ExplicitPatternsMap = new Map(); + const agentNames = await rulesService.listAgents(); + + for (const name of agentNames) { + try { + const agent = await rulesService.getAgent(name); + const activation = agent.activation as { explicit_patterns?: string[] } | undefined; + if (activation?.explicit_patterns && activation.explicit_patterns.length > 0) { + patternsMap.set(normalizeAgentName(agent.name), activation.explicit_patterns); + } + } catch { + // Skip agents that fail to load + } + } + + return patternsMap; + }; + + const primaryAgentResolver = new PrimaryAgentResolver( + getProjectConfig, + listPrimaryAgents, + loadExplicitPatterns, + ); const loadAutoConfig = async () => { try { diff --git a/apps/mcp-server/src/keyword/keyword.types.ts b/apps/mcp-server/src/keyword/keyword.types.ts index 1895bded..48345df0 100644 --- a/apps/mcp-server/src/keyword/keyword.types.ts +++ b/apps/mcp-server/src/keyword/keyword.types.ts @@ -311,7 +311,13 @@ export interface IncludedAgent { } /** Source of Primary Agent selection */ -export type PrimaryAgentSource = 'explicit' | 'config' | 'intent' | 'context' | 'default'; +export type PrimaryAgentSource = + | 'explicit' + | 'explicit_patterns' + | 'config' + | 'intent' + | 'context' + | 'default'; /** Result of Primary Agent resolution */ export interface PrimaryAgentResolutionResult { diff --git a/apps/mcp-server/src/keyword/primary-agent-resolver.ts b/apps/mcp-server/src/keyword/primary-agent-resolver.ts index 60e21384..ac49e092 100644 --- a/apps/mcp-server/src/keyword/primary-agent-resolver.ts +++ b/apps/mcp-server/src/keyword/primary-agent-resolver.ts @@ -3,10 +3,12 @@ * * Resolves which Primary Agent to use based on: * 1. Explicit request in prompt (highest priority) - * 2. Project configuration - * 3. Intent analysis (prompt content analysis) - * 4. Context (file path, project type) - * 5. Default fallback (software-engineer) + * 2. Explicit patterns from agent JSON activation fields + * 3. Recommended agent from PLAN mode + * 4. Project configuration + * 5. Intent analysis (prompt content analysis) + * 6. Context (file path, project type) + * 7. Default fallback (software-engineer) * * This is the main entry point for agent resolution. * Resolution logic is delegated to mode-specific strategies. @@ -19,6 +21,7 @@ import { type PrimaryAgentResolutionResult, type ResolutionContext, } from './keyword.types'; +import type { ExplicitPatternsMap } from './explicit-pattern-matcher'; import { EvalAgentStrategy, PlanAgentStrategy, @@ -26,6 +29,7 @@ import { type ResolutionStrategy, type GetProjectConfigFn, type ListPrimaryAgentsFn, + type LoadExplicitPatternsFn, type StrategyContext, } from './strategies'; @@ -47,9 +51,13 @@ export class PrimaryAgentResolver { private readonly planStrategy: ResolutionStrategy; private readonly actStrategy: ResolutionStrategy; + /** Cached explicit patterns map (loaded once, reused across calls) */ + private explicitPatternsCache: ExplicitPatternsMap | null = null; + constructor( private readonly getProjectConfig: GetProjectConfigFn, private readonly listPrimaryAgents: ListPrimaryAgentsFn, + private readonly loadExplicitPatterns?: LoadExplicitPatternsFn, ) { this.evalStrategy = new EvalAgentStrategy(); this.planStrategy = new PlanAgentStrategy(); @@ -81,6 +89,9 @@ export class PrimaryAgentResolver { const allAgents = await this.safeListPrimaryAgents(); const availableAgents = await this.filterExcludedAgents(allAgents); + // Load explicit patterns (cached after first call) + const explicitPatternsMap = await this.safeLoadExplicitPatterns(); + // Build strategy context const strategyContext: StrategyContext = { prompt, @@ -88,6 +99,7 @@ export class PrimaryAgentResolver { context, recommendedActAgent, isRecommendation, + explicitPatternsMap, }; // Delegate to mode-specific strategy @@ -168,4 +180,29 @@ export class PrimaryAgentResolver { return [...ALL_PRIMARY_AGENTS]; } } + + /** + * Safely load explicit patterns with caching. + * Returns empty map on error or if no loader is provided. + */ + private async safeLoadExplicitPatterns(): Promise { + if (this.explicitPatternsCache) { + return this.explicitPatternsCache; + } + + if (!this.loadExplicitPatterns) { + return new Map(); + } + + try { + this.explicitPatternsCache = await this.loadExplicitPatterns(); + this.logger.debug(`Loaded explicit patterns for ${this.explicitPatternsCache.size} agents`); + return this.explicitPatternsCache; + } catch (error) { + this.logger.warn( + `Failed to load explicit patterns: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + return new Map(); + } + } } diff --git a/apps/mcp-server/src/keyword/strategies/act-agent.strategy.spec.ts b/apps/mcp-server/src/keyword/strategies/act-agent.strategy.spec.ts index af3a7680..b3e2f9b9 100644 --- a/apps/mcp-server/src/keyword/strategies/act-agent.strategy.spec.ts +++ b/apps/mcp-server/src/keyword/strategies/act-agent.strategy.spec.ts @@ -63,6 +63,77 @@ describe('ActAgentStrategy', () => { }); }); + describe('explicit_patterns matching', () => { + const explicitPatternsMap = new Map([ + ['agent-architect', ['create agent', 'new agent', 'agent creation']], + ['test-engineer', ['write test code', 'unit test', 'with TDD']], + ['security-engineer', ['fix security vulnerability', 'implement JWT']], + ]); + + it('should match explicit_patterns from agent JSON', async () => { + const result = await strategy.resolve( + createActContext({ + prompt: 'We need to create agent for the new workflow', + explicitPatternsMap, + }), + ); + + expect(result.agentName).toBe('agent-architect'); + expect(result.source).toBe('explicit_patterns'); + expect(result.confidence).toBeGreaterThanOrEqual(0.9); + }); + + it('should prefer explicit request over explicit_patterns', async () => { + const result = await strategy.resolve( + createActContext({ + prompt: 'use backend-developer to create agent definitions', + explicitPatternsMap, + }), + ); + + expect(result.agentName).toBe('backend-developer'); + expect(result.source).toBe('explicit'); + }); + + it('should prefer explicit_patterns over recommended agent', async () => { + const result = await strategy.resolve( + createActContext({ + prompt: 'implement JWT authentication for the API', + recommendedActAgent: 'backend-developer', + explicitPatternsMap, + }), + ); + + expect(result.agentName).toBe('security-engineer'); + expect(result.source).toBe('explicit_patterns'); + }); + + it('should skip unavailable agents in explicit_patterns', async () => { + const result = await strategy.resolve( + createActContext({ + prompt: 'create agent for the workflow', + availableAgents: ['backend-developer', 'software-engineer'], + explicitPatternsMap, + }), + ); + + // agent-architect not available, should fall through + expect(result.agentName).not.toBe('agent-architect'); + }); + + it('should fall through when no explicit_patterns match', async () => { + const result = await strategy.resolve( + createActContext({ + prompt: 'refactor the login page', + explicitPatternsMap, + }), + ); + + // No pattern matches, should fall through to other resolution steps + expect(result.source).not.toBe('explicit_patterns'); + }); + }); + describe('recommended agent from PLAN mode', () => { it('should use recommended agent when available', async () => { const result = await strategy.resolve( diff --git a/apps/mcp-server/src/keyword/strategies/act-agent.strategy.ts b/apps/mcp-server/src/keyword/strategies/act-agent.strategy.ts index 486b9a6b..c385cc7b 100644 --- a/apps/mcp-server/src/keyword/strategies/act-agent.strategy.ts +++ b/apps/mcp-server/src/keyword/strategies/act-agent.strategy.ts @@ -23,6 +23,7 @@ import { META_AGENT_DISCUSSION_PATTERNS, type IntentPattern, } from '../patterns'; +import { matchExplicitPatterns } from '../explicit-pattern-matcher'; import type { ResolutionStrategy, StrategyContext, @@ -154,6 +155,17 @@ export class ActAgentStrategy implements ResolutionStrategy { return explicit; } + // 1.5. Check explicit_patterns from agent JSON activation fields + if (ctx.explicitPatternsMap && ctx.explicitPatternsMap.size > 0) { + const patternMatch = matchExplicitPatterns(prompt, ctx.explicitPatternsMap, availableAgents); + if (patternMatch) { + this.logger.debug( + `Explicit pattern match: ${patternMatch.agentName} (${patternMatch.reason})`, + ); + return patternMatch; + } + } + // 2. Use recommended agent from PLAN mode if provided if (recommendedActAgent && availableAgents.includes(recommendedActAgent)) { this.logger.debug(`Using recommended agent from PLAN: ${recommendedActAgent}`); diff --git a/apps/mcp-server/src/keyword/strategies/index.ts b/apps/mcp-server/src/keyword/strategies/index.ts index a78c8cf3..8aaa7922 100644 --- a/apps/mcp-server/src/keyword/strategies/index.ts +++ b/apps/mcp-server/src/keyword/strategies/index.ts @@ -13,6 +13,7 @@ export type { ProjectConfig, GetProjectConfigFn, ListPrimaryAgentsFn, + LoadExplicitPatternsFn, } from './resolution-strategy.interface'; // Strategies diff --git a/apps/mcp-server/src/keyword/strategies/resolution-strategy.interface.ts b/apps/mcp-server/src/keyword/strategies/resolution-strategy.interface.ts index 09b29684..b702070b 100644 --- a/apps/mcp-server/src/keyword/strategies/resolution-strategy.interface.ts +++ b/apps/mcp-server/src/keyword/strategies/resolution-strategy.interface.ts @@ -6,6 +6,7 @@ */ import type { PrimaryAgentResolutionResult, ResolutionContext } from '../keyword.types'; +import type { ExplicitPatternsMap } from '../explicit-pattern-matcher'; /** * Project config interface for Primary Agent configuration. @@ -25,6 +26,12 @@ export type GetProjectConfigFn = () => Promise; */ export type ListPrimaryAgentsFn = () => Promise; +/** + * Function type for loading explicit patterns from agent JSON files. + * Returns a map of agent name → explicit_patterns arrays. + */ +export type LoadExplicitPatternsFn = () => Promise; + /** * Context passed to resolution strategies. */ @@ -34,6 +41,8 @@ export interface StrategyContext { readonly context?: ResolutionContext; readonly recommendedActAgent?: string; readonly isRecommendation?: boolean; + /** Map of agent name → explicit_patterns from agent JSON activation fields */ + readonly explicitPatternsMap?: ExplicitPatternsMap; } /**