From c60044d5ce801dd592270f5f9ce926e1096ec0ee Mon Sep 17 00:00:00 2001 From: George Ng Date: Thu, 10 Sep 2026 16:15:21 -0700 Subject: [PATCH 1/9] Add shared structured action discovery and contracts Expose exact action discovery and closed TypeScript contracts through Dispatcher and RPC. Fingerprint execution-relevant schema and policy, report cached availability, and support trusted logical scope reuse without enabling execution. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ts/packages/agentSdk/src/agentInterface.ts | 11 + ts/packages/agentSdk/src/index.ts | 2 + .../dispatcher/src/context/appAgentManager.ts | 17 + .../dispatcher/dispatcher/src/dispatcher.ts | 15 + .../dispatcher/dispatcher/src/internal.ts | 1 + .../src/structuredAction/contract.ts | 179 ++++++ .../src/structuredAction/discovery.ts | 252 +++++++++ .../test/structuredActionDiscovery.spec.ts | 514 ++++++++++++++++++ .../dispatcher/rpc/src/dispatcherClient.ts | 6 + .../dispatcher/rpc/src/dispatcherServer.ts | 6 + .../dispatcher/rpc/src/dispatcherTypes.ts | 8 + .../dispatcher/rpc/test/dispatcherRpc.spec.ts | 112 ++++ .../dispatcher/types/src/dispatcher.ts | 10 + ts/packages/dispatcher/types/src/index.ts | 1 + .../dispatcher/types/src/structuredAction.ts | 97 ++++ 15 files changed, 1231 insertions(+) create mode 100644 ts/packages/dispatcher/dispatcher/src/structuredAction/contract.ts create mode 100644 ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts create mode 100644 ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts create mode 100644 ts/packages/dispatcher/types/src/structuredAction.ts diff --git a/ts/packages/agentSdk/src/agentInterface.ts b/ts/packages/agentSdk/src/agentInterface.ts index cbab7a91c1..09381157ac 100644 --- a/ts/packages/agentSdk/src/agentInterface.ts +++ b/ts/packages/agentSdk/src/agentInterface.ts @@ -74,6 +74,15 @@ export type GrammarContent = { sourceMap?: string | undefined; }; +export type ActionEffect = "read-only" | "state-changing" | "unknown"; + +export type ActionPolicy = { + // Omission is unknown, never an exemption from effect confirmation. + effects?: ActionEffect; + // Even a read-only action can explicitly require confirmation. + confirmation?: "required"; +}; + export type SchemaManifest = { description: string; schemaType: string | SchemaTypeNames; // string if there are only action schemas @@ -83,6 +92,8 @@ export type SchemaManifest = { injected?: boolean; // whether the translator is injected into other domains, default is false cached?: boolean; // whether the translator's action should be cached, default is true streamingActions?: string[]; + // Exact action names. Applies to structured invocation, not NL routing. + actionPolicies?: Record; }; export type ActionManifest = { diff --git a/ts/packages/agentSdk/src/index.ts b/ts/packages/agentSdk/src/index.ts index 01d3f26af0..76f637f991 100644 --- a/ts/packages/agentSdk/src/index.ts +++ b/ts/packages/agentSdk/src/index.ts @@ -9,6 +9,8 @@ export { SchemaContent, SchemaFormat, SchemaManifest, + ActionEffect, + ActionPolicy, AppAgent, AppAgentEvent, AgentMessageKind, diff --git a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts index 9fa481613f..12239920b2 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts @@ -321,6 +321,23 @@ export class AppAgentManager implements ActionConfigProvider { return this.readiness.get(appAgentName) ?? { state: "ready" }; } + public getReadinessSnapshot(appAgentName: string): { + source: "cached" | "not-supported" | "uninitialized" | "not-checked"; + report?: ReadinessReport; + } { + const record = this.getRecord(appAgentName); + if (record.sessionContext === undefined) { + return { source: "uninitialized" }; + } + const report = this.readiness.get(appAgentName); + if (report !== undefined) { + return { source: "cached", report: { ...report } }; + } + return record.appAgent?.checkReadiness === undefined + ? { source: "not-supported", report: { state: "ready" } } + : { source: "not-checked" }; + } + // True iff this agent has been observed to implement checkReadiness // at any point this session AND we currently don't have a cached // report for it. In practice this means: the agent was enabled at diff --git a/ts/packages/dispatcher/dispatcher/src/dispatcher.ts b/ts/packages/dispatcher/dispatcher/src/dispatcher.ts index 5a224965a5..31996e2cd4 100644 --- a/ts/packages/dispatcher/dispatcher/src/dispatcher.ts +++ b/ts/packages/dispatcher/dispatcher/src/dispatcher.ts @@ -36,6 +36,10 @@ import { import { randomUUID } from "node:crypto"; import { context as otelContext } from "@opentelemetry/api"; import { getAgentSchemas } from "./context/system/describe/agentSchemaInfo.js"; +import { + StructuredActionDiscovery, + type StructuredActionAccess, +} from "./structuredAction/discovery.js"; async function getDynamicDisplay( context: CommandHandlerContext, @@ -200,7 +204,12 @@ export function createDispatcherFromContext( context: CommandHandlerContext, connectionId?: ConnectionId, closeFn?: () => Promise, + structuredActionAccess?: StructuredActionAccess, ): Dispatcher { + const structuredActions = new StructuredActionDiscovery( + context, + structuredActionAccess, + ); const submitInput = ( command: string, clientRequestId: unknown, @@ -389,6 +398,12 @@ export function createDispatcherFromContext( async getAgentSchemas(agentName?: string) { return getAgentSchemas(context, agentName); }, + async searchActions(request) { + return structuredActions.searchActions(request); + }, + async getActionContract(identity) { + return structuredActions.getActionContract(identity); + }, async cancelCommand(requestId: string): Promise { const kind = context.requestQueue.classifyCancel(requestId, "user"); if (kind === "queued") { diff --git a/ts/packages/dispatcher/dispatcher/src/internal.ts b/ts/packages/dispatcher/dispatcher/src/internal.ts index cf2964d0f0..bfd62436b7 100644 --- a/ts/packages/dispatcher/dispatcher/src/internal.ts +++ b/ts/packages/dispatcher/dispatcher/src/internal.ts @@ -3,6 +3,7 @@ // Internal exports for agent server export { createDispatcherFromContext } from "./dispatcher.js"; +export type { StructuredActionAccess } from "./structuredAction/discovery.js"; export { closeCommandHandlerContext, initializeCommandHandlerContext, diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/contract.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/contract.ts new file mode 100644 index 0000000000..172b015b96 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/contract.ts @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createHash } from "node:crypto"; +import { + generateSchemaTypeDefinition, + getActionDescription, + toJSONParsedActionSchema, +} from "@typeagent/action-schema"; +import type { + ActionSchemaTypeDefinition, + SchemaType, +} from "@typeagent/action-schema"; +import { + structuredActionProtocolVersion, + type ActionAvailability, + type ActionContract, + type ActionExecutionPolicy, + type ActionIdentity, +} from "@typeagent/dispatcher-types"; +import type { ActionConfig } from "../translation/actionConfig.js"; + +function executionType(type: SchemaType): unknown { + switch (type.type) { + case "object": + return { + type: type.type, + fields: Object.fromEntries( + Object.entries(type.fields).map(([name, field]) => [ + name, + { + optional: field.optional === true, + type: executionType(field.type), + }, + ]), + ), + }; + case "array": + return { + type: type.type, + elementType: executionType(type.elementType), + }; + case "type-union": + return { type: type.type, types: type.types.map(executionType) }; + case "string-union": + return { type: type.type, typeEnum: type.typeEnum }; + case "type-reference": + return { type: type.type, name: type.name }; + default: + return { type: type.type }; + } +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .filter(([, item]) => item !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, item]) => [key, canonicalize(item)]), + ); + } + return value; +} + +function getPolicy( + config: ActionConfig, + actionName: string, +): ActionExecutionPolicy { + if ( + config.actionPolicies !== undefined && + (config.actionPolicies === null || + typeof config.actionPolicies !== "object" || + Array.isArray(config.actionPolicies)) + ) { + throw new Error( + `Invalid structured action policies for '${config.schemaName}'`, + ); + } + const declaration = Object.prototype.hasOwnProperty.call( + config.actionPolicies ?? {}, + actionName, + ) + ? config.actionPolicies?.[actionName] + : undefined; + if ( + declaration !== undefined && + (declaration === null || + typeof declaration !== "object" || + Array.isArray(declaration)) + ) { + throw new Error( + `Invalid structured action policy for '${config.schemaName}.${actionName}'`, + ); + } + const effects = + declaration?.effects === undefined ? "unknown" : declaration.effects; + if ( + (effects !== "unknown" && + effects !== "read-only" && + effects !== "state-changing") || + (declaration?.confirmation !== undefined && + declaration.confirmation !== "required") + ) { + throw new Error( + `Invalid structured action policy for '${config.schemaName}.${actionName}'`, + ); + } + return { + effects, + confirmation: + effects === "read-only" && declaration?.confirmation !== "required" + ? "not-required" + : "required", + }; +} + +export function createActionContract( + identity: ActionIdentity, + definition: ActionSchemaTypeDefinition, + config: ActionConfig, + availability: ActionAvailability, +): ActionContract { + const policy = getPolicy(config, identity.actionName); + const output: ActionContract["output"] = { + envelope: "ActionResult", + optional: true, + resultValue: { type: "unknown", optional: true }, + resultEntity: { type: "Entity", optional: true }, + entities: { type: "Entity[]", optional: true }, + }; + const interactions: ActionContract["interactions"] = { + mode: "may-require-interaction", + kinds: ["question", "choice", "form", "action-proposal"], + }; + // Reuse the serializer's dependency closure, including recursive references. + const serialized = toJSONParsedActionSchema({ + entry: { action: definition }, + actionSchemas: new Map([[identity.actionName, definition]]), + }); + const executionContract = { + protocolVersion: structuredActionProtocolVersion, + identity, + entry: serialized.entry, + types: Object.fromEntries( + Object.entries(serialized.types).map(([name, def]) => [ + name, + executionType(def.type), + ]), + ), + paramSpecs: definition.paramSpecs, + policy, + output, + interactions, + errorReasoning: config.errorReasoning, + streaming: + config.streamingActions?.includes(identity.actionName) ?? false, + }; + const fingerprint = createHash("sha256") + .update(JSON.stringify(canonicalize(executionContract))) + .digest("hex"); + return { + ...identity, + description: getActionDescription(definition) ?? "", + availability, + fingerprint, + input: { + format: "typescript", + typeName: definition.name, + schemaText: generateSchemaTypeDefinition(definition), + }, + policy, + output, + interactions, + }; +} diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts new file mode 100644 index 0000000000..5e640b51d8 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from "node:crypto"; +import { getActionDescription } from "@typeagent/action-schema"; +import { + structuredActionProtocolVersion, + type ActionAvailability, + type ActionContractResult, + type ActionIdentity, + type ActionSearchRequest, + type ActionSearchResult, + type ActionSummary, + type StructuredActionEnvelope, +} from "@typeagent/dispatcher-types"; +import type { AppAgentManager } from "../context/appAgentManager.js"; +import { getAppAgentName } from "../translation/agentTranslators.js"; +import { createActionContract } from "./contract.js"; + +// Host-only policy. Never deserialize this from a discovery/RPC request. +// Reuse scope only for the same authorized logical caller/conversation binding, +// including reconnects. Replace it whenever that binding or permissions change. +export type StructuredActionAccess = () => { + scope: object; + canDiscoverSchema(schemaName: string): boolean; +}; + +type DiscoveryContext = { + agents: AppAgentManager; + session: object; +}; + +const sessionScopes = new WeakMap>(); + +function validateString(value: unknown, name: string): asserts value is string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${name} must be a nonempty string`); + } +} + +function validateSearch(request: ActionSearchRequest): void { + if ( + request === null || + typeof request !== "object" || + Array.isArray(request) + ) { + throw new Error("Action search request must be an object"); + } + if (request.query !== undefined && typeof request.query !== "string") { + throw new Error("query must be a string"); + } + for (const key of ["agentName", "schemaName"] as const) { + if (request[key] !== undefined) { + validateString(request[key], key); + } + } + if ( + request.offset !== undefined && + (!Number.isSafeInteger(request.offset) || request.offset < 0) + ) { + throw new Error("offset must be a nonnegative safe integer"); + } + if ( + request.limit !== undefined && + (!Number.isSafeInteger(request.limit) || + request.limit < 1 || + request.limit > 200) + ) { + throw new Error("limit must be an integer between 1 and 200"); + } +} + +function getAvailability( + agents: AppAgentManager, + schemaName: string, +): ActionAvailability { + const agentName = getAppAgentName(schemaName); + const readiness = agents.getReadinessSnapshot(agentName); + const availability: ActionAvailability = { + state: "available", + schemaEnabled: agents.isSchemaEnabled(schemaName), + actionEnabled: agents.isActionEnabled(schemaName), + schemaActive: agents.isSchemaActive(schemaName), + actionActive: agents.isActionActive(schemaName), + readiness, + authorization: "checked-at-execution", + }; + const loadError = agents.getLoadError(agentName); + if (agents.isSchemaLoading(schemaName)) { + availability.state = "loading"; + } else if (loadError !== undefined) { + availability.state = "error"; + availability.message = loadError.message; + } else if (!availability.schemaEnabled || !availability.actionEnabled) { + availability.state = "disabled"; + } else if (!availability.schemaActive || !availability.actionActive) { + availability.state = "inactive"; + } else if (readiness.report === undefined) { + availability.state = "unknown"; + } else if (readiness.report.state !== "ready") { + availability.state = readiness.report.state; + } + if ( + availability.message === undefined && + readiness.report?.message !== undefined + ) { + availability.message = readiness.report.message; + } + return availability; +} + +export class StructuredActionDiscovery { + private readonly anonymousScope = {}; + + public constructor( + private readonly context: DiscoveryContext, + private readonly access?: StructuredActionAccess, + ) {} + + private bindScope() { + const policy = this.access?.(); + const permissionScope = policy?.scope ?? this.anonymousScope; + let scopes = sessionScopes.get(this.context.session); + if (scopes === undefined) { + scopes = new WeakMap(); + sessionScopes.set(this.context.session, scopes); + } + let scopeId = scopes.get(permissionScope); + if (scopeId === undefined) { + scopeId = randomUUID(); + scopes.set(permissionScope, scopeId); + } + const envelope: StructuredActionEnvelope = { + protocolVersion: structuredActionProtocolVersion, + scopeId, + }; + return { envelope, policy }; + } + + public async searchActions( + request: ActionSearchRequest = {}, + ): Promise { + validateSearch(request); + const { envelope, policy } = this.bindScope(); + const query = request.query?.trim().toLowerCase(); + const matches: ActionSummary[] = []; + for (const config of this.context.agents.getActionConfigs()) { + if ( + (request.schemaName !== undefined && + request.schemaName !== config.schemaName) || + (request.agentName !== undefined && + request.agentName !== getAppAgentName(config.schemaName)) || + policy?.canDiscoverSchema(config.schemaName) === false + ) { + continue; + } + const schema = + this.context.agents.getActionSchemaFileForConfig(config); + const availability = getAvailability( + this.context.agents, + config.schemaName, + ); + for (const [actionName, definition] of schema.parsedActionSchema + .actionSchemas) { + const description = getActionDescription(definition) ?? ""; + if ( + query && + !`${config.schemaName} ${actionName} ${description}` + .toLowerCase() + .includes(query) + ) { + continue; + } + matches.push({ + schemaName: config.schemaName, + actionName, + description, + availability, + }); + } + } + matches.sort((a, b) => { + const schemaOrder = + a.schemaName < b.schemaName + ? -1 + : a.schemaName > b.schemaName + ? 1 + : 0; + return ( + schemaOrder || + (a.actionName < b.actionName + ? -1 + : a.actionName > b.actionName + ? 1 + : 0) + ); + }); + const offset = request.offset ?? 0; + const end = offset + (request.limit ?? 50); + return { + ...envelope, + actions: matches.slice(offset, end), + total: matches.length, + ...(end < matches.length ? { nextOffset: end } : {}), + }; + } + + public async getActionContract( + identity: ActionIdentity, + ): Promise { + if ( + identity === null || + typeof identity !== "object" || + Array.isArray(identity) + ) { + throw new Error("Action identity must be an object"); + } + validateString(identity.schemaName, "schemaName"); + validateString(identity.actionName, "actionName"); + const { envelope, policy } = this.bindScope(); + // Check visibility before looking up or parsing the schema. + if (policy?.canDiscoverSchema(identity.schemaName) === false) { + return { ...envelope, status: "not-found" }; + } + const config = this.context.agents.tryGetActionConfig( + identity.schemaName, + ); + if (config === undefined) { + return { ...envelope, status: "not-found" }; + } + const schema = this.context.agents.getActionSchemaFileForConfig(config); + const definition = schema.parsedActionSchema.actionSchemas.get( + identity.actionName, + ); + if (definition === undefined) { + return { ...envelope, status: "not-found" }; + } + return { + ...envelope, + status: "found", + contract: createActionContract( + { + schemaName: identity.schemaName, + actionName: identity.actionName, + }, + definition, + config, + getAvailability(this.context.agents, identity.schemaName), + ), + }; + } +} diff --git a/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts b/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts new file mode 100644 index 0000000000..e65dc4e7ad --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts @@ -0,0 +1,514 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { jest } from "@jest/globals"; +import type { + ActionPolicy, + AppAgent, + ReadinessReport, +} from "@typeagent/agent-sdk"; +import type { + ActionContractResult, + ActionIdentity, +} from "@typeagent/dispatcher-types"; +import { parseActionSchemaSource } from "@typeagent/action-schema"; +import { AppAgentManager } from "../src/context/appAgentManager.js"; +import { PortRegistrar } from "../src/context/portRegistrar.js"; +import { + convertToActionConfig, + type ActionConfig, +} from "../src/translation/actionConfig.js"; +import { StructuredActionDiscovery } from "../src/structuredAction/discovery.js"; + +const source = ` +export type Actions = Select | Clear | Ping; +// Select an item. +export type Select = { + actionName: "select"; + parameters: { + item: Item; + note?: string; + comments?: string; + }; +}; +type Item = { + color: Color; + details?: { label: string; count?: number }; +}; +type Color = "red" | "blue"; +type Clear = { actionName: "clear"; parameters: { all: boolean } }; +type Ping = { actionName: "ping" }; +`; + +const identity: ActionIdentity = { + schemaName: "test.items", + actionName: "select", +}; + +type AgentFixture = { + schemas: Set; + actions: Set; + commands: boolean; + appAgent: AppAgent; + sessionContext?: object; +}; + +function fixture(content = source, policies?: Record) { + const agents = new AppAgentManager(undefined, new PortRegistrar()); + // Seed only the manager's persisted/loaded state; all discovery, parsing, + // enablement, readiness snapshots, and fingerprinting run their real code. + const state = agents as unknown as { + agents: Map; + actionConfigs: Map; + readiness: Map; + loadErrors: Map; + loadingSchemas: Set; + transientAgents: Record; + }; + const configs = convertToActionConfig("test", { + description: "Test agent", + emojiChar: "", + subActionManifests: { + items: { + schema: { + description: "Items", + schemaType: "Actions", + schemaFile: { format: "ts", content }, + ...(policies === undefined + ? {} + : { actionPolicies: policies }), + }, + }, + other: { + schema: { + description: "Other", + schemaType: "Other", + schemaFile: { + format: "ts", + content: + 'export type Other = { actionName: "select"; parameters: { id: number } };', + }, + }, + }, + }, + }); + for (const config of Object.values(configs)) { + state.actionConfigs.set(config.schemaName, config); + } + const hooks = { + executeAction: jest.fn>(), + updateAgentContext: + jest.fn>(), + setup: jest.fn>(), + checkReadiness: jest.fn>(), + }; + const agent: AgentFixture = { + schemas: new Set(Object.keys(configs)), + actions: new Set(Object.keys(configs)), + commands: true, + appAgent: hooks, + sessionContext: {}, + }; + state.agents.set("test", agent); + state.readiness.set("test", { state: "ready" }); + const context = { agents, session: {} }; + return { + agents, + state, + agent, + hooks, + context, + service: new StructuredActionDiscovery(context), + }; +} + +function found(result: ActionContractResult) { + if (result.status !== "found") { + throw new Error("Expected contract"); + } + return result.contract; +} + +async function fingerprint(content = source, policy?: ActionPolicy) { + const { service } = fixture( + content, + policy ? { select: policy } : undefined, + ); + return found(await service.getActionContract(identity)).fingerprint; +} + +describe("structured action contracts", () => { + it("retrieves exactly one action directly, with a closed dependency graph", async () => { + const { service, agents } = fixture(); + const enumerate = jest.spyOn(agents, "getActionConfigs"); + const result = await service.getActionContract(identity); + const contract = found(result); + expect(enumerate).not.toHaveBeenCalled(); + expect(result.protocolVersion).toBe(1); + expect(result.scopeId).toEqual(expect.any(String)); + expect(contract.input.format).toBe("typescript"); + expect(contract.input.schemaText).toContain( + 'type Color = "red" | "blue"', + ); + expect(contract.input.schemaText).toContain("note?: string"); + expect(contract.input.schemaText).toContain("details?:"); + expect(contract.input.schemaText).not.toContain("type Clear"); + expect(contract.input.schemaText).not.toContain("type Actions"); + const reparsed = parseActionSchemaSource( + contract.input.schemaText, + identity.schemaName, + contract.input.typeName, + ); + expect([...reparsed.actionSchemas.keys()]).toEqual(["select"]); + expect(contract.output).toMatchObject({ + envelope: "ActionResult", + resultValue: { type: "unknown", optional: true }, + resultEntity: { type: "Entity", optional: true }, + entities: { type: "Entity[]", optional: true }, + }); + }); + + it("distinguishes duplicate action names and rejects case-insensitive guesses", async () => { + const { service } = fixture(); + const other = found( + await service.getActionContract({ + schemaName: "test.other", + actionName: "select", + }), + ); + expect(other.input.schemaText).toContain("id: number"); + for (const missing of [ + { schemaName: "test", actionName: "select" }, + { schemaName: "TEST.items", actionName: "select" }, + { schemaName: "test.items", actionName: "SELECT" }, + ]) { + expect((await service.getActionContract(missing)).status).toBe( + "not-found", + ); + } + }); + + it("handles no parameters and an optional parameter object", async () => { + const { service } = fixture(); + expect( + found( + await service.getActionContract({ + ...identity, + actionName: "ping", + }), + ).input.schemaText, + ).not.toContain("parameters"); + const optional = fixture( + source.replace("parameters: {", "parameters?: {"), + ); + expect( + found(await optional.service.getActionContract(identity)).input + .schemaText, + ).toContain("parameters?:"); + }); + + it("closes recursive references without importing sibling actions", async () => { + const recursive = fixture( + source.replace( + "color: Color;", + "color: Color;\n children?: Item[];", + ), + ); + const contract = found( + await recursive.service.getActionContract(identity), + ); + expect(contract.input.schemaText).toContain("children?: Item[]"); + expect(contract.input.schemaText.match(/type Item =/g)).toHaveLength(1); + expect(contract.input.schemaText).not.toContain("type Clear"); + expect( + await fingerprint( + source.replace( + "color: Color;", + "color: Color;\n children?: Item[];", + ), + ), + ).toBe(contract.fingerprint); + }); + + it("fingerprints execution semantics, not descriptions, ordering, or siblings", async () => { + const original = await fingerprint(); + expect( + await fingerprint( + source.replace("Select an item.", "A better description."), + ), + ).toBe(original); + expect( + await fingerprint( + source.replace( + "note?: string;", + "note?: string; // explanation", + ), + ), + ).toBe(original); + expect( + await fingerprint( + source.replace( + "note?: string;\n comments?: string;", + "comments?: string;\n note?: string;", + ), + ), + ).toBe(original); + expect( + await fingerprint(source.replace("all: boolean", "all: string")), + ).toBe(original); + for (const changed of [ + source.replace( + 'type Color = "red" | "blue"', + 'type Color = "red" | "green"', + ), + source.replace("count?: number", "count?: string"), + source.replace("note?: string", "note: string"), + source.replace("comments?: string", "comments?: boolean"), + ]) { + expect(await fingerprint(changed)).not.toBe(original); + } + expect(await fingerprint(source, { effects: "read-only" })).not.toBe( + original, + ); + expect( + await fingerprint(source, { + effects: "read-only", + confirmation: "required", + }), + ).not.toBe(await fingerprint(source, { effects: "read-only" })); + }); + + it.each([ + [undefined, "unknown", "required"], + [{ effects: "state-changing" }, "state-changing", "required"], + [{ effects: "read-only" }, "read-only", "not-required"], + [ + { effects: "read-only", confirmation: "required" }, + "read-only", + "required", + ], + ] as const)( + "derives confirmation only from trusted policy %j", + async (policy, effects, confirmation) => { + const { service } = fixture( + source, + policy ? { select: policy } : undefined, + ); + const contract = found(await service.getActionContract(identity)); + expect(contract.policy).toEqual({ effects, confirmation }); + expect(contract.interactions.mode).toBe("may-require-interaction"); + }, + ); + + it("rejects malformed declarations instead of weakening confirmation", async () => { + const { service, agents } = fixture(); + Object.assign(agents.getActionConfig(identity.schemaName), { + actionPolicies: { + select: { effects: "read-only", confirmation: "never" }, + }, + }); + await expect(service.getActionContract(identity)).rejects.toThrow( + "Invalid structured action policy", + ); + }); +}); + +describe("structured action discovery", () => { + it("lists compact action summaries with filters and pagination", async () => { + const { service } = fixture(); + const page = await service.searchActions({ limit: 2 }); + expect(page.total).toBe(4); + expect(page.actions.map((a) => a.actionName)).toEqual([ + "clear", + "ping", + ]); + expect(page.nextOffset).toBe(2); + expect(page.actions[0]).not.toHaveProperty("input"); + if (page.nextOffset === undefined) { + throw new Error("Expected another page"); + } + const remaining = await service.searchActions({ + offset: page.nextOffset, + limit: 2, + }); + expect(remaining.actions.map((a) => a.schemaName)).toEqual([ + "test.items", + "test.other", + ]); + expect(remaining.nextOffset).toBeUndefined(); + expect((await service.searchActions({ query: "SELECT" })).total).toBe( + 2, + ); + expect( + (await service.searchActions({ schemaName: "test.other" })).total, + ).toBe(1); + expect( + (await service.searchActions({ agentName: "missing" })).total, + ).toBe(0); + expect((await service.searchActions({ query: "an item" })).total).toBe( + 1, + ); + expect((await service.searchActions({ offset: 50 })).actions).toEqual( + [], + ); + }); + + it.each([ + { limit: 0 }, + { limit: 201 }, + { offset: -1 }, + { offset: 0.5 }, + { schemaName: "" }, + ])("rejects malformed search %j", async (request) => { + await expect( + fixture().service.searchActions(request), + ).rejects.toThrow(); + }); + + it("keeps semantic fingerprints stable across readiness and enablement changes", async () => { + const { service, state, agent } = fixture(); + const first = await service.getActionContract(identity); + const original = found(first); + state.readiness.set("test", { + state: "setup-required", + message: "Sign in first", + }); + const needsSetup = await service.getActionContract(identity); + expect(found(needsSetup).availability.state).toBe("setup-required"); + expect(found(needsSetup).fingerprint).toBe(original.fingerprint); + expect(needsSetup.scopeId).toBe(first.scopeId); + agent.actions.delete(identity.schemaName); + expect(found(await service.getActionContract(identity))).toMatchObject({ + fingerprint: original.fingerprint, + availability: { state: "disabled", actionEnabled: false }, + }); + }); + + it("reports exact schema/action state, not command enablement", async () => { + const { service, state, agent } = fixture(); + agent.actions.clear(); + expect( + found(await service.getActionContract(identity)).availability.state, + ).toBe("disabled"); + agent.actions.add(identity.schemaName); + agent.schemas.clear(); + expect( + found(await service.getActionContract(identity)).availability.state, + ).toBe("disabled"); + agent.schemas.add(identity.schemaName); + state.transientAgents[identity.schemaName] = false; + expect( + found(await service.getActionContract(identity)).availability.state, + ).toBe("inactive"); + }); + + it("reports loading, failures, unsupported and unknown readiness without probing", async () => { + const { service, state, agent, hooks } = fixture(); + state.loadingSchemas.add(identity.schemaName); + expect( + found(await service.getActionContract(identity)).availability.state, + ).toBe("loading"); + state.loadingSchemas.clear(); + state.loadErrors.set("test", new Error("load failed")); + expect( + found(await service.getActionContract(identity)).availability.state, + ).toBe("error"); + state.loadErrors.clear(); + state.readiness.set("test", { + state: "unsupported", + message: "unsupported OS", + }); + expect( + found(await service.getActionContract(identity)).availability.state, + ).toBe("unsupported"); + state.readiness.clear(); + expect( + found(await service.getActionContract(identity)).availability + .readiness.source, + ).toBe("not-checked"); + delete agent.sessionContext; + expect( + found(await service.getActionContract(identity)).availability, + ).toMatchObject({ + state: "unknown", + readiness: { source: "uninitialized" }, + }); + await service.searchActions(); + for (const hook of Object.values(hooks)) { + expect(hook).not.toHaveBeenCalled(); + } + }); + + it("does not claim verified authentication for an agent without readiness support", async () => { + const { service, state, agent } = fixture(); + state.readiness.clear(); + agent.appAgent = {}; + expect( + found(await service.getActionContract(identity)).availability, + ).toMatchObject({ + state: "available", + readiness: { source: "not-supported" }, + authorization: "checked-at-execution", + }); + }); + + it("binds scope to the facade, live session, and trusted permission revision", async () => { + const { context, service } = fixture(); + const first = await service.getActionContract(identity); + expect((await service.searchActions()).scopeId).toBe(first.scopeId); + expect( + (await new StructuredActionDiscovery(context).searchActions()) + .scopeId, + ).not.toBe(first.scopeId); + context.session = {}; + expect((await service.searchActions()).scopeId).not.toBe(first.scopeId); + let scope = {}; + const restricted = new StructuredActionDiscovery(context, () => ({ + scope, + canDiscoverSchema: () => true, + })); + const before = await restricted.searchActions(); + const reconnected = new StructuredActionDiscovery(context, () => ({ + scope, + canDiscoverSchema: () => true, + })); + expect((await reconnected.searchActions()).scopeId).toBe( + before.scopeId, + ); + scope = {}; + expect((await restricted.searchActions()).scopeId).not.toBe( + before.scopeId, + ); + }); + + it("filters denied schemas before parsing and does not reveal their existence", async () => { + const { context, agents } = fixture(); + agents.getActionConfig("test.other").schemaFile = { + format: "ts", + content: "invalid schema", + }; + const scope = {}; + const service = new StructuredActionDiscovery(context, () => ({ + scope, + canDiscoverSchema: (name) => name === identity.schemaName, + })); + expect((await service.searchActions()).total).toBe(3); + expect( + await service.getActionContract({ + schemaName: "test.other", + actionName: "select", + }), + ).toEqual( + await service.getActionContract({ + schemaName: "secret", + actionName: "select", + }), + ); + }); + + it("propagates visible schema failures rather than returning empty success", async () => { + const { service } = fixture("invalid schema"); + await expect(service.getActionContract(identity)).rejects.toThrow(); + await expect(service.searchActions()).rejects.toThrow(); + }); +}); diff --git a/ts/packages/dispatcher/rpc/src/dispatcherClient.ts b/ts/packages/dispatcher/rpc/src/dispatcherClient.ts index e729ff07f7..3b0fcee870 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherClient.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherClient.ts @@ -211,6 +211,12 @@ export function createDispatcherRpcClient( async getAgentSchemas(...args) { return rpc.invoke("getAgentSchemas", ...args); }, + async searchActions(...args) { + return rpc.invoke("searchActions", ...args); + }, + async getActionContract(...args) { + return rpc.invoke("getActionContract", ...args); + }, async respondToChoice(...args) { return rpc.invoke("respondToChoice", ...args); }, diff --git a/ts/packages/dispatcher/rpc/src/dispatcherServer.ts b/ts/packages/dispatcher/rpc/src/dispatcherServer.ts index 8c30f46be0..23a6cf663f 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherServer.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherServer.ts @@ -97,6 +97,12 @@ export function createDispatcherRpcServer( getAgentSchemas: async (...args) => { return dispatcher.getAgentSchemas(...args); }, + searchActions: async (...args) => { + return dispatcher.searchActions(...args); + }, + getActionContract: async (...args) => { + return dispatcher.getActionContract(...args); + }, respondToChoice: async (...args) => { return dispatcher.respondToChoice(...args); }, diff --git a/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts b/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts index 176c1cca58..77480c7c5c 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts @@ -9,6 +9,10 @@ import type { } from "@typeagent/agent-sdk"; import type { AgentSchemaInfo, + ActionContractResult, + ActionIdentity, + ActionSearchRequest, + ActionSearchResult, CancelResult, CommandCompletionResult, CommandResult, @@ -99,6 +103,10 @@ export type DispatcherInvokeFunctions = { getAgentSchemas(agentName?: string): Promise; + searchActions(request?: ActionSearchRequest): Promise; + + getActionContract(identity: ActionIdentity): Promise; + respondToChoice( choiceId: string, response: diff --git a/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts b/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts index 209989fdab..728a951a22 100644 --- a/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts +++ b/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts @@ -6,6 +6,8 @@ import type { RpcStructuredLogger } from "@typeagent/agent-rpc/rpc"; import { createDispatcherRpcClient } from "../src/dispatcherClient.js"; import { createDispatcherRpcServer } from "../src/dispatcherServer.js"; import type { + ActionContractResult, + ActionSearchResult, CommandResult, Dispatcher, QueuedRequest, @@ -72,6 +74,8 @@ function makeStubDispatcher(overrides: Partial = {}): Dispatcher & { close: notImplemented("close") as any, getStatus: notImplemented("getStatus") as any, getAgentSchemas: notImplemented("getAgentSchemas") as any, + searchActions: notImplemented("searchActions"), + getActionContract: notImplemented("getActionContract"), respondToChoice: notImplemented("respondToChoice") as any, getDisplayHistory: notImplemented("getDisplayHistory") as any, async cancelCommand(...args) { @@ -149,6 +153,114 @@ describe("dispatcher RPC lifecycle options", () => { }); }); +describe("dispatcher RPC structured discovery", () => { + it("forwards exact identities and the complete versioned contract", async () => { + const identity = { schemaName: "test.sub", actionName: "select" }; + const summary = { + ...identity, + description: "Select", + availability: { + state: "setup-required" as const, + schemaEnabled: true, + actionEnabled: true, + schemaActive: true, + actionActive: true, + readiness: { + source: "cached" as const, + report: { + state: "setup-required" as const, + message: "Configure first", + }, + }, + authorization: "checked-at-execution" as const, + }, + }; + const searchResult: ActionSearchResult = { + protocolVersion: 1, + scopeId: "server-scope", + actions: [summary], + total: 1, + }; + const contractResult: ActionContractResult = { + protocolVersion: 1, + scopeId: "server-scope", + status: "found", + contract: { + ...summary, + fingerprint: "opaque-fingerprint", + input: { + format: "typescript", + typeName: "Select", + schemaText: + 'type Select = { actionName: "select"; parameters: { id?: string } };', + }, + policy: { effects: "unknown", confirmation: "required" }, + output: { + envelope: "ActionResult", + optional: true, + resultValue: { type: "unknown", optional: true }, + resultEntity: { type: "Entity", optional: true }, + entities: { type: "Entity[]", optional: true }, + }, + interactions: { + mode: "may-require-interaction", + kinds: ["question", "choice", "form", "action-proposal"], + }, + }, + }; + const calls: { method: string; input: unknown }[] = []; + const searchActions: Dispatcher["searchActions"] = async (input) => { + calls.push({ method: "search", input }); + return searchResult; + }; + const getActionContract: Dispatcher["getActionContract"] = async ( + input, + ) => { + calls.push({ method: "contract", input }); + return contractResult; + }; + const channels = createChannelPair(); + createDispatcherRpcServer( + makeStubDispatcher({ searchActions, getActionContract }), + channels.serverChannel, + ); + const { dispatcher } = createDispatcherRpcClient( + channels.clientChannel, + ); + const request = { schemaName: "test.sub", limit: 1 }; + await expect(dispatcher.searchActions(request)).resolves.toEqual( + searchResult, + ); + await expect(dispatcher.getActionContract(identity)).resolves.toEqual( + contractResult, + ); + expect(calls).toEqual([ + { method: "search", input: request }, + { method: "contract", input: identity }, + ]); + }); + + it("propagates discovery errors without a command fallback", async () => { + const channels = createChannelPair(); + createDispatcherRpcServer( + makeStubDispatcher({ + async getActionContract() { + throw new Error("Invalid action identity"); + }, + }), + channels.serverChannel, + ); + const { dispatcher } = createDispatcherRpcClient( + channels.clientChannel, + ); + await expect( + dispatcher.getActionContract({ + schemaName: "", + actionName: "select", + }), + ).rejects.toThrow("Invalid action identity"); + }); +}); describe("dispatcher RPC — cancelInteraction (fire-and-forget)", () => { it("sends a call message and does not wait for a reply", () => { const { serverChannel, clientChannel } = createChannelPair(); diff --git a/ts/packages/dispatcher/types/src/dispatcher.ts b/ts/packages/dispatcher/types/src/dispatcher.ts index 7bdbda27cb..40eb68dac7 100644 --- a/ts/packages/dispatcher/types/src/dispatcher.ts +++ b/ts/packages/dispatcher/types/src/dispatcher.ts @@ -18,6 +18,12 @@ import type { } from "./displayLogEntry.js"; import type { PendingInteractionResponse } from "./pendingInteraction.js"; import type { CancelResult, QueueSnapshot, SubmitResult } from "./queue.js"; +import type { + ActionContractResult, + ActionIdentity, + ActionSearchRequest, + ActionSearchResult, +} from "./structuredAction.js"; export const DispatcherName = "dispatcher"; export const DispatcherEmoji = "🤖"; @@ -508,6 +514,10 @@ export interface Dispatcher { */ getAgentSchemas(agentName?: string): Promise; + searchActions(request?: ActionSearchRequest): Promise; + + getActionContract(identity: ActionIdentity): Promise; + /** * Respond to a pending choice from an agent. * @param choiceId the choice ID returned from ChoiceManager.registerChoice diff --git a/ts/packages/dispatcher/types/src/index.ts b/ts/packages/dispatcher/types/src/index.ts index 80e53efa1c..cdf8d8e1b2 100644 --- a/ts/packages/dispatcher/types/src/index.ts +++ b/ts/packages/dispatcher/types/src/index.ts @@ -8,4 +8,5 @@ export * from "./pendingInteraction.js"; export * from "./queue.js"; export * from "./queueStateMirror.js"; export * from "./recordingDirective.js"; +export * from "./structuredAction.js"; export { awaitCommand } from "./awaitCommand.js"; diff --git a/ts/packages/dispatcher/types/src/structuredAction.ts b/ts/packages/dispatcher/types/src/structuredAction.ts new file mode 100644 index 0000000000..958995e5af --- /dev/null +++ b/ts/packages/dispatcher/types/src/structuredAction.ts @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ActionEffect, ReadinessReport } from "@typeagent/agent-sdk"; + +export const structuredActionProtocolVersion = 1; + +export type ActionIdentity = { + schemaName: string; + actionName: string; +}; + +export type ActionAvailability = { + state: + | "available" + | "disabled" + | "inactive" + | "loading" + | "setup-required" + | "unsupported" + | "unknown" + | "error"; + schemaEnabled: boolean; + actionEnabled: boolean; + schemaActive: boolean; + actionActive: boolean; + readiness: { + source: "cached" | "not-supported" | "uninitialized" | "not-checked"; + report?: ReadinessReport; + }; + message?: string; + // Discovery is not an authentication or resource-authorization check. + authorization: "checked-at-execution"; +}; + +export type ActionSummary = ActionIdentity & { + description: string; + availability: ActionAvailability; +}; + +export type ActionSearchRequest = { + query?: string; + agentName?: string; + schemaName?: string; + offset?: number; + limit?: number; +}; + +export type StructuredActionEnvelope = { + protocolVersion: typeof structuredActionProtocolVersion; + // Server-issued reuse boundary, not a bearer token or authorization grant. + scopeId: string; +}; + +export type ActionSearchResult = StructuredActionEnvelope & { + actions: ActionSummary[]; + total: number; + nextOffset?: number; +}; + +export type ActionExecutionPolicy = { + effects: ActionEffect; + confirmation: "required" | "not-required"; +}; + +export type ActionOutputContract = { + envelope: "ActionResult"; + optional: true; + resultValue: { type: "unknown"; optional: true }; + resultEntity: { type: "Entity"; optional: true }; + entities: { type: "Entity[]"; optional: true }; +}; + +export type ActionInteractionContract = { + // Agent hooks may request interactions even for read-only actions. + mode: "may-require-interaction"; + kinds: ("question" | "choice" | "form" | "action-proposal")[]; +}; + +export type ActionContract = ActionSummary & { + fingerprint: string; + input: { + format: "typescript"; + typeName: string; + schemaText: string; + }; + policy: ActionExecutionPolicy; + output: ActionOutputContract; + interactions: ActionInteractionContract; +}; + +export type ActionContractResult = StructuredActionEnvelope & + ( + | { status: "found"; contract: ActionContract } + // Deliberately does not distinguish absent and unauthorized identities. + | { status: "not-found" } + ); From f071da0735009a7e7f0de8191ecfa08f8fdf8891 Mon Sep 17 00:00:00 2001 From: George Ng Date: Wed, 16 Sep 2026 16:25:10 -0700 Subject: [PATCH 2/9] Simplify structured action availability Only expose active, enabled actions through discovery and exact contract lookup, and remove detailed availability state from the public protocol. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../dispatcher/src/context/appAgentManager.ts | 17 --- .../src/structuredAction/contract.ts | 3 - .../src/structuredAction/discovery.ts | 56 ++------- .../test/structuredActionDiscovery.spec.ts | 117 +++++------------- .../dispatcher/rpc/test/dispatcherRpc.spec.ts | 15 --- .../dispatcher/types/src/structuredAction.ts | 26 +--- 6 files changed, 44 insertions(+), 190 deletions(-) diff --git a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts index 12239920b2..9fa481613f 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts @@ -321,23 +321,6 @@ export class AppAgentManager implements ActionConfigProvider { return this.readiness.get(appAgentName) ?? { state: "ready" }; } - public getReadinessSnapshot(appAgentName: string): { - source: "cached" | "not-supported" | "uninitialized" | "not-checked"; - report?: ReadinessReport; - } { - const record = this.getRecord(appAgentName); - if (record.sessionContext === undefined) { - return { source: "uninitialized" }; - } - const report = this.readiness.get(appAgentName); - if (report !== undefined) { - return { source: "cached", report: { ...report } }; - } - return record.appAgent?.checkReadiness === undefined - ? { source: "not-supported", report: { state: "ready" } } - : { source: "not-checked" }; - } - // True iff this agent has been observed to implement checkReadiness // at any point this session AND we currently don't have a cached // report for it. In practice this means: the agent was enabled at diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/contract.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/contract.ts index 172b015b96..cba08be34f 100644 --- a/ts/packages/dispatcher/dispatcher/src/structuredAction/contract.ts +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/contract.ts @@ -13,7 +13,6 @@ import type { } from "@typeagent/action-schema"; import { structuredActionProtocolVersion, - type ActionAvailability, type ActionContract, type ActionExecutionPolicy, type ActionIdentity, @@ -122,7 +121,6 @@ export function createActionContract( identity: ActionIdentity, definition: ActionSchemaTypeDefinition, config: ActionConfig, - availability: ActionAvailability, ): ActionContract { const policy = getPolicy(config, identity.actionName); const output: ActionContract["output"] = { @@ -165,7 +163,6 @@ export function createActionContract( return { ...identity, description: getActionDescription(definition) ?? "", - availability, fingerprint, input: { format: "typescript", diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts index 5e640b51d8..01db8ba257 100644 --- a/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts @@ -5,7 +5,6 @@ import { randomUUID } from "node:crypto"; import { getActionDescription } from "@typeagent/action-schema"; import { structuredActionProtocolVersion, - type ActionAvailability, type ActionContractResult, type ActionIdentity, type ActionSearchRequest, @@ -70,45 +69,6 @@ function validateSearch(request: ActionSearchRequest): void { } } -function getAvailability( - agents: AppAgentManager, - schemaName: string, -): ActionAvailability { - const agentName = getAppAgentName(schemaName); - const readiness = agents.getReadinessSnapshot(agentName); - const availability: ActionAvailability = { - state: "available", - schemaEnabled: agents.isSchemaEnabled(schemaName), - actionEnabled: agents.isActionEnabled(schemaName), - schemaActive: agents.isSchemaActive(schemaName), - actionActive: agents.isActionActive(schemaName), - readiness, - authorization: "checked-at-execution", - }; - const loadError = agents.getLoadError(agentName); - if (agents.isSchemaLoading(schemaName)) { - availability.state = "loading"; - } else if (loadError !== undefined) { - availability.state = "error"; - availability.message = loadError.message; - } else if (!availability.schemaEnabled || !availability.actionEnabled) { - availability.state = "disabled"; - } else if (!availability.schemaActive || !availability.actionActive) { - availability.state = "inactive"; - } else if (readiness.report === undefined) { - availability.state = "unknown"; - } else if (readiness.report.state !== "ready") { - availability.state = readiness.report.state; - } - if ( - availability.message === undefined && - readiness.report?.message !== undefined - ) { - availability.message = readiness.report.message; - } - return availability; -} - export class StructuredActionDiscovery { private readonly anonymousScope = {}; @@ -150,16 +110,14 @@ export class StructuredActionDiscovery { request.schemaName !== config.schemaName) || (request.agentName !== undefined && request.agentName !== getAppAgentName(config.schemaName)) || - policy?.canDiscoverSchema(config.schemaName) === false + policy?.canDiscoverSchema(config.schemaName) === false || + !this.context.agents.isSchemaActive(config.schemaName) || + !this.context.agents.isActionActive(config.schemaName) ) { continue; } const schema = this.context.agents.getActionSchemaFileForConfig(config); - const availability = getAvailability( - this.context.agents, - config.schemaName, - ); for (const [actionName, definition] of schema.parsedActionSchema .actionSchemas) { const description = getActionDescription(definition) ?? ""; @@ -175,7 +133,6 @@ export class StructuredActionDiscovery { schemaName: config.schemaName, actionName, description, - availability, }); } } @@ -228,6 +185,12 @@ export class StructuredActionDiscovery { if (config === undefined) { return { ...envelope, status: "not-found" }; } + if ( + !this.context.agents.isSchemaActive(identity.schemaName) || + !this.context.agents.isActionActive(identity.schemaName) + ) { + return { ...envelope, status: "not-found" }; + } const schema = this.context.agents.getActionSchemaFileForConfig(config); const definition = schema.parsedActionSchema.actionSchemas.get( identity.actionName, @@ -245,7 +208,6 @@ export class StructuredActionDiscovery { }, definition, config, - getAvailability(this.context.agents, identity.schemaName), ), }; } diff --git a/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts b/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts index e65dc4e7ad..00820df610 100644 --- a/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts @@ -2,11 +2,7 @@ // Licensed under the MIT License. import { jest } from "@jest/globals"; -import type { - ActionPolicy, - AppAgent, - ReadinessReport, -} from "@typeagent/agent-sdk"; +import type { ActionPolicy, AppAgent } from "@typeagent/agent-sdk"; import type { ActionContractResult, ActionIdentity, @@ -60,9 +56,6 @@ function fixture(content = source, policies?: Record) { const state = agents as unknown as { agents: Map; actionConfigs: Map; - readiness: Map; - loadErrors: Map; - loadingSchemas: Set; transientAgents: Record; }; const configs = convertToActionConfig("test", { @@ -110,7 +103,6 @@ function fixture(content = source, policies?: Record) { sessionContext: {}, }; state.agents.set("test", agent); - state.readiness.set("test", { state: "ready" }); const context = { agents, session: {} }; return { agents, @@ -166,6 +158,7 @@ describe("structured action contracts", () => { resultEntity: { type: "Entity", optional: true }, entities: { type: "Entity[]", optional: true }, }); + expect(contract).not.toHaveProperty("availability"); }); it("distinguishes duplicate action names and rejects case-insensitive guesses", async () => { @@ -324,6 +317,7 @@ describe("structured action discovery", () => { ]); expect(page.nextOffset).toBe(2); expect(page.actions[0]).not.toHaveProperty("input"); + expect(page.actions[0]).not.toHaveProperty("availability"); if (page.nextOffset === undefined) { throw new Error("Expected another page"); } @@ -365,91 +359,48 @@ describe("structured action discovery", () => { ).rejects.toThrow(); }); - it("keeps semantic fingerprints stable across readiness and enablement changes", async () => { - const { service, state, agent } = fixture(); - const first = await service.getActionContract(identity); - const original = found(first); - state.readiness.set("test", { - state: "setup-required", - message: "Sign in first", - }); - const needsSetup = await service.getActionContract(identity); - expect(found(needsSetup).availability.state).toBe("setup-required"); - expect(found(needsSetup).fingerprint).toBe(original.fingerprint); - expect(needsSetup.scopeId).toBe(first.scopeId); - agent.actions.delete(identity.schemaName); - expect(found(await service.getActionContract(identity))).toMatchObject({ - fingerprint: original.fingerprint, - availability: { state: "disabled", actionEnabled: false }, - }); - }); + it("excludes actions unless their schema and action are active", async () => { + const { service, state, agent, hooks } = fixture(); + const assertHidden = async () => { + expect( + (await service.searchActions()).actions.some( + (action) => action.schemaName === identity.schemaName, + ), + ).toBe(false); + expect(await service.getActionContract(identity)).toMatchObject({ + status: "not-found", + }); + }; - it("reports exact schema/action state, not command enablement", async () => { - const { service, state, agent } = fixture(); - agent.actions.clear(); - expect( - found(await service.getActionContract(identity)).availability.state, - ).toBe("disabled"); + agent.actions.delete(identity.schemaName); + await assertHidden(); agent.actions.add(identity.schemaName); - agent.schemas.clear(); - expect( - found(await service.getActionContract(identity)).availability.state, - ).toBe("disabled"); + agent.schemas.delete(identity.schemaName); + await assertHidden(); agent.schemas.add(identity.schemaName); state.transientAgents[identity.schemaName] = false; - expect( - found(await service.getActionContract(identity)).availability.state, - ).toBe("inactive"); - }); + await assertHidden(); - it("reports loading, failures, unsupported and unknown readiness without probing", async () => { - const { service, state, agent, hooks } = fixture(); - state.loadingSchemas.add(identity.schemaName); - expect( - found(await service.getActionContract(identity)).availability.state, - ).toBe("loading"); - state.loadingSchemas.clear(); - state.loadErrors.set("test", new Error("load failed")); - expect( - found(await service.getActionContract(identity)).availability.state, - ).toBe("error"); - state.loadErrors.clear(); - state.readiness.set("test", { - state: "unsupported", - message: "unsupported OS", - }); - expect( - found(await service.getActionContract(identity)).availability.state, - ).toBe("unsupported"); - state.readiness.clear(); - expect( - found(await service.getActionContract(identity)).availability - .readiness.source, - ).toBe("not-checked"); - delete agent.sessionContext; - expect( - found(await service.getActionContract(identity)).availability, - ).toMatchObject({ - state: "unknown", - readiness: { source: "uninitialized" }, - }); - await service.searchActions(); for (const hook of Object.values(hooks)) { expect(hook).not.toHaveBeenCalled(); } }); - it("does not claim verified authentication for an agent without readiness support", async () => { - const { service, state, agent } = fixture(); - state.readiness.clear(); - agent.appAgent = {}; + it("filters inactive schemas before parsing them", async () => { + const { service, agents, agent } = fixture(); + agents.getActionConfig("test.other").schemaFile = { + format: "ts", + content: "invalid schema", + }; + agent.actions.delete("test.other"); + + expect((await service.searchActions()).total).toBe(3); expect( - found(await service.getActionContract(identity)).availability, - ).toMatchObject({ - state: "available", - readiness: { source: "not-supported" }, - authorization: "checked-at-execution", - }); + await service.getActionContract({ + schemaName: "test.other", + actionName: "select", + }), + ).toMatchObject({ status: "not-found" }); }); it("binds scope to the facade, live session, and trusted permission revision", async () => { diff --git a/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts b/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts index 728a951a22..f1c7e994c7 100644 --- a/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts +++ b/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts @@ -159,21 +159,6 @@ describe("dispatcher RPC structured discovery", () => { const summary = { ...identity, description: "Select", - availability: { - state: "setup-required" as const, - schemaEnabled: true, - actionEnabled: true, - schemaActive: true, - actionActive: true, - readiness: { - source: "cached" as const, - report: { - state: "setup-required" as const, - message: "Configure first", - }, - }, - authorization: "checked-at-execution" as const, - }, }; const searchResult: ActionSearchResult = { protocolVersion: 1, diff --git a/ts/packages/dispatcher/types/src/structuredAction.ts b/ts/packages/dispatcher/types/src/structuredAction.ts index 958995e5af..bc82b9f777 100644 --- a/ts/packages/dispatcher/types/src/structuredAction.ts +++ b/ts/packages/dispatcher/types/src/structuredAction.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { ActionEffect, ReadinessReport } from "@typeagent/agent-sdk"; +import type { ActionEffect } from "@typeagent/agent-sdk"; export const structuredActionProtocolVersion = 1; @@ -10,32 +10,8 @@ export type ActionIdentity = { actionName: string; }; -export type ActionAvailability = { - state: - | "available" - | "disabled" - | "inactive" - | "loading" - | "setup-required" - | "unsupported" - | "unknown" - | "error"; - schemaEnabled: boolean; - actionEnabled: boolean; - schemaActive: boolean; - actionActive: boolean; - readiness: { - source: "cached" | "not-supported" | "uninitialized" | "not-checked"; - report?: ReadinessReport; - }; - message?: string; - // Discovery is not an authentication or resource-authorization check. - authorization: "checked-at-execution"; -}; - export type ActionSummary = ActionIdentity & { description: string; - availability: ActionAvailability; }; export type ActionSearchRequest = { From 1527c88d033b37ab67c653890225617892db570b Mon Sep 17 00:00:00 2001 From: George Ng Date: Wed, 16 Sep 2026 16:58:59 -0700 Subject: [PATCH 3/9] Merge action search and contract discovery Return every matching closed contract from a required natural-language query, removing pagination, internal catalog filters, and the separate contract lookup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../director-actions.md | 24 +- .../dispatcher/dispatcher/src/dispatcher.ts | 3 - .../src/structuredAction/discovery.ts | 107 +------- .../test/structuredActionDiscovery.spec.ts | 236 +++++++++--------- .../dispatcher/rpc/src/dispatcherClient.ts | 3 - .../dispatcher/rpc/src/dispatcherServer.ts | 3 - .../dispatcher/rpc/src/dispatcherTypes.ts | 6 +- .../dispatcher/rpc/test/dispatcherRpc.spec.ts | 96 +++---- .../dispatcher/types/src/dispatcher.ts | 6 +- .../dispatcher/types/src/structuredAction.ts | 28 +-- 10 files changed, 186 insertions(+), 326 deletions(-) diff --git a/ts/docs/plans/copilot-direct-actions/director-actions.md b/ts/docs/plans/copilot-direct-actions/director-actions.md index a15b50ee88..63f3240af3 100644 --- a/ts/docs/plans/copilot-direct-actions/director-actions.md +++ b/ts/docs/plans/copilot-direct-actions/director-actions.md @@ -111,7 +111,7 @@ Discovery should provide enough information for Copilot to determine: - Input schema - Relevant constraints - Expected outputs and any authorization, confirmation, or interaction requirements -- Whether the action is currently available, and what setup is missing if it is not +- Only actions that are currently active and enabled **Conceptually:** @@ -127,30 +127,27 @@ Discovery should provide enough information for Copilot to determine: input schema input schema ``` -For large action catalogs, discovery should preferably support search and progressive disclosure rather than requiring the entire TypeAgent action catalog to be loaded into Copilot's context. +For large action catalogs, discovery should support search and progressive disclosure rather than requiring the entire TypeAgent action catalog to be registered as MCP tools or loaded into Copilot's context. -Use two required levels of progressive disclosure: +Search accepts one natural-language query and returns closed contracts for every matching action. Until discovery has a relevance ranker, it must not silently truncate the matching set. Callers should use a focused query when the active catalog is large. -1. **Action summary:** Search or list compact action identifiers, descriptions, and current availability. -2. **Action contract:** Retrieve one closed, self-contained contract with its parameters, referenced types, constraints, outputs, and interaction requirements. - -Server status and capability or schema names may be returned as metadata and search filters, but they should not be mandatory retrieval stages. Treating them as required levels would add round trips without improving the contract boundary. The action is the unit of selection, caching, and compatibility. +Each result is a closed, self-contained action contract with its identity, description, parameters, referenced types, constraints, outputs, and interaction requirements. This combines candidate discovery and contract hydration so the normal structured path requires only discovery and execution. The action remains the unit of selection, caching, and compatibility. The normal flow is: ```text -search actions → get selected action contract → execute action +search action contracts → execute selected action ``` -A caller that already knows the action should be able to fetch its contract directly; a caller with a current contract should not need to repeat discovery. A contract must include referenced enums and nested types without loading unrelated actions from the same schema. +A caller with a current contract should not need to repeat discovery. A contract must include referenced enums and nested types without loading unrelated actions from the same schema. -Contracts may be reused within the same server and session/permission scope. TypeAgent must detect an outdated contract before execution and ask the caller to refresh it using the exact-match mechanism below. +Contracts may be reused within the same server and session/permission scope. TypeAgent must detect an outdated contract before execution and ask the caller to refresh it with a new search. -Discovery must respect the caller's permissions. It neither enables actions nor grants permission to execute them. No match or an ambiguous match should lead to clarification or natural-language handling, not guessed action parameters. +Discovery must respect the caller's permissions. It neither enables actions nor grants permission to execute them. Disabled and inactive actions are excluded. No match or an ambiguous match should lead to clarification or natural-language handling, not guessed action parameters. ### Contract Versioning -Discovery responses include a protocol version for the structured-action envelope and an opaque fingerprint for each action contract. Execution must supply the fingerprint returned with the selected contract. +Discovery responses include a protocol version for the structured-action envelope and an opaque fingerprint for each returned action contract. Execution must supply the fingerprint returned with the selected contract. TypeAgent compares the supplied fingerprint with the current contract before any effect is possible. A mismatch returns `contract_stale` without executing the action. The caller must fetch the current contract and construct a new request; TypeAgent must not reinterpret parameters under the changed contract. @@ -162,8 +159,7 @@ Version 1 uses exact fingerprint matching rather than attempting semantic compat Expose a small, fixed set of operations through the existing TypeAgent MCP server: -- Search or list action summaries. -- Retrieve one complete action contract. +- Search complete action contracts with a natural-language query. - Execute one action against that contract. - Continue or cancel a pending interaction when the transport cannot represent that interaction directly. diff --git a/ts/packages/dispatcher/dispatcher/src/dispatcher.ts b/ts/packages/dispatcher/dispatcher/src/dispatcher.ts index 31996e2cd4..e7a350eafa 100644 --- a/ts/packages/dispatcher/dispatcher/src/dispatcher.ts +++ b/ts/packages/dispatcher/dispatcher/src/dispatcher.ts @@ -401,9 +401,6 @@ export function createDispatcherFromContext( async searchActions(request) { return structuredActions.searchActions(request); }, - async getActionContract(identity) { - return structuredActions.getActionContract(identity); - }, async cancelCommand(requestId: string): Promise { const kind = context.requestQueue.classifyCancel(requestId, "user"); if (kind === "queued") { diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts index 01db8ba257..a095096f88 100644 --- a/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts @@ -5,15 +5,12 @@ import { randomUUID } from "node:crypto"; import { getActionDescription } from "@typeagent/action-schema"; import { structuredActionProtocolVersion, - type ActionContractResult, - type ActionIdentity, + type ActionContract, type ActionSearchRequest, type ActionSearchResult, - type ActionSummary, type StructuredActionEnvelope, } from "@typeagent/dispatcher-types"; import type { AppAgentManager } from "../context/appAgentManager.js"; -import { getAppAgentName } from "../translation/agentTranslators.js"; import { createActionContract } from "./contract.js"; // Host-only policy. Never deserialize this from a discovery/RPC request. @@ -45,28 +42,7 @@ function validateSearch(request: ActionSearchRequest): void { ) { throw new Error("Action search request must be an object"); } - if (request.query !== undefined && typeof request.query !== "string") { - throw new Error("query must be a string"); - } - for (const key of ["agentName", "schemaName"] as const) { - if (request[key] !== undefined) { - validateString(request[key], key); - } - } - if ( - request.offset !== undefined && - (!Number.isSafeInteger(request.offset) || request.offset < 0) - ) { - throw new Error("offset must be a nonnegative safe integer"); - } - if ( - request.limit !== undefined && - (!Number.isSafeInteger(request.limit) || - request.limit < 1 || - request.limit > 200) - ) { - throw new Error("limit must be an integer between 1 and 200"); - } + validateString(request.query, "query"); } export class StructuredActionDiscovery { @@ -98,18 +74,14 @@ export class StructuredActionDiscovery { } public async searchActions( - request: ActionSearchRequest = {}, + request: ActionSearchRequest, ): Promise { validateSearch(request); const { envelope, policy } = this.bindScope(); - const query = request.query?.trim().toLowerCase(); - const matches: ActionSummary[] = []; + const query = request.query.trim().toLowerCase(); + const matches: ActionContract[] = []; for (const config of this.context.agents.getActionConfigs()) { if ( - (request.schemaName !== undefined && - request.schemaName !== config.schemaName) || - (request.agentName !== undefined && - request.agentName !== getAppAgentName(config.schemaName)) || policy?.canDiscoverSchema(config.schemaName) === false || !this.context.agents.isSchemaActive(config.schemaName) || !this.context.agents.isActionActive(config.schemaName) @@ -122,18 +94,19 @@ export class StructuredActionDiscovery { .actionSchemas) { const description = getActionDescription(definition) ?? ""; if ( - query && !`${config.schemaName} ${actionName} ${description}` .toLowerCase() .includes(query) ) { continue; } - matches.push({ - schemaName: config.schemaName, - actionName, - description, - }); + matches.push( + createActionContract( + { schemaName: config.schemaName, actionName }, + definition, + config, + ), + ); } } matches.sort((a, b) => { @@ -152,63 +125,9 @@ export class StructuredActionDiscovery { : 0) ); }); - const offset = request.offset ?? 0; - const end = offset + (request.limit ?? 50); - return { - ...envelope, - actions: matches.slice(offset, end), - total: matches.length, - ...(end < matches.length ? { nextOffset: end } : {}), - }; - } - - public async getActionContract( - identity: ActionIdentity, - ): Promise { - if ( - identity === null || - typeof identity !== "object" || - Array.isArray(identity) - ) { - throw new Error("Action identity must be an object"); - } - validateString(identity.schemaName, "schemaName"); - validateString(identity.actionName, "actionName"); - const { envelope, policy } = this.bindScope(); - // Check visibility before looking up or parsing the schema. - if (policy?.canDiscoverSchema(identity.schemaName) === false) { - return { ...envelope, status: "not-found" }; - } - const config = this.context.agents.tryGetActionConfig( - identity.schemaName, - ); - if (config === undefined) { - return { ...envelope, status: "not-found" }; - } - if ( - !this.context.agents.isSchemaActive(identity.schemaName) || - !this.context.agents.isActionActive(identity.schemaName) - ) { - return { ...envelope, status: "not-found" }; - } - const schema = this.context.agents.getActionSchemaFileForConfig(config); - const definition = schema.parsedActionSchema.actionSchemas.get( - identity.actionName, - ); - if (definition === undefined) { - return { ...envelope, status: "not-found" }; - } return { ...envelope, - status: "found", - contract: createActionContract( - { - schemaName: identity.schemaName, - actionName: identity.actionName, - }, - definition, - config, - ), + actions: matches, }; } } diff --git a/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts b/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts index 00820df610..1ac5469c4d 100644 --- a/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts @@ -3,10 +3,7 @@ import { jest } from "@jest/globals"; import type { ActionPolicy, AppAgent } from "@typeagent/agent-sdk"; -import type { - ActionContractResult, - ActionIdentity, -} from "@typeagent/dispatcher-types"; +import type { ActionIdentity } from "@typeagent/dispatcher-types"; import { parseActionSchemaSource } from "@typeagent/action-schema"; import { AppAgentManager } from "../src/context/appAgentManager.js"; import { PortRegistrar } from "../src/context/portRegistrar.js"; @@ -114,11 +111,17 @@ function fixture(content = source, policies?: Record) { }; } -function found(result: ActionContractResult) { - if (result.status !== "found") { - throw new Error("Expected contract"); +async function getContract( + service: StructuredActionDiscovery, + actionIdentity: ActionIdentity = identity, +) { + const result = await service.searchActions({ + query: `${actionIdentity.schemaName} ${actionIdentity.actionName}`, + }); + if (result.actions.length !== 1) { + throw new Error(`Expected one contract, got ${result.actions.length}`); } - return result.contract; + return result.actions[0]; } async function fingerprint(content = source, policy?: ActionPolicy) { @@ -126,16 +129,18 @@ async function fingerprint(content = source, policy?: ActionPolicy) { content, policy ? { select: policy } : undefined, ); - return found(await service.getActionContract(identity)).fingerprint; + return (await getContract(service)).fingerprint; } describe("structured action contracts", () => { - it("retrieves exactly one action directly, with a closed dependency graph", async () => { + it("hydrates an exact action with a closed dependency graph", async () => { const { service, agents } = fixture(); const enumerate = jest.spyOn(agents, "getActionConfigs"); - const result = await service.getActionContract(identity); - const contract = found(result); - expect(enumerate).not.toHaveBeenCalled(); + const result = await service.searchActions({ + query: `${identity.schemaName} ${identity.actionName}`, + }); + const contract = result.actions[0]; + expect(enumerate).toHaveBeenCalledTimes(1); expect(result.protocolVersion).toBe(1); expect(result.scopeId).toEqual(expect.any(String)); expect(contract.input.format).toBe("typescript"); @@ -161,42 +166,40 @@ describe("structured action contracts", () => { expect(contract).not.toHaveProperty("availability"); }); - it("distinguishes duplicate action names and rejects case-insensitive guesses", async () => { + it("preserves exact identities for duplicate action names", async () => { const { service } = fixture(); - const other = found( - await service.getActionContract({ - schemaName: "test.other", - actionName: "select", - }), - ); + const matches = await service.searchActions({ query: "select" }); + expect( + matches.actions.map(({ schemaName, actionName }) => ({ + schemaName, + actionName, + })), + ).toEqual([ + { schemaName: "test.items", actionName: "select" }, + { schemaName: "test.other", actionName: "select" }, + ]); + const other = await getContract(service, { + schemaName: "test.other", + actionName: "select", + }); expect(other.input.schemaText).toContain("id: number"); - for (const missing of [ - { schemaName: "test", actionName: "select" }, - { schemaName: "TEST.items", actionName: "select" }, - { schemaName: "test.items", actionName: "SELECT" }, - ]) { - expect((await service.getActionContract(missing)).status).toBe( - "not-found", - ); - } }); it("handles no parameters and an optional parameter object", async () => { const { service } = fixture(); expect( - found( - await service.getActionContract({ + ( + await getContract(service, { ...identity, actionName: "ping", - }), + }) ).input.schemaText, ).not.toContain("parameters"); const optional = fixture( source.replace("parameters: {", "parameters?: {"), ); expect( - found(await optional.service.getActionContract(identity)).input - .schemaText, + (await getContract(optional.service)).input.schemaText, ).toContain("parameters?:"); }); @@ -207,9 +210,7 @@ describe("structured action contracts", () => { "color: Color;\n children?: Item[];", ), ); - const contract = found( - await recursive.service.getActionContract(identity), - ); + const contract = await getContract(recursive.service); expect(contract.input.schemaText).toContain("children?: Item[]"); expect(contract.input.schemaText.match(/type Item =/g)).toHaveLength(1); expect(contract.input.schemaText).not.toContain("type Clear"); @@ -287,7 +288,7 @@ describe("structured action contracts", () => { source, policy ? { select: policy } : undefined, ); - const contract = found(await service.getActionContract(identity)); + const contract = await getContract(service); expect(contract.policy).toEqual({ effects, confirmation }); expect(contract.interactions.mode).toBe("may-require-interaction"); }, @@ -300,62 +301,57 @@ describe("structured action contracts", () => { select: { effects: "read-only", confirmation: "never" }, }, }); - await expect(service.getActionContract(identity)).rejects.toThrow( - "Invalid structured action policy", - ); + await expect( + service.searchActions({ + query: `${identity.schemaName} ${identity.actionName}`, + }), + ).rejects.toThrow("Invalid structured action policy"); }); }); describe("structured action discovery", () => { - it("lists compact action summaries with filters and pagination", async () => { + it("returns every matching action as a complete contract", async () => { const { service } = fixture(); - const page = await service.searchActions({ limit: 2 }); - expect(page.total).toBe(4); - expect(page.actions.map((a) => a.actionName)).toEqual([ + const result = await service.searchActions({ query: "test" }); + expect(result.actions.map((a) => a.actionName)).toEqual([ "clear", "ping", + "select", + "select", ]); - expect(page.nextOffset).toBe(2); - expect(page.actions[0]).not.toHaveProperty("input"); - expect(page.actions[0]).not.toHaveProperty("availability"); - if (page.nextOffset === undefined) { - throw new Error("Expected another page"); - } - const remaining = await service.searchActions({ - offset: page.nextOffset, - limit: 2, - }); - expect(remaining.actions.map((a) => a.schemaName)).toEqual([ - "test.items", - "test.other", - ]); - expect(remaining.nextOffset).toBeUndefined(); - expect((await service.searchActions({ query: "SELECT" })).total).toBe( - 2, - ); expect( - (await service.searchActions({ schemaName: "test.other" })).total, - ).toBe(1); + result.actions.every((action) => action.input !== undefined), + ).toBe(true); + expect(result.actions[0]).not.toHaveProperty("availability"); expect( - (await service.searchActions({ agentName: "missing" })).total, - ).toBe(0); - expect((await service.searchActions({ query: "an item" })).total).toBe( - 1, - ); - expect((await service.searchActions({ offset: 50 })).actions).toEqual( - [], - ); + (await service.searchActions({ query: "SELECT" })).actions, + ).toHaveLength(2); + expect( + (await service.searchActions({ query: "test.other select" })) + .actions, + ).toHaveLength(1); + expect( + (await service.searchActions({ query: "missing" })).actions, + ).toEqual([]); + expect( + (await service.searchActions({ query: "an item" })).actions, + ).toHaveLength(1); }); - it.each([ - { limit: 0 }, - { limit: 201 }, - { offset: -1 }, - { offset: 0.5 }, - { schemaName: "" }, - ])("rejects malformed search %j", async (request) => { + it.each([{ query: "" }, { query: " " }])( + "rejects malformed search %j", + async (request) => { + await expect( + fixture().service.searchActions(request), + ).rejects.toThrow(); + }, + ); + + it("rejects a missing search request", async () => { await expect( - fixture().service.searchActions(request), + fixture().service.searchActions( + undefined as unknown as { query: string }, + ), ).rejects.toThrow(); }); @@ -363,13 +359,9 @@ describe("structured action discovery", () => { const { service, state, agent, hooks } = fixture(); const assertHidden = async () => { expect( - (await service.searchActions()).actions.some( - (action) => action.schemaName === identity.schemaName, - ), - ).toBe(false); - expect(await service.getActionContract(identity)).toMatchObject({ - status: "not-found", - }); + (await service.searchActions({ query: identity.schemaName })) + .actions, + ).toEqual([]); }; agent.actions.delete(identity.schemaName); @@ -394,42 +386,49 @@ describe("structured action discovery", () => { }; agent.actions.delete("test.other"); - expect((await service.searchActions()).total).toBe(3); expect( - await service.getActionContract({ - schemaName: "test.other", - actionName: "select", - }), - ).toMatchObject({ status: "not-found" }); + (await service.searchActions({ query: "test" })).actions, + ).toHaveLength(3); + expect( + (await service.searchActions({ query: "test.other select" })) + .actions, + ).toEqual([]); }); it("binds scope to the facade, live session, and trusted permission revision", async () => { const { context, service } = fixture(); - const first = await service.getActionContract(identity); - expect((await service.searchActions()).scopeId).toBe(first.scopeId); + const first = await service.searchActions({ query: "select" }); + expect((await service.searchActions({ query: "test" })).scopeId).toBe( + first.scopeId, + ); expect( - (await new StructuredActionDiscovery(context).searchActions()) - .scopeId, + ( + await new StructuredActionDiscovery(context).searchActions({ + query: "test", + }) + ).scopeId, ).not.toBe(first.scopeId); context.session = {}; - expect((await service.searchActions()).scopeId).not.toBe(first.scopeId); + expect( + (await service.searchActions({ query: "test" })).scopeId, + ).not.toBe(first.scopeId); let scope = {}; const restricted = new StructuredActionDiscovery(context, () => ({ scope, canDiscoverSchema: () => true, })); - const before = await restricted.searchActions(); + const before = await restricted.searchActions({ query: "test" }); const reconnected = new StructuredActionDiscovery(context, () => ({ scope, canDiscoverSchema: () => true, })); - expect((await reconnected.searchActions()).scopeId).toBe( - before.scopeId, - ); + expect( + (await reconnected.searchActions({ query: "test" })).scopeId, + ).toBe(before.scopeId); scope = {}; - expect((await restricted.searchActions()).scopeId).not.toBe( - before.scopeId, - ); + expect( + (await restricted.searchActions({ query: "test" })).scopeId, + ).not.toBe(before.scopeId); }); it("filters denied schemas before parsing and does not reveal their existence", async () => { @@ -443,23 +442,22 @@ describe("structured action discovery", () => { scope, canDiscoverSchema: (name) => name === identity.schemaName, })); - expect((await service.searchActions()).total).toBe(3); expect( - await service.getActionContract({ - schemaName: "test.other", - actionName: "select", - }), - ).toEqual( - await service.getActionContract({ - schemaName: "secret", - actionName: "select", - }), - ); + (await service.searchActions({ query: "test" })).actions, + ).toHaveLength(3); + expect( + (await service.searchActions({ query: "test.other select" })) + .actions, + ).toEqual([]); + expect( + (await service.searchActions({ query: "secret select" })).actions, + ).toEqual([]); }); it("propagates visible schema failures rather than returning empty success", async () => { const { service } = fixture("invalid schema"); - await expect(service.getActionContract(identity)).rejects.toThrow(); - await expect(service.searchActions()).rejects.toThrow(); + await expect( + service.searchActions({ query: "test" }), + ).rejects.toThrow(); }); }); diff --git a/ts/packages/dispatcher/rpc/src/dispatcherClient.ts b/ts/packages/dispatcher/rpc/src/dispatcherClient.ts index 3b0fcee870..f60d4711e1 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherClient.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherClient.ts @@ -214,9 +214,6 @@ export function createDispatcherRpcClient( async searchActions(...args) { return rpc.invoke("searchActions", ...args); }, - async getActionContract(...args) { - return rpc.invoke("getActionContract", ...args); - }, async respondToChoice(...args) { return rpc.invoke("respondToChoice", ...args); }, diff --git a/ts/packages/dispatcher/rpc/src/dispatcherServer.ts b/ts/packages/dispatcher/rpc/src/dispatcherServer.ts index 23a6cf663f..2a54c7c621 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherServer.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherServer.ts @@ -100,9 +100,6 @@ export function createDispatcherRpcServer( searchActions: async (...args) => { return dispatcher.searchActions(...args); }, - getActionContract: async (...args) => { - return dispatcher.getActionContract(...args); - }, respondToChoice: async (...args) => { return dispatcher.respondToChoice(...args); }, diff --git a/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts b/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts index 77480c7c5c..f8e5ad8ba5 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts @@ -9,8 +9,6 @@ import type { } from "@typeagent/agent-sdk"; import type { AgentSchemaInfo, - ActionContractResult, - ActionIdentity, ActionSearchRequest, ActionSearchResult, CancelResult, @@ -103,9 +101,7 @@ export type DispatcherInvokeFunctions = { getAgentSchemas(agentName?: string): Promise; - searchActions(request?: ActionSearchRequest): Promise; - - getActionContract(identity: ActionIdentity): Promise; + searchActions(request: ActionSearchRequest): Promise; respondToChoice( choiceId: string, diff --git a/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts b/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts index f1c7e994c7..9e36da3aba 100644 --- a/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts +++ b/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts @@ -6,7 +6,6 @@ import type { RpcStructuredLogger } from "@typeagent/agent-rpc/rpc"; import { createDispatcherRpcClient } from "../src/dispatcherClient.js"; import { createDispatcherRpcServer } from "../src/dispatcherServer.js"; import type { - ActionContractResult, ActionSearchResult, CommandResult, Dispatcher, @@ -75,7 +74,6 @@ function makeStubDispatcher(overrides: Partial = {}): Dispatcher & { getStatus: notImplemented("getStatus") as any, getAgentSchemas: notImplemented("getAgentSchemas") as any, searchActions: notImplemented("searchActions"), - getActionContract: notImplemented("getActionContract"), respondToChoice: notImplemented("respondToChoice") as any, getDisplayHistory: notImplemented("getDisplayHistory") as any, async cancelCommand(...args) { @@ -154,83 +152,68 @@ describe("dispatcher RPC lifecycle options", () => { }); describe("dispatcher RPC structured discovery", () => { - it("forwards exact identities and the complete versioned contract", async () => { + it("forwards filters and complete versioned contracts", async () => { const identity = { schemaName: "test.sub", actionName: "select" }; - const summary = { - ...identity, - description: "Select", - }; const searchResult: ActionSearchResult = { protocolVersion: 1, scopeId: "server-scope", - actions: [summary], - total: 1, - }; - const contractResult: ActionContractResult = { - protocolVersion: 1, - scopeId: "server-scope", - status: "found", - contract: { - ...summary, - fingerprint: "opaque-fingerprint", - input: { - format: "typescript", - typeName: "Select", - schemaText: - 'type Select = { actionName: "select"; parameters: { id?: string } };', - }, - policy: { effects: "unknown", confirmation: "required" }, - output: { - envelope: "ActionResult", - optional: true, - resultValue: { type: "unknown", optional: true }, - resultEntity: { type: "Entity", optional: true }, - entities: { type: "Entity[]", optional: true }, - }, - interactions: { - mode: "may-require-interaction", - kinds: ["question", "choice", "form", "action-proposal"], + actions: [ + { + ...identity, + description: "Select", + fingerprint: "opaque-fingerprint", + input: { + format: "typescript", + typeName: "Select", + schemaText: + 'type Select = { actionName: "select"; parameters: { id?: string } };', + }, + policy: { effects: "unknown", confirmation: "required" }, + output: { + envelope: "ActionResult", + optional: true, + resultValue: { type: "unknown", optional: true }, + resultEntity: { type: "Entity", optional: true }, + entities: { type: "Entity[]", optional: true }, + }, + interactions: { + mode: "may-require-interaction", + kinds: [ + "question", + "choice", + "form", + "action-proposal", + ], + }, }, - }, + ], }; const calls: { method: string; input: unknown }[] = []; const searchActions: Dispatcher["searchActions"] = async (input) => { calls.push({ method: "search", input }); return searchResult; }; - const getActionContract: Dispatcher["getActionContract"] = async ( - input, - ) => { - calls.push({ method: "contract", input }); - return contractResult; - }; const channels = createChannelPair(); createDispatcherRpcServer( - makeStubDispatcher({ searchActions, getActionContract }), + makeStubDispatcher({ searchActions }), channels.serverChannel, ); const { dispatcher } = createDispatcherRpcClient( channels.clientChannel, ); - const request = { schemaName: "test.sub", limit: 1 }; + const request = { query: "select" }; await expect(dispatcher.searchActions(request)).resolves.toEqual( searchResult, ); - await expect(dispatcher.getActionContract(identity)).resolves.toEqual( - contractResult, - ); - expect(calls).toEqual([ - { method: "search", input: request }, - { method: "contract", input: identity }, - ]); + expect(calls).toEqual([{ method: "search", input: request }]); }); it("propagates discovery errors without a command fallback", async () => { const channels = createChannelPair(); createDispatcherRpcServer( makeStubDispatcher({ - async getActionContract() { - throw new Error("Invalid action identity"); + async searchActions() { + throw new Error("Invalid action filter"); }, }), channels.serverChannel, @@ -238,12 +221,9 @@ describe("dispatcher RPC structured discovery", () => { const { dispatcher } = createDispatcherRpcClient( channels.clientChannel, ); - await expect( - dispatcher.getActionContract({ - schemaName: "", - actionName: "select", - }), - ).rejects.toThrow("Invalid action identity"); + await expect(dispatcher.searchActions({ query: "" })).rejects.toThrow( + "Invalid action filter", + ); }); }); describe("dispatcher RPC — cancelInteraction (fire-and-forget)", () => { diff --git a/ts/packages/dispatcher/types/src/dispatcher.ts b/ts/packages/dispatcher/types/src/dispatcher.ts index 40eb68dac7..0ea8e9b094 100644 --- a/ts/packages/dispatcher/types/src/dispatcher.ts +++ b/ts/packages/dispatcher/types/src/dispatcher.ts @@ -19,8 +19,6 @@ import type { import type { PendingInteractionResponse } from "./pendingInteraction.js"; import type { CancelResult, QueueSnapshot, SubmitResult } from "./queue.js"; import type { - ActionContractResult, - ActionIdentity, ActionSearchRequest, ActionSearchResult, } from "./structuredAction.js"; @@ -514,9 +512,7 @@ export interface Dispatcher { */ getAgentSchemas(agentName?: string): Promise; - searchActions(request?: ActionSearchRequest): Promise; - - getActionContract(identity: ActionIdentity): Promise; + searchActions(request: ActionSearchRequest): Promise; /** * Respond to a pending choice from an agent. diff --git a/ts/packages/dispatcher/types/src/structuredAction.ts b/ts/packages/dispatcher/types/src/structuredAction.ts index bc82b9f777..cea0c573b9 100644 --- a/ts/packages/dispatcher/types/src/structuredAction.ts +++ b/ts/packages/dispatcher/types/src/structuredAction.ts @@ -10,16 +10,8 @@ export type ActionIdentity = { actionName: string; }; -export type ActionSummary = ActionIdentity & { - description: string; -}; - export type ActionSearchRequest = { - query?: string; - agentName?: string; - schemaName?: string; - offset?: number; - limit?: number; + query: string; }; export type StructuredActionEnvelope = { @@ -28,12 +20,6 @@ export type StructuredActionEnvelope = { scopeId: string; }; -export type ActionSearchResult = StructuredActionEnvelope & { - actions: ActionSummary[]; - total: number; - nextOffset?: number; -}; - export type ActionExecutionPolicy = { effects: ActionEffect; confirmation: "required" | "not-required"; @@ -53,7 +39,8 @@ export type ActionInteractionContract = { kinds: ("question" | "choice" | "form" | "action-proposal")[]; }; -export type ActionContract = ActionSummary & { +export type ActionContract = ActionIdentity & { + description: string; fingerprint: string; input: { format: "typescript"; @@ -65,9 +52,6 @@ export type ActionContract = ActionSummary & { interactions: ActionInteractionContract; }; -export type ActionContractResult = StructuredActionEnvelope & - ( - | { status: "found"; contract: ActionContract } - // Deliberately does not distinguish absent and unauthorized identities. - | { status: "not-found" } - ); +export type ActionSearchResult = StructuredActionEnvelope & { + actions: ActionContract[]; +}; From 30ecdefb8604c029c48cc53abf453a76f7e054ea Mon Sep 17 00:00:00 2001 From: George Ng Date: Wed, 16 Sep 2026 17:19:45 -0700 Subject: [PATCH 4/9] Document action discovery ranking follow-up Record BM25-style relevance ranking as the planned replacement for substring matching while preserving the two-call discovery and execution flow. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ts/docs/plans/copilot-direct-actions/director-actions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ts/docs/plans/copilot-direct-actions/director-actions.md b/ts/docs/plans/copilot-direct-actions/director-actions.md index 63f3240af3..d1318d6f47 100644 --- a/ts/docs/plans/copilot-direct-actions/director-actions.md +++ b/ts/docs/plans/copilot-direct-actions/director-actions.md @@ -131,6 +131,8 @@ For large action catalogs, discovery should support search and progressive discl Search accepts one natural-language query and returns closed contracts for every matching action. Until discovery has a relevance ranker, it must not silently truncate the matching set. Callers should use a focused query when the active catalog is large. +**TODO:** Replace the initial substring matcher with a relevance ranker such as BM25. The ranker should reuse TypeAgent's action metadata, return the most relevant contracts for the supplied intent, use stable identity ordering for ties, and preserve the two-call discovery-then-execution flow. Define and measure retrieval quality before introducing a result limit; until then, return every substring match so discovery does not silently omit the intended action. + Each result is a closed, self-contained action contract with its identity, description, parameters, referenced types, constraints, outputs, and interaction requirements. This combines candidate discovery and contract hydration so the normal structured path requires only discovery and execution. The action remains the unit of selection, caching, and compatibility. The normal flow is: From 359e213d5b55595c6145ac191a71d96672cdd1f4 Mon Sep 17 00:00:00 2001 From: George Ng Date: Thu, 17 Sep 2026 12:15:05 -0700 Subject: [PATCH 5/9] docs: capture future action discovery considerations Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../copilot-direct-actions/director-actions.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/ts/docs/plans/copilot-direct-actions/director-actions.md b/ts/docs/plans/copilot-direct-actions/director-actions.md index d1318d6f47..72b7bacfd0 100644 --- a/ts/docs/plans/copilot-direct-actions/director-actions.md +++ b/ts/docs/plans/copilot-direct-actions/director-actions.md @@ -1,7 +1,7 @@ # TypeAgent-Copilot Structured Action Invocation **Status:** Draft -**Last Updated:** 2026-09-10 +**Last Updated:** 2026-09-17 ## Summary @@ -131,8 +131,6 @@ For large action catalogs, discovery should support search and progressive discl Search accepts one natural-language query and returns closed contracts for every matching action. Until discovery has a relevance ranker, it must not silently truncate the matching set. Callers should use a focused query when the active catalog is large. -**TODO:** Replace the initial substring matcher with a relevance ranker such as BM25. The ranker should reuse TypeAgent's action metadata, return the most relevant contracts for the supplied intent, use stable identity ordering for ties, and preserve the two-call discovery-then-execution flow. Define and measure retrieval quality before introducing a result limit; until then, return every substring match so discovery does not silently omit the intended action. - Each result is a closed, self-contained action contract with its identity, description, parameters, referenced types, constraints, outputs, and interaction requirements. This combines candidate discovery and contract hydration so the normal structured path requires only discovery and execution. The action remains the unit of selection, caching, and compatibility. The normal flow is: @@ -219,6 +217,16 @@ Direct and MCP integration modes share the same transport-neutral structured-act MCP maps the service to MCP tools and structured content. Direct mode calls it through the dispatcher interface. This does not change ordinary Direct-mode prompts: user-originated natural language continues through TypeAgent's intent resolution, and only callers that already know the action and concrete parameters use the shared structured interface. +Delivery is layered. Layer 1 is the query-only `searchActions`, which returns complete contracts for every current match. Layer 2 adds `executeAction`. Layer 3 adds the MCP and Direct adapters over the shared service. The target foreground path remains two calls: discovery followed by execution. + +## Future Considerations + +- Replace substring matching with a measured relevance ranker such as BM25. Reuse action metadata, use stable identity ordering for ties, and evaluate retrieval quality before imposing a result limit. Until then, `searchActions` returns every substring match. +- A TypeAgent-aware Copilot plugin or adapter could prefetch a compact, permission-filtered agent catalog when it connects. Optional server-issued agent hints may boost ranking, but must not authorize an action or act as strict filters. +- Cache catalog data on the host outside model context where possible, with catalog version or change signals for invalidation. The server may also keep search documents, contracts, and fingerprints warm. +- Query-only discovery remains correct without prefetch, hints, or warm caches. Generic MCP hosts are not guaranteed to prefetch, so the fixed discovery operation remains the fallback. +- At scale, payload and model-context token cost are the primary risks; scanning the in-memory catalog is not expected to dominate. + ## Architectural Model ```text From 676d12f08fe3c7cee0986fc347fb5decc39493fe Mon Sep 17 00:00:00 2001 From: George Ng Date: Thu, 17 Sep 2026 12:29:17 -0700 Subject: [PATCH 6/9] docs: clarify structured action search semantics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../plans/copilot-direct-actions/director-actions.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ts/docs/plans/copilot-direct-actions/director-actions.md b/ts/docs/plans/copilot-direct-actions/director-actions.md index 72b7bacfd0..9542b73f35 100644 --- a/ts/docs/plans/copilot-direct-actions/director-actions.md +++ b/ts/docs/plans/copilot-direct-actions/director-actions.md @@ -129,7 +129,7 @@ Discovery should provide enough information for Copilot to determine: For large action catalogs, discovery should support search and progressive disclosure rather than requiring the entire TypeAgent action catalog to be registered as MCP tools or loaded into Copilot's context. -Search accepts one natural-language query and returns closed contracts for every matching action. Until discovery has a relevance ranker, it must not silently truncate the matching set. Callers should use a focused query when the active catalog is large. +`searchActions` accepts one required free-text query. The current implementation performs case-insensitive contiguous substring matching against each action's schema name, action name, and description; it does not interpret the query as natural language. It returns closed contracts for every matching action. Until discovery has a relevance ranker, it must not silently truncate the matching set. Callers should use a specific substring likely to occur in one of the matched fields when the active catalog is large. Each result is a closed, self-contained action contract with its identity, description, parameters, referenced types, constraints, outputs, and interaction requirements. This combines candidate discovery and contract hydration so the normal structured path requires only discovery and execution. The action remains the unit of selection, caching, and compatibility. @@ -143,7 +143,7 @@ A caller with a current contract should not need to repeat discovery. A contract Contracts may be reused within the same server and session/permission scope. TypeAgent must detect an outdated contract before execution and ask the caller to refresh it with a new search. -Discovery must respect the caller's permissions. It neither enables actions nor grants permission to execute them. Disabled and inactive actions are excluded. No match or an ambiguous match should lead to clarification or natural-language handling, not guessed action parameters. +Discovery must respect the caller's permissions. It neither enables actions nor grants permission to execute them. Disabled and inactive actions are excluded. A query with no substring match should lead to clarification or natural-language handling, not guessed action parameters. When multiple actions match, the caller must select from the returned contracts or clarify if it cannot do so confidently. ### Contract Versioning @@ -159,7 +159,7 @@ Version 1 uses exact fingerprint matching rather than attempting semantic compat Expose a small, fixed set of operations through the existing TypeAgent MCP server: -- Search complete action contracts with a natural-language query. +- Search complete action contracts with a required free-text substring query. - Execute one action against that contract. - Continue or cancel a pending interaction when the transport cannot represent that interaction directly. @@ -217,11 +217,11 @@ Direct and MCP integration modes share the same transport-neutral structured-act MCP maps the service to MCP tools and structured content. Direct mode calls it through the dispatcher interface. This does not change ordinary Direct-mode prompts: user-originated natural language continues through TypeAgent's intent resolution, and only callers that already know the action and concrete parameters use the shared structured interface. -Delivery is layered. Layer 1 is the query-only `searchActions`, which returns complete contracts for every current match. Layer 2 adds `executeAction`. Layer 3 adds the MCP and Direct adapters over the shared service. The target foreground path remains two calls: discovery followed by execution. +Delivery is layered. Layer 1 is the query-only `searchActions`, which returns complete contracts for every current case-insensitive contiguous substring match across schema name, action name, and description. Layer 2 adds `executeAction`. Layer 3 adds the MCP and Direct adapters over the shared service. The target foreground path remains two calls: discovery followed by execution. ## Future Considerations -- Replace substring matching with a measured relevance ranker such as BM25. Reuse action metadata, use stable identity ordering for ties, and evaluate retrieval quality before imposing a result limit. Until then, `searchActions` returns every substring match. +- Replace the current case-insensitive contiguous substring matching over schema name, action name, and description with a measured relevance ranker such as BM25. Reuse action metadata, use stable identity ordering for ties, and evaluate retrieval quality before imposing a result limit. Until then, `searchActions` treats its required free-text query as a literal substring and returns every match. - A TypeAgent-aware Copilot plugin or adapter could prefetch a compact, permission-filtered agent catalog when it connects. Optional server-issued agent hints may boost ranking, but must not authorize an action or act as strict filters. - Cache catalog data on the host outside model context where possible, with catalog version or change signals for invalidation. The server may also keep search documents, contracts, and fingerprints warm. - Query-only discovery remains correct without prefetch, hints, or warm caches. Generic MCP hosts are not guaranteed to prefetch, so the fixed discovery operation remains the fallback. From 4359207bf553611b17db1e437296a30181e391c0 Mon Sep 17 00:00:00 2001 From: George Ng Date: Thu, 17 Sep 2026 13:51:26 -0700 Subject: [PATCH 7/9] Reuse semantic ranking for action discovery Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../director-actions.md | 15 +- .../dispatcher/src/context/appAgentManager.ts | 31 ++- .../src/structuredAction/discovery.ts | 94 ++++++--- .../src/translation/actionCandidateRanker.ts | 42 ++++ .../translation/actionSchemaSemanticMap.ts | 55 +++-- .../test/structuredActionDiscovery.spec.ts | 190 ++++++++++++++++++ 6 files changed, 375 insertions(+), 52 deletions(-) create mode 100644 ts/packages/dispatcher/dispatcher/src/translation/actionCandidateRanker.ts diff --git a/ts/docs/plans/copilot-direct-actions/director-actions.md b/ts/docs/plans/copilot-direct-actions/director-actions.md index 9542b73f35..d530da118a 100644 --- a/ts/docs/plans/copilot-direct-actions/director-actions.md +++ b/ts/docs/plans/copilot-direct-actions/director-actions.md @@ -129,7 +129,9 @@ Discovery should provide enough information for Copilot to determine: For large action catalogs, discovery should support search and progressive disclosure rather than requiring the entire TypeAgent action catalog to be registered as MCP tools or loaded into Copilot's context. -`searchActions` accepts one required free-text query. The current implementation performs case-insensitive contiguous substring matching against each action's schema name, action name, and description; it does not interpret the query as natural language. It returns closed contracts for every matching action. Until discovery has a relevance ranker, it must not silently truncate the matching set. Callers should use a specific substring likely to occur in one of the matched fields when the active catalog is large. +`searchActions` accepts one required free-text query. When the existing semantic action candidate index is available, discovery reuses it to return the five highest-ranked permitted actions. Results are ordered by descending semantic score with stable schema-name/action-name ordering for ties. Ranking scores remain internal and are not part of the discovery or RPC contract. + +If semantic ranking is unavailable or fails, discovery falls back to the existing case-insensitive contiguous substring match across schema name, action name, and description. The fallback returns every match in stable identity order rather than silently truncating the set. A successful semantic search with zero candidates is authoritative and does not trigger literal fallback. Each result is a closed, self-contained action contract with its identity, description, parameters, referenced types, constraints, outputs, and interaction requirements. This combines candidate discovery and contract hydration so the normal structured path requires only discovery and execution. The action remains the unit of selection, caching, and compatibility. @@ -143,7 +145,7 @@ A caller with a current contract should not need to repeat discovery. A contract Contracts may be reused within the same server and session/permission scope. TypeAgent must detect an outdated contract before execution and ask the caller to refresh it with a new search. -Discovery must respect the caller's permissions. It neither enables actions nor grants permission to execute them. Disabled and inactive actions are excluded. A query with no substring match should lead to clarification or natural-language handling, not guessed action parameters. When multiple actions match, the caller must select from the returned contracts or clarify if it cannot do so confidently. +Discovery must respect the caller's permissions. It neither enables actions nor grants permission to execute them. Denied, disabled, and inactive actions are filtered before ranking and contract hydration. A query with no candidates should lead to clarification or natural-language handling, not guessed action parameters. When multiple actions match, the caller must select from the returned contracts or clarify if it cannot do so confidently. ### Contract Versioning @@ -159,7 +161,7 @@ Version 1 uses exact fingerprint matching rather than attempting semantic compat Expose a small, fixed set of operations through the existing TypeAgent MCP server: -- Search complete action contracts with a required free-text substring query. +- Search complete action contracts with a required free-text query. - Execute one action against that contract. - Continue or cancel a pending interaction when the transport cannot represent that interaction directly. @@ -217,11 +219,14 @@ Direct and MCP integration modes share the same transport-neutral structured-act MCP maps the service to MCP tools and structured content. Direct mode calls it through the dispatcher interface. This does not change ordinary Direct-mode prompts: user-originated natural language continues through TypeAgent's intent resolution, and only callers that already know the action and concrete parameters use the shared structured interface. -Delivery is layered. Layer 1 is the query-only `searchActions`, which returns complete contracts for every current case-insensitive contiguous substring match across schema name, action name, and description. Layer 2 adds `executeAction`. Layer 3 adds the MCP and Direct adapters over the shared service. The target foreground path remains two calls: discovery followed by execution. +Delivery is layered. Layer 1 is the query-only `searchActions`, which uses the shared semantic candidate index when available and hydrates complete contracts only for the permitted ranked candidates. Literal matching remains the offline fallback when ranking is unavailable or fails. Layer 2 adds `executeAction`. Layer 3 adds the MCP and Direct adapters over the shared service. The target foreground path remains two calls: discovery followed by execution. + +This reuse is intentionally limited to candidate ranking. Structured discovery does not reuse the ordinary dispatcher pipeline's grammar matching, translator cache, conversation context, LLM schema/action selection, or parameter translation. Copilot still selects one returned contract and supplies its structured parameters in the separate execution call. ## Future Considerations -- Replace the current case-insensitive contiguous substring matching over schema name, action name, and description with a measured relevance ranker such as BM25. Reuse action metadata, use stable identity ordering for ties, and evaluate retrieval quality before imposing a result limit. Until then, `searchActions` treats its required free-text query as a literal substring and returns every match. +- Evaluate BM25 as a local fallback or retrieval improvement. It could avoid a remote embedding dependency while providing better relevance than literal substring matching. Preserve stable identity ordering for ties and measure retrieval quality before changing ranking or limits. +- After parity validation, migrate the dispatcher's remaining `semanticSearchActionSchema` callers to the shared candidate-ranker result. Keep the compatibility wrapper while those callers still depend on the legacy semantic-search shape. - A TypeAgent-aware Copilot plugin or adapter could prefetch a compact, permission-filtered agent catalog when it connects. Optional server-issued agent hints may boost ranking, but must not authorize an action or act as strict filters. - Cache catalog data on the host outside model context where possible, with catalog version or change signals for invalidation. The server may also keep search documents, contracts, and fingerprints warm. - Query-only discovery remains correct without prefetch, hints, or warm caches. Generic MCP hosts are not guaranteed to prefetch, so the fixed discovery operation remains the fallback. diff --git a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts index 9fa481613f..71113502cc 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts @@ -30,6 +30,10 @@ import { ActionSchemaSemanticMap, EmbeddingCache, } from "../translation/actionSchemaSemanticMap.js"; +import { + type ActionCandidateFilter, + type ActionCandidateRanker, +} from "../translation/actionCandidateRanker.js"; import { ActionSchemaFileCache } from "../translation/actionSchemaFileCache.js"; import path from "node:path"; import { randomUUID } from "node:crypto"; @@ -190,7 +194,9 @@ function loadGrammar( ); } -export class AppAgentManager implements ActionConfigProvider { +export class AppAgentManager + implements ActionConfigProvider, ActionCandidateRanker +{ // TODO: the per-agent routing artifacts below - action schemas // (`actionConfigs` / `actionSchemaFileCache`), grammars (built per record in // `agents`), and action embeddings (`actionSemanticMap`) - are built and @@ -760,9 +766,30 @@ export class AppAgentManager implements ActionConfigProvider { filter: (schemaName: string) => boolean = (schemaName) => this.isSchemaActive(schemaName), ) { - return this.actionSemanticMap?.nearestNeighbors( + const candidates = await this.rankActionCandidates( request, maxMatches, + (schemaName) => filter(schemaName), + ); + return candidates?.map(({ schemaName, score, definition }) => ({ + score, + item: { + actionSchemaFile: this.getActionSchemaFileForConfig( + this.getActionConfig(schemaName), + ), + definition, + }, + })); + } + + public async rankActionCandidates( + request: string, + maxCandidates: number, + filter: ActionCandidateFilter, + ) { + return this.actionSemanticMap?.rankActionCandidates( + request, + maxCandidates, filter, ); } diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts index a095096f88..15f6534d57 100644 --- a/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts @@ -11,7 +11,19 @@ import { type StructuredActionEnvelope, } from "@typeagent/dispatcher-types"; import type { AppAgentManager } from "../context/appAgentManager.js"; +import { + compareActionCandidateIdentity, + type ActionCandidateFilter, + type ActionCandidateRanker, + type ActionCandidateResult, +} from "../translation/actionCandidateRanker.js"; import { createActionContract } from "./contract.js"; +import registerDebug from "debug"; + +const debugError = registerDebug( + "typeagent:dispatcher:structuredActionDiscovery:error", +); +const rankedCandidateLimit = 5; // Host-only policy. Never deserialize this from a discovery/RPC request. // Reuse scope only for the same authorized logical caller/conversation binding, @@ -51,6 +63,7 @@ export class StructuredActionDiscovery { public constructor( private readonly context: DiscoveryContext, private readonly access?: StructuredActionAccess, + private readonly candidateRanker: ActionCandidateRanker = context.agents, ) {} private bindScope() { @@ -77,15 +90,63 @@ export class StructuredActionDiscovery { request: ActionSearchRequest, ): Promise { validateSearch(request); + await this.context.agents.waitUntilReady(); const { envelope, policy } = this.bindScope(); - const query = request.query.trim().toLowerCase(); + const canUseCandidate: ActionCandidateFilter = (schemaName) => + policy?.canDiscoverSchema(schemaName) !== false && + this.context.agents.isSchemaActive(schemaName) && + this.context.agents.isActionActive(schemaName); + + let candidates: ActionCandidateResult[] | undefined; + try { + candidates = await this.candidateRanker.rankActionCandidates( + request.query.trim(), + rankedCandidateLimit, + canUseCandidate, + ); + } catch (error) { + debugError("Action candidate ranking failed: %O", error); + } + + const actions = + candidates === undefined + ? this.findLiteralMatches(request.query, canUseCandidate) + : this.hydrateRankedCandidates(candidates, canUseCandidate); + return { + ...envelope, + actions, + }; + } + + private hydrateRankedCandidates( + candidates: ActionCandidateResult[], + canUseCandidate: ActionCandidateFilter, + ): ActionContract[] { + return candidates + .filter(({ schemaName, actionName }) => + canUseCandidate(schemaName, actionName), + ) + .sort( + (a, b) => + b.score - a.score || compareActionCandidateIdentity(a, b), + ) + .map(({ schemaName, actionName, definition }) => + createActionContract( + { schemaName, actionName }, + definition, + this.context.agents.getActionConfig(schemaName), + ), + ); + } + + private findLiteralMatches( + request: string, + canUseCandidate: ActionCandidateFilter, + ): ActionContract[] { + const query = request.trim().toLowerCase(); const matches: ActionContract[] = []; for (const config of this.context.agents.getActionConfigs()) { - if ( - policy?.canDiscoverSchema(config.schemaName) === false || - !this.context.agents.isSchemaActive(config.schemaName) || - !this.context.agents.isActionActive(config.schemaName) - ) { + if (!canUseCandidate(config.schemaName, "")) { continue; } const schema = @@ -109,25 +170,6 @@ export class StructuredActionDiscovery { ); } } - matches.sort((a, b) => { - const schemaOrder = - a.schemaName < b.schemaName - ? -1 - : a.schemaName > b.schemaName - ? 1 - : 0; - return ( - schemaOrder || - (a.actionName < b.actionName - ? -1 - : a.actionName > b.actionName - ? 1 - : 0) - ); - }); - return { - ...envelope, - actions: matches, - }; + return matches.sort(compareActionCandidateIdentity); } } diff --git a/ts/packages/dispatcher/dispatcher/src/translation/actionCandidateRanker.ts b/ts/packages/dispatcher/dispatcher/src/translation/actionCandidateRanker.ts new file mode 100644 index 0000000000..2de54c84b5 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/translation/actionCandidateRanker.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ActionSchemaTypeDefinition } from "@typeagent/action-schema"; + +export type ActionCandidateFilter = ( + schemaName: string, + actionName: string, +) => boolean; + +export type ActionCandidateResult = Readonly<{ + schemaName: string; + actionName: string; + score: number; + definition: ActionSchemaTypeDefinition; +}>; + +/** + * Ranks action definitions without depending on a caller or transport. + * Undefined means ranking is unavailable; an empty array is a successful + * search with no candidates. + */ +export interface ActionCandidateRanker { + rankActionCandidates( + request: string, + maxCandidates: number, + filter: ActionCandidateFilter, + ): Promise; +} + +export function compareActionCandidateIdentity( + left: Pick, + right: Pick, +): number { + if (left.schemaName !== right.schemaName) { + return left.schemaName < right.schemaName ? -1 : 1; + } + if (left.actionName !== right.actionName) { + return left.actionName < right.actionName ? -1 : 1; + } + return 0; +} diff --git a/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaSemanticMap.ts b/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaSemanticMap.ts index 23ffb3cd31..2bf9617f8d 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaSemanticMap.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaSemanticMap.ts @@ -9,29 +9,34 @@ import { generateEmbeddingWithRetry, generateTextEmbeddingsWithRetry, NormalizedEmbedding, - ScoredItem, similarity, SimilarityType, - TopNCollection, } from "@typeagent/agent-runtime"; import { TextEmbeddingModel, tryCreateEmbeddingModel, } from "@typeagent/aiclient"; import registerDebug from "debug"; +import { + compareActionCandidateIdentity, + type ActionCandidateFilter, + type ActionCandidateRanker, + type ActionCandidateResult, +} from "./actionCandidateRanker.js"; const debug = registerDebug("typeagent:dispatcher:semantic"); const debugError = registerDebug("typeagent:dispatcher:semantic:error"); type Entry = { embedding: NormalizedEmbedding; - actionSchemaFile: ActionSchemaFile; + schemaName: string; + actionName: string; definition: ActionSchemaTypeDefinition; }; export type EmbeddingCache = Map; -export class ActionSchemaSemanticMap { +export class ActionSchemaSemanticMap implements ActionCandidateRanker { private readonly actionSemanticMaps = new Map>(); private readonly model: TextEmbeddingModel | undefined; // Set when no embedding provider is configured, or when embedding @@ -84,7 +89,8 @@ export class ActionSchemaSemanticMap { if (embedding) { actionSemanticMap.set(key, { embedding, - actionSchemaFile, + schemaName: config.schemaName, + actionName: name, definition, }); reuseCount++; @@ -113,7 +119,8 @@ export class ActionSchemaSemanticMap { for (let i = 0; i < keys.length; i++) { actionSemanticMap.set(keys[i], { embedding: embeddings[i], - actionSchemaFile, + schemaName: config.schemaName, + actionName: definitions[i].name, definition: definitions[i], }); } @@ -147,14 +154,14 @@ export class ActionSchemaSemanticMap { this.actionSemanticMaps.delete(schemaName); } - public async nearestNeighbors( + public async rankActionCandidates( request: string, - maxMatches: number, - filter: (schemaName: string) => boolean, + maxCandidates: number, + filter: ActionCandidateFilter, minScore: number = 0, - ): Promise[]> { + ): Promise { if (!this.enabled) { - return []; + return undefined; } let embedding: NormalizedEmbedding; try { @@ -163,25 +170,35 @@ export class ActionSchemaSemanticMap { this.disable( `Failed to embed request for semantic schema selection: ${e?.message ?? e}`, ); - return []; + return undefined; } - const matches = new TopNCollection(maxMatches, {} as Entry); - for (const [name, actionSemanticMap] of this.actionSemanticMaps) { - if (!filter(name)) { - continue; - } + const matches: ActionCandidateResult[] = []; + for (const actionSemanticMap of this.actionSemanticMaps.values()) { for (const entry of actionSemanticMap.values()) { + if (!filter(entry.schemaName, entry.actionName)) { + continue; + } const score = similarity( entry.embedding, embedding, SimilarityType.Dot, ); if (score >= minScore) { - matches.push(entry, score); + matches.push({ + schemaName: entry.schemaName, + actionName: entry.actionName, + score, + definition: entry.definition, + }); } } } - return matches.byRank(); + return matches + .sort( + (a, b) => + b.score - a.score || compareActionCandidateIdentity(a, b), + ) + .slice(0, maxCandidates); } public embeddings(): [string, NormalizedEmbedding][] { diff --git a/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts b/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts index 1ac5469c4d..fd832585dc 100644 --- a/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts @@ -11,6 +11,10 @@ import { convertToActionConfig, type ActionConfig, } from "../src/translation/actionConfig.js"; +import type { + ActionCandidateRanker, + ActionCandidateResult, +} from "../src/translation/actionCandidateRanker.js"; import { StructuredActionDiscovery } from "../src/structuredAction/discovery.js"; const source = ` @@ -132,6 +136,34 @@ async function fingerprint(content = source, policy?: ActionPolicy) { return (await getContract(service)).fingerprint; } +function candidate( + agents: AppAgentManager, + schemaName: string, + actionName: string, + score: number, +): ActionCandidateResult { + const schema = agents.getActionSchemaFileForConfig( + agents.getActionConfig(schemaName), + ); + const definition = schema.parsedActionSchema.actionSchemas.get(actionName); + if (definition === undefined) { + throw new Error(`Missing test action ${schemaName}.${actionName}`); + } + return { schemaName, actionName, score, definition }; +} + +function createRanker( + implementation: ActionCandidateRanker["rankActionCandidates"], +): ActionCandidateRanker & { + rankActionCandidates: jest.MockedFunction< + ActionCandidateRanker["rankActionCandidates"] + >; +} { + return { + rankActionCandidates: jest.fn(implementation), + }; +} + describe("structured action contracts", () => { it("hydrates an exact action with a closed dependency graph", async () => { const { service, agents } = fixture(); @@ -310,6 +342,164 @@ describe("structured action contracts", () => { }); describe("structured action discovery", () => { + it("hydrates ranked candidates by score with stable identity ties", async () => { + const { agents, context } = fixture(); + const ranker = createRanker(async () => [ + candidate(agents, "test.items", "ping", 0.7), + candidate(agents, "test.other", "select", 0.9), + candidate(agents, "test.items", "select", 0.7), + candidate(agents, "test.items", "clear", 0.7), + ]); + const service = new StructuredActionDiscovery( + context, + undefined, + ranker, + ); + + const result = await service.searchActions({ query: " choose item " }); + + expect(ranker.rankActionCandidates).toHaveBeenCalledWith( + "choose item", + 5, + expect.any(Function), + ); + expect( + result.actions.map(({ schemaName, actionName }) => ({ + schemaName, + actionName, + })), + ).toEqual([ + { schemaName: "test.other", actionName: "select" }, + { schemaName: "test.items", actionName: "clear" }, + { schemaName: "test.items", actionName: "ping" }, + { schemaName: "test.items", actionName: "select" }, + ]); + expect( + result.actions.every((action) => action.input !== undefined), + ).toBe(true); + expect(result.actions[0]).not.toHaveProperty("score"); + }); + + it("does not use literal fallback after a successful zero-candidate ranking", async () => { + const { agents, context } = fixture(); + const enumerate = jest.spyOn(agents, "getActionConfigs"); + const ranker = createRanker(async () => []); + const service = new StructuredActionDiscovery( + context, + undefined, + ranker, + ); + + expect( + (await service.searchActions({ query: "select" })).actions, + ).toEqual([]); + expect(enumerate).not.toHaveBeenCalled(); + }); + + it.each(["unavailable", "failure"] as const)( + "uses stable literal fallback when ranking is %s", + async (condition) => { + const { context } = fixture(); + const ranker = createRanker(async () => { + if (condition === "failure") { + throw new Error("ranking failed"); + } + return undefined; + }); + const service = new StructuredActionDiscovery( + context, + undefined, + ranker, + ); + + expect( + ( + await service.searchActions({ + query: "select", + }) + ).actions.map(({ schemaName, actionName }) => ({ + schemaName, + actionName, + })), + ).toEqual([ + { schemaName: "test.items", actionName: "select" }, + { schemaName: "test.other", actionName: "select" }, + ]); + }, + ); + + it("passes trusted permission filtering into ranking", async () => { + const { agents, context } = fixture(); + const candidates = [ + candidate(agents, "test.items", "select", 0.8), + candidate(agents, "test.other", "select", 0.9), + ]; + const filterResults = new Map(); + const ranker = createRanker(async (_request, maxCandidates, filter) => + candidates + .filter(({ schemaName, actionName }) => { + const allowed = filter(schemaName, actionName); + filterResults.set(schemaName, allowed); + return allowed; + }) + .slice(0, maxCandidates), + ); + agents.getActionConfig("test.other").schemaFile = { + format: "ts", + content: "invalid denied schema", + }; + const service = new StructuredActionDiscovery( + context, + () => ({ + scope: {}, + canDiscoverSchema: (schemaName) => schemaName !== "test.other", + }), + ranker, + ); + + expect( + (await service.searchActions({ query: "select" })).actions.map( + ({ schemaName }) => schemaName, + ), + ).toEqual(["test.items"]); + expect(filterResults).toEqual( + new Map([ + ["test.items", true], + ["test.other", false], + ]), + ); + }); + + it("waits for pending schemas before ranking or catalog access", async () => { + const { agents, context } = fixture(); + const pendingState = agents as unknown as { + loadingSchemas: Set; + notifyReadyIfDone(): void; + }; + pendingState.loadingSchemas.add("test.pending"); + const enumerate = jest.spyOn(agents, "getActionConfigs"); + const ranker = createRanker(async () => []); + const service = new StructuredActionDiscovery( + context, + undefined, + ranker, + ); + + const result = service.searchActions({ query: "select" }); + await Promise.resolve(); + expect(ranker.rankActionCandidates).not.toHaveBeenCalled(); + expect(enumerate).not.toHaveBeenCalled(); + + await expect( + service.searchActions(undefined as unknown as { query: string }), + ).rejects.toThrow("Action search request must be an object"); + + pendingState.loadingSchemas.delete("test.pending"); + pendingState.notifyReadyIfDone(); + await expect(result).resolves.toMatchObject({ actions: [] }); + expect(ranker.rankActionCandidates).toHaveBeenCalledTimes(1); + }); + it("returns every matching action as a complete contract", async () => { const { service } = fixture(); const result = await service.searchActions({ query: "test" }); From 7307a051fdfa7e60034027f38e7ca8fb62440c30 Mon Sep 17 00:00:00 2001 From: George Ng Date: Thu, 17 Sep 2026 16:22:09 -0700 Subject: [PATCH 8/9] Fix structured action discovery lifecycle Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../director-actions.md | 2 + .../src/mcpAgentProvider.ts | 70 ++++- .../test/mcpAgentProvider.spec.ts | 67 +++++ .../src/agentProvider/agentProvider.ts | 3 + .../dispatcher/src/context/appAgentManager.ts | 41 ++- .../src/structuredAction/discovery.ts | 27 +- .../translation/actionSchemaSemanticMap.ts | 99 ++++++- .../test/actionSchemaSemanticMap.spec.ts | 270 ++++++++++++++++++ .../test/structuredActionDiscovery.spec.ts | 90 +++++- 9 files changed, 636 insertions(+), 33 deletions(-) create mode 100644 ts/packages/defaultAgentProvider/test/mcpAgentProvider.spec.ts create mode 100644 ts/packages/dispatcher/dispatcher/test/actionSchemaSemanticMap.spec.ts diff --git a/ts/docs/plans/copilot-direct-actions/director-actions.md b/ts/docs/plans/copilot-direct-actions/director-actions.md index d530da118a..38e53ea38c 100644 --- a/ts/docs/plans/copilot-direct-actions/director-actions.md +++ b/ts/docs/plans/copilot-direct-actions/director-actions.md @@ -221,6 +221,8 @@ MCP maps the service to MCP tools and structured content. Direct mode calls it t Delivery is layered. Layer 1 is the query-only `searchActions`, which uses the shared semantic candidate index when available and hydrates complete contracts only for the permitted ranked candidates. Literal matching remains the offline fallback when ranking is unavailable or fails. Layer 2 adds `executeAction`. Layer 3 adds the MCP and Direct adapters over the shared service. The target foreground path remains two calls: discovery followed by execution. +Dynamic schema updates rebuild their semantic entries from the final parsed schema and replace the prior entries only when the new index is ready. Discovery resolves each ranked identity against the current parsed definition before creating a contract, so a concurrent schema update cannot hydrate a stale definition. Background schema startup failures settle readiness while retaining the schema error for existing status and enablement behavior. + This reuse is intentionally limited to candidate ranking. Structured discovery does not reuse the ordinary dispatcher pipeline's grammar matching, translator cache, conversation context, LLM schema/action selection, or parameter translation. Copilot still selects one returned contract and supplies its structured parameters in the separate execution call. ## Future Considerations diff --git a/ts/packages/defaultAgentProvider/src/mcpAgentProvider.ts b/ts/packages/defaultAgentProvider/src/mcpAgentProvider.ts index 7760def50c..2e218746da 100644 --- a/ts/packages/defaultAgentProvider/src/mcpAgentProvider.ts +++ b/ts/packages/defaultAgentProvider/src/mcpAgentProvider.ts @@ -42,6 +42,7 @@ export type McpAppAgent = { agent: AppAgent; connection: McpConnection | undefined; serverProcess?: ChildProcess | undefined; + loadError?: Error; }; export type McpAppAgentRecord = { agentP: Promise; @@ -243,6 +244,7 @@ function createMcpAppAgentRecord( let connection: McpConnection | undefined; let serverProcess: ChildProcess | undefined; let agent: AppAgent; + let loadError: Error | undefined; try { if (info.serverCommand !== undefined) { const occupied = @@ -317,9 +319,11 @@ function createMcpAppAgentRecord( return convertToolResult(action.actionName, result); }, }; - } catch (error: any) { + } catch (error: unknown) { + loadError = + error instanceof Error ? error : new Error(String(error)); debugError( - `[${appAgentName}] failed to connect: ${error?.message ?? error}`, + `[${appAgentName}] failed to connect: ${loadError.message}`, ); if (connection !== undefined) { await connection.close().catch(() => {}); @@ -332,7 +336,7 @@ function createMcpAppAgentRecord( agent = { updateAgentContext() { // Delay throwing error until the agent is used. - throw error; + throw loadError; }, }; } @@ -354,6 +358,7 @@ function createMcpAppAgentRecord( connection, agent, serverProcess, + ...(loadError === undefined ? {} : { loadError }), }; }; return { @@ -381,9 +386,12 @@ export function createMcpAppAgentProvider( agentName: string, manifest: AppAgentManifest, ) => void)[] = []; + const schemaFailedCallbacks: ((agentName: string, error: Error) => void)[] = + []; // Manifests that are already resolved (so late-registered callbacks fire immediately) const resolvedManifests = new Map(); + const schemaFailures = new Map(); function startBackgroundAgent(appAgentName: string) { if ( @@ -405,19 +413,41 @@ export function createMcpAppAgentProvider( instanceConfig?.[appAgentName], ); backgroundRecords.set(appAgentName, record); + schemaFailures.delete(appAgentName); - record.agentP - .then((agentData) => { + const notifyFailure = (error: Error) => { + if (backgroundRecords.get(appAgentName) === record) { + backgroundRecords.delete(appAgentName); + } + schemaFailures.set(appAgentName, error); + for (const cb of schemaFailedCallbacks) { + cb(appAgentName, error); + } + }; + + record.agentP.then( + (agentData) => { if (agentData.connection !== undefined) { + schemaFailures.delete(appAgentName); resolvedManifests.set(appAgentName, agentData.manifest); for (const cb of schemaReadyCallbacks) { cb(appAgentName, agentData.manifest); } + } else { + notifyFailure( + agentData.loadError ?? + new Error( + `MCP agent '${appAgentName}' failed to load`, + ), + ); } - }) - .catch(() => { - // errors surface when the agent is actually used - }); + }, + (error: unknown) => { + notifyFailure( + error instanceof Error ? error : new Error(String(error)), + ); + }, + ); } function getMpcAppAgentRecord(appAgentName: string) { @@ -438,6 +468,21 @@ export function createMcpAppAgentProvider( if (info === undefined) { throw new Error(`Invalid app agent: ${appAgentName}`); } + if (info.serverCommand !== undefined) { + // Retry through the background path so a recovered connection also + // publishes its generated schema to the dispatcher. + startBackgroundAgent(appAgentName); + const retry = backgroundRecords.get(appAgentName); + if (retry === undefined) { + throw new Error( + `Failed to start MCP app agent: ${appAgentName}`, + ); + } + retry.count++; + backgroundRecords.delete(appAgentName); + mcpAppAgents.set(appAgentName, retry); + return retry; + } const record = createMcpAppAgentRecord( name, version, @@ -467,6 +512,13 @@ export function createMcpAppAgentProvider( } }, + onSchemaFailed(callback) { + schemaFailedCallbacks.push(callback); + for (const [agentName, error] of schemaFailures) { + callback(agentName, error); + } + }, + async getAppAgentManifest(appAgentName: string) { const info = infos[appAgentName]; if (info === undefined) { diff --git a/ts/packages/defaultAgentProvider/test/mcpAgentProvider.spec.ts b/ts/packages/defaultAgentProvider/test/mcpAgentProvider.spec.ts new file mode 100644 index 0000000000..1c337a8c8e --- /dev/null +++ b/ts/packages/defaultAgentProvider/test/mcpAgentProvider.spec.ts @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createMcpAppAgentProvider } from "../src/mcpAgentProvider.js"; + +describe("createMcpAppAgentProvider", () => { + it("reports and settles a failed background server-command startup", async () => { + const provider = createMcpAppAgentProvider("test", "0.0.0", { + failing: { + emojiChar: "", + description: "Failing server", + serverCommand: process.execPath, + serverCommandArgs: ["-e", "process.exit(17)"], + }, + }); + let failureCount = 0; + let resolveFirstFailure!: (value: { + agentName: string; + error: Error; + }) => void; + let resolveRetryFailure!: (value: { + agentName: string; + error: Error; + }) => void; + const firstFailure = new Promise<{ + agentName: string; + error: Error; + }>((resolve) => { + resolveFirstFailure = resolve; + }); + const retryFailure = new Promise<{ + agentName: string; + error: Error; + }>((resolve) => { + resolveRetryFailure = resolve; + }); + provider.onSchemaFailed?.((agentName, error) => { + failureCount++; + const failure = { agentName, error }; + if (failureCount === 1) { + resolveFirstFailure(failure); + } else { + resolveRetryFailure(failure); + } + }); + + const manifest = provider.getAppAgentManifest("failing"); + expect(provider.getLoadingAgentNames?.()).toEqual(["failing"]); + await manifest; + + await expect(firstFailure).resolves.toMatchObject({ + agentName: "failing", + error: { + message: expect.stringContaining( + "exited with code 17 before starting", + ), + }, + }); + expect(provider.getLoadingAgentNames?.()).toEqual([]); + + await provider.loadAppAgent("failing"); + await expect(retryFailure).resolves.toMatchObject({ + agentName: "failing", + }); + expect(failureCount).toBe(2); + }); +}); diff --git a/ts/packages/dispatcher/dispatcher/src/agentProvider/agentProvider.ts b/ts/packages/dispatcher/dispatcher/src/agentProvider/agentProvider.ts index 11fc43b67e..1f682c5d20 100644 --- a/ts/packages/dispatcher/dispatcher/src/agentProvider/agentProvider.ts +++ b/ts/packages/dispatcher/dispatcher/src/agentProvider/agentProvider.ts @@ -32,6 +32,9 @@ export interface AppAgentProvider { onSchemaReady?: ( callback: (agentName: string, manifest: AppAgentManifest) => void, ) => void; + onSchemaFailed?: ( + callback: (agentName: string, error: Error) => void, + ) => void; getLoadingAgentNames?(): string[]; /** * When false, newly attached sessions persist disabled command, schema, and diff --git a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts index 71113502cc..a0320c72f8 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts @@ -822,7 +822,10 @@ export class AppAgentManager await Promise.all(semanticMapP); debug("Finish action embeddings"); - if (provider.onSchemaReady && stateRefreshFn) { + if ( + stateRefreshFn && + (provider.onSchemaReady || provider.onSchemaFailed) + ) { // Mark only the agents that are actually loading asynchronously (e.g. // serverCommand MCP agents with slow startup). Agents that failed // synchronously should show ❌, not ⏳. @@ -843,7 +846,7 @@ export class AppAgentManager } } - provider.onSchemaReady(async (agentName, manifest) => { + provider.onSchemaReady?.(async (agentName, manifest) => { try { const refreshSemanticMapP: Promise[] = []; this.refreshAgentSchema( @@ -861,11 +864,35 @@ export class AppAgentManager debugError( `Failed to refresh schema for agent '${agentName}': ${e}`, ); + } finally { + this.clearLoadingSchemasForAgent(agentName); + } + }); + provider.onSchemaFailed?.(async (agentName, error) => { + this.clearLoadingSchemasForAgent(agentName); + debugError( + `Failed to load schema for agent '${agentName}': ${error.message}`, + ); + try { + await stateRefreshFn(); + } catch (e) { + debugError( + `Failed to refresh state after schema load failure for agent '${agentName}': ${e}`, + ); } }); } } + private clearLoadingSchemasForAgent(appAgentName: string): void { + for (const schemaName of this.loadingSchemas) { + if (getAppAgentName(schemaName) === appAgentName) { + this.loadingSchemas.delete(schemaName); + } + } + this.notifyReadyIfDone(); + } + private refreshAgentSchema( appAgentName: string, manifest: AppAgentManifest, @@ -1629,8 +1656,16 @@ export class AppAgentManager // Invalidate cached parsed schema so it gets re-parsed from new content this.actionSchemaFileCache.unloadActionSchemaFile(schemaName); + const actionSchemaFile = + this.actionSchemaFileCache.getActionSchemaFile(config); + + // Replace the semantic entries only after all new embeddings are ready. + await this.actionSemanticMap?.replaceActionSchemaFile( + config, + actionSchemaFile, + ); - // Clear translator cache so next translation uses the updated schema + // Clear translator cache so next translation uses the updated schema. context.translatorCache.clear(); debug(`Loaded dynamic schema for ${schemaName}`); diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts index 15f6534d57..9e9fc7244c 100644 --- a/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts @@ -130,13 +130,26 @@ export class StructuredActionDiscovery { (a, b) => b.score - a.score || compareActionCandidateIdentity(a, b), ) - .map(({ schemaName, actionName, definition }) => - createActionContract( - { schemaName, actionName }, - definition, - this.context.agents.getActionConfig(schemaName), - ), - ); + .flatMap(({ schemaName, actionName }) => { + const config = + this.context.agents.tryGetActionConfig(schemaName); + if (config === undefined) { + return []; + } + const definition = this.context.agents + .getActionSchemaFileForConfig(config) + .parsedActionSchema.actionSchemas.get(actionName); + if (definition === undefined) { + return []; + } + return [ + createActionContract( + { schemaName, actionName }, + definition, + config, + ), + ]; + }); } private findLiteralMatches( diff --git a/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaSemanticMap.ts b/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaSemanticMap.ts index 2bf9617f8d..30b6a78eec 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaSemanticMap.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaSemanticMap.ts @@ -34,18 +34,26 @@ type Entry = { definition: ActionSchemaTypeDefinition; }; +type PendingEntry = { + key: string; + actionName: string; + definition: ActionSchemaTypeDefinition; +}; + export type EmbeddingCache = Map; export class ActionSchemaSemanticMap implements ActionCandidateRanker { private readonly actionSemanticMaps = new Map>(); + private readonly schemaVersions = new Map(); private readonly model: TextEmbeddingModel | undefined; // Set when no embedding provider is configured, or when embedding // generation fails at load time. In that state semantic schema // selection is unavailable and callers fall back to inline/search // routing instead of the daemon failing to start. private disabled: boolean; - public constructor(model?: TextEmbeddingModel) { - this.model = model ?? tryCreateEmbeddingModel(); + public constructor(model?: TextEmbeddingModel | null) { + this.model = + model === null ? undefined : (model ?? tryCreateEmbeddingModel()); this.disabled = this.model === undefined; if (this.disabled) { debug( @@ -70,8 +78,6 @@ export class ActionSchemaSemanticMap implements ActionCandidateRanker { if (!this.enabled) { return; } - const keys: string[] = []; - const definitions: ActionSchemaTypeDefinition[] = []; if (this.actionSemanticMaps.has(config.schemaName)) { throw new Error( @@ -79,8 +85,68 @@ export class ActionSchemaSemanticMap implements ActionCandidateRanker { ); } + const version = this.beginSchemaUpdate(config.schemaName); + const actionSemanticMap = await this.createActionSemanticMap( + config, + actionSchemaFile, + cache, + ); + if ( + actionSemanticMap !== undefined && + this.enabled && + this.schemaVersions.get(config.schemaName) === version + ) { + if (this.actionSemanticMaps.has(config.schemaName)) { + throw new Error( + `Internal Error: Duplicate schemaName ${config.schemaName}`, + ); + } + this.actionSemanticMaps.set(config.schemaName, actionSemanticMap); + } + } + + /** + * Rebuilds a schema's entries off to the side and swaps them in together. + * Searches continue to see the previous complete schema until the new + * embeddings are ready. + */ + public async replaceActionSchemaFile( + config: ActionConfig, + actionSchemaFile: ActionSchemaFile, + cache?: EmbeddingCache, + ): Promise { + if (!this.enabled) { + return; + } + const version = this.beginSchemaUpdate(config.schemaName); + const actionSemanticMap = await this.createActionSemanticMap( + config, + actionSchemaFile, + cache, + ); + if ( + actionSemanticMap !== undefined && + this.enabled && + this.schemaVersions.get(config.schemaName) === version + ) { + this.actionSemanticMaps.set(config.schemaName, actionSemanticMap); + } + } + + private beginSchemaUpdate(schemaName: string): number { + const version = (this.schemaVersions.get(schemaName) ?? 0) + 1; + this.schemaVersions.set(schemaName, version); + return version; + } + + private async createActionSemanticMap( + config: ActionConfig, + actionSchemaFile: ActionSchemaFile, + cache?: EmbeddingCache, + ): Promise | undefined> { const actionSemanticMap = new Map(); - this.actionSemanticMaps.set(config.schemaName, actionSemanticMap); + const keys: string[] = []; + const pendingEntries: PendingEntry[] = []; let reuseCount = 0; for (const [name, definition] of actionSchemaFile.parsedActionSchema .actionSchemas) { @@ -96,7 +162,11 @@ export class ActionSchemaSemanticMap implements ActionCandidateRanker { reuseCount++; } else { keys.push(key); - definitions.push(definition); + pendingEntries.push({ + key, + actionName: name, + definition, + }); } } @@ -116,24 +186,26 @@ export class ActionSchemaSemanticMap implements ActionCandidateRanker { debug( `Received ${embeddings.length} embeddings for ${config.schemaName} in ${Date.now() - start}ms`, ); - for (let i = 0; i < keys.length; i++) { - actionSemanticMap.set(keys[i], { + for (let i = 0; i < pendingEntries.length; i++) { + const pending = pendingEntries[i]; + actionSemanticMap.set(pending.key, { embedding: embeddings[i], schemaName: config.schemaName, - actionName: definitions[i].name, - definition: definitions[i], + actionName: pending.actionName, + definition: pending.definition, }); } } catch (e: any) { + const reason = `Failed to get embeddings for ${config.schemaName} after ${Date.now() - start}ms: ${e?.message ?? e}`; // Do not fail agent initialization (which would exit the // daemon) when embeddings are unavailable at load time. // Disable semantic schema selection and fall back to // inline/search routing instead. - this.disable( - `Failed to get embeddings for ${config.schemaName} after ${Date.now() - start}ms: ${e?.message ?? e}`, - ); + this.disable(reason); + return undefined; } } + return actionSemanticMap; } private disable(reason: string): void { @@ -151,6 +223,7 @@ export class ActionSchemaSemanticMap implements ActionCandidateRanker { } public removeActionSchemaFile(schemaName: string) { + this.beginSchemaUpdate(schemaName); this.actionSemanticMaps.delete(schemaName); } diff --git a/ts/packages/dispatcher/dispatcher/test/actionSchemaSemanticMap.spec.ts b/ts/packages/dispatcher/dispatcher/test/actionSchemaSemanticMap.spec.ts new file mode 100644 index 0000000000..c22b34af75 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/actionSchemaSemanticMap.spec.ts @@ -0,0 +1,270 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ActionPolicy } from "@typeagent/agent-sdk"; +import type { TextEmbeddingModel } from "@typeagent/aiclient"; +import { parseActionSchemaSource } from "@typeagent/action-schema"; +import { convertToActionConfig } from "../src/translation/actionConfig.js"; +import type { ActionSchemaFile } from "../src/translation/actionConfigProvider.js"; +import { ActionSchemaSemanticMap } from "../src/translation/actionSchemaSemanticMap.js"; +import { createActionContract } from "../src/structuredAction/contract.js"; + +function embeddingModel( + getEmbedding: (text: string) => number[], +): TextEmbeddingModel { + return { + maxBatchSize: 1, + async generateEmbedding(text) { + return { success: true, data: getEmbedding(text) }; + }, + }; +} + +function schemaFixture( + source: string, + policies?: Record, +) { + const schemaName = "test.widgets"; + const configs = convertToActionConfig("test", { + description: "Test agent", + emojiChar: "", + subActionManifests: { + widgets: { + schema: { + description: "Widgets", + schemaType: "Actions", + schemaFile: { format: "ts", content: source }, + ...(policies === undefined + ? {} + : { actionPolicies: policies }), + }, + }, + }, + }); + const config = configs[schemaName]; + const actionSchemaFile: ActionSchemaFile = { + schemaName, + sourceHash: source, + parsedActionSchema: parseActionSchemaSource( + source, + schemaName, + "Actions", + ), + }; + return { config, actionSchemaFile }; +} + +describe("ActionSchemaSemanticMap", () => { + it("preserves the action map key and contract semantics for fresh and cached embeddings", async () => { + const source = ` +export type Actions = CreateWidgetType; +// Create a widget. +export type CreateWidgetType = { + actionName: "createWidget"; + parameters: { name: string }; +}; +`; + const policy = { + effects: "state-changing", + confirmation: "required", + } as const; + const { config, actionSchemaFile } = schemaFixture(source, { + createWidget: policy, + }); + const model = embeddingModel(() => [1, 0]); + const freshMap = new ActionSchemaSemanticMap(model); + await freshMap.addActionSchemaFile(config, actionSchemaFile); + + const fresh = await freshMap.rankActionCandidates( + "create", + 1, + () => true, + ); + expect(fresh).toHaveLength(1); + expect(fresh?.[0].actionName).toBe("createWidget"); + expect(fresh?.[0].definition.name).toBe("CreateWidgetType"); + + let cachedModelCalls = 0; + const cachedMap = new ActionSchemaSemanticMap( + embeddingModel(() => { + cachedModelCalls++; + return [1, 0]; + }), + ); + await cachedMap.addActionSchemaFile( + config, + actionSchemaFile, + new Map(freshMap.embeddings()), + ); + const cached = await cachedMap.rankActionCandidates( + "create", + 1, + () => true, + ); + + expect(cachedModelCalls).toBe(1); + expect(cached?.[0].actionName).toBe("createWidget"); + expect(cached?.[0].definition.name).toBe("CreateWidgetType"); + + const freshContract = createActionContract( + { + schemaName: fresh![0].schemaName, + actionName: fresh![0].actionName, + }, + fresh![0].definition, + config, + ); + const cachedContract = createActionContract( + { + schemaName: cached![0].schemaName, + actionName: cached![0].actionName, + }, + cached![0].definition, + config, + ); + expect(cachedContract).toMatchObject({ + schemaName: "test.widgets", + actionName: "createWidget", + policy: freshContract.policy, + fingerprint: freshContract.fingerprint, + }); + }); + + it("returns undefined when semantic ranking is unavailable", async () => { + const map = new ActionSchemaSemanticMap(null); + await expect( + map.rankActionCandidates("anything", 5, () => true), + ).resolves.toBeUndefined(); + }); + + it("orders by score, keeps identity ties stable, and filters before slicing", async () => { + const source = ` +export type Actions = TopType | BetaType | AlphaType | LowType; +// top +export type TopType = { actionName: "zeta" }; +// tie +export type BetaType = { actionName: "beta" }; +// tie +export type AlphaType = { actionName: "alpha" }; +// low +export type LowType = { actionName: "low" }; +`; + const { config, actionSchemaFile } = schemaFixture(source); + const map = new ActionSchemaSemanticMap( + embeddingModel((text) => { + if (text === "query" || text.includes(" top")) { + return [1, 0]; + } + if (text.includes(" tie")) { + return [0.8, 0.6]; + } + return [0, 1]; + }), + ); + await map.addActionSchemaFile(config, actionSchemaFile); + + const ranked = await map.rankActionCandidates("query", 3, () => true); + expect(ranked?.map(({ actionName }) => actionName)).toEqual([ + "zeta", + "alpha", + "beta", + ]); + + const filtered = await map.rankActionCandidates( + "query", + 2, + (_schemaName, actionName) => actionName !== "zeta", + ); + expect(filtered?.map(({ actionName }) => actionName)).toEqual([ + "alpha", + "beta", + ]); + }); + + it("keeps the complete old schema until the latest replacement is ready", async () => { + const oldFixture = schemaFixture(` +export type Actions = OldType; +// old +export type OldType = { actionName: "oldAction" }; +`); + const firstFixture = schemaFixture(` +export type Actions = FirstType; +// first +export type FirstType = { actionName: "firstAction" }; +`); + const secondFixture = schemaFixture(` +export type Actions = SecondType; +// second +export type SecondType = { actionName: "secondAction" }; +`); + let resolveFirst!: (value: { success: true; data: number[] }) => void; + let resolveSecond!: (value: { success: true; data: number[] }) => void; + const firstEmbedding = new Promise<{ + success: true; + data: number[]; + }>((resolve) => { + resolveFirst = resolve; + }); + const secondEmbedding = new Promise<{ + success: true; + data: number[]; + }>((resolve) => { + resolveSecond = resolve; + }); + const model: TextEmbeddingModel = { + maxBatchSize: 1, + generateEmbedding(text) { + if (text.includes("firstAction")) { + return firstEmbedding; + } + if (text.includes("secondAction")) { + return secondEmbedding; + } + return Promise.resolve({ success: true, data: [1, 0] }); + }, + }; + const map = new ActionSchemaSemanticMap(model); + await map.addActionSchemaFile( + oldFixture.config, + oldFixture.actionSchemaFile, + ); + const firstReplacement = map.replaceActionSchemaFile( + firstFixture.config, + firstFixture.actionSchemaFile, + ); + const secondReplacement = map.replaceActionSchemaFile( + secondFixture.config, + secondFixture.actionSchemaFile, + ); + const beforeReplacement = await map.rankActionCandidates( + "query", + 5, + () => true, + ); + expect(beforeReplacement?.map(({ actionName }) => actionName)).toEqual([ + "oldAction", + ]); + + resolveSecond({ success: true, data: [1, 0] }); + await secondReplacement; + const afterSecond = await map.rankActionCandidates( + "query", + 5, + () => true, + ); + expect(afterSecond?.map(({ actionName }) => actionName)).toEqual([ + "secondAction", + ]); + + resolveFirst({ success: true, data: [1, 0] }); + await firstReplacement; + const afterFirst = await map.rankActionCandidates( + "query", + 5, + () => true, + ); + expect(afterFirst?.map(({ actionName }) => actionName)).toEqual([ + "secondAction", + ]); + }); +}); diff --git a/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts b/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts index fd832585dc..00c5cad484 100644 --- a/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts @@ -15,6 +15,7 @@ import type { ActionCandidateRanker, ActionCandidateResult, } from "../src/translation/actionCandidateRanker.js"; +import type { AppAgentProvider } from "../src/agentProvider/agentProvider.js"; import { StructuredActionDiscovery } from "../src/structuredAction/discovery.js"; const source = ` @@ -111,7 +112,11 @@ function fixture(content = source, policies?: Record) { agent, hooks, context, - service: new StructuredActionDiscovery(context), + service: new StructuredActionDiscovery( + context, + undefined, + createRanker(async () => undefined), + ), }; } @@ -380,6 +385,36 @@ describe("structured action discovery", () => { expect(result.actions[0]).not.toHaveProperty("score"); }); + it("hydrates ranked identities from the current schema definition", async () => { + const { agents, context } = fixture(); + const stale = candidate(agents, "test.items", "select", 1); + const config = agents.getActionConfig("test.items"); + config.schemaFile = { + format: "ts", + content: source.replace("note?: string;", "note: number;"), + }; + ( + agents as unknown as { + actionSchemaFileCache: { + unloadActionSchemaFile(schemaName: string): void; + }; + } + ).actionSchemaFileCache.unloadActionSchemaFile("test.items"); + const service = new StructuredActionDiscovery( + context, + undefined, + createRanker(async () => [stale]), + ); + + const result = await service.searchActions({ query: "select" }); + + expect(result.actions).toHaveLength(1); + expect(result.actions[0].input.schemaText).toContain("note: number"); + expect(result.actions[0].input.schemaText).not.toContain( + "note?: string", + ); + }); + it("does not use literal fallback after a successful zero-candidate ranking", async () => { const { agents, context } = fixture(); const enumerate = jest.spyOn(agents, "getActionConfigs"); @@ -500,6 +535,59 @@ describe("structured action discovery", () => { expect(ranker.rankActionCandidates).toHaveBeenCalledTimes(1); }); + it("settles readiness when a provider reports background schema failure", async () => { + const agents = new AppAgentManager(undefined, new PortRegistrar()); + let reportFailure: + | ((agentName: string, error: Error) => void) + | undefined; + const provider: AppAgentProvider = { + getAppAgentNames: () => ["slow"], + getAppAgentManifest: async () => ({ + emojiChar: "", + description: "Slow agent", + schema: { + description: "Loading", + schemaType: "Actions", + schemaFile: { format: "ts", content: "" }, + }, + }), + loadAppAgent: async () => ({}), + unloadAppAgent: async () => {}, + getLoadingAgentNames: () => ["slow"], + onSchemaFailed: (callback) => { + reportFailure = callback; + }, + }; + await agents.addProvider( + provider, + undefined, + undefined, + undefined, + undefined, + async () => {}, + ); + expect(agents.isSchemaLoading("slow")).toBe(true); + + let settled = false; + const ready = agents.waitUntilReady().then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + reportFailure?.("slow", new Error("server startup failed")); + await ready; + + expect(settled).toBe(true); + expect(agents.isSchemaLoading("slow")).toBe(false); + const record = ( + agents as unknown as { + agents: Map }>; + } + ).agents.get("slow"); + expect(record?.schemaErrors.has("slow")).toBe(true); + }); + it("returns every matching action as a complete contract", async () => { const { service } = fixture(); const result = await service.searchActions({ query: "test" }); From e2326658f5d8e7227de413ad07a052df3e7bcf81 Mon Sep 17 00:00:00 2001 From: George Ng Date: Thu, 17 Sep 2026 22:24:38 -0700 Subject: [PATCH 9/9] Remove structured action contract fingerprints Use live action identity and current schema validation as the execution compatibility boundary instead of contract hashes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../director-actions.md | 31 +++---- .../src/structuredAction/contract.ts | 81 +------------------ .../test/actionSchemaSemanticMap.spec.ts | 2 +- .../test/structuredActionDiscovery.spec.ts | 66 +-------------- .../dispatcher/rpc/test/dispatcherRpc.spec.ts | 3 +- .../dispatcher/types/src/structuredAction.ts | 1 - 6 files changed, 16 insertions(+), 168 deletions(-) diff --git a/ts/docs/plans/copilot-direct-actions/director-actions.md b/ts/docs/plans/copilot-direct-actions/director-actions.md index 38e53ea38c..4575d83c5d 100644 --- a/ts/docs/plans/copilot-direct-actions/director-actions.md +++ b/ts/docs/plans/copilot-direct-actions/director-actions.md @@ -143,19 +143,13 @@ search action contracts → execute selected action A caller with a current contract should not need to repeat discovery. A contract must include referenced enums and nested types without loading unrelated actions from the same schema. -Contracts may be reused within the same server and session/permission scope. TypeAgent must detect an outdated contract before execution and ask the caller to refresh it with a new search. +Contracts may be reused within the same server and session/permission scope. Execution resolves the exact action identity against the current schema and validates the submitted parameters before any effect is possible. If the action was removed or its parameters are no longer valid, execution returns the corresponding unavailable or validation failure rather than reinterpreting the request. Discovery must respect the caller's permissions. It neither enables actions nor grants permission to execute them. Denied, disabled, and inactive actions are filtered before ranking and contract hydration. A query with no candidates should lead to clarification or natural-language handling, not guessed action parameters. When multiple actions match, the caller must select from the returned contracts or clarify if it cannot do so confidently. -### Contract Versioning +### Protocol Versioning -Discovery responses include a protocol version for the structured-action envelope and an opaque fingerprint for each returned action contract. Execution must supply the fingerprint returned with the selected contract. - -TypeAgent compares the supplied fingerprint with the current contract before any effect is possible. A mismatch returns `contract_stale` without executing the action. The caller must fetch the current contract and construct a new request; TypeAgent must not reinterpret parameters under the changed contract. - -The initial implementation may conservatively use the existing schema source hash. The target fingerprint should hash a canonical representation of the selected action's execution-relevant contract, including parameter types, required fields, constraints, referenced definitions, outputs, and interaction shape. Descriptions and transient availability, authentication, permission, and readiness state must not affect the fingerprint. - -Version 1 uses exact fingerprint matching rather than attempting semantic compatibility between arbitrary schema changes. This intentionally favors a safe refresh over complex compatibility rules for unions, nested types, constraints, and interaction results. +Discovery responses include a protocol version for the structured-action envelope. Versioning applies to the shared request and response shapes. Execution compatibility is determined from the current action definition when the call is made. ## MCP Interface @@ -169,7 +163,7 @@ These operations are normal MCP tools. Individual TypeAgent actions remain data Use the existing `schemaName` and `actionName` as the action identity. Keep them as separate request fields even if discovery also provides a joined display identifier. -The MCP package is a transport adapter over a shared structured-action service in the dispatcher or agent server. Discovery, contract generation, fingerprinting, validation, readiness checks, execution, and interaction state do not belong in the MCP adapter. +The MCP package is a transport adapter over a shared structured-action service in the dispatcher or agent server. Discovery, contract generation, validation, readiness checks, execution, and interaction state do not belong in the MCP adapter. ## Responsibility Boundaries @@ -194,14 +188,13 @@ Bind calls to the intended conversation and make any use of prior-turn context e For structured invocation, Copilot selecting an action does not count as user confirmation. Before execution, TypeAgent must: 1. Bind the request to the correct caller, TypeAgent session, and Copilot conversation. -2. Reject a stale contract. -3. Resolve the current action and validate its parameters. -4. Check that the schema and action are enabled. -5. Run agent readiness and setup checks. -6. Preserve authentication and resource authorization enforced by the owning service. -7. Request user confirmation for destructive, external, costly, or sensitive effects. +2. Resolve the current action and validate its parameters. +3. Check that the schema and action are enabled. +4. Run agent readiness and setup checks. +5. Preserve authentication and resource authorization enforced by the owning service. +6. Request user confirmation for destructive, external, costly, or sensitive effects. -A required choice or form returns `requires_interaction` with an opaque, session-bound, single-use interaction ID. A later call submits the user's response or cancels the interaction. The integration must not choose a default answer on the user's behalf. Completion, failure, cancellation, `contract_stale`, unavailability, and uncertain execution after a disconnect or timeout must remain distinct result states. +A required choice or form returns `requires_interaction` with an opaque, session-bound, single-use interaction ID. A later call submits the user's response or cancels the interaction. The integration must not choose a default answer on the user's behalf. Completion, failure, cancellation, validation failure, unavailability, and uncertain execution after a disconnect or timeout must remain distinct result states. ## Multi-Step Behavior @@ -215,7 +208,7 @@ Do not expose a general-purpose structured plan API in version 1. Such an API wo ## Shared Integration Service -Direct and MCP integration modes share the same transport-neutral structured-action service. It owns discovery, action identity and contracts, fingerprints, validation, readiness and authorization checks, execution, structured results, and interaction and cancellation semantics. +Direct and MCP integration modes share the same transport-neutral structured-action service. It owns discovery, action identity and contracts, validation, readiness and authorization checks, execution, structured results, and interaction and cancellation semantics. MCP maps the service to MCP tools and structured content. Direct mode calls it through the dispatcher interface. This does not change ordinary Direct-mode prompts: user-originated natural language continues through TypeAgent's intent resolution, and only callers that already know the action and concrete parameters use the shared structured interface. @@ -230,7 +223,7 @@ This reuse is intentionally limited to candidate ranking. Structured discovery d - Evaluate BM25 as a local fallback or retrieval improvement. It could avoid a remote embedding dependency while providing better relevance than literal substring matching. Preserve stable identity ordering for ties and measure retrieval quality before changing ranking or limits. - After parity validation, migrate the dispatcher's remaining `semanticSearchActionSchema` callers to the shared candidate-ranker result. Keep the compatibility wrapper while those callers still depend on the legacy semantic-search shape. - A TypeAgent-aware Copilot plugin or adapter could prefetch a compact, permission-filtered agent catalog when it connects. Optional server-issued agent hints may boost ranking, but must not authorize an action or act as strict filters. -- Cache catalog data on the host outside model context where possible, with catalog version or change signals for invalidation. The server may also keep search documents, contracts, and fingerprints warm. +- Cache catalog data on the host outside model context where possible, with catalog version or change signals for invalidation. The server may also keep search documents and contracts warm. - Query-only discovery remains correct without prefetch, hints, or warm caches. Generic MCP hosts are not guaranteed to prefetch, so the fixed discovery operation remains the fallback. - At scale, payload and model-context token cost are the primary risks; scanning the in-memory catalog is not expected to dominate. diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/contract.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/contract.ts index cba08be34f..897a20c383 100644 --- a/ts/packages/dispatcher/dispatcher/src/structuredAction/contract.ts +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/contract.ts @@ -1,70 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { createHash } from "node:crypto"; import { generateSchemaTypeDefinition, getActionDescription, - toJSONParsedActionSchema, -} from "@typeagent/action-schema"; -import type { - ActionSchemaTypeDefinition, - SchemaType, } from "@typeagent/action-schema"; +import type { ActionSchemaTypeDefinition } from "@typeagent/action-schema"; import { - structuredActionProtocolVersion, type ActionContract, type ActionExecutionPolicy, type ActionIdentity, } from "@typeagent/dispatcher-types"; import type { ActionConfig } from "../translation/actionConfig.js"; -function executionType(type: SchemaType): unknown { - switch (type.type) { - case "object": - return { - type: type.type, - fields: Object.fromEntries( - Object.entries(type.fields).map(([name, field]) => [ - name, - { - optional: field.optional === true, - type: executionType(field.type), - }, - ]), - ), - }; - case "array": - return { - type: type.type, - elementType: executionType(type.elementType), - }; - case "type-union": - return { type: type.type, types: type.types.map(executionType) }; - case "string-union": - return { type: type.type, typeEnum: type.typeEnum }; - case "type-reference": - return { type: type.type, name: type.name }; - default: - return { type: type.type }; - } -} - -function canonicalize(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(canonicalize); - } - if (value !== null && typeof value === "object") { - return Object.fromEntries( - Object.entries(value) - .filter(([, item]) => item !== undefined) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([key, item]) => [key, canonicalize(item)]), - ); - } - return value; -} - function getPolicy( config: ActionConfig, actionName: string, @@ -134,36 +82,9 @@ export function createActionContract( mode: "may-require-interaction", kinds: ["question", "choice", "form", "action-proposal"], }; - // Reuse the serializer's dependency closure, including recursive references. - const serialized = toJSONParsedActionSchema({ - entry: { action: definition }, - actionSchemas: new Map([[identity.actionName, definition]]), - }); - const executionContract = { - protocolVersion: structuredActionProtocolVersion, - identity, - entry: serialized.entry, - types: Object.fromEntries( - Object.entries(serialized.types).map(([name, def]) => [ - name, - executionType(def.type), - ]), - ), - paramSpecs: definition.paramSpecs, - policy, - output, - interactions, - errorReasoning: config.errorReasoning, - streaming: - config.streamingActions?.includes(identity.actionName) ?? false, - }; - const fingerprint = createHash("sha256") - .update(JSON.stringify(canonicalize(executionContract))) - .digest("hex"); return { ...identity, description: getActionDescription(definition) ?? "", - fingerprint, input: { format: "typescript", typeName: definition.name, diff --git a/ts/packages/dispatcher/dispatcher/test/actionSchemaSemanticMap.spec.ts b/ts/packages/dispatcher/dispatcher/test/actionSchemaSemanticMap.spec.ts index c22b34af75..c4e91d0018 100644 --- a/ts/packages/dispatcher/dispatcher/test/actionSchemaSemanticMap.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/actionSchemaSemanticMap.spec.ts @@ -126,7 +126,7 @@ export type CreateWidgetType = { schemaName: "test.widgets", actionName: "createWidget", policy: freshContract.policy, - fingerprint: freshContract.fingerprint, + input: freshContract.input, }); }); diff --git a/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts b/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts index 00c5cad484..7e0ae08a60 100644 --- a/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts @@ -54,7 +54,7 @@ type AgentFixture = { function fixture(content = source, policies?: Record) { const agents = new AppAgentManager(undefined, new PortRegistrar()); // Seed only the manager's persisted/loaded state; all discovery, parsing, - // enablement, readiness snapshots, and fingerprinting run their real code. + // enablement and readiness snapshots run their real code. const state = agents as unknown as { agents: Map; actionConfigs: Map; @@ -133,14 +133,6 @@ async function getContract( return result.actions[0]; } -async function fingerprint(content = source, policy?: ActionPolicy) { - const { service } = fixture( - content, - policy ? { select: policy } : undefined, - ); - return (await getContract(service)).fingerprint; -} - function candidate( agents: AppAgentManager, schemaName: string, @@ -251,62 +243,6 @@ describe("structured action contracts", () => { expect(contract.input.schemaText).toContain("children?: Item[]"); expect(contract.input.schemaText.match(/type Item =/g)).toHaveLength(1); expect(contract.input.schemaText).not.toContain("type Clear"); - expect( - await fingerprint( - source.replace( - "color: Color;", - "color: Color;\n children?: Item[];", - ), - ), - ).toBe(contract.fingerprint); - }); - - it("fingerprints execution semantics, not descriptions, ordering, or siblings", async () => { - const original = await fingerprint(); - expect( - await fingerprint( - source.replace("Select an item.", "A better description."), - ), - ).toBe(original); - expect( - await fingerprint( - source.replace( - "note?: string;", - "note?: string; // explanation", - ), - ), - ).toBe(original); - expect( - await fingerprint( - source.replace( - "note?: string;\n comments?: string;", - "comments?: string;\n note?: string;", - ), - ), - ).toBe(original); - expect( - await fingerprint(source.replace("all: boolean", "all: string")), - ).toBe(original); - for (const changed of [ - source.replace( - 'type Color = "red" | "blue"', - 'type Color = "red" | "green"', - ), - source.replace("count?: number", "count?: string"), - source.replace("note?: string", "note: string"), - source.replace("comments?: string", "comments?: boolean"), - ]) { - expect(await fingerprint(changed)).not.toBe(original); - } - expect(await fingerprint(source, { effects: "read-only" })).not.toBe( - original, - ); - expect( - await fingerprint(source, { - effects: "read-only", - confirmation: "required", - }), - ).not.toBe(await fingerprint(source, { effects: "read-only" })); }); it.each([ diff --git a/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts b/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts index 9e36da3aba..6402297beb 100644 --- a/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts +++ b/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts @@ -152,7 +152,7 @@ describe("dispatcher RPC lifecycle options", () => { }); describe("dispatcher RPC structured discovery", () => { - it("forwards filters and complete versioned contracts", async () => { + it("forwards queries and complete contracts", async () => { const identity = { schemaName: "test.sub", actionName: "select" }; const searchResult: ActionSearchResult = { protocolVersion: 1, @@ -161,7 +161,6 @@ describe("dispatcher RPC structured discovery", () => { { ...identity, description: "Select", - fingerprint: "opaque-fingerprint", input: { format: "typescript", typeName: "Select", diff --git a/ts/packages/dispatcher/types/src/structuredAction.ts b/ts/packages/dispatcher/types/src/structuredAction.ts index cea0c573b9..599e5b60d4 100644 --- a/ts/packages/dispatcher/types/src/structuredAction.ts +++ b/ts/packages/dispatcher/types/src/structuredAction.ts @@ -41,7 +41,6 @@ export type ActionInteractionContract = { export type ActionContract = ActionIdentity & { description: string; - fingerprint: string; input: { format: "typescript"; typeName: string;