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
13 changes: 9 additions & 4 deletions ts/docs/architecture/core/actionGrammar.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,22 +234,27 @@ agents at once.
The `generation/` subsystem uses LLMs to automatically create grammar
rules from action schemas or confirmed user interactions.

**Three generation strategies:**
**Four generation strategies:**

1. **`ClaudeGrammarGenerator`** — Analyzes individual request/action pairs.
1. **`CopilotGrammarGenerator`** — The runtime default for confirmed
request/action pairs. It uses the Copilot SDK with `gpt-5.6-sol` to
extract linguistic patterns, parameter mappings, and alternative
phrasings. Produces `GrammarAnalysis` with rule patterns.

2. **`ClaudeGrammarGenerator`** — Retained for existing CLI and API callers.
Given a natural language request and its confirmed action, Claude
extracts linguistic patterns, parameter mappings, and alternative
phrasings. Produces `GrammarAnalysis` with rule patterns.

2. **`SchemaToGrammarGenerator`** — Batch generation from action schemas.
3. **`SchemaToGrammarGenerator`** — Batch generation from action schemas.
Reads `.pas.json` (Parameter Action Schema) files — JSON
representations of an agent's TypeScript action types, containing
action names, parameter types, and descriptions extracted from the
agent's schema `.ts` file. From these schemas, the generator
produces example natural language requests for each action and
synthesizes complete `.agr` grammar text with test cases.

3. **`ScenarioBasedGrammarGenerator`** — Uses pre-defined scenario templates
4. **`ScenarioBasedGrammarGenerator`** — Uses pre-defined scenario templates
(music player, calendar, lists) to generate grammar rules for common
action patterns without LLM calls.

Expand Down
1 change: 1 addition & 0 deletions ts/packages/actionGrammar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.150",
"@github/copilot-sdk": "1.0.13",
"@typeagent/action-schema": "workspace:*",
"@typeagent/common-utils": "workspace:*",
"@typeagent/config": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type {
AssistantMessageEvent,
MessageOptions,
SessionConfig,
} from "@github/copilot-sdk";
import registerDebug from "debug";
import { GrammarGenerator } from "./grammarGenerator.js";

const debug = registerDebug("typeagent:actionGrammar:copilotGrammarGenerator");

export const defaultCopilotGrammarModel = "gpt-5.6-sol";

export interface CopilotGrammarSession {
sendAndWait(
promptOrOptions: string | MessageOptions,
timeout?: number,
): Promise<AssistantMessageEvent | undefined>;
disconnect(): Promise<void>;
}

export interface CopilotGrammarClient {
createSession(config: SessionConfig): Promise<CopilotGrammarSession>;
stop(): Promise<unknown>;
}

export type CopilotGrammarClientFactory = () => Promise<CopilotGrammarClient>;

async function createCopilotGrammarClient(): Promise<CopilotGrammarClient> {
const { CopilotClient } = await import("@github/copilot-sdk");
const client = new CopilotClient();
await client.start();
return client;
}

export class CopilotGrammarGenerator extends GrammarGenerator {
constructor(
private readonly model: string = defaultCopilotGrammarModel,
private readonly clientFactory: CopilotGrammarClientFactory = createCopilotGrammarClient,
) {
super("Copilot");
}

protected async queryModel(fullPrompt: string): Promise<string> {
const client = await this.clientFactory();
let session: CopilotGrammarSession | undefined;
try {
session = await client.createSession({
model: this.model,
streaming: false,
});
const response = await session.sendAndWait({
prompt: fullPrompt,
});
return response?.data?.content ?? "";
} finally {
if (session !== undefined) {
try {
await session.disconnect();
} catch (error) {
debug("Failed to disconnect Copilot session: %O", error);
}
}
try {
await client.stop();
} catch (error) {
debug("Failed to stop Copilot client: %O", error);
}
}
}
}
96 changes: 50 additions & 46 deletions ts/packages/actionGrammar/src/generation/grammarGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export interface Conversion {
}

/**
* A helper rule defined by Claude for this specific grammar (e.g., rule-specific filler)
* A helper rule defined by the model for this specific grammar (e.g., rule-specific filler)
*/
export interface AdditionalRule {
/** Rule name without angle brackets, e.g. "ExtraneousPhrase" */
Expand All @@ -100,7 +100,7 @@ export interface GrammarAnalysis {
grammarPattern: RuleRHS;
// Reasoning about the choices made
reasoning: string;
// Optional: extra rules Claude defined for this grammar (e.g., rule-specific filler)
// Optional: extra rules the model defined for this grammar (e.g., rule-specific filler)
additionalRules?: AdditionalRule[];
// Optional: new phrases to add to global phrase-set matchers (idempotent)
phrasesToAdd?: Array<{ matcherName: string; phrase: string }>;
Expand Down Expand Up @@ -285,12 +285,10 @@ Output:
}`;
}

export class ClaudeGrammarGenerator {
private model: string;
export abstract class GrammarGenerator {
protected constructor(private readonly providerName: string) {}

constructor(model: string = "claude-sonnet-4-20250514") {
this.model = model;
}
protected abstract queryModel(fullPrompt: string): Promise<string>;

async generateGrammar(
testCase: GrammarTestCase,
Expand All @@ -306,7 +304,7 @@ export class ClaudeGrammarGenerator {

/**
* Refine a previously generated grammar rule that failed to match the original request.
* Gives Claude specific feedback: the failed rule, the tokenized request, and hints.
* Gives the model specific feedback: the failed rule, the tokenized request, and hints.
*/
async refineGrammar(
testCase: GrammarTestCase,
Expand Down Expand Up @@ -350,36 +348,9 @@ Generate a corrected rule now.`;
}

