Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions apps/mcp-server/src/keyword/explicit-pattern-matcher.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
65 changes: 65 additions & 0 deletions apps/mcp-server/src/keyword/explicit-pattern-matcher.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>;

/**
* 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}"`,
};
}
26 changes: 25 additions & 1 deletion apps/mcp-server/src/keyword/keyword.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -85,7 +86,30 @@ export const KEYWORD_SERVICE = 'KEYWORD_SERVICE';
return primaryAgents;
};

const primaryAgentResolver = new PrimaryAgentResolver(getProjectConfig, listPrimaryAgents);
const loadExplicitPatterns = async (): Promise<ExplicitPatternsMap> => {
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 {
Expand Down
8 changes: 7 additions & 1 deletion apps/mcp-server/src/keyword/keyword.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
45 changes: 41 additions & 4 deletions apps/mcp-server/src/keyword/primary-agent-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -19,13 +21,15 @@ import {
type PrimaryAgentResolutionResult,
type ResolutionContext,
} from './keyword.types';
import type { ExplicitPatternsMap } from './explicit-pattern-matcher';
import {
EvalAgentStrategy,
PlanAgentStrategy,
ActAgentStrategy,
type ResolutionStrategy,
type GetProjectConfigFn,
type ListPrimaryAgentsFn,
type LoadExplicitPatternsFn,
type StrategyContext,
} from './strategies';

Expand All @@ -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();
Expand Down Expand Up @@ -81,13 +89,17 @@ 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,
availableAgents,
context,
recommendedActAgent,
isRecommendation,
explicitPatternsMap,
};

// Delegate to mode-specific strategy
Expand Down Expand Up @@ -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<ExplicitPatternsMap> {
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();
}
}
}
Loading