diff --git a/ts/packages/actionGrammar/src/agentGrammarRegistry.ts b/ts/packages/actionGrammar/src/agentGrammarRegistry.ts index 81f8558ae..c01b1f171 100644 --- a/ts/packages/actionGrammar/src/agentGrammarRegistry.ts +++ b/ts/packages/actionGrammar/src/agentGrammarRegistry.ts @@ -46,9 +46,13 @@ export class AgentGrammar { * They are merged as alternatives to enable cache hits on similar requests. * * @param agrText Grammar rules in .agr format (from grammarGenerator) + * @param checkedVariables Optional set of variable names with validation (checked_wildcard paramSpec) * @returns Success status and any errors */ - addGeneratedRules(agrText: string): { + addGeneratedRules( + agrText: string, + checkedVariables?: Set, + ): { success: boolean; errors: string[]; unresolvedEntities?: string[]; @@ -71,6 +75,11 @@ export class AgentGrammar { }; } + // Add checked variables if provided + if (checkedVariables && checkedVariables.size > 0) { + newGrammar.checkedVariables = checkedVariables; + } + // Validate entity references const unresolvedEntities = this.validateEntityReferences(newGrammar); if (unresolvedEntities.length > 0) { @@ -95,6 +104,19 @@ export class AgentGrammar { mergedGrammar.entities = Array.from(existingEntities); } + // Merge checked variables + if (newGrammar.checkedVariables || this.grammar.checkedVariables) { + const existingChecked = new Set( + this.grammar.checkedVariables || [], + ); + if (newGrammar.checkedVariables) { + for (const varName of newGrammar.checkedVariables) { + existingChecked.add(varName); + } + } + mergedGrammar.checkedVariables = existingChecked; + } + // Recompile NFA try { const newNFA = compileGrammarToNFA( @@ -239,6 +261,10 @@ export interface AgentMatchResult { matched: boolean; agentId?: string; // Which agent matched captures: Map; + // Priority counts for sorting matches + fixedStringPartCount?: number; + checkedWildcardCount?: number; + uncheckedWildcardCount?: number; // Debugging info attemptedAgents?: string[]; tokensConsumed?: number | undefined; @@ -390,6 +416,9 @@ export class AgentGrammarRegistry { matched: true, agentId: id, captures: result.captures, + fixedStringPartCount: result.fixedStringPartCount, + checkedWildcardCount: result.checkedWildcardCount, + uncheckedWildcardCount: result.uncheckedWildcardCount, attemptedAgents, tokensConsumed: result.tokensConsumed, }; diff --git a/ts/packages/actionGrammar/src/generation/index.ts b/ts/packages/actionGrammar/src/generation/index.ts index 96d1e9529..a5abda62c 100644 --- a/ts/packages/actionGrammar/src/generation/index.ts +++ b/ts/packages/actionGrammar/src/generation/index.ts @@ -37,6 +37,17 @@ import { ClaudeGrammarGenerator, GrammarAnalysis } from "./grammarGenerator.js"; import { loadSchemaInfo } from "./schemaReader.js"; import { GrammarTestCase } from "./testTypes.js"; +/** + * Convert plural parameter names to singular for grammar variable names + * e.g., "artists" -> "artist" + */ +function getSingularVariableName(paramName: string): string { + if (paramName.endsWith("s") && paramName.length > 1) { + return paramName.slice(0, -1); + } + return paramName; +} + /** * Cache population API for agentServer integration */ @@ -66,6 +77,8 @@ export interface CachePopulationResult { success: boolean; // The generated grammar rule text (if successful) generatedRule?: string; + // Checked variable names (parameters with checked_wildcard paramSpec) + checkedVariables?: Set; // Reason for rejection (if not successful) rejectionReason?: string; // The grammar analysis performed @@ -117,12 +130,33 @@ export async function populateCache( schemaInfo, ); - return { + // Extract checked variables from the action parameters + const checkedVariables = new Set(); + const actionInfo = schemaInfo.actions.get(testCase.action.actionName); + if (actionInfo) { + for (const [paramName, paramInfo] of actionInfo.parameters) { + if (paramInfo.paramSpec === "checked_wildcard") { + // Handle array parameters (convert plural to singular) + const varName = Array.isArray( + testCase.action.parameters[paramName], + ) + ? getSingularVariableName(paramName) + : paramName; + checkedVariables.add(varName); + } + } + } + + const result: CachePopulationResult = { success: true, generatedRule: grammarRule, analysis, warnings: [], }; + if (checkedVariables.size > 0) { + result.checkedVariables = checkedVariables; + } + return result; } catch (error) { return { success: false, diff --git a/ts/packages/actionGrammar/src/grammarMetadata.ts b/ts/packages/actionGrammar/src/grammarMetadata.ts new file mode 100644 index 000000000..6b12e4233 --- /dev/null +++ b/ts/packages/actionGrammar/src/grammarMetadata.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import fs from "fs"; +import { + fromJSONParsedActionSchema, + ParsedActionSchemaJSON, + ActionSchemaTypeDefinition, +} from "@typeagent/action-schema"; +import { Grammar } from "./grammarTypes.js"; + +/** + * Enrich a Grammar with checked variable metadata from a .pas.json schema file + * + * Reads the schema file to find parameters with checked_wildcard paramSpec + * and adds their variable names to grammar.checkedVariables + * + * @param grammar The grammar to enrich + * @param schemaPath Path to the .pas.json file + * @returns The enriched grammar (modifies in place and returns for chaining) + */ +export function enrichGrammarWithCheckedVariables( + grammar: Grammar, + schemaPath: string, +): Grammar { + try { + const jsonContent = fs.readFileSync(schemaPath, "utf8"); + const json: ParsedActionSchemaJSON = JSON.parse(jsonContent); + const parsedSchema = fromJSONParsedActionSchema(json); + + const checkedVariables = new Set(); + + // Process each action to find parameters with checked_wildcard paramSpec + for (const [ + _actionName, + actionDef, + ] of parsedSchema.actionSchemas.entries()) { + const paramSpecs = (actionDef as ActionSchemaTypeDefinition) + .paramSpecs; + + if (!paramSpecs || typeof paramSpecs !== "object") { + continue; + } + + // Find all parameters with checked_wildcard paramSpec + for (const [paramName, paramSpec] of Object.entries(paramSpecs)) { + if (paramSpec === "checked_wildcard") { + // Add the parameter name as a checked variable + // Also handle array element specs (e.g., "artists.*" -> "artist") + if (paramName.endsWith(".*")) { + const baseName = paramName.slice(0, -2); + // Convert plural to singular for grammar variable names + const singularName = getSingularVariableName(baseName); + checkedVariables.add(singularName); + } else { + checkedVariables.add(paramName); + } + } + } + } + + // Add to grammar + if (checkedVariables.size > 0) { + grammar.checkedVariables = checkedVariables; + } + + return grammar; + } catch (error) { + // If schema file doesn't exist or can't be read, return grammar unchanged + console.warn( + `Could not read schema file ${schemaPath} for checked variable metadata: ${error}`, + ); + return grammar; + } +} + +/** + * Convert plural parameter names to singular for grammar variable names + * e.g., "artists" -> "artist", "devices" -> "device" + */ +function getSingularVariableName(plural: string): string { + if (plural.endsWith("ies")) { + return plural.slice(0, -3) + "y"; + } else if (plural.endsWith("ses") || plural.endsWith("shes")) { + return plural.slice(0, -2); + } else if (plural.endsWith("s")) { + return plural.slice(0, -1); + } + return plural; +} diff --git a/ts/packages/actionGrammar/src/grammarTypes.ts b/ts/packages/actionGrammar/src/grammarTypes.ts index bc4d4a80c..c633f0401 100644 --- a/ts/packages/actionGrammar/src/grammarTypes.ts +++ b/ts/packages/actionGrammar/src/grammarTypes.ts @@ -55,6 +55,7 @@ export type GrammarRule = { export type Grammar = { rules: GrammarRule[]; entities?: string[] | undefined; // Entity types this grammar depends on (e.g. ["Ordinal", "CalendarDate"]) + checkedVariables?: Set | undefined; // Variable names with validation (checked_wildcard paramSpec) }; /** diff --git a/ts/packages/actionGrammar/src/index.ts b/ts/packages/actionGrammar/src/index.ts index 0645717ea..3fd5f1a26 100644 --- a/ts/packages/actionGrammar/src/index.ts +++ b/ts/packages/actionGrammar/src/index.ts @@ -35,9 +35,19 @@ export { } from "./dynamicGrammarLoader.js"; // NFA system -export type { NFA, NFAState, NFATransition } from "./nfa.js"; -export { matchNFA, type NFAMatchResult } from "./nfaInterpreter.js"; +export type { + NFA, + NFAState, + NFATransition, + AcceptStatePriorityHint, +} from "./nfa.js"; +export { + matchNFA, + sortNFAMatches, + type NFAMatchResult, +} from "./nfaInterpreter.js"; export { compileGrammarToNFA } from "./nfaCompiler.js"; +export { enrichGrammarWithCheckedVariables } from "./grammarMetadata.js"; // Agent Grammar Registry export type { AgentMatchResult } from "./agentGrammarRegistry.js"; diff --git a/ts/packages/actionGrammar/src/nfa.ts b/ts/packages/actionGrammar/src/nfa.ts index ab3277d71..a4311faef 100644 --- a/ts/packages/actionGrammar/src/nfa.ts +++ b/ts/packages/actionGrammar/src/nfa.ts @@ -28,11 +28,24 @@ export interface NFATransition { // For wildcard transitions: metadata about the variable variable?: string | undefined; typeName?: string | undefined; + checked?: boolean | undefined; // true if wildcard has validation (entity type or checked_wildcard paramSpec) // Target state to: number; } +/** + * Priority hint for an accepting state + * Used when multiple grammar rules share an accepting state (e.g., in DFA construction) + * Tracks the best-case priority achievable through this state + */ +export interface AcceptStatePriorityHint { + // Best achievable counts for any path leading to this state + minFixedStringPartCount: number; // Highest fixed string count from any rule + maxCheckedWildcardCount: number; // Most checked wildcards from any rule + minUncheckedWildcardCount: number; // Fewest unchecked wildcards from any rule +} + /** * An NFA state with outgoing transitions */ @@ -43,6 +56,10 @@ export interface NFAState { // If true, this is an accepting/final state accepting: boolean; + // Optional: Priority hint for accepting states (used in DFA minimization/merging) + // When multiple rules merge into one accepting state, this tracks the best possible priority + priorityHint?: AcceptStatePriorityHint | undefined; + // Optional: capture variable value when reaching this state capture?: | { @@ -88,12 +105,20 @@ export class NFABuilder { tokens?: string[], variable?: string, typeName?: string, + checked?: boolean, ): void { const state = this.states[from]; if (!state) { throw new Error(`State ${from} does not exist`); } - state.transitions.push({ type, to, tokens, variable, typeName }); + state.transitions.push({ + type, + to, + tokens, + variable, + typeName, + checked, + }); } addTokenTransition(from: number, to: number, tokens: string[]): void { @@ -109,8 +134,17 @@ export class NFABuilder { to: number, variable: string, typeName?: string, + checked?: boolean, ): void { - this.addTransition(from, to, "wildcard", undefined, variable, typeName); + this.addTransition( + from, + to, + "wildcard", + undefined, + variable, + typeName, + checked, + ); } build(startState: number, name?: string): NFA { diff --git a/ts/packages/actionGrammar/src/nfaCompiler.ts b/ts/packages/actionGrammar/src/nfaCompiler.ts index 1a295e873..94aba7d2b 100644 --- a/ts/packages/actionGrammar/src/nfaCompiler.ts +++ b/ts/packages/actionGrammar/src/nfaCompiler.ts @@ -46,6 +46,7 @@ export function compileGrammarToNFA(grammar: Grammar, name?: string): NFA { rule, ruleEntry, acceptState, + grammar.checkedVariables, ); // If rule didn't connect to accept state, add epsilon transition @@ -66,6 +67,7 @@ function compileRuleFromState( rule: GrammarRule, startState: number, finalState: number, + checkedVariables?: Set, ): number { let currentState = startState; @@ -75,7 +77,13 @@ function compileRuleFromState( const isLast = i === rule.parts.length - 1; const nextState = isLast ? finalState : builder.createState(false); - currentState = compilePart(builder, part, currentState, nextState); + currentState = compilePart( + builder, + part, + currentState, + nextState, + checkedVariables, + ); } return currentState; @@ -90,19 +98,32 @@ function compilePart( part: GrammarPart, fromState: number, toState: number, + checkedVariables?: Set, ): number { switch (part.type) { case "string": return compileStringPart(builder, part, fromState, toState); case "wildcard": - return compileWildcardPart(builder, part, fromState, toState); + return compileWildcardPart( + builder, + part, + fromState, + toState, + checkedVariables, + ); case "number": return compileNumberPart(builder, part, fromState, toState); case "rules": - return compileRulesPart(builder, part, fromState, toState); + return compileRulesPart( + builder, + part, + fromState, + toState, + checkedVariables, + ); default: throw new Error(`Unknown part type: ${(part as any).type}`); @@ -145,7 +166,16 @@ function compileWildcardPart( part: VarStringPart, fromState: number, toState: number, + checkedVariables?: Set, ): number { + // Determine if this wildcard is checked + // A wildcard is checked if: + // 1. It has a non-string typeName (entity type like MusicDevice, Ordinal, etc.) + // 2. It's in the checkedVariables set (has checked_wildcard paramSpec) + const hasEntityType = part.typeName && part.typeName !== "string"; + const hasCheckedParamSpec = checkedVariables?.has(part.variable) ?? false; + const isChecked = hasEntityType || hasCheckedParamSpec; + if (part.optional) { // Optional: can skip via epsilon or match via wildcard builder.addEpsilonTransition(fromState, toState); @@ -154,6 +184,7 @@ function compileWildcardPart( toState, part.variable, part.typeName, + isChecked, ); return toState; } @@ -164,6 +195,7 @@ function compileWildcardPart( toState, part.variable, part.typeName, + isChecked, ); return toState; } @@ -202,6 +234,7 @@ function compileRulesPart( part: RulesPart, fromState: number, toState: number, + checkedVariables?: Set, ): number { if (part.rules.length === 0) { // Empty rules - epsilon transition @@ -220,7 +253,13 @@ function compileRulesPart( for (const rule of part.rules) { const ruleEntry = builder.createState(false); builder.addEpsilonTransition(nestedEntry, ruleEntry); - compileRuleFromState(builder, rule, ruleEntry, nestedExit); + compileRuleFromState( + builder, + rule, + ruleEntry, + nestedExit, + checkedVariables, + ); } // Connect exit diff --git a/ts/packages/actionGrammar/src/nfaInterpreter.ts b/ts/packages/actionGrammar/src/nfaInterpreter.ts index 9c7df5fff..fc62b4155 100644 --- a/ts/packages/actionGrammar/src/nfaInterpreter.ts +++ b/ts/packages/actionGrammar/src/nfaInterpreter.ts @@ -14,6 +14,10 @@ import { globalEntityRegistry } from "./entityRegistry.js"; export interface NFAMatchResult { matched: boolean; captures: Map; + // Priority counts for sorting matches + fixedStringPartCount: number; // # of token transitions taken + checkedWildcardCount: number; // # of wildcard transitions with type constraints + uncheckedWildcardCount: number; // # of wildcard transitions without type constraints // Debugging info visitedStates?: number[] | undefined; tokensConsumed?: number | undefined; @@ -24,11 +28,24 @@ interface NFAExecutionState { tokenIndex: number; captures: Map; path: number[]; // For debugging + // Priority counts for this execution path + fixedStringPartCount: number; + checkedWildcardCount: number; + uncheckedWildcardCount: number; } /** * Run an NFA against a sequence of tokens * Uses epsilon-closure and parallel state tracking + * + * When multiple grammar rules match, this function: + * 1. Follows all legal transitions in parallel (multiple execution threads) + * 2. Collects ALL threads that reach accepting states when input is exhausted + * 3. Sorts accepting threads by priority (fixed strings > checked wildcards > unchecked) + * 4. Returns the highest-priority match + * + * Note: For future DFA construction where accepting states may be merged, + * use NFAState.priorityHint to track the best achievable priority for merged states. */ export function matchNFA( nfa: NFA, @@ -42,6 +59,9 @@ export function matchNFA( tokenIndex: 0, captures: new Map(), path: [nfa.startState], + fixedStringPartCount: 0, + checkedWildcardCount: 0, + uncheckedWildcardCount: 0, }, ]); @@ -79,6 +99,9 @@ export function matchNFA( return { matched: false, captures: new Map(), + fixedStringPartCount: 0, + checkedWildcardCount: 0, + uncheckedWildcardCount: 0, visitedStates: debug ? Array.from(allVisitedStates) : undefined, tokensConsumed: tokenIndex, }; @@ -95,22 +118,35 @@ export function matchNFA( } } - // Check if any current state is accepting + // Collect ALL accepting threads (multiple rules may match) + const acceptingThreads: NFAMatchResult[] = []; for (const state of currentStates) { if (nfa.acceptingStates.includes(state.stateId)) { - return { + acceptingThreads.push({ matched: true, captures: state.captures, + fixedStringPartCount: state.fixedStringPartCount, + checkedWildcardCount: state.checkedWildcardCount, + uncheckedWildcardCount: state.uncheckedWildcardCount, visitedStates: debug ? Array.from(allVisitedStates) : undefined, tokensConsumed: tokens.length, - }; + }); } } + // If any threads reached accepting states, return the best one by priority + if (acceptingThreads.length > 0) { + const sorted = sortNFAMatches(acceptingThreads); + return sorted[0]; // Best match by priority rules + } + // Processed all tokens but not in accepting state return { matched: false, captures: new Map(), + fixedStringPartCount: 0, + checkedWildcardCount: 0, + uncheckedWildcardCount: 0, visitedStates: debug ? Array.from(allVisitedStates) : undefined, tokensConsumed: tokens.length, }; @@ -136,6 +172,9 @@ function tryTransition( tokenIndex: tokenIndex + 1, captures: new Map(currentState.captures), path: [...currentState.path, trans.to], + fixedStringPartCount: currentState.fixedStringPartCount + 1, + checkedWildcardCount: currentState.checkedWildcardCount, + uncheckedWildcardCount: currentState.uncheckedWildcardCount, }; } return undefined; @@ -200,11 +239,26 @@ function tryTransition( } } + // Determine if this is a checked or unchecked wildcard + // A wildcard is checked if: + // 1. The transition has checked=true (from checked_wildcard paramSpec or entity type) + // 2. Legacy: typeName is not "string" (entity type like MusicDevice, Ordinal) + const isChecked = + trans.checked === true || + (trans.typeName && trans.typeName !== "string"); + return { stateId: trans.to, tokenIndex: tokenIndex + 1, captures: newCaptures, path: [...currentState.path, trans.to], + fixedStringPartCount: currentState.fixedStringPartCount, + checkedWildcardCount: isChecked + ? currentState.checkedWildcardCount + 1 + : currentState.checkedWildcardCount, + uncheckedWildcardCount: isChecked + ? currentState.uncheckedWildcardCount + : currentState.uncheckedWildcardCount + 1, }; case "epsilon": @@ -219,22 +273,30 @@ function tryTransition( /** * Compute epsilon closure of a set of states * Returns all states reachable via epsilon transitions + * + * IMPORTANT: Multiple execution threads can reach the same NFA state with different + * priority counts. We must preserve ALL threads, not deduplicate by state ID alone. */ function epsilonClosure( nfa: NFA, states: NFAExecutionState[], ): NFAExecutionState[] { const result: NFAExecutionState[] = []; - const visited = new Set(); + // Track visited states by (stateId, fixedCount, checkedCount, uncheckedCount) tuple + // to allow multiple threads at the same NFA state with different priority counts + const visited = new Set(); const queue = [...states]; while (queue.length > 0) { const state = queue.shift()!; - if (visited.has(state.stateId)) { + // Create unique key for this execution thread + const key = `${state.stateId}-${state.fixedStringPartCount}-${state.checkedWildcardCount}-${state.uncheckedWildcardCount}`; + + if (visited.has(key)) { continue; } - visited.add(state.stateId); + visited.add(key); result.push(state); const nfaState = nfa.states[state.stateId]; @@ -248,6 +310,9 @@ function epsilonClosure( tokenIndex: state.tokenIndex, captures: new Map(state.captures), path: [...state.path, trans.to], + fixedStringPartCount: state.fixedStringPartCount, + checkedWildcardCount: state.checkedWildcardCount, + uncheckedWildcardCount: state.uncheckedWildcardCount, }); } } @@ -325,3 +390,40 @@ export function printMatchResult( return lines.join("\n"); } + +/** + * Sort NFA match results by priority + * + * Priority rules (highest to lowest): + * 1. Rules without unchecked wildcards always beat rules with them + * 2. More fixed string parts > fewer fixed string parts + * 3. More checked wildcards > fewer checked wildcards + * 4. Fewer unchecked wildcards > more unchecked wildcards + */ +export function sortNFAMatches(matches: T[]): T[] { + return matches.sort((a, b) => { + // Rule 1: Prefer matches without unchecked wildcards + if (a.uncheckedWildcardCount === 0) { + if (b.uncheckedWildcardCount !== 0) { + return -1; // a wins (no unchecked wildcards) + } + } else { + if (b.uncheckedWildcardCount === 0) { + return 1; // b wins (no unchecked wildcards) + } + } + + // Rule 2: Prefer more fixed string parts + if (a.fixedStringPartCount !== b.fixedStringPartCount) { + return b.fixedStringPartCount - a.fixedStringPartCount; + } + + // Rule 3: Prefer more checked wildcards + if (a.checkedWildcardCount !== b.checkedWildcardCount) { + return b.checkedWildcardCount - a.checkedWildcardCount; + } + + // Rule 4: Prefer fewer unchecked wildcards + return a.uncheckedWildcardCount - b.uncheckedWildcardCount; + }); +} diff --git a/ts/packages/actionGrammar/test/nfaPriority.spec.ts b/ts/packages/actionGrammar/test/nfaPriority.spec.ts new file mode 100644 index 000000000..678464991 --- /dev/null +++ b/ts/packages/actionGrammar/test/nfaPriority.spec.ts @@ -0,0 +1,642 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Comprehensive tests for NFA priority system + * + * These tests verify that when multiple grammar rules match the same input, + * the NFA matcher correctly selects the highest-priority match according to: + * 1. Rules without unchecked wildcards > rules with unchecked wildcards (absolute) + * 2. More fixed string parts > fewer fixed string parts + * 3. More checked wildcards > fewer checked wildcards + * 4. Fewer unchecked wildcards > more unchecked wildcards + */ + +import { compileGrammarToNFA, matchNFA, Grammar } from "../src/index.js"; + +describe("NFA Priority System", () => { + test("Rule 1: No unchecked wildcards beats rules with unchecked wildcards", () => { + // Rule A: "play music" -> 2 fixed strings, 0 wildcards (highest priority) + // Rule B: "play $(track:string)" -> 1 fixed + 1 unchecked wildcard + const grammar: Grammar = { + rules: [ + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "track", + typeName: "string", + optional: false, + }, + ], + value: { + type: "object", + value: { + action: { type: "literal", value: "playWildcard" }, + }, + }, + }, + { + parts: [ + { type: "string", value: ["play"] }, + { type: "string", value: ["music"] }, + ], + value: { + type: "object", + value: { + action: { type: "literal", value: "playMusic" }, + }, + }, + }, + ], + }; + + const nfa = compileGrammarToNFA(grammar); + const result = matchNFA(nfa, ["play", "music"]); + + expect(result.matched).toBe(true); + expect(result.fixedStringPartCount).toBe(2); // Should match "play music" rule + expect(result.uncheckedWildcardCount).toBe(0); + expect(result.checkedWildcardCount).toBe(0); + }); + + test("Rule 2: More fixed strings beats fewer fixed strings", () => { + // Rule A: "play track $(name:string)" -> 2 fixed + 1 unchecked + // Rule B: "play $(track:string)" -> 1 fixed + 1 unchecked (should lose) + const grammar: Grammar = { + rules: [ + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "track", + typeName: "string", + optional: false, + }, + ], + value: { + type: "object", + value: { + action: { type: "literal", value: "playShort" }, + }, + }, + }, + { + parts: [ + { type: "string", value: ["play"] }, + { type: "string", value: ["track"] }, + { + type: "wildcard", + variable: "name", + typeName: "string", + optional: false, + }, + ], + value: { + type: "object", + value: { + action: { type: "literal", value: "playLong" }, + }, + }, + }, + ], + }; + + const nfa = compileGrammarToNFA(grammar); + const result = matchNFA(nfa, ["play", "track", "something"]); + + expect(result.matched).toBe(true); + expect(result.fixedStringPartCount).toBe(2); // Should match longer fixed string rule + expect(result.uncheckedWildcardCount).toBe(1); + }); + + test("Rule 3: More checked wildcards beats fewer checked wildcards", () => { + // Rule A: "play $(track:number) by $(artist:number)" -> 2 checked wildcards + // Rule B: "play $(track:number)" -> 1 checked wildcard (should lose) + const grammar: Grammar = { + rules: [ + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "track", + typeName: "number", + optional: false, + }, + ], + value: { + type: "object", + value: { + action: { type: "literal", value: "playOne" }, + }, + }, + }, + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "track", + typeName: "number", + optional: false, + }, + { type: "string", value: ["by"] }, + { + type: "wildcard", + variable: "artist", + typeName: "number", + optional: false, + }, + ], + value: { + type: "object", + value: { + action: { type: "literal", value: "playTwo" }, + }, + }, + }, + ], + }; + + const nfa = compileGrammarToNFA(grammar); + const result = matchNFA(nfa, ["play", "123", "by", "456"]); + + expect(result.matched).toBe(true); + expect(result.fixedStringPartCount).toBe(2); // "play" and "by" + expect(result.checkedWildcardCount).toBe(2); // Should match rule with 2 checked wildcards + expect(result.uncheckedWildcardCount).toBe(0); + }); + + test("Rule 4: Fewer unchecked wildcards beats more unchecked wildcards", () => { + // Rule A: "play $(track:string) by $(artist:string)" -> 2 unchecked + // Rule B: "play $(track:string)" -> 1 unchecked (should win) + const grammar: Grammar = { + rules: [ + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "track", + typeName: "string", + optional: false, + }, + { type: "string", value: ["by"] }, + { + type: "wildcard", + variable: "artist", + typeName: "string", + optional: false, + }, + ], + value: { + type: "object", + value: { + action: { type: "literal", value: "playTwo" }, + }, + }, + }, + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "track", + typeName: "string", + optional: false, + }, + ], + value: { + type: "object", + value: { + action: { type: "literal", value: "playOne" }, + }, + }, + }, + ], + }; + + const nfa = compileGrammarToNFA(grammar); + const result = matchNFA(nfa, ["play", "something"]); + + expect(result.matched).toBe(true); + expect(result.fixedStringPartCount).toBe(1); // "play" + expect(result.uncheckedWildcardCount).toBe(1); // Should match rule with fewer unchecked + expect(result.checkedWildcardCount).toBe(0); + }); + + test("Priority level 1 overrides level 2: No unchecked > more fixed strings", () => { + // Rule A: "play music now" -> 3 fixed strings, 0 wildcards (priority level 1) + // Rule B: "play $(x:string) $(y:string) $(z:string) $(w:string)" -> 1 fixed + 4 unchecked (priority level 2) + const grammar: Grammar = { + rules: [ + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "x", + typeName: "string", + optional: false, + }, + { + type: "wildcard", + variable: "y", + typeName: "string", + optional: false, + }, + { + type: "wildcard", + variable: "z", + typeName: "string", + optional: false, + }, + ], + value: { + type: "object", + value: { + action: { type: "literal", value: "manyWildcards" }, + }, + }, + }, + { + parts: [ + { type: "string", value: ["play"] }, + { type: "string", value: ["music"] }, + { type: "string", value: ["now"] }, + ], + value: { + type: "object", + value: { + action: { type: "literal", value: "allFixed" }, + }, + }, + }, + ], + }; + + const nfa = compileGrammarToNFA(grammar); + const result = matchNFA(nfa, ["play", "music", "now"]); + + expect(result.matched).toBe(true); + expect(result.fixedStringPartCount).toBe(3); + expect(result.uncheckedWildcardCount).toBe(0); // Level 1 wins even with fewer total tokens + expect(result.checkedWildcardCount).toBe(0); + }); + + test("checked_wildcard paramSpec creates checked wildcards", () => { + // Rule A: "play $(track:string)" with track as checked variable -> 1 checked + // Rule B: "play $(song:string)" without checked -> 1 unchecked (should lose) + const grammar: Grammar = { + rules: [ + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "song", + typeName: "string", + optional: false, + }, + ], + value: { + type: "object", + value: { + action: { type: "literal", value: "unchecked" }, + }, + }, + }, + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "track", + typeName: "string", + optional: false, + }, + ], + value: { + type: "object", + value: { + action: { type: "literal", value: "checked" }, + }, + }, + }, + ], + checkedVariables: new Set(["track"]), // Mark track as checked + }; + + const nfa = compileGrammarToNFA(grammar); + const result = matchNFA(nfa, ["play", "something"]); + + expect(result.matched).toBe(true); + expect(result.fixedStringPartCount).toBe(1); + expect(result.checkedWildcardCount).toBe(1); // Should match checked wildcard rule + expect(result.uncheckedWildcardCount).toBe(0); + }); + + test("Entity types create checked wildcards", () => { + // Rule A: "play $(n:number)" -> entity type = checked + // Rule B: "play $(track:string)" -> no entity = unchecked (should lose) + const grammar: Grammar = { + rules: [ + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "track", + typeName: "string", + optional: false, + }, + ], + value: { + type: "object", + value: { + action: { type: "literal", value: "unchecked" }, + }, + }, + }, + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "n", + typeName: "number", + optional: false, + }, + ], + value: { + type: "object", + value: { + action: { type: "literal", value: "checked" }, + }, + }, + }, + ], + }; + + const nfa = compileGrammarToNFA(grammar); + const result = matchNFA(nfa, ["play", "123"]); + + expect(result.matched).toBe(true); + expect(result.fixedStringPartCount).toBe(1); + expect(result.checkedWildcardCount).toBe(1); // Should match entity-typed rule + expect(result.uncheckedWildcardCount).toBe(0); + }); + + test("Complex scenario: All priority levels", () => { + // Test all priority levels in one grammar + const grammar: Grammar = { + rules: [ + // Lowest priority: 1 fixed + 2 unchecked wildcards + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "a", + typeName: "string", + optional: false, + }, + { + type: "wildcard", + variable: "b", + typeName: "string", + optional: false, + }, + ], + value: { + type: "object", + value: { + priority: { type: "literal", value: "lowest" }, + }, + }, + }, + // Mid priority: 1 fixed + 1 unchecked wildcard + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "track", + typeName: "string", + optional: false, + }, + ], + value: { + type: "object", + value: { priority: { type: "literal", value: "mid" } }, + }, + }, + // High priority: 1 fixed + 1 checked wildcard + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "n", + typeName: "number", + optional: false, + }, + ], + value: { + type: "object", + value: { priority: { type: "literal", value: "high" } }, + }, + }, + // Highest priority: 2 fixed strings + { + parts: [ + { type: "string", value: ["play"] }, + { type: "string", value: ["music"] }, + ], + value: { + type: "object", + value: { + priority: { type: "literal", value: "highest" }, + }, + }, + }, + ], + }; + + const nfa = compileGrammarToNFA(grammar); + const result = matchNFA(nfa, ["play", "music"]); + + expect(result.matched).toBe(true); + expect(result.fixedStringPartCount).toBe(2); + expect(result.uncheckedWildcardCount).toBe(0); + expect(result.checkedWildcardCount).toBe(0); + }); + + test("Tie-breaking: When priorities are equal, first valid match wins", () => { + // Both rules have identical priority (1 fixed + 1 unchecked wildcard) + // Should return first valid match found + const grammar: Grammar = { + rules: [ + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "track", + typeName: "string", + optional: false, + }, + ], + value: { + type: "object", + value: { rule: { type: "literal", value: "A" } }, + }, + }, + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "song", + typeName: "string", + optional: false, + }, + ], + value: { + type: "object", + value: { rule: { type: "literal", value: "B" } }, + }, + }, + ], + }; + + const nfa = compileGrammarToNFA(grammar); + const result = matchNFA(nfa, ["play", "something"]); + + expect(result.matched).toBe(true); + expect(result.fixedStringPartCount).toBe(1); + expect(result.uncheckedWildcardCount).toBe(1); + expect(result.checkedWildcardCount).toBe(0); + // With identical priorities, either match is acceptable + }); + + test("Optional parts don't affect priority when skipped", () => { + // Rule A: "play (please)? music" -> If "please" is matched: 3 fixed, else: 2 fixed + // Rule B: "play music" -> 2 fixed + const grammar: Grammar = { + rules: [ + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "rules", + optional: true, + rules: [ + { + parts: [ + { type: "string", value: ["please"] }, + ], + }, + ], + }, + { type: "string", value: ["music"] }, + ], + value: { + type: "object", + value: { rule: { type: "literal", value: "optional" } }, + }, + }, + { + parts: [ + { type: "string", value: ["play"] }, + { type: "string", value: ["music"] }, + ], + value: { + type: "object", + value: { rule: { type: "literal", value: "fixed" } }, + }, + }, + ], + }; + + const nfa = compileGrammarToNFA(grammar); + + // Without "please", both rules have same priority (2 fixed) + const result1 = matchNFA(nfa, ["play", "music"]); + expect(result1.matched).toBe(true); + expect(result1.fixedStringPartCount).toBe(2); + + // With "please", optional rule wins (3 fixed) + const result2 = matchNFA(nfa, ["play", "please", "music"]); + expect(result2.matched).toBe(true); + expect(result2.fixedStringPartCount).toBe(3); + }); + + test("Mixed checked and unchecked wildcards", () => { + // Rule A: "play $(track:string) by $(artist:number)" -> 1 unchecked + 1 checked + // Rule B: "play $(a:string) by $(b:string)" -> 2 unchecked (should lose - fewer checked) + const grammar: Grammar = { + rules: [ + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "a", + typeName: "string", + optional: false, + }, + { type: "string", value: ["by"] }, + { + type: "wildcard", + variable: "b", + typeName: "string", + optional: false, + }, + ], + value: { + type: "object", + value: { + rule: { type: "literal", value: "allUnchecked" }, + }, + }, + }, + { + parts: [ + { type: "string", value: ["play"] }, + { + type: "wildcard", + variable: "track", + typeName: "string", + optional: false, + }, + { type: "string", value: ["by"] }, + { + type: "wildcard", + variable: "artist", + typeName: "number", + optional: false, + }, + ], + value: { + type: "object", + value: { + rule: { type: "literal", value: "mixedChecked" }, + }, + }, + }, + ], + }; + + const nfa = compileGrammarToNFA(grammar); + const result = matchNFA(nfa, ["play", "something", "by", "123"]); + + expect(result.matched).toBe(true); + expect(result.fixedStringPartCount).toBe(2); // "play" and "by" + expect(result.checkedWildcardCount).toBe(1); // artist:number + expect(result.uncheckedWildcardCount).toBe(1); // track:string + }); +}); diff --git a/ts/packages/cache/src/cache/cache.ts b/ts/packages/cache/src/cache/cache.ts index 90ec5369f..d16a76259 100644 --- a/ts/packages/cache/src/cache/cache.ts +++ b/ts/packages/cache/src/cache/cache.ts @@ -408,6 +408,7 @@ export class AgentCache { const addResult = agentGrammar.addGeneratedRules( genResult.generatedRule, + genResult.checkedVariables, ); if (addResult.success) { // Sync to the grammar store used for matching diff --git a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts index 1650b0122..cf704d725 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts @@ -20,6 +20,7 @@ import { import { getAppAgentName } from "../translation/agentTranslators.js"; import { createSessionContext } from "../execute/sessionContext.js"; import { AppAgentProvider } from "../agentProvider/agentProvider.js"; +import { getPackageFilePath } from "../utils/getPackageFilePath.js"; import registerDebug from "debug"; import { DispatcherName } from "./dispatcher/dispatcherUtils.js"; import { @@ -39,6 +40,7 @@ import { grammarFromJson, AgentGrammarRegistry, compileGrammarToNFA, + enrichGrammarWithCheckedVariables, } from "action-grammar"; const debug = registerDebug("typeagent:dispatcher:agents"); @@ -332,6 +334,27 @@ export class AppAgentManager implements ActionConfigProvider { // Also add to NFA grammar registry if using NFA system if (useNFAGrammar && agentGrammarRegistry) { try { + // Enrich grammar with checked variables from .pas.json if available + if (config.compiledSchemaFilePath) { + try { + const pasJsonPath = + getPackageFilePath( + config.compiledSchemaFilePath, + ); + enrichGrammarWithCheckedVariables( + g, + pasJsonPath, + ); + debug( + `Enriched grammar with checked variables for schema: ${schemaName}`, + ); + } catch (enrichError) { + debug( + `Could not enrich grammar with checked variables for ${schemaName}: ${enrichError}`, + ); + } + } + const nfa = compileGrammarToNFA( g, schemaName,