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" } + );