11/**
22 * Built-in skill-trigger evaluator.
33 *
4- * Detects whether the agent invoked a named skill as its first tool call .
5- * Supports multiple provider kinds via static tool-name mappings.
6- * For providers not covered here, use a code-grader instead .
4+ * Detects whether the agent invoked a named skill during a session .
5+ * Works with canonical tool names produced by normalizeToolCall() — no
6+ * provider-specific matching logic needed .
77 *
88 * Detection logic:
9- * - Only the FIRST tool call matters .
10- * - Skill tool: checks input.[skillInputField] contains the skill name (case-sensitive substring) .
11- * - Read tool: checks input.[readInputField] contains the skill name (case-sensitive substring) .
12- * - Any other tool as first call means the skill was not triggered .
9+ * - Scans ALL tool calls (not just the first) for skill invocation evidence .
10+ * - Skill tool: checks `tool === 'Skill'` and ` input.skill` contains the skill name.
11+ * - Read tool: checks `tool === 'Read'` and ` input.file_path` contains a skills/ path .
12+ * - Fallback: checks tool output for skill file path references .
1313 * - Supports negative cases via should_trigger: false.
1414 *
15- * To add a new provider:
16- * 1. Create a ToolMatcher with the provider's tool names and input fields.
17- * 2. Add entries to PROVIDER_TOOL_SEMANTICS mapping the provider kind(s) to the matcher.
18- * 3. If the provider's tool-call format doesn't fit the ToolMatcher model, use a code-grader instead.
15+ * Prerequisites:
16+ * All providers and import parsers must call normalizeToolCall() when
17+ * constructing ToolCall objects. This ensures canonical tool names
18+ * ("Skill", "Read", "Write", "Edit", "Bash") and canonical input field
19+ * names (input.skill, input.file_path) regardless of provider.
1920 */
2021
21- import type { ProviderKind } from '../providers/types.js' ;
2222import type { SkillTriggerEvaluatorConfig } from '../types.js' ;
2323import type { EvaluationContext , EvaluationScore , Evaluator } from './types.js' ;
2424
25- /** Tool-name semantics for different provider kinds. */
26- interface ToolMatcher {
27- /** Tool names that indicate skill invocation. */
28- readonly skillTools : readonly string [ ] ;
29- /** Input field that contains the skill name for skill tools. */
30- readonly skillInputField : string ;
31- /** Tool names that indicate file read. */
32- readonly readTools : readonly string [ ] ;
33- /** Input field that contains the skill name for read tools. */
34- readonly readInputField : string ;
35- /** Tool-name prefixes that encode the skill directly in the tool name. */
36- readonly skillToolPrefixes ?: readonly string [ ] ;
37- /** Tool-name prefixes that encode the file path directly in the tool name. */
38- readonly readToolPrefixes ?: readonly string [ ] ;
39- /** Alternate input field names that may contain the file path. */
40- readonly readInputFields ?: readonly string [ ] ;
41- }
42-
43- const CLAUDE_MATCHER : ToolMatcher = {
44- skillTools : [ 'Skill' ] ,
45- skillInputField : 'skill' ,
46- readTools : [ 'Read' ] ,
47- readInputField : 'file_path' ,
48- } ;
49-
50- /** Copilot uses ACP protocol — tool names vary by version and context. */
51- const COPILOT_MATCHER : ToolMatcher = {
52- skillTools : [ 'Skill' , 'skill' ] ,
53- skillInputField : 'skill' ,
54- readTools : [ 'Read File' , 'readFile' , 'Read' , 'readTextFile' ] ,
55- readInputField : 'file_path' ,
56- skillToolPrefixes : [ 'Using skill: ' ] ,
57- readToolPrefixes : [ 'Viewing ' ] ,
58- readInputFields : [ 'file_path' , 'path' ] ,
59- } ;
60-
61- /**
62- * Pi CLI reads skill files using the lowercase `read` tool with a `path` argument.
63- * Skills are auto-discovered from `.agents/skills/` relative to the working directory.
64- *
65- * Skill lookup order (workspace-scoped first):
66- * 1. .agents/skills/<skill-name>/SKILL.md (workspace-relative, auto-discovered)
67- * 2. ~/.agents/skills/<skill-name>/SKILL.md (global fallback)
68- */
69- const PI_CODING_AGENT_MATCHER : ToolMatcher = {
70- skillTools : [ ] ,
71- skillInputField : 'skill' ,
72- readTools : [ 'read' ] ,
73- readInputField : 'path' ,
74- readInputFields : [ 'path' , 'file_path' , 'filePath' ] ,
75- } ;
76-
77- /**
78- * Codex reads skill files via command_execution using a bash sed command containing
79- * the skill file path. The skill name appears in the command string, so we match
80- * any command_execution whose command field includes the skill name.
81- *
82- * Skill lookup order (workspace-scoped first):
83- * 1. .agents/skills/<skill-name>/SKILL.md (workspace-relative)
84- * 2. .codex/skills/<skill-name>/SKILL.md (fallback)
85- * 3. ~/.agents/skills/<skill-name>/SKILL.md (global fallback)
86- *
87- * MCP-based skill invocation (`mcp:<server>/<skill-name>`) is also supported for
88- * Codex configurations that surface skills as MCP tools.
89- */
90- const CODEX_MATCHER : ToolMatcher = {
91- skillTools : [ ] ,
92- skillInputField : 'skill' ,
93- readTools : [ 'command_execution' ] ,
94- readInputField : 'command' ,
95- skillToolPrefixes : [ 'mcp:' ] ,
96- readToolPrefixes : [ 'mcp:' ] ,
97- readInputFields : [ 'command' , 'path' , 'file_path' , 'filePath' ] ,
98- } ;
99-
100- /**
101- * Static mapping of provider kinds to their tool-name semantics.
102- * Providers not listed here fall back to CLAUDE_MATCHER.
103- */
104- const PROVIDER_TOOL_SEMANTICS : Partial < Record < ProviderKind , ToolMatcher > > = {
105- claude : CLAUDE_MATCHER ,
106- 'claude-cli' : CLAUDE_MATCHER ,
107- 'claude-sdk' : CLAUDE_MATCHER ,
108- codex : CODEX_MATCHER ,
109- 'pi-coding-agent' : PI_CODING_AGENT_MATCHER ,
110- 'pi-cli' : PI_CODING_AGENT_MATCHER ,
111- 'copilot-cli' : COPILOT_MATCHER ,
112- 'copilot-log' : COPILOT_MATCHER ,
113- 'copilot-sdk' : COPILOT_MATCHER ,
114- vscode : COPILOT_MATCHER ,
115- 'vscode-insiders' : COPILOT_MATCHER ,
116- } ;
117-
11825export class SkillTriggerEvaluator implements Evaluator {
11926 readonly kind = 'skill-trigger' ;
12027
@@ -124,19 +31,9 @@ export class SkillTriggerEvaluator implements Evaluator {
12431 this . config = config ;
12532 }
12633
127- private resolveMatcher ( providerKind : ProviderKind | undefined ) : ToolMatcher {
128- if ( providerKind ) {
129- const match = PROVIDER_TOOL_SEMANTICS [ providerKind ] ;
130- if ( match ) return match ;
131- }
132- return CLAUDE_MATCHER ;
133- }
134-
13534 evaluate ( context : EvaluationContext ) : EvaluationScore {
13635 const skillName = this . config . skill ;
13736 const shouldTrigger = this . config . should_trigger !== false ;
138- const providerKind = context . provider ?. kind as ProviderKind | undefined ;
139- const matcher = this . resolveMatcher ( providerKind ) ;
14037
14138 const allToolCalls = ( context . output ?? [ ] ) . flatMap ( ( msg ) => msg . toolCalls ?? [ ] ) ;
14239
@@ -147,42 +44,23 @@ export class SkillTriggerEvaluator implements Evaluator {
14744 const toolName = toolCall . tool ?? '' ;
14845 const input = ( toolCall . input ?? { } ) as Record < string , unknown > ;
14946
150- if ( matcher . skillTools . includes ( toolName ) ) {
151- const skillArg = String ( input [ matcher . skillInputField ] ?? '' ) ;
47+ if ( toolName === 'Skill' ) {
48+ const skillArg = String ( input . skill ?? '' ) ;
15249 if ( skillArg . includes ( skillName ) ) {
15350 triggered = true ;
154- evidence = `Skill tool invoked with ${ matcher . skillInputField } ="${ skillArg } "` ;
51+ evidence = `Skill tool invoked with skill ="${ skillArg } "` ;
15552 break ;
15653 }
157- } else if (
158- matcher . skillToolPrefixes ?. some (
159- ( prefix ) => toolName . startsWith ( prefix ) && toolName . includes ( skillName ) ,
160- )
161- ) {
162- triggered = true ;
163- evidence = `Skill tool invoked via tool name "${ toolName } "` ;
164- break ;
165- } else if ( matcher . readTools . includes ( toolName ) ) {
166- const filePath = this . readPathFromInput ( input , matcher ) ;
167- if ( filePath . includes ( skillName ) ) {
54+ } else if ( toolName === 'Read' ) {
55+ const filePath = String ( input . file_path ?? '' ) ;
56+ if ( filePath . includes ( `skills/${ skillName } /` ) ) {
16857 triggered = true ;
16958 evidence = `Read tool loaded skill file: ${ filePath } ` ;
17059 break ;
17160 }
172- } else if (
173- matcher . readToolPrefixes ?. some (
174- ( prefix ) => toolName . startsWith ( prefix ) && toolName . includes ( skillName ) ,
175- )
176- ) {
177- triggered = true ;
178- evidence = `Read tool loaded skill file via tool name "${ toolName } "` ;
179- break ;
18061 }
18162
18263 // Fallback: check if a tool's output contains a skill file path.
183- // Some providers (e.g., copilot-sdk) discover skill content via search
184- // tools (grep/glob) whose inputs don't reference the skill name, but
185- // whose outputs include skill file paths like ".agents/skills/<name>/SKILL.md".
18664 if ( ! triggered && toolCall . output != null ) {
18765 const outputStr =
18866 typeof toolCall . output === 'string' ? toolCall . output : JSON . stringify ( toolCall . output ) ;
@@ -228,15 +106,4 @@ export class SkillTriggerEvaluator implements Evaluator {
228106 expectedAspectCount : 1 ,
229107 } ;
230108 }
231-
232- private readPathFromInput ( input : Record < string , unknown > , matcher : ToolMatcher ) : string {
233- const fields = matcher . readInputFields ?? [ matcher . readInputField ] ;
234- for ( const field of fields ) {
235- const value = input [ field ] ;
236- if ( value !== undefined && value !== null ) {
237- return String ( value ) ;
238- }
239- }
240- return '' ;
241- }
242109}
0 commit comments