diff --git a/ts/docs/plans/copilot-direct-actions/director-actions.md b/ts/docs/plans/copilot-direct-actions/director-actions.md index a15b50ee88..4575d83c5d 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 @@ -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,43 +127,35 @@ 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: +`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. -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. +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. -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. 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. 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. 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 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 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 required free-text query. - Execute one action against that contract. - Continue or cancel a pending interaction when the transport cannot represent that interaction directly. @@ -171,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 @@ -196,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 @@ -217,10 +208,25 @@ 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. +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 + +- 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 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. + ## Architectural Model ```text 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/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 9fa481613f..a0320c72f8 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, ); } @@ -795,7 +822,10 @@ export class AppAgentManager implements ActionConfigProvider { 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 ⏳. @@ -816,7 +846,7 @@ export class AppAgentManager implements ActionConfigProvider { } } - provider.onSchemaReady(async (agentName, manifest) => { + provider.onSchemaReady?.(async (agentName, manifest) => { try { const refreshSemanticMapP: Promise[] = []; this.refreshAgentSchema( @@ -834,11 +864,35 @@ export class AppAgentManager implements ActionConfigProvider { 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, @@ -1602,8 +1656,16 @@ export class AppAgentManager implements ActionConfigProvider { // 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/dispatcher.ts b/ts/packages/dispatcher/dispatcher/src/dispatcher.ts index 5a224965a5..e7a350eafa 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,9 @@ export function createDispatcherFromContext( async getAgentSchemas(agentName?: string) { return getAgentSchemas(context, agentName); }, + async searchActions(request) { + return structuredActions.searchActions(request); + }, 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..897a20c383 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/contract.ts @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + generateSchemaTypeDefinition, + getActionDescription, +} from "@typeagent/action-schema"; +import type { ActionSchemaTypeDefinition } from "@typeagent/action-schema"; +import { + type ActionContract, + type ActionExecutionPolicy, + type ActionIdentity, +} from "@typeagent/dispatcher-types"; +import type { ActionConfig } from "../translation/actionConfig.js"; + +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, +): 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"], + }; + return { + ...identity, + description: getActionDescription(definition) ?? "", + 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..9e9fc7244c --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from "node:crypto"; +import { getActionDescription } from "@typeagent/action-schema"; +import { + structuredActionProtocolVersion, + type ActionContract, + type ActionSearchRequest, + type ActionSearchResult, + 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, +// 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"); + } + validateString(request.query, "query"); +} + +export class StructuredActionDiscovery { + private readonly anonymousScope = {}; + + public constructor( + private readonly context: DiscoveryContext, + private readonly access?: StructuredActionAccess, + private readonly candidateRanker: ActionCandidateRanker = context.agents, + ) {} + + 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); + await this.context.agents.waitUntilReady(); + const { envelope, policy } = this.bindScope(); + 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), + ) + .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( + request: string, + canUseCandidate: ActionCandidateFilter, + ): ActionContract[] { + const query = request.trim().toLowerCase(); + const matches: ActionContract[] = []; + for (const config of this.context.agents.getActionConfigs()) { + if (!canUseCandidate(config.schemaName, "")) { + continue; + } + const schema = + this.context.agents.getActionSchemaFileForConfig(config); + for (const [actionName, definition] of schema.parsedActionSchema + .actionSchemas) { + const description = getActionDescription(definition) ?? ""; + if ( + !`${config.schemaName} ${actionName} ${description}` + .toLowerCase() + .includes(query) + ) { + continue; + } + matches.push( + createActionContract( + { schemaName: config.schemaName, actionName }, + definition, + config, + ), + ); + } + } + 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..30b6a78eec 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaSemanticMap.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaSemanticMap.ts @@ -9,38 +9,51 @@ 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; +}; + +type PendingEntry = { + key: string; + actionName: string; definition: ActionSchemaTypeDefinition; }; export type EmbeddingCache = Map; -export class ActionSchemaSemanticMap { +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( @@ -65,8 +78,6 @@ export class ActionSchemaSemanticMap { if (!this.enabled) { return; } - const keys: string[] = []; - const definitions: ActionSchemaTypeDefinition[] = []; if (this.actionSemanticMaps.has(config.schemaName)) { throw new Error( @@ -74,8 +85,68 @@ export class ActionSchemaSemanticMap { ); } + 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) { @@ -84,13 +155,18 @@ export class ActionSchemaSemanticMap { if (embedding) { actionSemanticMap.set(key, { embedding, - actionSchemaFile, + schemaName: config.schemaName, + actionName: name, definition, }); reuseCount++; } else { keys.push(key); - definitions.push(definition); + pendingEntries.push({ + key, + actionName: name, + definition, + }); } } @@ -110,23 +186,26 @@ export class ActionSchemaSemanticMap { 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], - actionSchemaFile, - definition: definitions[i], + schemaName: config.schemaName, + 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 { @@ -144,17 +223,18 @@ export class ActionSchemaSemanticMap { } public removeActionSchemaFile(schemaName: string) { + this.beginSchemaUpdate(schemaName); 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 +243,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/actionSchemaSemanticMap.spec.ts b/ts/packages/dispatcher/dispatcher/test/actionSchemaSemanticMap.spec.ts new file mode 100644 index 0000000000..c4e91d0018 --- /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, + input: freshContract.input, + }); + }); + + 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 new file mode 100644 index 0000000000..7e0ae08a60 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/structuredActionDiscovery.spec.ts @@ -0,0 +1,677 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { jest } from "@jest/globals"; +import type { ActionPolicy, AppAgent } from "@typeagent/agent-sdk"; +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"; +import { + convertToActionConfig, + type ActionConfig, +} from "../src/translation/actionConfig.js"; +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 = ` +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 and readiness snapshots run their real code. + const state = agents as unknown as { + agents: Map; + actionConfigs: Map; + 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); + const context = { agents, session: {} }; + return { + agents, + state, + agent, + hooks, + context, + service: new StructuredActionDiscovery( + context, + undefined, + createRanker(async () => undefined), + ), + }; +} + +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.actions[0]; +} + +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(); + const enumerate = jest.spyOn(agents, "getActionConfigs"); + 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"); + 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 }, + }); + expect(contract).not.toHaveProperty("availability"); + }); + + it("preserves exact identities for duplicate action names", async () => { + const { service } = fixture(); + 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"); + }); + + it("handles no parameters and an optional parameter object", async () => { + const { service } = fixture(); + expect( + ( + await getContract(service, { + ...identity, + actionName: "ping", + }) + ).input.schemaText, + ).not.toContain("parameters"); + const optional = fixture( + source.replace("parameters: {", "parameters?: {"), + ); + expect( + (await getContract(optional.service)).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 = 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"); + }); + + 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 = await getContract(service); + 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.searchActions({ + query: `${identity.schemaName} ${identity.actionName}`, + }), + ).rejects.toThrow("Invalid structured action policy"); + }); +}); + +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("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"); + 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("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" }); + expect(result.actions.map((a) => a.actionName)).toEqual([ + "clear", + "ping", + "select", + "select", + ]); + expect( + result.actions.every((action) => action.input !== undefined), + ).toBe(true); + expect(result.actions[0]).not.toHaveProperty("availability"); + expect( + (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([{ 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( + undefined as unknown as { query: string }, + ), + ).rejects.toThrow(); + }); + + it("excludes actions unless their schema and action are active", async () => { + const { service, state, agent, hooks } = fixture(); + const assertHidden = async () => { + expect( + (await service.searchActions({ query: identity.schemaName })) + .actions, + ).toEqual([]); + }; + + agent.actions.delete(identity.schemaName); + await assertHidden(); + agent.actions.add(identity.schemaName); + agent.schemas.delete(identity.schemaName); + await assertHidden(); + agent.schemas.add(identity.schemaName); + state.transientAgents[identity.schemaName] = false; + await assertHidden(); + + for (const hook of Object.values(hooks)) { + expect(hook).not.toHaveBeenCalled(); + } + }); + + 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({ 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.searchActions({ query: "select" }); + expect((await service.searchActions({ query: "test" })).scopeId).toBe( + first.scopeId, + ); + expect( + ( + await new StructuredActionDiscovery(context).searchActions({ + query: "test", + }) + ).scopeId, + ).not.toBe(first.scopeId); + context.session = {}; + 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({ query: "test" }); + const reconnected = new StructuredActionDiscovery(context, () => ({ + scope, + canDiscoverSchema: () => true, + })); + expect( + (await reconnected.searchActions({ query: "test" })).scopeId, + ).toBe(before.scopeId); + scope = {}; + expect( + (await restricted.searchActions({ query: "test" })).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({ 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.searchActions({ query: "test" }), + ).rejects.toThrow(); + }); +}); diff --git a/ts/packages/dispatcher/rpc/src/dispatcherClient.ts b/ts/packages/dispatcher/rpc/src/dispatcherClient.ts index e729ff07f7..f60d4711e1 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherClient.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherClient.ts @@ -211,6 +211,9 @@ export function createDispatcherRpcClient( async getAgentSchemas(...args) { return rpc.invoke("getAgentSchemas", ...args); }, + async searchActions(...args) { + return rpc.invoke("searchActions", ...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..2a54c7c621 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherServer.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherServer.ts @@ -97,6 +97,9 @@ export function createDispatcherRpcServer( getAgentSchemas: async (...args) => { return dispatcher.getAgentSchemas(...args); }, + searchActions: async (...args) => { + return dispatcher.searchActions(...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..f8e5ad8ba5 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts @@ -9,6 +9,8 @@ import type { } from "@typeagent/agent-sdk"; import type { AgentSchemaInfo, + ActionSearchRequest, + ActionSearchResult, CancelResult, CommandCompletionResult, CommandResult, @@ -99,6 +101,8 @@ export type DispatcherInvokeFunctions = { getAgentSchemas(agentName?: string): Promise; + searchActions(request: ActionSearchRequest): 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..6402297beb 100644 --- a/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts +++ b/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts @@ -6,6 +6,7 @@ import type { RpcStructuredLogger } from "@typeagent/agent-rpc/rpc"; import { createDispatcherRpcClient } from "../src/dispatcherClient.js"; import { createDispatcherRpcServer } from "../src/dispatcherServer.js"; import type { + ActionSearchResult, CommandResult, Dispatcher, QueuedRequest, @@ -72,6 +73,7 @@ function makeStubDispatcher(overrides: Partial = {}): Dispatcher & { close: notImplemented("close") as any, getStatus: notImplemented("getStatus") as any, getAgentSchemas: notImplemented("getAgentSchemas") as any, + searchActions: notImplemented("searchActions"), respondToChoice: notImplemented("respondToChoice") as any, getDisplayHistory: notImplemented("getDisplayHistory") as any, async cancelCommand(...args) { @@ -149,6 +151,80 @@ describe("dispatcher RPC lifecycle options", () => { }); }); +describe("dispatcher RPC structured discovery", () => { + it("forwards queries and complete contracts", async () => { + const identity = { schemaName: "test.sub", actionName: "select" }; + const searchResult: ActionSearchResult = { + protocolVersion: 1, + scopeId: "server-scope", + actions: [ + { + ...identity, + description: "Select", + 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 channels = createChannelPair(); + createDispatcherRpcServer( + makeStubDispatcher({ searchActions }), + channels.serverChannel, + ); + const { dispatcher } = createDispatcherRpcClient( + channels.clientChannel, + ); + const request = { query: "select" }; + await expect(dispatcher.searchActions(request)).resolves.toEqual( + searchResult, + ); + expect(calls).toEqual([{ method: "search", input: request }]); + }); + + it("propagates discovery errors without a command fallback", async () => { + const channels = createChannelPair(); + createDispatcherRpcServer( + makeStubDispatcher({ + async searchActions() { + throw new Error("Invalid action filter"); + }, + }), + channels.serverChannel, + ); + const { dispatcher } = createDispatcherRpcClient( + channels.clientChannel, + ); + await expect(dispatcher.searchActions({ query: "" })).rejects.toThrow( + "Invalid action filter", + ); + }); +}); 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..0ea8e9b094 100644 --- a/ts/packages/dispatcher/types/src/dispatcher.ts +++ b/ts/packages/dispatcher/types/src/dispatcher.ts @@ -18,6 +18,10 @@ import type { } from "./displayLogEntry.js"; import type { PendingInteractionResponse } from "./pendingInteraction.js"; import type { CancelResult, QueueSnapshot, SubmitResult } from "./queue.js"; +import type { + ActionSearchRequest, + ActionSearchResult, +} from "./structuredAction.js"; export const DispatcherName = "dispatcher"; export const DispatcherEmoji = "🤖"; @@ -508,6 +512,8 @@ export interface Dispatcher { */ getAgentSchemas(agentName?: string): Promise; + searchActions(request: ActionSearchRequest): 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..599e5b60d4 --- /dev/null +++ b/ts/packages/dispatcher/types/src/structuredAction.ts @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ActionEffect } from "@typeagent/agent-sdk"; + +export const structuredActionProtocolVersion = 1; + +export type ActionIdentity = { + schemaName: string; + actionName: string; +}; + +export type ActionSearchRequest = { + query: string; +}; + +export type StructuredActionEnvelope = { + protocolVersion: typeof structuredActionProtocolVersion; + // Server-issued reuse boundary, not a bearer token or authorization grant. + scopeId: string; +}; + +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 = ActionIdentity & { + description: string; + input: { + format: "typescript"; + typeName: string; + schemaText: string; + }; + policy: ActionExecutionPolicy; + output: ActionOutputContract; + interactions: ActionInteractionContract; +}; + +export type ActionSearchResult = StructuredActionEnvelope & { + actions: ActionContract[]; +};