private async queryAndParse(fullPrompt: string): Promise<GrammarAnalysis> {
// Use the Agent SDK query function
const queryInstance = query({
prompt: fullPrompt,
options: {
model: this.model,
...claudeExecutableOption(),
},
});

// Collect the result from the SDK
let responseText = "";
for await (const message of queryInstance) {
if (message.type === "result") {
if (message.subtype === "success") {
responseText = message.result || "";
break;
} else {
const errors =
"errors" in message
? (message as any).errors
: undefined;
throw new Error(
`Claude query failed: ${errors?.join(", ") || "Unknown error"}`,
);
}
}
}

const responseText = await this.queryModel(fullPrompt);
if (!responseText) {
throw new Error("No response from Claude");
throw new Error(`No response from ${this.providerName}`);
}

return this.parseAnalysis(responseText);
Expand Down Expand Up @@ -467,7 +438,7 @@ Generate a corrected rule now.`;
const jsonStart = text.indexOf("{");
if (jsonStart === -1) {
throw new Error(
`No JSON object found in Claude response. Response starts with: "${text.substring(0, 100)}..."`,
`No JSON object found in ${this.providerName} response. Response starts with: "${text.substring(0, 100)}..."`,
);
}

Expand All @@ -476,7 +447,7 @@ Generate a corrected rule now.`;
const preamble = text.substring(0, jsonStart).trim();
if (preamble.length > 0) {
debug(
`Claude included text before JSON: "${preamble.substring(0, 100)}..."`,
`${this.providerName} included text before JSON: "${preamble.substring(0, 100)}..."`,
);
}
}
Expand All @@ -500,7 +471,7 @@ Generate a corrected rule now.`;

if (!jsonText) {
throw new Error(
`Found opening brace but no matching closing brace in Claude response. Text from brace: "${text.substring(jsonStart, jsonStart + 100)}..."`,
`Found opening brace but no matching closing brace in ${this.providerName} response. Text from brace: "${text.substring(jsonStart, jsonStart + 100)}..."`,
);
}

Expand All @@ -509,7 +480,7 @@ Generate a corrected rule now.`;
analysis = JSON.parse(jsonText);
} catch (error) {
throw new Error(
`Failed to parse JSON from Claude response: ${error instanceof Error ? error.message : String(error)}\nJSON text preview: ${jsonText.substring(0, 300)}...\nFull response preview: ${text.substring(0, 300)}...`,
`Failed to parse JSON from ${this.providerName} response: ${error instanceof Error ? error.message : String(error)}\nJSON text preview: ${jsonText.substring(0, 300)}...\nFull response preview: ${text.substring(0, 300)}...`,
);
}

Expand All @@ -531,15 +502,15 @@ Generate a corrected rule now.`;
throw new Error("Rejected cases must include rejectionReason");
}

// Clean up any unwanted text that Claude might have inserted into string fields
// Clean up any unwanted text that the model inserted into string fields
this.sanitizeAnalysisStrings(analysis);

return analysis;
}

/**
* Remove copyright notices, comments, and other unwanted text from analysis string fields
* Claude sometimes inserts these into the JSON, making the grammar patterns invalid
* Models sometimes insert these into the JSON, making the grammar patterns invalid
*/
private sanitizeAnalysisStrings(analysis: GrammarAnalysis): void {
const commentPatterns = [
Expand All @@ -564,7 +535,7 @@ Generate a corrected rule now.`;

if (hadComments) {
debug(
`Removed comment/copyright text from Claude response. Original: "${str.substring(0, 100)}..."`,
`Removed comment/copyright text from model response. Original: "${str.substring(0, 100)}..."`,
);
}

Expand Down Expand Up @@ -678,7 +649,7 @@ Generate a corrected rule now.`;
}
}

// Replace types in the matchPattern — normalize any type Claude used to the correct one
// Replace types in the matchPattern with the schema's required type
let matchPattern = analysis.grammarPattern.matchPattern;
for (const [varName, wildcardType] of wildcardTypes) {
// Match $(varName:AnyType) and replace with $(varName:CorrectType)
Expand All @@ -695,7 +666,7 @@ Generate a corrected rule now.`;
// Only rule-specific helper rules (additionalRules) need to be prepended.
const preambleRules: string[] = [];

// Inject any rule-specific helper rules Claude defined
// Inject any rule-specific helper rules the model defined
if (analysis.additionalRules) {
for (const rule of analysis.additionalRules) {
preambleRules.push(rule.ruleText);
Expand Down Expand Up @@ -743,3 +714,36 @@ Generate a corrected rule now.`;
return paramName;
}
}

export class ClaudeGrammarGenerator extends GrammarGenerator {
constructor(private readonly model: string = "claude-sonnet-4-20250514") {
super("Claude");
}

protected async queryModel(fullPrompt: string): Promise<string> {
const queryInstance = query({
prompt: fullPrompt,
options: {
model: this.model,
...claudeExecutableOption(),
},
});

for await (const message of queryInstance) {
if (message.type !== "result") {
continue;
}
if (message.subtype === "success") {
return message.result || "";
}
const errors =
"errors" in message && Array.isArray(message.errors)
? message.errors
: undefined;
throw new Error(
`Claude query failed: ${errors?.join(", ") || "Unknown error"}`,
);
}
return "";
}
}
Loading
Loading