diff --git a/ts/packages/agentRpc/src/client.ts b/ts/packages/agentRpc/src/client.ts index e42a6e31ad..de26956080 100644 --- a/ts/packages/agentRpc/src/client.ts +++ b/ts/packages/agentRpc/src/client.ts @@ -631,6 +631,28 @@ export async function createAgentRpcClient( // The shim needs to implement all the APIs regardless whether the actual agent // has that API. We remove remove it the one that is not necessary below. + async function invokeWithActionCancellation( + context: ActionContext, + contextParams: ActionContextParams, + invoke: () => Promise, + ): Promise { + const signal = context.abortSignal; + signal?.throwIfAborted(); + const onAbort = () => + rpc.send("cancelAction", { + actionContextId: contextParams.actionContextId, + }); + signal?.addEventListener("abort", onAbort, { once: true }); + try { + const pending = invoke(); + return await (context.waitForCompletionOnAbort + ? pending + : raceWithSignal(pending, signal)); + } finally { + signal?.removeEventListener("abort", onAbort); + } + } + const agent: Required = { initializeAgentContext(settings?: AppAgentInitSettings) { return rpc.invoke("initializeAgentContext", { @@ -653,32 +675,14 @@ export async function createAgentRpcClient( action: TypeAgentAction, context: ActionContext, ) { - return withActionContextAsync(context, (contextParams) => { - const signal = context.abortSignal; - if (signal) { - const onAbort = () => - rpc.send("cancelAction", { - actionContextId: contextParams.actionContextId, - }); - signal.addEventListener("abort", onAbort, { once: true }); - return raceWithSignal( - rpc.invoke("executeAction", { - ...contextParams, - action, - }), - signal, - ).finally(() => { - signal.removeEventListener("abort", onAbort); - }); - } - return raceWithSignal( + return withActionContextAsync(context, (contextParams) => + invokeWithActionCancellation(context, contextParams, () => rpc.invoke("executeAction", { ...contextParams, action, }), - signal, - ); - }); + ), + ); }, validateWildcardMatch( action: AppAction, @@ -809,6 +813,12 @@ export async function createAgentRpcClient( entityTypeName, }); }, + cancelChoice(choiceId: string, context: SessionContext) { + return rpc.invoke("cancelChoice", { + ...getContextParam(context), + choiceId, + }); + }, handleChoice( choiceId: string, response: @@ -819,11 +829,13 @@ export async function createAgentRpcClient( context: ActionContext, ) { return withActionContextAsync(context, (contextParams) => - rpc.invoke("handleChoice", { - ...contextParams, - choiceId, - response, - }), + invokeWithActionCancellation(context, contextParams, () => + rpc.invoke("handleChoice", { + ...contextParams, + choiceId, + response, + }), + ), ); }, getDynamicSchema( diff --git a/ts/packages/agentRpc/src/common.ts b/ts/packages/agentRpc/src/common.ts index 38bc9cbb1c..3e2bffe634 100644 --- a/ts/packages/agentRpc/src/common.ts +++ b/ts/packages/agentRpc/src/common.ts @@ -138,20 +138,27 @@ export function createChannelProvider( return; } if (message.name === undefined) { - debugError( - `Missing channel name in message: ${JSON.stringify(message)}`, - ); + debugError("Missing channel name in message"); return; } const channelAdapter = channelAdapters.get(message.name); if (channelAdapter === undefined) { debugError( - `Invalid channel name ${message.name} in message (available: ${Array.from(channelAdapters.keys()).join(", ")})`, + `Invalid channel name in message (available channels: ${channelAdapters.size})`, ); return; } - const msgType = message.message?.type || "unknown"; - const callId = message.message?.callId ?? "n/a"; + // Remote envelopes may contain capabilities or action parameters. + // Log only recognized routing metadata, never arbitrary wire values. + const type = message.message?.type; + const msgType = + typeof type === "string" && + ["call", "invoke", "invokeResult", "invokeError"].includes(type) + ? type + : "unknown"; + const callId = Number.isSafeInteger(message.message?.callId) + ? message.message.callId + : "n/a"; debug( `routing message to channel: ${message.name} (type=${msgType}, callId=${callId})`, ); diff --git a/ts/packages/agentRpc/src/rpc.ts b/ts/packages/agentRpc/src/rpc.ts index e99b32b5f2..856fa8cf85 100644 --- a/ts/packages/agentRpc/src/rpc.ts +++ b/ts/packages/agentRpc/src/rpc.ts @@ -92,6 +92,14 @@ export type RpcTracingOptions = { ) => RpcCorrelationFields | undefined; }; +/** The transport cannot establish whether an in-flight invocation completed. */ +export class RpcDisconnectedError extends Error { + constructor(message = "Agent channel disconnected") { + super(message); + this.name = "RpcDisconnectedError"; + } +} + export type RpcOptions = { // When true, a disconnect rejects in-flight calls but leaves invoke/send // intact so the rpc can be reattached to a fresh channel via rebind(). @@ -185,11 +193,11 @@ export function createRpc< let connected = true; let bindGeneration = 0; const errorFunc = () => { - throw new Error("Agent channel disconnected"); + throw new RpcDisconnectedError(); }; const rejectAllPending = (reason: string) => { for (const pendingInvoke of pending.values()) { - pendingInvoke.reject(new Error(reason)); + pendingInvoke.reject(new RpcDisconnectedError(reason)); } pending.clear(); }; @@ -565,7 +573,7 @@ export function createRpc< methodName as string, nextCallId++, ); - const error = new Error("Agent channel disconnected"); + const error = new RpcDisconnectedError(); emitStructuredStarted(options?.logger, lifecycle); emitStructuredCompleted(options?.logger, { ...lifecycle, @@ -605,7 +613,7 @@ export function createRpc< }; try { if (!connected) { - throw new Error("Agent channel disconnected"); + throw new RpcDisconnectedError(); } const correlation = getOutboundCorrelation( options?.tracing, @@ -672,7 +680,7 @@ export function createRpc< invoke(methodName, args), send: (methodName: keyof CallTargetFunctions, ...args: any[]) => { if (!connected) { - throw new Error("Agent channel disconnected"); + throw new RpcDisconnectedError(); } out( { diff --git a/ts/packages/agentRpc/src/server.ts b/ts/packages/agentRpc/src/server.ts index 8a19d4110c..5f6c921d7e 100644 --- a/ts/packages/agentRpc/src/server.ts +++ b/ts/packages/agentRpc/src/server.ts @@ -311,15 +311,26 @@ export function createAgentRpcServer( param.entityTypeName, ); }, + async cancelChoice(param) { + await agent.cancelChoice?.( + param.choiceId, + getSessionContextShim(param), + ); + }, async handleChoice(param) { if (agent.handleChoice === undefined) { throw new Error("Invalid invocation of handleChoice"); } - return agent.handleChoice( - param.choiceId, - param.response, - getActionContextShim(param), - ); + try { + return await agent.handleChoice( + param.choiceId, + param.response, + getActionContextShim(param), + ); + } finally { + if (param.actionContextId !== undefined) + actionAbortControllers.delete(param.actionContextId); + } }, async getDynamicSchema(param) { if (agent.getDynamicSchema === undefined) { diff --git a/ts/packages/agentRpc/src/types.ts b/ts/packages/agentRpc/src/types.ts index 9136dd7318..f84236811a 100644 --- a/ts/packages/agentRpc/src/types.ts +++ b/ts/packages/agentRpc/src/types.ts @@ -276,6 +276,9 @@ export type AgentInvokeFunctions = { | QuestionFormResponse; }, ): Promise; + cancelChoice( + param: Partial & { choiceId: string }, + ): Promise; getDynamicSchema( param: Partial & { schemaName: string }, ): Promise; diff --git a/ts/packages/agentRpc/test/actionContext.spec.ts b/ts/packages/agentRpc/test/actionContext.spec.ts index 4c642f8094..bd22ef8389 100644 --- a/ts/packages/agentRpc/test/actionContext.spec.ts +++ b/ts/packages/agentRpc/test/actionContext.spec.ts @@ -12,8 +12,87 @@ import { type ChannelProviderAdapter, } from "../src/common.js"; import { createAgentRpcServer } from "../src/server.js"; +import { + ChoiceManager, + createYesNoChoiceResult, +} from "@typeagent/agent-sdk/helpers/action"; describe("agent action context RPC", () => { + test("cancels a real SDK choice over agent RPC without invoking its callback", async () => { + let clientProvider: ChannelProviderAdapter; + let serverProvider: ChannelProviderAdapter; + clientProvider = createChannelProviderAdapter( + "choice-client", + (message, callback) => { + queueMicrotask(() => + serverProvider.notifyMessage(structuredClone(message)), + ); + callback?.(null); + }, + ); + serverProvider = createChannelProviderAdapter( + "choice-server", + (message, callback) => { + queueMicrotask(() => + clientProvider.notifyMessage(structuredClone(message)), + ); + callback?.(null); + }, + ); + const choices = new ChoiceManager(); + let invoked = 0; + const agent: AppAgent = { + initializeAgentContext: async () => ({}), + executeAction: async () => + createYesNoChoiceResult(choices, "Confirm", async () => { + invoked++; + return undefined; + }), + handleChoice: (id, response, context) => + choices.handleChoice(id, response, context), + cancelChoice: async (id) => { + choices.cancelChoice(id); + }, + }; + const server = createAgentRpcServer("choice", agent, serverProvider); + const client = await createAgentRpcClient( + "choice", + clientProvider, + server.agentInterface, + ); + try { + const agentContext = await client.initializeAgentContext?.(); + const sessionContext = { + agentContext, + sessionContextId: "choice-session", + } as SessionContext; + const actionContext = { + sessionContext, + isFromReasoningLoop: false, + } as ActionContext; + const result = await client.executeAction!( + { schemaName: "choice", actionName: "test" }, + actionContext, + ); + if ( + result === undefined || + result.error !== undefined || + result.pendingChoice === undefined + ) + throw new Error("Expected a pending choice"); + const id = result.pendingChoice.choiceId; + await client.cancelChoice!(id, sessionContext); + await expect( + client.handleChoice!(id, true, actionContext), + ).rejects.toThrow("Choice not found or expired"); + expect(invoked).toBe(0); + } finally { + server.closeFn(); + clientProvider.notifyDisconnected(); + serverProvider.notifyDisconnected(); + } + }); + test("propagates workingDirectory to the out-of-process agent", async () => { let clientProvider: ChannelProviderAdapter; let serverProvider: ChannelProviderAdapter; diff --git a/ts/packages/agentRpc/test/channelLogging.spec.ts b/ts/packages/agentRpc/test/channelLogging.spec.ts new file mode 100644 index 0000000000..508deb0095 --- /dev/null +++ b/ts/packages/agentRpc/test/channelLogging.spec.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import debug from "debug"; +import { format } from "node:util"; +import { createChannelProviderAdapter } from "../src/common.js"; + +describe("channel diagnostic privacy", () => { + it("never logs payloads or untrusted routing fields, including malformed join envelopes", () => { + const previousNamespaces = debug.disable(); + const previousLog = debug.log; + const logs: string[] = []; + const marker = "test-only-private-payload-marker"; + debug.log = (...args: unknown[]) => { + logs.push(format(...args)); + }; + debug.enable("typeagent:channel-redaction:*"); + try { + const sent: unknown[] = []; + const received: unknown[] = []; + const provider = createChannelProviderAdapter( + "channel-redaction", + (message) => { + sent.push(message); + }, + ); + const channel = provider.createChannel("dispatcher"); + channel.on("message", (message) => { + received.push(message); + }); + const payload = { structuredActions: { resumeToken: marker } }; + const invoke = { + type: "invoke", + name: "joinConversation", + callId: 1, + args: [payload], + }; + const result = { type: "invokeResult", callId: 1, result: payload }; + channel.send(invoke); + provider.notifyMessage({ name: "dispatcher", message: invoke }); + provider.notifyMessage({ name: "dispatcher", message: result }); + provider.notifyMessage({ ...payload, message: invoke }); + provider.notifyMessage({ name: marker, message: invoke }); + provider.notifyMessage({ + name: "dispatcher", + message: { type: marker, callId: marker, result: payload }, + }); + provider.notifyDisconnected(); + + expect(sent).toEqual([{ name: "dispatcher", message: invoke }]); + expect(received.slice(0, 2)).toEqual([invoke, result]); + expect(logs.length).toBeGreaterThan(0); + expect(logs.join("\n")).toContain("Missing channel name"); + expect(logs.join("\n")).toContain("type=invoke"); + expect(logs.join("\n")).not.toContain(marker); + expect(logs.join("\n")).not.toContain("resumeToken"); + } finally { + debug.log = previousLog; + debug.enable(previousNamespaces); + } + }); +}); diff --git a/ts/packages/agentSdk/src/agentInterface.ts b/ts/packages/agentSdk/src/agentInterface.ts index 09381157ac..2d66d00c78 100644 --- a/ts/packages/agentSdk/src/agentInterface.ts +++ b/ts/packages/agentSdk/src/agentInterface.ts @@ -197,6 +197,7 @@ export interface AppAgent extends Partial { ): Promise; // Choice (yes/no confirmation, multi-select, or multi-question form) + cancelChoice?(choiceId: string, context: SessionContext): Promise; handleChoice?( choiceId: string, response: @@ -478,6 +479,9 @@ export interface ActionContext { readonly actionIO: ActionIO; readonly sessionContext: SessionContext; readonly abortSignal?: AbortSignal | undefined; + // Hosts retaining shared execution state require transports to await the + // actual handler after forwarding abort, rather than racing its response. + readonly waitForCompletionOnAbort?: boolean; // true when this action was dispatched from within the reasoning loop (via MCP execute_action), // false when dispatched directly from the translator. Agents can use this to decide whether diff --git a/ts/packages/agentSdk/src/helpers/choiceManager.ts b/ts/packages/agentSdk/src/helpers/choiceManager.ts index da61797e0b..37c4b2b943 100644 --- a/ts/packages/agentSdk/src/helpers/choiceManager.ts +++ b/ts/packages/agentSdk/src/helpers/choiceManager.ts @@ -39,6 +39,11 @@ export class ChoiceManager { return id; } + /** Release a suspended callback without fabricating a user answer. */ + cancelChoice(choiceId: string): boolean { + return this.callbacks.delete(choiceId); + } + async handleChoice( choiceId: string, response: ChoiceResponse, diff --git a/ts/packages/agentSdk/test/choiceCancellation.spec.ts b/ts/packages/agentSdk/test/choiceCancellation.spec.ts new file mode 100644 index 0000000000..ecbdf44ca5 --- /dev/null +++ b/ts/packages/agentSdk/test/choiceCancellation.spec.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ActionContext } from "../src/agentInterface.js"; +import { ChoiceManager } from "../src/helpers/choiceManager.js"; + +describe("ChoiceManager cancellation", () => { + it("removes a callback without invoking it with a fabricated default", async () => { + const choices = new ChoiceManager(); + let invoked = 0; + const callback = async () => { + invoked++; + return undefined; + }; + const id = choices.registerChoice(callback); + expect(choices.cancelChoice(id)).toBe(true); + expect(choices.cancelChoice(id)).toBe(false); + await expect( + choices.handleChoice(id, true, {} as ActionContext), + ).rejects.toThrow("Choice not found or expired"); + expect(invoked).toBe(0); + }); +}); diff --git a/ts/packages/agentServer/client/src/agentServerClient.ts b/ts/packages/agentServer/client/src/agentServerClient.ts index 3f42da0c24..d1ca796e2b 100644 --- a/ts/packages/agentServer/client/src/agentServerClient.ts +++ b/ts/packages/agentServer/client/src/agentServerClient.ts @@ -149,6 +149,8 @@ export type ConversationDispatcher = { pendingInteractions?: NonNullable< JoinConversationResult["pendingInteractions"] >; + /** Retain only in trusted memory; pass back on an explicit resumed join. */ + structuredActions?: JoinConversationResult["structuredActions"]; }; export type AgentServerConnection = { @@ -542,6 +544,9 @@ export function createAgentServerConnection( connectionId: result.connectionId, queueSnapshot: result.queueSnapshot, pendingInteractions: result.pendingInteractions ?? [], + ...(result.structuredActions === undefined + ? {} + : { structuredActions: result.structuredActions }), }; }, @@ -832,7 +837,8 @@ export async function connectAgentServer( createChannelProviderAdapter( "agent-server:client", (message: any) => { - debug("Sending message to server:", message); + // Join payloads can carry private resume capabilities. + debug("Sending message to server"); ws.send(JSON.stringify(message)); }, ); @@ -843,7 +849,7 @@ export async function connectAgentServer( settle(channel); }; ws.onmessage = (event: WebSocket.MessageEvent) => { - debug("Received message from server:", event.data); + debug("Received message from server"); channel.notifyMessage(JSON.parse(event.data.toString())); }; ws.onclose = (event: WebSocket.CloseEvent) => { diff --git a/ts/packages/agentServer/client/test/reconnect.spec.ts b/ts/packages/agentServer/client/test/reconnect.spec.ts index c94c729f2d..b8c23cbdc2 100644 --- a/ts/packages/agentServer/client/test/reconnect.spec.ts +++ b/ts/packages/agentServer/client/test/reconnect.spec.ts @@ -9,13 +9,18 @@ import { ConversationInfo, } from "@typeagent/agent-server-protocol"; import WebSocket, { WebSocketServer } from "ws"; +import { jest } from "@jest/globals"; +import registerDebug from "debug"; import { connectAgentServer } from "../src/agentServerClient.js"; import { fakeClientIO } from "./conversation-stubConnection.js"; // Spin up a real ws server that speaks the agent-rpc control channel so the // reconnect/rebind path is exercised over the actual wire format. -async function startStubServer(convs: ConversationInfo[]): Promise<{ +async function startStubServer( + convs: ConversationInfo[], + resumeToken?: string, +): Promise<{ url: string; dropSockets: () => void; liveSocketCount: () => number; @@ -60,6 +65,9 @@ async function startStubServer(convs: ConversationInfo[]): Promise<{ connectionId: "conn-1", name: "Shell", pendingInteractions: [pendingInteraction], + ...(resumeToken === undefined + ? {} + : { structuredActions: { resumeToken } }), }), createConversation: async (name: string) => ({ conversationId: "c-new", @@ -227,3 +235,30 @@ describe("connectAgentServer leaveConversation on a dead channel", () => { } }); }); + +describe("connectAgentServer structured capabilities", () => { + test("does not log private capabilities in either wire direction", async () => { + const token = "private-resume-capability-".padEnd(43, "x"); + const stub = await startStubServer([], token); + const previous = registerDebug.disable(); + const log = jest + .spyOn(registerDebug, "log") + .mockImplementation(() => {}); + registerDebug.enable("*"); + const connection = await connectAgentServer(stub.url); + try { + const joined = await connection.joinConversation(fakeClientIO, { + conversationId: "c1", + structuredActions: { resumeToken: token }, + }); + expect(joined.structuredActions).toEqual({ resumeToken: token }); + expect(log).toHaveBeenCalled(); + expect(JSON.stringify(log.mock.calls)).not.toContain(token); + } finally { + await connection.close(); + await stub.close(); + registerDebug.enable(previous); + log.mockRestore(); + } + }); +}); diff --git a/ts/packages/agentServer/protocol/src/protocol.ts b/ts/packages/agentServer/protocol/src/protocol.ts index 1f8a3fb350..c0f6889812 100644 --- a/ts/packages/agentServer/protocol/src/protocol.ts +++ b/ts/packages/agentServer/protocol/src/protocol.ts @@ -65,6 +65,14 @@ export type DispatcherConnectOptions = { filter?: boolean; // filter to message for own request. Default is false (no filtering) clientType?: "shell" | "extension" | "android"; // identifies the connecting client type conversationId?: string; // join a specific conversation by UUID. If omitted, connects to the default conversation. + /** + * Opt into isolated structured-action ownership. Requires conversationId. + * Resume only with the capability returned by an earlier join of that + * same live conversation. Never substitute a client-supplied identity. + */ + structuredActions?: { + resumeToken?: string; + }; }; /** @@ -153,6 +161,14 @@ export type JoinConversationResult = { /** Server-side queue snapshot at join time. Omitted when idle/empty; * older clients ignore the field. */ queueSnapshot?: QueueSnapshot; + /** + * Private, in-memory resume capability for this logical structured caller. + * Keep it out of logs, model prompts, history, and persisted metadata. + * A resumed join revokes structured access on the previous connection. + */ + structuredActions?: { + resumeToken: string; + }; }; /** diff --git a/ts/packages/agentServer/server/src/connectionHandler.ts b/ts/packages/agentServer/server/src/connectionHandler.ts index 3985364d7c..ffda6621ca 100644 --- a/ts/packages/agentServer/server/src/connectionHandler.ts +++ b/ts/packages/agentServer/server/src/connectionHandler.ts @@ -24,6 +24,7 @@ import type { PortRegistrar } from "agent-dispatcher"; import type { ConversationManager } from "./conversationManager.js"; import { resolveTunnelUrlForDiscovery } from "./tunnelResolver.js"; import { getSpeechToken } from "./speechToken.js"; +import { validateStructuredActionJoin } from "./structuredActionBindings.js"; import registerDebug from "debug"; // Disconnect cleanup is best effort, so a failure cannot be surfaced to anyone: @@ -249,6 +250,8 @@ export function createAgentServerConnectionHandler( string, { dispatcher: Dispatcher; connectionId: string } >(); + const joiningConversations = new Set(); + let disconnected = false; // Client-hosted agents this connection registered, per conversation. // Keyed by instance so disconnect removes only this connection's @@ -326,23 +329,34 @@ export function createAgentServerConnectionHandler( getMacroRun: async (runId) => macroManager.getMacroRun(runId), joinConversation: async (options?: DispatcherConnectOptions) => { + validateStructuredActionJoin(options); + if (disconnected) { + throw new Error("Agent connection is disconnected"); + } // Resolve conversation ID first (may auto-create default) const conversationId = await conversationManager.resolveConversationId( options?.conversationId, ); - if (joinedConversations.has(conversationId)) { + if (disconnected) { + throw new Error("Agent connection is disconnected"); + } + if ( + joinedConversations.has(conversationId) || + joiningConversations.has(conversationId) + ) { throw new Error( `Already joined conversation '${conversationId}'. Call leaveConversation() before joining again.`, ); } - // Create conversation-namespaced channels - const clientIOChannel = channelProvider.createChannel( - getClientIOChannelName(conversationId), - ); + joiningConversations.add(conversationId); + let acquiredConnectionId: string | undefined; try { + const clientIOChannel = channelProvider.createChannel( + getClientIOChannelName(conversationId), + ); const clientIORpcClient = createClientIORpcClient(clientIOChannel); @@ -380,6 +394,10 @@ export function createAgentServerConnectionHandler( }, options, ); + acquiredConnectionId = result.connectionId; + if (disconnected) { + throw new Error("Agent connection is disconnected"); + } const dispatcherChannel = channelProvider.createChannel( getDispatcherChannelName(conversationId), @@ -490,12 +508,27 @@ export function createAgentServerConnectionHandler( if (result.queueSnapshot !== undefined) { joinResult.queueSnapshot = result.queueSnapshot; } + if (result.structuredActions !== undefined) { + joinResult.structuredActions = result.structuredActions; + } return joinResult; } catch (e) { - channelProvider.deleteChannel( - getClientIOChannelName(conversationId), - ); + try { + if (acquiredConnectionId !== undefined) { + await conversationManager.leaveConversation( + conversationId, + acquiredConnectionId, + ); + } + } finally { + joinedConversations.delete(conversationId); + channelProvider.deleteChannel( + getClientIOChannelName(conversationId), + ); + } throw e; + } finally { + joiningConversations.delete(conversationId); } }, @@ -722,6 +755,7 @@ export function createAgentServerConnectionHandler( // Clean up all conversations on disconnect channelProvider.on("disconnect", () => { + disconnected = true; onDisconnect?.(); if (staleNotifier !== undefined) { staleNotifiers.delete(staleNotifier); diff --git a/ts/packages/agentServer/server/src/conversationManager.ts b/ts/packages/agentServer/server/src/conversationManager.ts index 087b738b68..c2c462017c 100644 --- a/ts/packages/agentServer/server/src/conversationManager.ts +++ b/ts/packages/agentServer/server/src/conversationManager.ts @@ -12,6 +12,7 @@ import { ConversationMatch, ConversationContentMatch, ConversationSource, + JoinConversationResult, RenameConversationOptions, } from "@typeagent/agent-server-protocol"; import { @@ -55,6 +56,7 @@ import { type ConversationSummaryTranslator, } from "./conversationSummary.js"; import { lockInstanceDir } from "agent-dispatcher/internal"; +import { validateStructuredActionJoin } from "./structuredActionBindings.js"; import registerDebug from "debug"; const debugConversation = registerDebug("agent-server:conversation"); @@ -182,6 +184,7 @@ export type ConversationManager = { name: string; pendingInteractions: PendingInteractionRequest[]; queueSnapshot?: QueueSnapshot; + structuredActions?: JoinConversationResult["structuredActions"]; }>; leaveConversation( conversationId: string, @@ -1129,21 +1132,46 @@ export async function createConversationManager( name: string; pendingInteractions: PendingInteractionRequest[]; queueSnapshot?: QueueSnapshot; + structuredActions?: JoinConversationResult["structuredActions"]; }> { + validateStructuredActionJoin(options, conversationId); const record = conversations.get(conversationId); if (record === undefined) { throw new Error(`Conversation not found: ${conversationId}`); } + if (options?.structuredActions !== undefined && record.readOnly) { + throw new Error( + "Structured execution is unavailable in a read-only conversation", + ); + } + if ( + options?.structuredActions?.resumeToken !== undefined && + record.sharedDispatcher === undefined + ) { + throw new Error( + "Structured action resume state is unavailable; do not replay an interrupted action", + ); + } cancelIdleTimer(record); const sharedDispatcher = await ensureDispatcher(record); - const dispatcher = sharedDispatcher.join( - clientIO, - closeFn, - options, - ); - touchConversation(conversationId); - await saveMetadata(); + let dispatcher: Dispatcher | undefined; + try { + dispatcher = sharedDispatcher.join(clientIO, closeFn, options); + touchConversation(conversationId); + await saveMetadata(); + } catch (error) { + try { + if (dispatcher?.connectionId !== undefined) { + await sharedDispatcher.leave(dispatcher.connectionId); + } + } finally { + if (sharedDispatcher.clientCount === 0) { + startIdleTimer(record); + } + } + throw error; + } debugConversation( `Client joined conversation "${record.name}" (${conversationId}), clients: ${sharedDispatcher.clientCount}`, @@ -1166,6 +1194,7 @@ export async function createConversationManager( name: string; pendingInteractions: PendingInteractionRequest[]; queueSnapshot?: QueueSnapshot; + structuredActions?: JoinConversationResult["structuredActions"]; } = { dispatcher, connectionId: dispatcher.connectionId!, @@ -1178,6 +1207,13 @@ export async function createConversationManager( if (queueSnapshot !== undefined) { result.queueSnapshot = queueSnapshot; } + const structuredActions = + sharedDispatcher.getStructuredActionBinding( + dispatcher.connectionId!, + ); + if (structuredActions !== undefined) { + result.structuredActions = structuredActions; + } return result; }, diff --git a/ts/packages/agentServer/server/src/sharedDispatcher.ts b/ts/packages/agentServer/server/src/sharedDispatcher.ts index 9abc00a32a..2f0a0ce863 100644 --- a/ts/packages/agentServer/server/src/sharedDispatcher.ts +++ b/ts/packages/agentServer/server/src/sharedDispatcher.ts @@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"; import { DispatcherConnectOptions, + JoinConversationResult, registerClientType, unregisterClient, } from "@typeagent/agent-server-protocol"; @@ -38,6 +39,11 @@ import { selectWorkingDirectoryProposal, resolveWorkingDirectory, } from "./workingDirectoryPolicy.js"; +import { + StructuredActionBindings, + type StructuredActionLease, + validateStructuredActionJoin, +} from "./structuredActionBindings.js"; import registerDebug from "debug"; const debugConnect = registerDebug("agent-server:connect"); @@ -479,6 +485,10 @@ export async function createSharedDispatcher( ...options, clientIO, }); + const structuredBindings = new StructuredActionBindings( + () => context.session, + ); + const structuredLeases = new Map(); // Intercept display methods on the shared clientIO to mirror display // traffic into the DisplayLog for later replay. Patches context.clientIO @@ -624,6 +634,19 @@ export async function createSharedDispatcher( // so interactions created before disconnect are unroutable after // reconnect. See docs/async-clientio-design.md §Open Questions. const connectionId = (nextConnectionId++).toString(); + const anonymousScope = {}; + validateStructuredActionJoin(options); + const structuredLease = + options?.structuredActions === undefined + ? undefined + : structuredBindings.acquire( + options.conversationId!, + connectionId, + options.structuredActions.resumeToken, + ); + if (structuredLease !== undefined) { + structuredLeases.set(connectionId, structuredLease); + } let selectedWorkingDirectory: string | undefined; const wasEmpty = clients.size === 0; clients.set(connectionId, { @@ -642,6 +665,8 @@ export async function createSharedDispatcher( context, connectionId, async () => { + structuredLease?.release(); + structuredLeases.delete(connectionId); clients.delete(connectionId); dispatchers.delete(connectionId); unregisterClient(connectionId); @@ -677,6 +702,12 @@ export async function createSharedDispatcher( `Client disconnected: ${connectionId} (total clients: ${clients.size})`, ); }, + structuredLease?.access ?? + (() => ({ + scope: anonymousScope, + canDiscoverSchema: () => clients.has(connectionId), + canExecute: false, + })), ); dispatchers.set(connectionId, dispatcher); debugConnect( @@ -820,6 +851,12 @@ export async function createSharedDispatcher( return dispatcher; }, + getStructuredActionBinding(connectionId) { + const lease = structuredLeases.get(connectionId); + return lease === undefined + ? undefined + : { resumeToken: lease.resumeToken }; + }, respondToInteraction(response: PendingInteractionResponse): void { debugInteractionInfo("respondToInteraction", { interactionId: response.interactionId, @@ -938,6 +975,7 @@ export async function createSharedDispatcher( }, async close() { cancelNoClientsGraceTimer(); + structuredBindings.close(); pendingInteractions.cancelAll( new Error("SharedDispatcher closing"), ); @@ -1058,6 +1096,9 @@ export type SharedDispatcher = { closeFn: () => void, options?: DispatcherConnectOptions, ): Dispatcher; + getStructuredActionBinding( + connectionId: string, + ): JoinConversationResult["structuredActions"]; respondToInteraction(response: PendingInteractionResponse): void; cancelInteraction(interactionId: string): void; getPendingInteractions( diff --git a/ts/packages/agentServer/server/src/structuredActionBindings.ts b/ts/packages/agentServer/server/src/structuredActionBindings.ts new file mode 100644 index 0000000000..ad7dd76115 --- /dev/null +++ b/ts/packages/agentServer/server/src/structuredActionBindings.ts @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomBytes } from "node:crypto"; +import type { StructuredActionAccess } from "agent-dispatcher/internal"; +import type { DispatcherConnectOptions } from "@typeagent/agent-server-protocol"; + +export const MAX_STRUCTURED_ACTION_BINDINGS = 100; +export const STRUCTURED_ACTION_BINDING_IDLE_MS = 30 * 60 * 1000; + +type Binding = { + conversationId: string; + scope: object; + connectionId: string | undefined; + lastUsed: number; +}; + +export type StructuredActionLease = { + resumeToken: string; + access: StructuredActionAccess; + release(): void; +}; + +export function validateStructuredActionJoin( + options: DispatcherConnectOptions | undefined, + conversationId?: string, +): void { + if (options?.structuredActions === undefined) { + return; + } + const structured = options.structuredActions; + if ( + structured === null || + typeof structured !== "object" || + Array.isArray(structured) || + Object.keys(structured).some((key) => key !== "resumeToken") + ) { + throw new Error("Invalid structured action join options"); + } + if ( + typeof options.conversationId !== "string" || + options.conversationId.trim().length === 0 || + (conversationId !== undefined && + options.conversationId !== conversationId) + ) { + throw new Error( + "Structured actions require the explicit target conversationId", + ); + } + if ( + structured.resumeToken !== undefined && + (typeof structured.resumeToken !== "string" || + !/^[A-Za-z0-9_-]{43}$/.test(structured.resumeToken)) + ) { + throw new Error("Invalid structured action resume capability"); + } +} + +/** + * Resume ownership within the existing local-server transport trust boundary. + * Tokens never enter the dispatcher scope or any persisted/broadcast metadata. + */ +export class StructuredActionBindings { + private readonly bindings = new Map(); + private session: object; + private closed = false; + + public constructor( + private readonly getSession: () => object, + private readonly now: () => number = Date.now, + ) { + this.session = getSession(); + } + + private prune(): void { + const session = this.getSession(); + if (session !== this.session) { + this.bindings.clear(); + this.session = session; + } + const cutoff = this.now() - STRUCTURED_ACTION_BINDING_IDLE_MS; + for (const [token, binding] of this.bindings) { + if (binding.lastUsed <= cutoff) { + this.bindings.delete(token); + } + } + } + + public acquire( + conversationId: string, + connectionId: string, + resumeToken?: string, + ): StructuredActionLease { + if (this.closed) { + throw new Error("Structured action binding is closed"); + } + this.prune(); + let token: string; + let binding: Binding; + if (resumeToken !== undefined) { + const existing = this.bindings.get(resumeToken); + if ( + existing === undefined || + existing.conversationId !== conversationId + ) { + throw new Error( + "Structured action resume state is unavailable; do not replay an interrupted action", + ); + } + token = resumeToken; + binding = existing; + } else { + if (this.bindings.size >= MAX_STRUCTURED_ACTION_BINDINGS) { + throw new Error("Structured action binding capacity reached"); + } + token = randomBytes(32).toString("base64url"); + binding = { + conversationId, + scope: {}, + connectionId, + lastUsed: this.now(), + }; + this.bindings.set(token, binding); + } + // No await between resolving a token and revoking the previous lease. + binding.connectionId = connectionId; + binding.lastUsed = this.now(); + const deniedScope = {}; + let released = false; + const isCurrent = () => { + this.prune(); + return ( + !this.closed && + !released && + this.bindings.get(token) === binding && + binding.connectionId === connectionId + ); + }; + return { + resumeToken: token, + access: () => { + const current = isCurrent(); + if (current) { + binding.lastUsed = this.now(); + } + return { + scope: current ? binding.scope : deniedScope, + canDiscoverSchema: () => isCurrent(), + isActive: isCurrent, + }; + }, + release: () => { + released = true; + if (binding.connectionId === connectionId) { + binding.connectionId = undefined; + } + }, + }; + } + + public close(): void { + this.closed = true; + this.bindings.clear(); + } +} diff --git a/ts/packages/agentServer/server/test/conversationManager.spec.ts b/ts/packages/agentServer/server/test/conversationManager.spec.ts index 5ea6725b0c..2ce7af1a40 100644 --- a/ts/packages/agentServer/server/test/conversationManager.spec.ts +++ b/ts/packages/agentServer/server/test/conversationManager.spec.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, test } from "@jest/globals"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import type { DispatcherOptions } from "agent-dispatcher"; +import type { ClientIO, DispatcherOptions } from "agent-dispatcher"; import { createConversationManager } from "../src/conversationManager.js"; const tempDirs: string[] = []; @@ -155,3 +155,63 @@ describe("ConversationManager renameConversation", () => { await manager.close(); }); }); + +describe("ConversationManager structured joins", () => { + test("rejects a read-only conversation before initializing an execution dispatcher", async () => { + const manager = await createConversationManager( + "test-host", + {} as DispatcherOptions, + await createTempDir(), + ); + try { + const imported = await manager.importCopilotMirror({ + sessionId: "copilot-session", + name: "Read-only mirror", + createdAt: new Date(0).toISOString(), + displayLogEntries: [], + lastSyncedTurnIndex: 0, + }); + await expect( + manager.joinConversation( + imported.conversationId, + {} as ClientIO, + () => {}, + { + conversationId: imported.conversationId, + structuredActions: {}, + }, + ), + ).rejects.toThrow( + "Structured execution is unavailable in a read-only conversation", + ); + expect((await manager.listConversations())[0].clientCount).toBe(0); + } finally { + await manager.close(); + } + }); + + test("rejects mismatched explicit targets before restoring a dispatcher", async () => { + const manager = await createConversationManager( + "test-host", + {} as DispatcherOptions, + await createTempDir(), + ); + try { + const conversation = await manager.createConversation("Target"); + await expect( + manager.joinConversation( + conversation.conversationId, + {} as ClientIO, + () => {}, + { + conversationId: "different", + structuredActions: {}, + }, + ), + ).rejects.toThrow("explicit target conversationId"); + expect((await manager.listConversations())[0].clientCount).toBe(0); + } finally { + await manager.close(); + } + }); +}); diff --git a/ts/packages/agentServer/server/test/structuredActionBindings.spec.ts b/ts/packages/agentServer/server/test/structuredActionBindings.spec.ts new file mode 100644 index 0000000000..024a7e24bc --- /dev/null +++ b/ts/packages/agentServer/server/test/structuredActionBindings.spec.ts @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, test } from "@jest/globals"; +import type { DispatcherConnectOptions } from "@typeagent/agent-server-protocol"; +import { + MAX_STRUCTURED_ACTION_BINDINGS, + STRUCTURED_ACTION_BINDING_IDLE_MS, + StructuredActionBindings, + validateStructuredActionJoin, +} from "../src/structuredActionBindings.js"; + +function fixture() { + let session = {}; + let now = 1; + const bindings = new StructuredActionBindings( + () => session, + () => now, + ); + return { + bindings, + replaceSession() { + session = {}; + }, + advance(ms: number) { + now += ms; + }, + }; +} + +describe("structured action logical bindings", () => { + test("issues isolated high-entropy capabilities and hides them from access", () => { + const { bindings } = fixture(); + const first = bindings.acquire("conversation", "one"); + const second = bindings.acquire("conversation", "two"); + expect(first.resumeToken).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(second.resumeToken).not.toBe(first.resumeToken); + expect(first.access().scope).not.toBe(second.access().scope); + expect(first.access().canDiscoverSchema("list")).toBe(true); + expect(JSON.stringify(first.access())).not.toContain(first.resumeToken); + }); + + test("resumes only the same live conversation and revokes the old facade", () => { + const { bindings } = fixture(); + const first = bindings.acquire("conversation", "one"); + const scope = first.access().scope; + expect(() => + bindings.acquire("different", "two", first.resumeToken), + ).toThrow("resume state is unavailable"); + expect(first.access().scope).toBe(scope); + + const second = bindings.acquire( + "conversation", + "two", + first.resumeToken, + ); + expect(second.access().scope).toBe(scope); + expect(first.access().scope).not.toBe(scope); + expect(first.access().canDiscoverSchema("list")).toBe(false); + first.release(); + expect(second.access().canDiscoverSchema("list")).toBe(true); + }); + + test("the most recent takeover wins without resurrecting prior leases", () => { + const { bindings } = fixture(); + const first = bindings.acquire("conversation", "one"); + const scope = first.access().scope; + const second = bindings.acquire( + "conversation", + "two", + first.resumeToken, + ); + const third = bindings.acquire( + "conversation", + "three", + first.resumeToken, + ); + expect(third.access().scope).toBe(scope); + for (const stale of [first, second]) { + expect(stale.access().canDiscoverSchema("list")).toBe(false); + } + third.release(); + expect(third.access().canDiscoverSchema("list")).toBe(false); + const fourth = bindings.acquire( + "conversation", + "four", + first.resumeToken, + ); + expect(fourth.access().scope).toBe(scope); + expect(second.access().canDiscoverSchema("list")).toBe(false); + }); + + test("a policy snapshot also stops disclosing after takeover", () => { + const { bindings } = fixture(); + const first = bindings.acquire("conversation", "one"); + const policy = first.access(); + bindings.acquire("conversation", "two", first.resumeToken); + expect(policy.canDiscoverSchema("list")).toBe(false); + }); + + test("released ownership resumes within its lifetime, not with a guessed identity", () => { + const { bindings } = fixture(); + const first = bindings.acquire("conversation", "one"); + const scope = first.access().scope; + first.release(); + expect(() => + bindings.acquire("conversation", "two", "copilot-session-id"), + ).toThrow("resume state is unavailable"); + const second = bindings.acquire( + "conversation", + "two", + first.resumeToken, + ); + expect(second.access().scope).toBe(scope); + }); + + test("replacing the live Session invalidates old tokens and scope", () => { + const { bindings, replaceSession } = fixture(); + const first = bindings.acquire("conversation", "one"); + const scope = first.access().scope; + replaceSession(); + expect(first.access().scope).not.toBe(scope); + expect(() => + bindings.acquire("conversation", "two", first.resumeToken), + ).toThrow("resume state is unavailable"); + const second = bindings.acquire("conversation", "two"); + expect(second.access().scope).not.toBe(scope); + }); + + test("idle expiry is checked at access and resume, not only when making room", () => { + const { bindings, advance } = fixture(); + const first = bindings.acquire("conversation", "one"); + advance(STRUCTURED_ACTION_BINDING_IDLE_MS); + expect(first.access().canDiscoverSchema("list")).toBe(false); + expect(() => + bindings.acquire("conversation", "two", first.resumeToken), + ).toThrow("resume state is unavailable"); + }); + + test("valid use renews idle lifetime", () => { + const { bindings, advance } = fixture(); + const first = bindings.acquire("conversation", "one"); + advance(STRUCTURED_ACTION_BINDING_IDLE_MS - 1); + expect(first.access().canDiscoverSchema("list")).toBe(true); + advance(2); + expect(first.access().canDiscoverSchema("list")).toBe(true); + }); + + test("capacity never evicts a live binding and expiry frees space", () => { + const { bindings, advance } = fixture(); + const first = bindings.acquire("conversation", "zero"); + for (let i = 1; i < MAX_STRUCTURED_ACTION_BINDINGS; i++) { + bindings.acquire("conversation", String(i)); + } + expect(() => bindings.acquire("conversation", "overflow")).toThrow( + "capacity reached", + ); + expect(first.access().canDiscoverSchema("list")).toBe(true); + const resumed = bindings.acquire( + "conversation", + "new", + first.resumeToken, + ); + expect(resumed.access().canDiscoverSchema("list")).toBe(true); + advance(STRUCTURED_ACTION_BINDING_IDLE_MS); + expect(() => + bindings.acquire("conversation", "replacement"), + ).not.toThrow(); + }); + + test("close and a new server registry cannot resume old operations", () => { + const { bindings } = fixture(); + const first = bindings.acquire("conversation", "one"); + bindings.close(); + expect(first.access().canDiscoverSchema("list")).toBe(false); + expect(() => + bindings.acquire("conversation", "two", first.resumeToken), + ).toThrow("binding is closed"); + expect(() => + fixture().bindings.acquire( + "conversation", + "two", + first.resumeToken, + ), + ).toThrow("resume state is unavailable"); + }); +}); + +describe("structured join validation", () => { + test("legacy joins stay optional; structured joins require explicit conversation", () => { + expect(() => validateStructuredActionJoin(undefined)).not.toThrow(); + expect(() => validateStructuredActionJoin({})).not.toThrow(); + expect(() => + validateStructuredActionJoin({ structuredActions: {} }), + ).toThrow("explicit target conversationId"); + expect(() => + validateStructuredActionJoin({ + conversationId: "conversation", + structuredActions: {}, + }), + ).not.toThrow(); + }); + + test.each([ + null, + [], + "session", + { resumeToken: 1 }, + { resumeToken: "" }, + { resumeToken: "copilot-session-id" }, + { approved: true }, + ])("rejects malformed structured join options %#", (structuredActions) => { + const options = { + conversationId: "conversation", + structuredActions, + } as unknown as DispatcherConnectOptions; + expect(() => validateStructuredActionJoin(options)).toThrow(); + }); + + test("validates the resolved target and accepts only the opaque capability shape", () => { + const options: DispatcherConnectOptions = { + conversationId: "conversation", + structuredActions: { resumeToken: "a".repeat(43) }, + }; + expect(() => + validateStructuredActionJoin(options, "conversation"), + ).not.toThrow(); + expect(() => + validateStructuredActionJoin(options, "different"), + ).toThrow("explicit target conversationId"); + }); +}); diff --git a/ts/packages/agentServer/server/test/structuredActionHost.spec.ts b/ts/packages/agentServer/server/test/structuredActionHost.spec.ts new file mode 100644 index 0000000000..20328594da --- /dev/null +++ b/ts/packages/agentServer/server/test/structuredActionHost.spec.ts @@ -0,0 +1,428 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, test } from "@jest/globals"; +import type { + AppAgent, + AppAgentManifest, + ActionResult, +} from "@typeagent/agent-sdk"; +import { ChoiceManager } from "@typeagent/agent-sdk/helpers/action"; +import type { AppAgentProvider } from "agent-dispatcher"; +import type { + ClientIO, + Dispatcher, + StructuredActionExecutionResult, + StructuredActionResponse, +} from "@typeagent/dispatcher-types"; +import type { DispatcherConnectOptions } from "@typeagent/agent-server-protocol"; +import { + createChannelProviderAdapter, + type ChannelProviderAdapter, +} from "@typeagent/agent-rpc/channel"; +import { createAgentServerConnection } from "@typeagent/agent-server-client"; +import type { MacroManager } from "@typeagent/copilot-macros"; +import type { ConversationManager } from "../src/conversationManager.js"; +import { createAgentServerConnectionHandler } from "../src/connectionHandler.js"; +import { createSharedDispatcher } from "../src/sharedDispatcher.js"; + +const manifest: AppAgentManifest = { + description: "Offline host execution fixture", + emojiChar: "", + schema: { + description: "Host actions", + schemaType: "Actions", + schemaFile: { + format: "ts", + content: ` + export type Actions = Read | Write; + type Params = { mode: "plain" | "question" | "choice" }; + type Read = { actionName: "read"; parameters: Params }; + type Write = { actionName: "write"; parameters: Params }; + `, + }, + actionPolicies: { read: { effects: "read-only" } }, + }, +}; + +function prompt(result: StructuredActionExecutionResult) { + if (result.status !== "requires_interaction") { + throw new Error(`Expected interaction, got ${result.status}`); + } + return result; +} + +async function fixture() { + let entered = 0; + let effects = 0; + const messages: unknown[] = []; + const choices = new ChoiceManager(); + const complete = (): ActionResult => ({ + entities: [{ name: "item", type: ["Item"], uniqueId: "item-1" }], + resultEntity: { name: "item", type: ["Item"], uniqueId: "item-1" }, + resultValue: { ids: ["item-1"] }, + historyText: "Done", + displayContent: { type: "html", content: "Done" }, + }); + const agent: AppAgent = { + checkReadiness: async () => ({ state: "ready" }), + cancelChoice: async (id) => { + choices.cancelChoice(id); + }, + handleChoice: (id, response, context) => + choices.handleChoice(id, response, context), + executeAction: async (action, context) => { + entered++; + if (action.parameters?.mode === "question") { + await context.sessionContext.popupQuestion( + "Allow?", + ["Yes", "No"], + 0, + ); + } else if (action.parameters?.mode === "choice") { + return { + entities: [], + pendingChoice: { + type: "yesNo", + message: "Continue?", + choiceId: choices.registerChoice(async (response) => { + if (response === true) effects++; + return complete(); + }), + }, + }; + } + effects++; + return complete(); + }, + }; + const provider: AppAgentProvider = { + getAppAgentNames: () => ["hostTest"], + getAppAgentManifest: async () => manifest, + loadAppAgent: async () => agent, + unloadAppAgent: async () => {}, + }; + const shared = await createSharedDispatcher("structured-host-test", { + agents: { schemas: ["hostTest"], actions: ["hostTest"] }, + appAgentProviders: [provider], + translation: { enabled: false }, + explainer: { enabled: false }, + cache: { enabled: false }, + collectCommandResult: true, + metrics: true, + conversationMemorySettings: { + requestKnowledgeExtraction: false, + actionResultEntityStorage: false, + actionResultKnowledgeExtraction: false, + }, + }); + const manager = { + async resolveConversationId(id?: string) { + return id ?? "conversation"; + }, + async joinConversation( + _id: string, + io: ClientIO, + close: () => void, + options?: DispatcherConnectOptions, + ) { + const dispatcher = shared.join(io, close, options); + return { + dispatcher, + connectionId: dispatcher.connectionId, + name: "Test", + pendingInteractions: shared.getPendingInteractions( + dispatcher.connectionId!, + true, + ), + structuredActions: shared.getStructuredActionBinding( + dispatcher.connectionId!, + ), + }; + }, + leaveConversation: async (_id: string, connectionId: string) => + shared.leave(connectionId), + } as unknown as ConversationManager; + const { handler } = createAgentServerConnectionHandler({ + conversationManager: manager, + macroManager: {} as MacroManager, + shutdown() {}, + getUserIdentity: () => ({ + username: "test", + displayName: "Test", + initial: "T", + }), + }); + const io: ClientIO = { + clear() {}, + exit() {}, + shutdown() {}, + setUserRequest() {}, + setDisplayInfo() {}, + setDisplay: (message) => messages.push(message), + appendDisplay: (message) => messages.push(message), + appendDiagnosticData: (_request, data) => messages.push(data), + setDynamicDisplay() {}, + question: async () => { + throw new Error("Structured question leaked"); + }, + proposeAction: async () => { + throw new Error("Structured proposal leaked"); + }, + notify() {}, + openLocalView: async () => {}, + closeLocalView: async () => {}, + requestChoice: (...args) => messages.push(args), + requestForm: (...args) => messages.push(args), + requestInteraction: (interaction) => messages.push(interaction), + interactionResolved() {}, + interactionCancelled() {}, + takeAction() {}, + }; + const closeConnections: (() => Promise)[] = []; + return { + shared, + messages, + counts: () => ({ entered, effects }), + async join(structured: { resumeToken?: string } | false = {}) { + let client: ChannelProviderAdapter | undefined; + const server = createChannelProviderAdapter( + "host-test-server", + (message) => client?.notifyMessage(structuredClone(message)), + ); + client = createChannelProviderAdapter( + "host-test-client", + (message) => server.notifyMessage(structuredClone(message)), + ); + const clientAdapter = client; + const disconnect = () => { + server.notifyDisconnected(); + clientAdapter.notifyDisconnected(); + }; + handler(server, disconnect); + const connection = createAgentServerConnection(client, disconnect); + closeConnections.push(() => connection.close()); + const joined = await connection.joinConversation(io, { + conversationId: "conversation", + filter: true, + ...(structured === false + ? {} + : { structuredActions: structured }), + }); + return { ...joined, disconnect, connection }; + }, + async close() { + for (const close of closeConnections) await close(); + await shared.close(); + }, + }; +} + +async function execute( + dispatcher: Dispatcher, + actionName: "write" | "read", + mode = "plain", +) { + const found = await dispatcher.getActionContract({ + schemaName: "hostTest", + actionName, + }); + if (found.status !== "found") throw new Error("Expected action contract"); + return dispatcher.executeAction({ + protocolVersion: found.protocolVersion, + scopeId: found.scopeId, + schemaName: found.contract.schemaName, + actionName: found.contract.actionName, + fingerprint: found.contract.fingerprint, + parameters: { mode }, + }); +} + +function respond( + dispatcher: Dispatcher, + result: StructuredActionExecutionResult, + response: StructuredActionResponse, +) { + const interaction = prompt(result); + return dispatcher.continueAction({ + protocolVersion: interaction.protocolVersion, + scopeId: interaction.scopeId, + operationId: interaction.operationId, + interactionId: interaction.interactionId, + response, + }); +} + +describe("real structured shared host and dispatcher RPC", () => { + test("resumes confirmation after takeover, preserving actual values and private prompts", async () => { + const host = await fixture(); + try { + const first = await host.join(); + const pending = prompt(await execute(first.dispatcher, "write")); + expect(host.counts()).toEqual({ entered: 0, effects: 0 }); + if (first.structuredActions === undefined) + throw new Error("Expected binding"); + const second = await host.join(first.structuredActions); + const stale = await respond(first.dispatcher, pending, { + type: "confirmation", + approved: true, + }); + expect(stale.status).not.toBe("completed"); + expect(host.counts().effects).toBe(0); + const completed = await respond(second.dispatcher, pending, { + type: "confirmation", + approved: true, + }); + expect(completed.status).toBe("completed"); + expect(completed.results[0].result).toMatchObject({ + resultValue: { ids: ["item-1"] }, + resultEntity: { uniqueId: "item-1" }, + }); + expect(host.counts()).toEqual({ entered: 1, effects: 1 }); + expect(host.shared.pendingInteractions.size).toBe(0); + const broadcast = JSON.stringify(host.messages); + expect(broadcast).not.toContain(pending.interactionId); + expect(broadcast).not.toContain( + first.structuredActions?.resumeToken, + ); + } finally { + await host.close(); + } + }); + + test.each(["question", "choice"])( + "resumes the actual %s callback after the originator disconnects", + async (mode) => { + const host = await fixture(); + try { + const first = await host.join(); + const pending = prompt( + await execute(first.dispatcher, "read", mode), + ); + if (first.structuredActions === undefined) + throw new Error("Expected binding"); + first.disconnect(); + const second = await host.join(first.structuredActions); + const completed = await respond( + second.dispatcher, + pending, + mode === "question" + ? { type: "question", selected: 0 } + : { type: "yesNo", value: true }, + ); + expect(completed.status).toBe("completed"); + expect(host.counts()).toEqual({ entered: 1, effects: 1 }); + const repeated = await respond( + second.dispatcher, + pending, + mode === "question" + ? { type: "question", selected: 0 } + : { type: "yesNo", value: true }, + ); + expect(repeated).toEqual(completed); + expect(host.counts().effects).toBe(1); + } finally { + await host.close(); + } + }, + ); + + test("no-client grace cancels a default-affirmative prompt without executing it", async () => { + const host = await fixture(); + try { + host.shared.__testSetNoClientsGraceMs(5); + const first = await host.join(); + const pending = prompt( + await execute(first.dispatcher, "read", "question"), + ); + if (first.structuredActions === undefined) + throw new Error("Expected binding"); + first.disconnect(); + await new Promise((resolve) => setTimeout(resolve, 30)); + const second = await host.join(first.structuredActions); + const cancelled = await respond(second.dispatcher, pending, { + type: "question", + selected: 0, + }); + expect(cancelled.status).toBe("execution_uncertain"); + expect(host.counts()).toEqual({ entered: 1, effects: 0 }); + expect(host.shared.getQueueSnapshot().running).toBeNull(); + } finally { + await host.close(); + } + }); + + test("legacy new-command supersession unblocks structured work without answering", async () => { + const host = await fixture(); + try { + const owner = await host.join(); + const pending = prompt( + await execute(owner.dispatcher, "read", "question"), + ); + const submission = await owner.dispatcher.submitCommand("@help"); + if (!submission.ok) + throw new Error("Expected queued legacy command"); + await submission.entry.completion; + const cancelled = await respond(owner.dispatcher, pending, { + type: "question", + selected: 0, + }); + expect(cancelled.status).toBe("execution_uncertain"); + expect(host.counts().effects).toBe(0); + expect(host.shared.getQueueSnapshot().running).toBeNull(); + } finally { + await host.close(); + } + }); + + test("non-opt-in clients can discover but cannot execute", async () => { + const host = await fixture(); + try { + const legacy = await host.join(false); + expect(legacy.structuredActions).toBeUndefined(); + const denied = await execute(legacy.dispatcher, "read"); + expect(denied.status).toBe("unavailable"); + expect(host.counts()).toEqual({ entered: 0, effects: 0 }); + } finally { + await host.close(); + } + }); + + test("a different logical owner cannot consume or cancel a confirmation", async () => { + const host = await fixture(); + try { + const owner = await host.join(); + const pending = prompt(await execute(owner.dispatcher, "write")); + const outsider = await host.join(); + expect( + ( + await respond(outsider.dispatcher, pending, { + type: "confirmation", + approved: true, + }) + ).status, + ).not.toBe("completed"); + expect( + ( + await outsider.dispatcher.cancelAction({ + protocolVersion: pending.protocolVersion, + scopeId: pending.scopeId, + operationId: pending.operationId, + }) + ).status, + ).toBe("failed"); + expect(host.counts()).toEqual({ entered: 0, effects: 0 }); + expect( + ( + await respond(owner.dispatcher, pending, { + type: "confirmation", + approved: true, + }) + ).status, + ).toBe("completed"); + expect(host.counts()).toEqual({ entered: 1, effects: 1 }); + } finally { + await host.close(); + } + }); +}); diff --git a/ts/packages/agentServer/server/test/structuredActionJoinLifecycle.spec.ts b/ts/packages/agentServer/server/test/structuredActionJoinLifecycle.spec.ts new file mode 100644 index 0000000000..34a9e90bed --- /dev/null +++ b/ts/packages/agentServer/server/test/structuredActionJoinLifecycle.spec.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, describe, expect, jest, test } from "@jest/globals"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { ClientIO, DispatcherOptions } from "agent-dispatcher"; + +const closeDispatcher = jest.fn(async () => {}); +const createDispatcher = jest.fn(async () => ({ + clientCount: 0, + join() { + throw new Error("Structured action resume state is unavailable"); + }, + prewarmReasoning() {}, + close: closeDispatcher, +})); +jest.unstable_mockModule("../src/sharedDispatcher.js", () => ({ + createSharedDispatcher: createDispatcher, +})); +const { createConversationManager } = await import( + "../src/conversationManager.js" +); + +const tempDirs: string[] = []; +afterEach(async () => { + jest.useRealTimers(); + jest.clearAllMocks(); + for (const directory of tempDirs.splice(0)) { + await rm(directory, { recursive: true, force: true }); + } +}); + +async function fixture() { + const directory = await mkdtemp(path.join(os.tmpdir(), "structured-join-")); + tempDirs.push(directory); + return createConversationManager( + "test", + {} as DispatcherOptions, + directory, + 100, + true, + ); +} + +describe("structured join manager lifecycle", () => { + test("a rejected resume restores idle cleanup on an already loaded dispatcher", async () => { + const manager = await fixture(); + try { + const conversation = await manager.createConversation("default"); + await manager.prewarmMostRecentConversation(); + jest.useFakeTimers(); + await expect( + manager.joinConversation( + conversation.conversationId, + {} as ClientIO, + () => {}, + { + conversationId: conversation.conversationId, + structuredActions: { resumeToken: "a".repeat(43) }, + }, + ), + ).rejects.toThrow("resume state is unavailable"); + expect(closeDispatcher).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(100); + expect(closeDispatcher).toHaveBeenCalledTimes(1); + } finally { + await manager.close(); + } + }); + + test("lost resume state does not initialize a replacement dispatcher", async () => { + const manager = await fixture(); + try { + const conversation = await manager.createConversation("target"); + await expect( + manager.joinConversation( + conversation.conversationId, + {} as ClientIO, + () => {}, + { + conversationId: conversation.conversationId, + structuredActions: { resumeToken: "a".repeat(43) }, + }, + ), + ).rejects.toThrow("resume state is unavailable"); + expect(createDispatcher).not.toHaveBeenCalled(); + } finally { + await manager.close(); + } + }); +}); diff --git a/ts/packages/agentServer/server/test/structuredActionJoinRpc.spec.ts b/ts/packages/agentServer/server/test/structuredActionJoinRpc.spec.ts new file mode 100644 index 0000000000..f473876226 --- /dev/null +++ b/ts/packages/agentServer/server/test/structuredActionJoinRpc.spec.ts @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, jest, test } from "@jest/globals"; +import { + createChannelProviderAdapter, + type ChannelProviderAdapter, +} from "@typeagent/agent-rpc/channel"; +import { + createAgentServerConnection, + type AgentServerConnection, +} from "@typeagent/agent-server-client"; +import type { DispatcherConnectOptions } from "@typeagent/agent-server-protocol"; +import type { MacroManager } from "@typeagent/copilot-macros"; +import type { ClientIO, Dispatcher } from "@typeagent/dispatcher-types"; +import type { ConversationManager } from "../src/conversationManager.js"; +import { createAgentServerConnectionHandler } from "../src/connectionHandler.js"; +import { + StructuredActionBindings, + type StructuredActionLease, +} from "../src/structuredActionBindings.js"; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +function fixture() { + const session = {}; + const bindings = new StructuredActionBindings(() => session); + let nextConnection = 0; + const joinedOptions: (DispatcherConnectOptions | undefined)[] = []; + const resolveConversationId = jest.fn( + async (id?: string) => id ?? "default", + ); + const leases = new Map(); + const closeCallbacks = new Map void>(); + const left = deferred(); + let pause: + | { + entered: ReturnType; + resume: ReturnType; + } + | undefined; + const leaveConversation = jest.fn( + async (_conversationId: string, connectionId: string) => { + leases.get(connectionId)?.release(); + leases.delete(connectionId); + closeCallbacks.get(connectionId)?.(); + closeCallbacks.delete(connectionId); + left.resolve(); + }, + ); + const manager = { + resolveConversationId, + async joinConversation( + conversationId: string, + _clientIO: ClientIO, + closeFn: () => void, + options?: DispatcherConnectOptions, + ) { + joinedOptions.push(options); + const connectionId = String(++nextConnection); + const lease = + options?.structuredActions === undefined + ? undefined + : bindings.acquire( + conversationId, + connectionId, + options.structuredActions.resumeToken, + ); + if (lease !== undefined) { + leases.set(connectionId, lease); + } + closeCallbacks.set(connectionId, closeFn); + const paused = pause; + pause = undefined; + paused?.entered.resolve(); + await paused?.resume.promise; + return { + dispatcher: {} as Dispatcher, + connectionId, + name: "Test conversation", + pendingInteractions: [], + ...(lease === undefined + ? {} + : { + structuredActions: { resumeToken: lease.resumeToken }, + }), + }; + }, + leaveConversation, + } as unknown as ConversationManager; + const { handler } = createAgentServerConnectionHandler({ + conversationManager: manager, + macroManager: {} as MacroManager, + shutdown() {}, + getUserIdentity: () => ({ + username: "test", + displayName: "test", + initial: "T", + }), + }); + const connections: AgentServerConnection[] = []; + const disconnectors = new Map void>(); + return { + joinedOptions, + resolveConversationId, + leaveConversation, + leases, + left: left.promise, + pauseNextJoin() { + pause = { entered: deferred(), resume: deferred() }; + return { + entered: pause.entered.promise, + resume: pause.resume.resolve, + }; + }, + disconnect(connection: AgentServerConnection) { + disconnectors.get(connection)?.(); + }, + connect() { + let client: ChannelProviderAdapter | undefined; + const server = createChannelProviderAdapter("server", (message) => + client?.notifyMessage(message), + ); + client = createChannelProviderAdapter("client", (message) => + server.notifyMessage(message), + ); + handler(server, () => {}); + const connection = createAgentServerConnection(client, () => {}); + const clientAdapter = client; + disconnectors.set(connection, () => { + server.notifyDisconnected(); + clientAdapter.notifyDisconnected(); + }); + connections.push(connection); + return connection; + }, + async close() { + for (const connection of connections) { + await connection.close(); + } + bindings.close(); + }, + }; +} + +describe("structured join RPC plumbing", () => { + test("returns a private capability to the originator and accepts explicit resume", async () => { + const server = fixture(); + try { + const first = await server + .connect() + .joinConversation({} as ClientIO, { + conversationId: "conversation", + structuredActions: {}, + }); + expect(first.structuredActions?.resumeToken).toMatch( + /^[A-Za-z0-9_-]{43}$/, + ); + if (first.structuredActions === undefined) { + throw new Error("Expected structured binding"); + } + const second = await server + .connect() + .joinConversation({} as ClientIO, { + conversationId: "conversation", + structuredActions: first.structuredActions, + }); + expect(second.structuredActions).toEqual(first.structuredActions); + expect(second.connectionId).not.toBe(first.connectionId); + expect(server.joinedOptions[1]?.structuredActions).toEqual( + first.structuredActions, + ); + expect(first.pendingInteractions).toEqual([]); + expect(first.queueSnapshot).toBeUndefined(); + } finally { + await server.close(); + } + }); + + test("does not allocate a default conversation for a malformed structured join", async () => { + const server = fixture(); + try { + await expect( + server.connect().joinConversation({} as ClientIO, { + structuredActions: {}, + }), + ).rejects.toThrow("explicit target conversationId"); + expect(server.resolveConversationId).not.toHaveBeenCalled(); + expect(server.joinedOptions).toEqual([]); + } finally { + await server.close(); + } + }); + + test("failed resume does not silently create a new capability", async () => { + const server = fixture(); + try { + await expect( + server.connect().joinConversation({} as ClientIO, { + conversationId: "conversation", + structuredActions: { resumeToken: "a".repeat(43) }, + }), + ).rejects.toThrow("resume state is unavailable"); + } finally { + await server.close(); + } + }); + + test("legacy joins do not receive a structured capability", async () => { + const server = fixture(); + try { + const joined = await server + .connect() + .joinConversation({} as ClientIO); + expect(joined.conversationId).toBe("default"); + expect(joined.structuredActions).toBeUndefined(); + } finally { + await server.close(); + } + }); + + test("disconnect during async join releases the acquired ownership", async () => { + const server = fixture(); + const paused = server.pauseNextJoin(); + try { + const connection = server.connect(); + const pending = connection.joinConversation({} as ClientIO, { + conversationId: "conversation", + structuredActions: {}, + }); + const rejected = expect(pending).rejects.toThrow("disconnected"); + await paused.entered; + expect(server.leases.size).toBe(1); + const lease = [...server.leases.values()][0]; + server.disconnect(connection); + await rejected; + paused.resume(); + await server.left; + expect(server.leaveConversation).toHaveBeenCalledWith( + "conversation", + "1", + ); + expect(server.leases.size).toBe(0); + expect(lease.access().canDiscoverSchema("list")).toBe(false); + } finally { + paused.resume(); + await server.close(); + } + }); +}); diff --git a/ts/packages/agents/browser/src/agent/browserActionHandler.mts b/ts/packages/agents/browser/src/agent/browserActionHandler.mts index 6bafadb344..c0833a01e8 100644 --- a/ts/packages/agents/browser/src/agent/browserActionHandler.mts +++ b/ts/packages/agents/browser/src/agent/browserActionHandler.mts @@ -454,6 +454,12 @@ export function instantiate(): AppAgent { content: _webFlowStore.generateDynamicSchemaText(), }; }, + cancelChoice: async ( + choiceId: string, + context: SessionContext, + ) => { + context.agentContext.choiceManager?.cancelChoice(choiceId); + }, handleChoice: async ( choiceId: string, response: boolean | number[], diff --git a/ts/packages/agents/calendar/src/calendarActionHandlerV3.ts b/ts/packages/agents/calendar/src/calendarActionHandlerV3.ts index 0f9f75532f..27ac680b8f 100644 --- a/ts/packages/agents/calendar/src/calendarActionHandlerV3.ts +++ b/ts/packages/agents/calendar/src/calendarActionHandlerV3.ts @@ -1403,6 +1403,9 @@ export function instantiate(): AppAgent { action: AppAction, context: ActionContext, ) => handler.executeAction(action, context), + cancelChoice: async (choiceId: string) => { + handler.choiceManager.cancelChoice(choiceId); + }, handleChoice: ( choiceId: string, response: boolean | number[], diff --git a/ts/packages/agents/code/src/codeActionHandler.ts b/ts/packages/agents/code/src/codeActionHandler.ts index 28df1adcf7..62caab8688 100644 --- a/ts/packages/agents/code/src/codeActionHandler.ts +++ b/ts/packages/agents/code/src/codeActionHandler.ts @@ -170,6 +170,11 @@ export function instantiate(): AppAgent { getKnownCodePort(), ); }, + cancelChoice: async (choiceId, context) => { + ( + context.agentContext as CodeActionContext + ).choiceManager.cancelChoice(choiceId); + }, handleChoice: async (choiceId, response, context) => { const ctx = (context as ActionContext) .sessionContext.agentContext; diff --git a/ts/packages/agents/desktop/src/actionHandler.ts b/ts/packages/agents/desktop/src/actionHandler.ts index c29bccdaca..bad83d994c 100644 --- a/ts/packages/agents/desktop/src/actionHandler.ts +++ b/ts/packages/agents/desktop/src/actionHandler.ts @@ -34,6 +34,12 @@ export function instantiate(): AppAgent { initializeAgentContext: initializeDesktopContext, updateAgentContext: updateDesktopContext, executeAction: executeDesktopAction, + async cancelChoice( + choiceId: string, + context: SessionContext, + ) { + context.agentContext.choiceManager.cancelChoice(choiceId); + }, async handleChoice( choiceId: string, response: boolean | number[], diff --git a/ts/packages/agents/email/src/emailActionHandler.ts b/ts/packages/agents/email/src/emailActionHandler.ts index ae1a5d3077..a1f5b678c1 100644 --- a/ts/packages/agents/email/src/emailActionHandler.ts +++ b/ts/packages/agents/email/src/emailActionHandler.ts @@ -339,6 +339,11 @@ export function instantiate(): AppAgent { executeAction: executeEmailAction, checkReadiness: checkEmailReadiness, setup: setupEmail, + cancelChoice: async (choiceId, context) => { + ( + context.agentContext as EmailActionContext + ).choiceManager.cancelChoice(choiceId); + }, handleChoice: async (choiceId, response, context) => { const ctx = (context as ActionContext) .sessionContext.agentContext; diff --git a/ts/packages/agents/github-cli/src/github-cliActionHandler.ts b/ts/packages/agents/github-cli/src/github-cliActionHandler.ts index 9b963340c6..a71eef413f 100644 --- a/ts/packages/agents/github-cli/src/github-cliActionHandler.ts +++ b/ts/packages/agents/github-cli/src/github-cliActionHandler.ts @@ -72,6 +72,11 @@ export function instantiate(): AppAgent { ), // Routes user yes/no responses (from createYesNoChoiceResult) // back to the registered ChoiceManager callback. + cancelChoice: async (choiceId, context) => { + ( + context.agentContext as GithubCliActionContext + ).choiceManager.cancelChoice(choiceId); + }, handleChoice: async (choiceId, response, context) => { const ctx = (context as ActionContext) .sessionContext.agentContext; diff --git a/ts/packages/agents/list/src/listActionHandler.ts b/ts/packages/agents/list/src/listActionHandler.ts index 13effef5be..6f69cbbf39 100644 --- a/ts/packages/agents/list/src/listActionHandler.ts +++ b/ts/packages/agents/list/src/listActionHandler.ts @@ -24,6 +24,11 @@ export function instantiate(): AppAgent { updateAgentContext: updateListContext, executeAction: executeListAction, validateWildcardMatch: listValidateWildcardMatch, + cancelChoice: async (choiceId, context) => { + ( + context.agentContext as ListActionContext + ).choiceManager.cancelChoice(choiceId); + }, handleChoice: (choiceId, response, context) => ( context as ActionContext diff --git a/ts/packages/agents/osNotifications/src/osNotificationsActionHandler.ts b/ts/packages/agents/osNotifications/src/osNotificationsActionHandler.ts index fb6384df52..6a85a1b26d 100644 --- a/ts/packages/agents/osNotifications/src/osNotificationsActionHandler.ts +++ b/ts/packages/agents/osNotifications/src/osNotificationsActionHandler.ts @@ -434,6 +434,11 @@ export function instantiate(): AppAgent { // new ActionResult which the dispatcher renders. The AppAgent // signature types context as ActionContext; cast to our // agent context to access choiceManager. + cancelChoice: async (choiceId, context) => { + (context.agentContext as AgentContext).choiceManager.cancelChoice( + choiceId, + ); + }, handleChoice: async (choiceId, response, context) => { const ctx = (context as ActionContext).sessionContext .agentContext; diff --git a/ts/packages/agents/screencapture/src/screencaptureActionHandler.ts b/ts/packages/agents/screencapture/src/screencaptureActionHandler.ts index 7cdac68b84..6340004c25 100644 --- a/ts/packages/agents/screencapture/src/screencaptureActionHandler.ts +++ b/ts/packages/agents/screencapture/src/screencaptureActionHandler.ts @@ -85,6 +85,11 @@ export function instantiate(): AppAgent { // the registered ChoiceManager callback — same shape as // osNotifications. The AppAgent signature types context as // ActionContext; cast to access our agent context. + cancelChoice: async (choiceId, context) => { + ( + context.agentContext as ScreencaptureActionContext + ).choiceManager.cancelChoice(choiceId); + }, handleChoice: async (choiceId, response, context) => { const ctx = (context as ActionContext) .sessionContext.agentContext; diff --git a/ts/packages/agents/windowsClock/src/windowsClockActionHandler.ts b/ts/packages/agents/windowsClock/src/windowsClockActionHandler.ts index bfa7c2593b..512d873068 100644 --- a/ts/packages/agents/windowsClock/src/windowsClockActionHandler.ts +++ b/ts/packages/agents/windowsClock/src/windowsClockActionHandler.ts @@ -276,6 +276,12 @@ export function instantiate(): AppAgent { ); } }, + async cancelChoice( + choiceId: string, + context: SessionContext, + ) { + context.agentContext.choiceManager.cancelChoice(choiceId); + }, async handleChoice( choiceId: string, response: boolean | number[], diff --git a/ts/packages/dispatcher/dispatcher/src/command/command.ts b/ts/packages/dispatcher/dispatcher/src/command/command.ts index 4841ec8041..5b5424f940 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/command.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/command.ts @@ -41,6 +41,7 @@ import { } from "@typeagent/dispatcher-types"; import { DispatcherName } from "../context/dispatcher/dispatcherUtils.js"; import { getAppAgentName } from "../internal.js"; +import { getStructuredExecution } from "../structuredAction/executionHooks.js"; import { logCommandException, logRequestCompleted, @@ -438,6 +439,9 @@ export async function processCommandNoLock( request: originalInput, error: e, }); + if (getStructuredExecution(context) !== undefined) { + throw e; + } } } @@ -504,6 +508,7 @@ export async function processCommand( attachments?: string[], options?: ProcessCommandOptions, parentContext?: Context, + work?: { kind: "structured-action"; run(): Promise }, ): Promise { const isCommand = originalInput.trimStart().startsWith("@"); // Create the AbortController *before* acquiring the lock so that a @@ -573,11 +578,16 @@ export async function processCommand( : undefined; context.clientIO.setUserRequest(requestId, originalInput); try { - await processCommandNoLock( - originalInput, - context, - attachments, - ); + if (work !== undefined) { + abortController.signal.throwIfAborted(); + await work.run(); + } else { + await processCommandNoLock( + originalInput, + context, + attachments, + ); + } } catch (e: any) { if (e.name === "AbortError") { const activeSpan = trace.getActiveSpan(); diff --git a/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts b/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts index 1aeadeb876..8d62946d18 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts @@ -139,6 +139,7 @@ import { RequestQueue } from "../queue/requestQueue.js"; import type { QueueExecutionContext } from "../queue/requestQueue.js"; import { createSnapshotCoalescer } from "../queue/snapshotCoalescer.js"; import { processCommand as runProcessCommand } from "../command/command.js"; +import { closeStructuredActions } from "../structuredAction/executionHooks.js"; const debug = registerDebug("typeagent:dispatcher:init"); const debugError = registerDebug("typeagent:dispatcher:init:error"); @@ -1416,6 +1417,7 @@ export async function initializeCommandHandlerContext( qctx.attachments, qctx.options, qctx.traceContext, + qctx.work, ); try { context.displayLog.logCommandResult( @@ -1951,6 +1953,7 @@ function processSetAppAgentStateResult( export async function closeCommandHandlerContext( context: CommandHandlerContext, ) { + closeStructuredActions(context); // Stop accepting exclusive mutations in this closing session. context.appAgentProviderSetController.dispose(); // Tear down any reasoning subagents (spawned command-executor processes and diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/dispatcherAgent.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/dispatcherAgent.ts index 8bcb746785..0fe70cdbd0 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/dispatcherAgent.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/dispatcherAgent.ts @@ -604,6 +604,11 @@ export const dispatcherManifest: AppAgentManifest = { export const dispatcherAgent: AppAgent = { executeAction: executeDispatcherAction, + cancelChoice: async (choiceId, context) => { + ( + context.agentContext as CommandHandlerContext + ).choiceManager.cancelChoice(choiceId); + }, handleChoice: async (choiceId, response, context) => { const systemContext = (context as ActionContext) .sessionContext.agentContext; diff --git a/ts/packages/dispatcher/dispatcher/src/context/pendingInteractionManager.ts b/ts/packages/dispatcher/dispatcher/src/context/pendingInteractionManager.ts index 858d4689a7..562acec863 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/pendingInteractionManager.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/pendingInteractionManager.ts @@ -8,6 +8,7 @@ import type { } from "@typeagent/dispatcher-types"; type PendingEntry = { + rejectOnCancel?: boolean; type: PendingInteractionType; requestId?: RequestId; resolve: (value: any) => void; @@ -33,6 +34,7 @@ export class PendingInteractionManager { create( request: PendingInteractionRequest, timeoutMs?: number, + options?: { rejectOnCancel?: boolean }, ): Promise { return new Promise((resolve, reject) => { const entry: PendingEntry = { @@ -40,6 +42,7 @@ export class PendingInteractionManager { resolve, reject, request, + ...(options?.rejectOnCancel ? { rejectOnCancel: true } : {}), }; if (request.requestId !== undefined) { @@ -95,6 +98,10 @@ export class PendingInteractionManager { clearTimeout(entry.timeoutTimer); } + if (entry.rejectOnCancel) { + entry.reject(error); + return true; + } // For question, resolve with defaultId if one was explicitly provided; // otherwise reject — no declared safe fallback exists. if (entry.type === "question") { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/systemAgent.ts b/ts/packages/dispatcher/dispatcher/src/context/system/systemAgent.ts index 68f7ea083d..02248abf64 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/systemAgent.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/systemAgent.ts @@ -369,6 +369,11 @@ export const systemAgent: AppAgent = { getTemplateCompletion: getSystemTemplateCompletion, executeAction: executeSystemAction as unknown as AppAgent["executeAction"], handleChoice: handleSystemChoice, + cancelChoice: async (choiceId, context) => { + ( + context.agentContext as CommandHandlerContext + ).choiceManager.cancelChoice(choiceId); + }, getCommands: commandInterface.getCommands, getCommandCompletion: commandInterface.getCommandCompletion, executeCommand: commandInterface.executeCommand, diff --git a/ts/packages/dispatcher/dispatcher/src/dispatcher.ts b/ts/packages/dispatcher/dispatcher/src/dispatcher.ts index 31996e2cd4..c711648e1e 100644 --- a/ts/packages/dispatcher/dispatcher/src/dispatcher.ts +++ b/ts/packages/dispatcher/dispatcher/src/dispatcher.ts @@ -26,7 +26,7 @@ import { import { getDispatcherStatus, processCommand } from "./command/command.js"; import { getCommandCompletion } from "./command/completion.js"; import { getActionContext } from "./execute/actionContext.js"; -import { emitActionResult } from "./execute/actionHandlers.js"; +import { emitActionResult, executeActions } from "./execute/actionHandlers.js"; import { closeCommandHandlerContext, CommandHandlerContext, @@ -40,6 +40,7 @@ import { StructuredActionDiscovery, type StructuredActionAccess, } from "./structuredAction/discovery.js"; +import { StructuredActionExecution } from "./structuredAction/execution.js"; async function getDynamicDisplay( context: CommandHandlerContext, @@ -210,6 +211,12 @@ export function createDispatcherFromContext( context, structuredActionAccess, ); + const structuredExecution = new StructuredActionExecution( + context, + structuredActions, + { executeActions, getActionContext }, + connectionId, + ); const submitInput = ( command: string, clientRequestId: unknown, @@ -404,6 +411,10 @@ export function createDispatcherFromContext( async getActionContract(identity) { return structuredActions.getActionContract(identity); }, + executeAction: (request) => structuredExecution.executeAction(request), + continueAction: (request) => + structuredExecution.continueAction(request), + cancelAction: (request) => structuredExecution.cancelAction(request), async cancelCommand(requestId: string): Promise { const kind = context.requestQueue.classifyCancel(requestId, "user"); if (kind === "queued") { @@ -584,6 +595,9 @@ export function createDispatcherFromContext( | { selected: number; remember: boolean } | QuestionFormResponse, ) { + if (!context.pendingChoiceRoutes.has(choiceId)) { + throw new Error("Choice not found or expired"); + } return context.commandLock(async () => { const pending = context.pendingChoiceRoutes.get(choiceId); if (!pending) { diff --git a/ts/packages/dispatcher/dispatcher/src/execute/actionContext.ts b/ts/packages/dispatcher/dispatcher/src/execute/actionContext.ts index 1182fbabb3..b52166b48f 100644 --- a/ts/packages/dispatcher/dispatcher/src/execute/actionContext.ts +++ b/ts/packages/dispatcher/dispatcher/src/execute/actionContext.ts @@ -11,6 +11,7 @@ import { import { CommandHandlerContext } from "../context/commandHandlerContext.js"; import { makeClientIOMessage } from "../context/interactiveIO.js"; import { RequestId } from "@typeagent/dispatcher-types"; +import { getStructuredExecution } from "../structuredAction/executionHooks.js"; export type ActionContextWithClose = { actionContext: ActionContext; @@ -36,6 +37,9 @@ export function getActionContext( ); const actionIO: ActionIO = { setDisplay(content: DisplayContent): void { + getStructuredExecution(context, requestId.requestId)?.display( + content, + ); context.displayCount++; context.clientIO.setDisplay( makeClientIOMessage( @@ -55,6 +59,9 @@ export function getActionContext( // it, so an action that only shows a spinner still gets the // synthesized "completed" acknowledgment. if (mode !== "temporary") { + getStructuredExecution(context, requestId.requestId)?.display( + content, + ); context.displayCount++; } context.clientIO.appendDisplay( @@ -77,10 +84,16 @@ export function getActionContext( }; const actionContext: ActionContext = { streamingContext: undefined, - isFromReasoningLoop: context.isInsideReasoningLoop, + waitForCompletionOnAbort: + getStructuredExecution(context, requestId.requestId) !== undefined, + isFromReasoningLoop: + getStructuredExecution(context, requestId.requestId) === + undefined && context.isInsideReasoningLoop, workingDirectory: systemContext.currentOptions?.workingDirectory, activityContext: // Only make activityContext available if the action is from the same agent. + getStructuredExecution(context, requestId.requestId) === + undefined && context.activityContext?.appAgentName === appAgentName ? structuredClone(context.activityContext) : undefined, diff --git a/ts/packages/dispatcher/dispatcher/src/execute/actionHandlers.ts b/ts/packages/dispatcher/dispatcher/src/execute/actionHandlers.ts index b87989eec6..fd6fb7f2a4 100644 --- a/ts/packages/dispatcher/dispatcher/src/execute/actionHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/execute/actionHandlers.ts @@ -63,6 +63,8 @@ import { } from "../otel/actionSpan.js"; import { otel } from "@typeagent/telemetry"; import { getActionContext } from "./actionContext.js"; +import { getStructuredExecution } from "../structuredAction/executionHooks.js"; +import { RpcDisconnectedError } from "@typeagent/agent-rpc/rpc"; import { AgentNotReadyError, getErrorDisplayContent, @@ -216,6 +218,11 @@ function rethrowIfActionCancelled( error: unknown, systemContext: CommandHandlerContext, ): void { + if ( + error instanceof RpcDisconnectedError && + getStructuredExecution(systemContext) !== undefined + ) + throw error; if ( (error as { name?: unknown })?.name === "AbortError" || systemContext.currentAbortSignal?.aborted @@ -243,9 +250,13 @@ async function executeFlowForActionSpan( string, unknown >; + const structured = getStructuredExecution(systemContext); + await structured?.guard(executableAction.action, "enter"); + // Entering the interpreter itself has no effects; each step marks its own entry. + structured?.effect(executableAction.action, false); try { const result = await processFlow( - flowDef, + structured === undefined ? flowDef : structuredClone(flowDef), flowParams, context, actionIndex, @@ -291,11 +302,13 @@ async function executeHandlerForActionSpan( let setupResult: ActionResult | undefined; try { - setupResult = await checkAgentReady( - appAgentName, - systemContext, - actionContext, - ); + if (getStructuredExecution(systemContext) === undefined) { + setupResult = await checkAgentReady( + appAgentName, + systemContext, + actionContext, + ); + } } catch (error) { rethrowIfActionCancelled(error, systemContext); recordActionSetupFailure(span, "agent_not_ready"); @@ -314,6 +327,9 @@ async function executeHandlerForActionSpan( } const displayCountBefore = systemContext.displayCount; + const structured = getStructuredExecution(systemContext); + await structured?.guard(executableAction.action, "enter"); + structured?.effect(executableAction.action); try { const handlerResult = await appAgent.executeAction( executableAction.action, @@ -369,6 +385,7 @@ export async function executeAction( sessionCtx._systemContext ?? sessionCtx.agentContext; const appAgentName = getAppAgentName(schemaName); + await getStructuredExecution(systemContext)?.guard(action, "prepare"); const requestId = getRequestId(systemContext); const appAgent = systemContext.agents.getAppAgent(appAgentName); @@ -406,12 +423,14 @@ export async function executeAction( // Reuse the same streaming action context if one is available. const { actionContext, closeActionContext } = - getStreamingActionContext( - appAgentName, - actionIndex, - systemContext, - action, - ) ?? + (getStructuredExecution(systemContext) === undefined + ? getStreamingActionContext( + appAgentName, + actionIndex, + systemContext, + action, + ) + : undefined) ?? getActionContext( appAgentName, systemContext, @@ -474,6 +493,21 @@ export async function executeAction( appAgent, actionContext, }); + const structured = getStructuredExecution(systemContext); + if (structured !== undefined) + outcome.result = structuredClone(outcome.result); + structured?.result(action, outcome.result); + if ( + structured !== undefined && + outcome.result.error === undefined && + outcome.result.pendingChoice !== undefined + ) { + outcome.result = await structured.choice( + action, + outcome.result, + actionContext, + ); + } // If the agent ran to completion but a cancel arrived while it was executing, // discard the result and treat this as a cancellation. systemContext.currentAbortSignal?.throwIfAborted(); @@ -502,7 +536,6 @@ export async function executeAction( success: outcome.result.error === undefined, elapsedMs: Date.now() - actionStartedAt, }); - closeActionContext(); return outcome.result; } catch (error) { logActionCompleted(systemContext.logger, { @@ -516,6 +549,8 @@ export async function executeAction( error, }); throw error; + } finally { + closeActionContext(); } }, // The same signal the completion event above uses, so the span and @@ -655,7 +690,10 @@ export function emitActionResult( result.dynamicDisplayNextRefreshMs!, ); } - if (result.pendingChoice !== undefined) { + if ( + result.pendingChoice !== undefined && + getStructuredExecution(systemContext, requestId.requestId) === undefined + ) { const pc = result.pendingChoice; systemContext.pendingChoiceRoutes.set(pc.choiceId, { agentName: appAgentName, @@ -778,11 +816,16 @@ export async function executeActions( actions: ExecutableAction[], entities: PromptEntity[] | undefined, context: ActionContext, + observeResult?: (action: ExecutableAction, result: ActionResult) => void, + startActionIndex = 0, ): Promise { const sessionCtx = context.sessionContext as any; const systemContext: CommandHandlerContext = sessionCtx._systemContext ?? sessionCtx.agentContext; const commandResult = getCommandResult(systemContext); + const structured = getStructuredExecution(systemContext); + for (const { action } of actions) + await structured?.guard(action, "prepare"); if (commandResult !== undefined) { commandResult.actions = actions.map(({ action }) => action); } @@ -798,7 +841,7 @@ export async function executeActions( return; } - let actionIndex = 0; + let actionIndex = startActionIndex; while (actionQueue.length !== 0) { systemContext.currentAbortSignal?.throwIfAborted(); const pending = actionQueue.shift()!; @@ -807,6 +850,10 @@ export async function executeActions( const action = executableAction.action; if (isPendingRequestAction(action)) { + if (structured !== undefined) + throw new Error( + "Structured execution cannot translate pending requests", + ); const translationResult = await translatePendingRequestAction( action, context, @@ -845,6 +892,7 @@ export async function executeActions( context, actionIndex, ); + observeResult?.(executableAction, result); // add the action result to memory whether it has error or not. if ( @@ -950,7 +998,9 @@ export async function executeActions( if (result.additionalActions !== undefined) { try { const actions = getAdditionalExecutableActions( - result.additionalActions, + structured === undefined + ? result.additionalActions + : structuredClone(result.additionalActions), action.schemaName, systemContext, ); @@ -959,6 +1009,7 @@ export async function executeActions( ...(await toPendingActions(context, actions, undefined)), ); } catch (e) { + if (structured !== undefined) throw e; throw new Error( `${action.schemaName}.${action.actionName} returned an invalid action: ${e}`, ); diff --git a/ts/packages/dispatcher/dispatcher/src/execute/flowInterpreter.ts b/ts/packages/dispatcher/dispatcher/src/execute/flowInterpreter.ts index 563e22ccad..e82ca4b131 100644 --- a/ts/packages/dispatcher/dispatcher/src/execute/flowInterpreter.ts +++ b/ts/packages/dispatcher/dispatcher/src/execute/flowInterpreter.ts @@ -8,9 +8,10 @@ import { } from "@typeagent/agent-sdk/helpers/action"; import { displayStatus } from "@typeagent/agent-sdk/helpers/display"; import { type CommandHandlerContext } from "../context/commandHandlerContext.js"; -import { executeAction } from "./actionHandlers.js"; +import { executeAction, executeActions } from "./actionHandlers.js"; import { toExecutableActions } from "@typeagent/agent-cache"; import type { FullAction, ParamObjectType } from "@typeagent/agent-cache"; +import { getStructuredExecution } from "../structuredAction/executionHooks.js"; // ── Flow definition types ──────────────────────────────────────────────────── @@ -289,6 +290,11 @@ export async function processFlow( let result: ActionResult; if (step.type === "script") { + if (getStructuredExecution(systemContext) !== undefined) { + throw new Error( + "Structured flow script steps require a discoverable action contract", + ); + } // Script step — execute via PowerShell runner displayStatus( `[flow:${flowDef.name}] ${step.id}: powershell script`, @@ -310,11 +316,52 @@ export async function processFlow( }; const [executableAction] = toExecutableActions([action]); - result = await executeAction(executableAction, context, stepIndex); + if (getStructuredExecution(systemContext) !== undefined) { + let observed: ActionResult | undefined; + const error = await executeActions( + [executableAction], + undefined, + context, + (observedAction, actionResult) => { + if (observedAction === executableAction) { + observed = actionResult; + } + }, + stepIndex, + ); + result = + error !== undefined + ? createActionResultFromError(error.error) + : (observed ?? + createActionResultFromError( + "Flow step was not executed", + )); + if ( + result.error === undefined && + result.additionalActions !== undefined + ) { + // Descendants already ran inside executeActions. Returning + // them from the flow would schedule them a second time. + const settled = { ...result }; + delete settled.additionalActions; + result = settled; + } + } else { + result = await executeAction( + executableAction, + context, + stepIndex, + ); + } } const text = extractText(result); - const data = tryParseJson(text) ?? text; + const data = + getStructuredExecution(systemContext) !== undefined && + result.error === undefined && + result.resultValue !== undefined + ? result.resultValue + : (tryParseJson(text) ?? text); stepResults.set(step.id, { actionResult: result, text, data }); if (result.error !== undefined) { diff --git a/ts/packages/dispatcher/dispatcher/src/execute/pendingActions.ts b/ts/packages/dispatcher/dispatcher/src/execute/pendingActions.ts index 4c6b41ccae..bdb9c94e83 100644 --- a/ts/packages/dispatcher/dispatcher/src/execute/pendingActions.ts +++ b/ts/packages/dispatcher/dispatcher/src/execute/pendingActions.ts @@ -42,6 +42,7 @@ import { getObjectProperty } from "@typeagent/common-utils"; import { ActionSchemaFile } from "../translation/actionConfigProvider.js"; import { tryGetActionParametersType } from "../translation/actionSchemaUtils.js"; import { isPendingRequestAction } from "../translation/pendingRequest.js"; +import { getStructuredExecution } from "../structuredAction/executionHooks.js"; const debugEntities = registerDebug("typeagent:dispatcher:actions:entities"); @@ -716,6 +717,7 @@ async function resolveEntityWithAgent( `Resolving ${type} entity with agent ${appAgentName}: ${value}`, ); displayStatus(`Resolving ${type}: ${value}`, context); + getStructuredExecution(context.sessionContext.agentContext)?.effect(); const result = await agent.resolveEntity( type, value, @@ -792,7 +794,10 @@ function createParameterEntityResolver( ): ParameterEntityResolver { const agentContext = context.sessionContext.agentContext; const agents = agentContext.agents; - const conversationMemory = agentContext.conversationMemory; + const conversationMemory = + getStructuredExecution(agentContext) === undefined + ? agentContext.conversationMemory + : undefined; const resultEntityMap = new Set(); const clarifyEntities: ClarifyResolvedEntity[] = []; const promptEntityMap = toPromptEntityMap(entities); @@ -883,6 +888,10 @@ function createParameterEntityResolver( } } + await getStructuredExecution(agentContext)?.guard( + action, + "enter", + ); resolveEntityResult = await resolveEntityWithAgent( agents, appAgentName, @@ -890,6 +899,10 @@ function createParameterEntityResolver( value, context, ); + await getStructuredExecution(agentContext)?.guard( + action, + "enter", + ); if (resolveEntityResult !== undefined) { return processResolvedEntityResult( @@ -1079,14 +1092,18 @@ export async function toPendingActions( let resultEntityResolver: EntityResolver | undefined; const systemContext = context.sessionContext.agentContext; const agents = systemContext.agents; + const structured = getStructuredExecution(systemContext); const entityResolver = createParameterEntityResolver( context, - entities, - systemContext.session.getConfig().translation.entity, + structured === undefined ? entities : undefined, + structured === undefined + ? systemContext.session.getConfig().translation.entity + : { resolve: true, clarify: false, filter: false }, ); const pendingActions: PendingAction[] = []; for (const executableAction of actions) { + await structured?.guard(executableAction.action, "prepare"); if (isPendingRequestAction(executableAction.action)) { // Pending request action is an internal action. It doesn't have any entities. continue; @@ -1096,6 +1113,7 @@ export async function toPendingActions( executableAction.action, entityResolver, ); + await structured?.guard(executableAction.action, "enter"); if (entityResolver.clarifyResolvedEntities.length > 0) { const clarifyEntityAction: TypeAgentAction = { diff --git a/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts b/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts index 7d7b55af05..4202fce9e4 100644 --- a/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts +++ b/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts @@ -19,6 +19,7 @@ import { IndexData } from "@typeagent/image-memory"; import { IndexManager } from "../context/indexManager.js"; import { validateGrammarPatternsImpl } from "../validation/grammarValidationService.mjs"; import registerDebug from "debug"; +import { getStructuredExecution } from "../structuredAction/executionHooks.js"; const debug = registerDebug("typeagent:dispatcher:sessionContext"); const debugClientCountWarn = registerDebug( @@ -228,7 +229,9 @@ export function createSessionContext( defaultId?: number, ): Promise { return context.clientIO.question( - undefined, + getStructuredExecution(context) === undefined + ? undefined + : context.currentRequestId, message, choices, defaultId, diff --git a/ts/packages/dispatcher/dispatcher/src/queue/requestQueue.ts b/ts/packages/dispatcher/dispatcher/src/queue/requestQueue.ts index c60255f6e5..b373d1d0e9 100644 --- a/ts/packages/dispatcher/dispatcher/src/queue/requestQueue.ts +++ b/ts/packages/dispatcher/dispatcher/src/queue/requestQueue.ts @@ -59,6 +59,8 @@ export interface QueueLogger { * broadcast redaction happens elsewhere. */ export interface QueueExecutionContext { + /** Typed work runs inside the common request lifecycle, never the parser. */ + work?: { kind: "structured-action"; run(): Promise }; requestId: string; originatorConnectionId: string; text: string; @@ -74,6 +76,7 @@ export type InnerProcessCommand = ( /** Inputs accepted by `RequestQueue.submit`. */ export interface QueueSubmitInput { + work?: QueueExecutionContext["work"]; text: string; originatorConnectionId: string; attachments?: string[]; @@ -90,6 +93,7 @@ export interface QueueSubmitInput { * loop resolves on terminal state. */ interface InternalEntry extends QueuedRequest { + work?: QueueExecutionContext["work"]; traceContext?: Context; completion: Promise; resolveCompletion: (result: CommandResult | undefined) => void; @@ -508,6 +512,7 @@ export class RequestQueue { if (input.traceContext !== undefined) { entry.traceContext = input.traceContext; } + if (input.work !== undefined) entry.work = input.work; return entry; } @@ -528,6 +533,7 @@ export class RequestQueue { cancelReason: _cr, blockedOn: _bo, traceContext: _tc, + work: _work, ...pub } = entry; const out: QueuedRequest = { ...pub }; @@ -590,6 +596,23 @@ export class RequestQueue { ); } + private executionContext(entry: InternalEntry): QueueExecutionContext { + const context: QueueExecutionContext = { + requestId: entry.requestId, + originatorConnectionId: entry.originatorConnectionId, + text: entry.text, + }; + if (entry.clientRequestId !== undefined) + context.clientRequestId = entry.clientRequestId; + if (entry.attachments !== undefined) + context.attachments = entry.attachments; + if (entry.options !== undefined) context.options = entry.options; + if (entry.traceContext !== undefined) + context.traceContext = entry.traceContext; + if (entry.work !== undefined) context.work = entry.work; + return context; + } + private async processEntry(entry: InternalEntry): Promise { let result: CommandResult | undefined; let error: unknown = undefined; @@ -600,19 +623,9 @@ export class RequestQueue { entry.error = `cancelled:${entry.cancelReason}`; result = { cancelled: true }; } else { - const ctx: QueueExecutionContext = { - requestId: entry.requestId, - originatorConnectionId: entry.originatorConnectionId, - text: entry.text, - }; - if (entry.clientRequestId !== undefined) - ctx.clientRequestId = entry.clientRequestId; - if (entry.attachments !== undefined) - ctx.attachments = entry.attachments; - if (entry.options !== undefined) ctx.options = entry.options; - if (entry.traceContext !== undefined) - ctx.traceContext = entry.traceContext; - result = await this.innerProcessCommand(ctx); + result = await this.innerProcessCommand( + this.executionContext(entry), + ); if (result?.cancelled) { state = "cancelled"; if (entry.error === undefined) { diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts index 5e640b51d8..ce138202ff 100644 --- a/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/discovery.ts @@ -23,6 +23,10 @@ import { createActionContract } from "./contract.js"; export type StructuredActionAccess = () => { scope: object; canDiscoverSchema(schemaName: string): boolean; + // Discovery-only facades deny execution without hiding contracts. + // Omitted for existing/direct callers, which retain execution access. + canExecute?: boolean; + isActive?(): boolean; }; type DiscoveryContext = { @@ -117,8 +121,14 @@ export class StructuredActionDiscovery { private readonly access?: StructuredActionAccess, ) {} - private bindScope() { + public bindScope() { const policy = this.access?.(); + if ( + policy?.isActive?.() === false || + (this.access !== undefined && policy === undefined) + ) { + throw new Error("Structured action access has been revoked"); + } const permissionScope = policy?.scope ?? this.anonymousScope; let scopes = sessionScopes.get(this.context.session); if (scopes === undefined) { @@ -208,6 +218,13 @@ export class StructuredActionDiscovery { public async getActionContract( identity: ActionIdentity, ): Promise { + return this.getActionContractSnapshot(identity); + } + + /** Synchronous final gate: no event-loop turn between checking and handler entry. */ + public getActionContractSnapshot( + identity: ActionIdentity, + ): ActionContractResult { if ( identity === null || typeof identity !== "object" || diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/execution.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/execution.ts new file mode 100644 index 0000000000..a73c566418 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/execution.ts @@ -0,0 +1,962 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from "node:crypto"; +import { context as otelContext } from "@opentelemetry/api"; +import type { FullAction } from "@typeagent/agent-cache"; +import type { + ActionContext, + ActionResult, + DisplayContent, +} from "@typeagent/agent-sdk"; +import { getStructuredFallback } from "@typeagent/agent-sdk/helpers/display"; +import { convert as htmlToText } from "html-to-text"; +import { createActionResultNoDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { validateAction } from "@typeagent/action-schema"; +import { RpcDisconnectedError } from "@typeagent/agent-rpc/rpc"; +import { + QueueFullError, + ServerStoppingError, + type ExecuteActionRequest, + type ContinueActionRequest, + type CancelActionRequest, + type StructuredActionExecutionResult, + type StructuredActionError, + type StructuredActionPrompt, + type StructuredActionResponse, + type ActionContract, +} from "@typeagent/dispatcher-types"; +import type { CommandHandlerContext } from "../context/commandHandlerContext.js"; +import type { executeActions } from "../execute/actionHandlers.js"; +import type { getActionContext } from "../execute/actionContext.js"; +import { getAppAgentName } from "../translation/agentTranslators.js"; +import type { StructuredActionDiscovery } from "./discovery.js"; +import { + installStructuredInteractionRouting, + registerStructuredActionCleanup, + runStructuredExecution, + type StructuredExecutionHooks, +} from "./executionHooks.js"; +import { + immutable, + keys, + nonempty, + object, + validateJson, + validateResponse, +} from "./validation.js"; + +const OPERATION_TTL = 10 * 60_000; +const MAX_OPERATIONS = 100; + +// The facade supplies its existing engine entry points. Keeping the state +// machine independent of engine imports avoids a dispatcher/barrel cycle. +type ExecutionRuntime = { + executeActions: typeof executeActions; + getActionContext: typeof getActionContext; +}; + +type FailureStatus = + | "failed" + | "contract_stale" + | "unavailable" + | "cancelled" + | "execution_uncertain"; + +class ExecutionFailure extends Error { + constructor( + readonly code: StructuredActionError["code"], + message: string, + readonly status: FailureStatus = "failed", + ) { + super(message); + } +} + +function binding(discovery: StructuredActionDiscovery) { + let current: ReturnType; + try { + current = discovery.bindScope(); + } catch { + throw new ExecutionFailure( + "invalid_scope", + "Structured action access is no longer active", + ); + } + if (current.policy?.canExecute === false) { + throw new ExecutionFailure( + "unavailable", + "Structured action execution is not enabled for this connection", + "unavailable", + ); + } + return current; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +type PendingPrompt = { + id: string; + prompt: StructuredActionPrompt; + answer: ReturnType>; +}; + +type Registry = { + closed: boolean; + disposed: ReturnType>; + live: Map; + terminal: Map< + string, + { operation: Operation; timer: ReturnType } + >; +}; +const registries = new WeakMap(); + +function registryFor(context: CommandHandlerContext): Registry { + let registry = registries.get(context); + if (registry === undefined) { + registry = { + closed: false, + disposed: deferred(), + live: new Map(), + terminal: new Map(), + }; + registries.set(context, registry); + const current = registry; + registerStructuredActionCleanup(context, () => { + current.closed = true; + for (const operation of current.live.values()) + operation.cancel("Dispatcher closed"); + for (const record of current.terminal.values()) + clearTimeout(record.timer); + current.terminal.clear(); + current.disposed.resolve(); + }); + } + return registry; +} + +function requestEnvelope( + value: unknown, + allowed: string[], +): asserts value is Record { + validateJson(value); + object(value); + keys(value, ["protocolVersion", "scopeId", ...allowed]); + if (value.protocolVersion !== 1) + throw new Error("Unsupported structured action protocol version"); + nonempty(value.scopeId, "scopeId"); +} + +function failure( + scopeId: string, + operationId: string, + error: unknown, +): StructuredActionExecutionResult { + const cause = + error instanceof ExecutionFailure + ? error + : new ExecutionFailure( + "invalid_request", + error instanceof Error ? error.message : String(error), + ); + return { + protocolVersion: 1, + scopeId, + operationId, + status: cause.status, + error: { code: cause.code, message: cause.message }, + output: [], + results: [], + }; +} + +class Operation implements StructuredExecutionHooks { + readonly id = randomUUID(); + readonly expiresAt = Date.now() + OPERATION_TTL; + readonly session: object; + readonly scope: object; + readonly request: ExecuteActionRequest; + readonly results: StructuredActionExecutionResult["results"] = []; + readonly output: string[] = []; + pending: PendingPrompt | undefined; + terminal: StructuredActionExecutionResult | undefined; + discovery: StructuredActionDiscovery; + private delivery = deferred(); + private readonly contracts = new Map(); + private readonly approved = new WeakSet(); + private possibleEffects = false; + private promptTail: Promise = Promise.resolve(); + private queuedPrompts = 0; + private timer: ReturnType; + + constructor( + readonly context: CommandHandlerContext, + discovery: StructuredActionDiscovery, + request: ExecuteActionRequest, + private readonly registry: Registry, + private readonly runtime: ExecutionRuntime, + ) { + this.discovery = discovery; + this.request = immutable(request); + this.session = context.session; + this.scope = binding(discovery).policy?.scope ?? discovery; + this.timer = setTimeout( + () => this.cancel("Operation expired", "interaction_expired"), + OPERATION_TTL, + ); + this.timer.unref(); + } + + authorize(discovery = this.discovery): void { + const current = binding(discovery); + if ( + this.context.session !== this.session || + current.envelope.scopeId !== this.request.scopeId || + (current.policy?.scope ?? discovery) !== this.scope + ) { + throw new ExecutionFailure( + "invalid_scope", + "Operation belongs to a different scope or Session", + ); + } + } + + private checkLive(): void { + if (this.terminal !== undefined) + throw new DOMException("Operation ended", "AbortError"); + this.context.currentAbortSignal?.throwIfAborted(); + this.authorize(); + if (Date.now() >= this.expiresAt) { + this.cancel("Operation expired", "interaction_expired"); + throw new DOMException("Operation expired", "AbortError"); + } + } + + private contract(action: FullAction): ActionContract { + this.checkLive(); + // Meta actions invoke translation, reasoning, or implicit context binding. + if (getAppAgentName(action.schemaName) === "dispatcher") { + throw new ExecutionFailure( + "unavailable", + "Dispatcher meta actions are not structured executable actions", + "unavailable", + ); + } + const result = this.discovery.getActionContractSnapshot(action); + this.checkLive(); + if (result.status !== "found") + throw new ExecutionFailure( + "unavailable", + "Action is unavailable", + "unavailable", + ); + const contract = result.contract; + const key = `${action.schemaName}\0${action.actionName}`; + const expected = + this.contracts.get(key)?.fingerprint ?? + (action.schemaName === this.request.schemaName && + action.actionName === this.request.actionName + ? this.request.fingerprint + : contract.fingerprint); + if ( + result.scopeId !== this.request.scopeId || + contract.fingerprint !== expected + ) { + throw new ExecutionFailure( + "contract_stale", + "Action contract changed; discover and submit a new action", + "contract_stale", + ); + } + const availability = contract.availability; + if (availability.state !== "available") { + throw new ExecutionFailure( + "unavailable", + availability.message ?? + `Action is ${availability.state}. Configure or refresh '${getAppAgentName(action.schemaName)}' before retrying.`, + "unavailable", + ); + } + if ( + this.context.agents + .getFlow(action.schemaName, action.actionName) + ?.steps.some((step) => step.type === "script") + ) { + throw new ExecutionFailure( + "unavailable", + "Flow contains a script step without a discoverable action contract", + "unavailable", + ); + } + const config = this.context.agents.tryGetActionConfig( + action.schemaName, + ); + const definition = + config === undefined + ? undefined + : this.context.agents + .getActionSchemaFileForConfig(config) + .parsedActionSchema.actionSchemas.get(action.actionName); + if (definition === undefined) + throw new ExecutionFailure( + "unavailable", + "Action schema is unavailable", + "unavailable", + ); + const { entities: _entities, ...input } = action as FullAction & { + entities?: unknown; + }; + validateAction(definition, input); + this.contracts.set(key, immutable(contract)); + return contract; + } + + async guard( + action: FullAction, + _phase: "prepare" | "enter", + ): Promise { + const contract = this.contract(action); + if (!this.approved.has(action)) { + if ( + contract.policy.effects !== "read-only" || + contract.policy.confirmation === "required" + ) { + const response = await this.prompt({ + type: "confirmation", + action: { + protocolVersion: 1, + scopeId: this.request.scopeId, + schemaName: action.schemaName, + actionName: action.actionName, + fingerprint: contract.fingerprint, + ...(action.parameters === undefined + ? {} + : { parameters: action.parameters }), + }, + contract, + }); + if (response.type !== "confirmation" || !response.approved) { + this.cancel("Action was not approved"); + throw new DOMException( + "Action was not approved", + "AbortError", + ); + } + this.contract(action); + } + this.approved.add(action); + } + this.checkLive(); + } + + effect(action?: FullAction, possibleEffects = true): void { + this.checkLive(); + this.revalidate(); + // Internal result references may survive preparation, but never entry. + if (action?.parameters !== undefined) + validateJson(action.parameters, true); + this.possibleEffects ||= possibleEffects; + } + + result(action: FullAction, result: ActionResult): void { + if (this.terminal !== undefined) return; + this.results.push({ + action: structuredClone(action), + result: structuredClone(result), + }); + if (result.error !== undefined) this.display(result.error); + else if (result.historyText !== undefined) + this.display(result.historyText); + else if (result.displayContent !== undefined) + this.display(result.displayContent); + } + + display(content: DisplayContent): void { + if (this.terminal !== undefined) return; + let text: string; + if (typeof content === "string") text = content; + else if (Array.isArray(content)) + text = content + .map((item) => (Array.isArray(item) ? item.join(" ") : item)) + .join("\n"); + else if (content.type === "structured") { + this.display(getStructuredFallback(content, "text")); + return; + } else { + const alternate = content.alternates?.find( + (item) => item.type === "text" || item.type === "markdown", + ); + const value = alternate?.content ?? content.content; + text = + typeof value === "string" + ? value + : value + .map((item) => + Array.isArray(item) ? item.join(" ") : item, + ) + .join("\n"); + if (alternate === undefined && content.type === "html") + text = htmlToText(text); + } + if (text.length > 0 && this.output[this.output.length - 1] !== text) + this.output.push(text); + } + + async choice( + action: FullAction, + initial: ActionResult, + actionContext: ActionContext, + ): Promise { + let result = initial; + const agentName = getAppAgentName(action.schemaName); + const agent = this.context.agents.getAppAgent(agentName); + const additionalActions = + initial.error === undefined + ? [...(initial.additionalActions ?? [])] + : []; + while ( + result.error === undefined && + result.pendingChoice !== undefined + ) { + const { choiceId, ...prompt } = result.pendingChoice; + try { + const response = await this.prompt(prompt); + await this.guard(action, "enter"); + if (agent.handleChoice === undefined) + throw new Error( + "Agent does not support choice continuation", + ); + const answer = choiceAnswer(response); + this.effect(action); + result = structuredClone( + (await agent.handleChoice( + choiceId, + answer, + actionContext, + )) ?? createActionResultNoDisplay("Choice completed."), + ); + if ( + this.terminal !== undefined && + result.error === undefined && + result.pendingChoice !== undefined + ) { + await agent.cancelChoice?.( + result.pendingChoice.choiceId, + actionContext.sessionContext, + ); + } + this.checkLive(); + this.result(action, result); + if (result.error === undefined) + additionalActions.push(...(result.additionalActions ?? [])); + } finally { + await agent.cancelChoice?.( + choiceId, + actionContext.sessionContext, + ); + } + } + return result.error === undefined && additionalActions.length > 0 + ? { ...result, additionalActions } + : result; + } + + async prompt( + prompt: StructuredActionPrompt, + ): Promise { + this.checkLive(); + if (this.queuedPrompts >= MAX_OPERATIONS) + throw new ExecutionFailure( + "queue_full", + "Too many pending interactions", + "unavailable", + ); + const snapshot = immutable(prompt); + this.queuedPrompts++; + const answer = this.promptTail.then(() => this.showPrompt(snapshot)); + this.promptTail = answer.then( + () => undefined, + () => undefined, + ); + try { + return await answer; + } finally { + this.queuedPrompts--; + } + } + + private async showPrompt( + prompt: StructuredActionPrompt, + ): Promise { + this.checkLive(); + if (this.pending !== undefined) + throw new Error("An operation already has a pending interaction"); + const pending: PendingPrompt = { + id: randomUUID(), + prompt: immutable(prompt), + answer: deferred(), + }; + this.pending = pending; + this.context.requestQueue.markBlocked(this.id, "interaction"); + this.publish({ + ...this.envelope(), + status: "requires_interaction", + interactionId: pending.id, + expiresAt: this.expiresAt, + prompt: pending.prompt, + }); + try { + const response = await pending.answer.promise; + this.checkLive(); + this.revalidate(); + return response; + } finally { + if (this.pending === pending) this.pending = undefined; + this.context.requestQueue.markUnblocked(this.id); + } + } + + private revalidate(): void { + for (const saved of this.contracts.values()) { + this.checkLive(); + const current = this.discovery.getActionContractSnapshot(saved); + this.checkLive(); + if ( + current.status !== "found" || + current.contract.fingerprint !== saved.fingerprint + ) { + throw new ExecutionFailure( + "contract_stale", + "Contract changed while awaiting an interaction", + "contract_stale", + ); + } + if (current.contract.availability.state !== "available") { + throw new ExecutionFailure( + "unavailable", + "Action is no longer available", + "unavailable", + ); + } + } + } + + continue( + request: ContinueActionRequest, + discovery: StructuredActionDiscovery, + ): Promise { + this.authorize(discovery); + if (this.terminal !== undefined) + return Promise.resolve(structuredClone(this.terminal)); + const pending = this.pending; + if (pending === undefined || pending.id !== request.interactionId) { + throw new ExecutionFailure( + "interaction_consumed", + "Interaction is missing or already consumed", + ); + } + if (Date.now() >= this.expiresAt) { + this.cancel("Operation expired", "interaction_expired"); + return this.wait(); + } + validateResponse(pending.prompt, request.response); + const answer = immutable(request.response); + // Claim synchronously, after validation and before any await. + this.discovery = discovery; + this.pending = undefined; + const next = this.wait(); + pending.answer.resolve(answer); + return next; + } + + wait(): Promise { + return this.terminal === undefined + ? this.delivery.promise.then((result) => structuredClone(result)) + : Promise.resolve(structuredClone(this.terminal)); + } + + private envelope() { + return { + protocolVersion: 1 as const, + scopeId: this.request.scopeId, + operationId: this.id, + output: structuredClone(this.output), + results: structuredClone(this.results), + }; + } + + private publish(result: StructuredActionExecutionResult): void { + const delivery = this.delivery; + this.delivery = deferred(); + delivery.resolve(result); + } + + finish(error?: unknown): void { + if (this.terminal !== undefined) return; + if ( + error === undefined && + this.context.currentRequestId?.requestId === this.id && + this.context.commandResult?.disposition?.status === "failed" + ) { + error = new ExecutionFailure( + "execution_failed", + this.context.commandResult.lastError ?? + "A nested command failed", + ); + } + const result = + error === undefined + ? { ...this.envelope(), status: "completed" as const } + : { + ...failure( + this.request.scopeId, + this.id, + error instanceof ExecutionFailure + ? error + : new ExecutionFailure( + "execution_failed", + error instanceof Error + ? error.message + : String(error), + ), + ), + output: structuredClone(this.output), + results: structuredClone(this.results), + }; + this.terminal = result; + if (this.context.currentRequestId?.requestId === this.id) { + const command = (this.context.commandResult ??= {}); + command.disposition = + result.status === "completed" + ? { status: "handled", path: "action" } + : { + status: "failed", + path: "action", + mayHaveSideEffects: this.possibleEffects, + }; + if ( + result.status === "cancelled" || + result.status === "execution_uncertain" + ) + command.cancelled = true; + if ("error" in result) command.lastError = result.error.message; + } + clearTimeout(this.timer); + this.pending?.answer.reject( + new DOMException("Operation ended", "AbortError"), + ); + this.pending = undefined; + this.publish(result); + } + + cancel( + message: string, + code: StructuredActionError["code"] = "cancelled", + ): void { + if (this.terminal !== undefined) return; + this.finish( + new ExecutionFailure( + code, + message, + this.possibleEffects ? "execution_uncertain" : "cancelled", + ), + ); + const queue = this.context.requestQueue; + if (!queue.cancelQueued(this.id, "user")) { + queue.cancelRunning(this.id, "user"); + this.context.activeRequests.get(this.id)?.abort(); + } + } + + retire(): void { + this.registry.live.delete(this.id); + if (this.registry.closed) return; + const timer = setTimeout( + () => this.registry.terminal.delete(this.id), + OPERATION_TTL, + ); + timer.unref(); + this.registry.terminal.set(this.id, { operation: this, timer }); + while (this.registry.terminal.size > MAX_OPERATIONS) { + const oldest = this.registry.terminal.entries().next().value!; + clearTimeout(oldest[1].timer); + this.registry.terminal.delete(oldest[0]); + } + } + + async run(): Promise { + if (this.terminal !== undefined) return; + const signal = this.context.currentAbortSignal; + const aborted = () => this.cancel("Execution cancelled"); + signal?.addEventListener("abort", aborted, { once: true }); + try { + await runStructuredExecution( + this.context, + this.id, + this, + async () => { + const action = { + schemaName: this.request.schemaName, + actionName: this.request.actionName, + ...(this.request.parameters === undefined + ? {} + : { + parameters: structuredClone( + this.request.parameters, + ), + }), + } as FullAction; + // Validate before even creating an ActionContext or resolving entities. + await this.guard(action, "prepare"); + const { actionContext, closeActionContext } = + this.runtime.getActionContext( + "dispatcher", + this.context, + { requestId: this.id }, + 0, + ); + try { + const error = await this.runtime.executeActions( + [{ action }], + undefined, + actionContext as ActionContext, + ); + this.checkLive(); + if (error !== undefined) + throw new ExecutionFailure( + "execution_failed", + error.error, + ); + if (this.results.length === 0) + throw new ExecutionFailure( + "execution_failed", + "Action handler was not entered", + ); + this.finish(); + } finally { + closeActionContext(); + } + }, + ); + } catch (error) { + if (error instanceof RpcDisconnectedError) { + this.finish( + new ExecutionFailure( + "execution_state_lost", + "Agent transport was lost; completion is unknown. Close this dispatcher before starting new work; do not replay.", + "execution_uncertain", + ), + ); + // A disconnected worker may still be executing. Stop admission + // and retain the shared lock until this context is destroyed. + void this.context.requestQueue.drainAndStop(); + await this.registry.disposed.promise; + } else if (signal?.aborted) this.cancel("Execution cancelled"); + else this.finish(error); + } finally { + signal?.removeEventListener("abort", aborted); + } + } +} + +function choiceAnswer(response: StructuredActionResponse) { + switch (response.type) { + case "yesNo": + return response.value; + case "multiChoice": + return response.selected; + case "pickRemember": + return { selected: response.selected, remember: response.remember }; + case "form": + return response.value; + default: + throw new Error("Invalid SDK choice response"); + } +} + +export class StructuredActionExecution { + private readonly registry: Registry; + constructor( + private readonly context: CommandHandlerContext, + private readonly discovery: StructuredActionDiscovery, + private readonly runtime: ExecutionRuntime, + private readonly connectionId?: string, + ) { + this.registry = registryFor(context); + } + + async executeAction( + input: ExecuteActionRequest, + ): Promise { + let operation: Operation | undefined; + try { + requestEnvelope(input, [ + "schemaName", + "actionName", + "fingerprint", + "parameters", + ]); + nonempty(input.schemaName, "schemaName"); + nonempty(input.actionName, "actionName"); + nonempty(input.fingerprint, "fingerprint"); + if (input.parameters !== undefined) { + object(input.parameters); + validateJson(input.parameters, true); + } + const request = immutable(input); + if (this.registry.closed) throw new ServerStoppingError(); + if (binding(this.discovery).envelope.scopeId !== request.scopeId) + throw new ExecutionFailure( + "invalid_scope", + "Invalid structured action scope", + ); + installStructuredInteractionRouting(this.context); + if (this.registry.live.size >= MAX_OPERATIONS) + throw new QueueFullError(MAX_OPERATIONS); + operation = new Operation( + this.context, + this.discovery, + request, + this.registry, + this.runtime, + ); + this.registry.live.set(operation.id, operation); + const response = operation.wait(); + const current = operation; + const entry = this.context.requestQueue.submit({ + text: `Structured action: ${request.schemaName}.${request.actionName}`, + requestId: operation.id, + originatorConnectionId: this.connectionId ?? "", + options: { noReasoning: true }, + ...(this.context.telemetryOptions.joinActiveTrace + ? { traceContext: otelContext.active() } + : {}), + work: { kind: "structured-action", run: () => current.run() }, + }); + void entry.completion.then( + (result) => { + if (result?.cancelled) + current.cancel("Execution cancelled"); + else current.finish(); + current.retire(); + }, + (error: unknown) => { + current.finish(error); + current.retire(); + }, + ); + return await response; + } catch (error) { + const cause = + error instanceof QueueFullError + ? new ExecutionFailure( + "queue_full", + "Structured action queue is full", + "unavailable", + ) + : error instanceof ServerStoppingError + ? new ExecutionFailure( + "server_stopping", + "Dispatcher is stopping", + "unavailable", + ) + : error; + if (operation !== undefined) { + operation.finish(cause); + operation.retire(); + return operation.wait(); + } + return failure( + typeof input?.scopeId === "string" ? input.scopeId : "", + "", + cause, + ); + } + } + + async continueAction( + input: ContinueActionRequest, + ): Promise { + try { + requestEnvelope(input, [ + "operationId", + "interactionId", + "response", + ]); + nonempty(input.operationId, "operationId"); + nonempty(input.interactionId, "interactionId"); + const request = immutable(input); + return await this.find(request).continue(request, this.discovery); + } catch (error) { + return failure( + typeof input?.scopeId === "string" ? input.scopeId : "", + typeof input?.operationId === "string" ? input.operationId : "", + error instanceof ExecutionFailure + ? error + : new ExecutionFailure( + "invalid_response", + error instanceof Error + ? error.message + : String(error), + ), + ); + } + } + + async cancelAction( + input: CancelActionRequest, + ): Promise { + try { + requestEnvelope(input, ["operationId", "interactionId"]); + nonempty(input.operationId, "operationId"); + if (input.interactionId !== undefined) + nonempty(input.interactionId, "interactionId"); + const operation = this.find(input); + if ( + input.interactionId !== undefined && + operation.pending?.id !== input.interactionId + ) { + throw new ExecutionFailure( + "interaction_consumed", + "Interaction is missing or already consumed", + ); + } + operation.cancel("Execution cancelled"); + return await operation.wait(); + } catch (error) { + return failure( + typeof input?.scopeId === "string" ? input.scopeId : "", + typeof input?.operationId === "string" ? input.operationId : "", + error, + ); + } + } + + private find(input: { scopeId: string; operationId: string }): Operation { + if (binding(this.discovery).envelope.scopeId !== input.scopeId) + throw new ExecutionFailure( + "invalid_scope", + "Invalid structured action scope", + ); + const operation = + this.registry.live.get(input.operationId) ?? + this.registry.terminal.get(input.operationId)?.operation; + if (operation === undefined) + throw new ExecutionFailure( + "execution_state_lost", + "Execution state is unavailable; do not replay the action", + "execution_uncertain", + ); + operation.authorize(this.discovery); + return operation; + } +} diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/executionHooks.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/executionHooks.ts new file mode 100644 index 0000000000..1e70d1e51b --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/executionHooks.ts @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { AsyncLocalStorage } from "node:async_hooks"; +import type { FullAction } from "@typeagent/agent-cache"; +import type { + ActionContext, + ActionResult, + DisplayContent, +} from "@typeagent/agent-sdk"; +import type { CommandHandlerContext } from "../context/commandHandlerContext.js"; +import type { + StructuredActionPrompt, + StructuredActionResponse, +} from "@typeagent/dispatcher-types"; + +export interface StructuredExecutionHooks { + guard(action: FullAction, phase: "prepare" | "enter"): Promise; + effect(action?: FullAction, possibleEffects?: boolean): void; + display(content: DisplayContent): void; + result(action: FullAction, result: ActionResult): void; + choice( + action: FullAction, + result: ActionResult, + context: ActionContext, + ): Promise; + prompt(prompt: StructuredActionPrompt): Promise; +} + +const requests = new WeakMap< + CommandHandlerContext, + Map +>(); +const active = new AsyncLocalStorage(); +const cleanup = new WeakMap void>(); + +export function registerStructuredActionCleanup( + context: CommandHandlerContext, + close: () => void, +) { + cleanup.set(context, close); +} + +export function closeStructuredActions(context: CommandHandlerContext) { + cleanup.get(context)?.(); + cleanup.delete(context); +} + +export function getStructuredExecution( + context: CommandHandlerContext, + requestId = context.currentRequestId?.requestId, +) { + return requestId === undefined + ? undefined + : requests.get(context)?.get(requestId); +} + +export async function runStructuredExecution( + context: CommandHandlerContext, + requestId: string, + hooks: StructuredExecutionHooks, + run: () => Promise, +) { + let registry = requests.get(context); + if (registry === undefined) { + registry = new Map(); + requests.set(context, registry); + } + registry.set(requestId, hooks); + try { + await active.run(hooks, run); + } finally { + registry.delete(requestId); + } +} + +const routed = new WeakSet(); + +/** Install once, outside requests. Prompts never reach legacy broadcast/ID registries. */ +export function installStructuredInteractionRouting( + context: CommandHandlerContext, +) { + if (routed.has(context)) return; + routed.add(context); + const client = context.clientIO; + const route = (requestId?: { requestId: string }) => + requestId === undefined + ? active.getStore() + : getStructuredExecution(context, requestId.requestId); + context.clientIO = new Proxy(client, { + get(target, key, receiver) { + if (key === "question") { + return async (...args: Parameters) => { + const hooks = route(args[0]); + if (hooks === undefined) return client.question(...args); + const response = await hooks.prompt({ + type: "question", + message: args[1], + choices: args[2], + ...(args[3] === undefined + ? {} + : { defaultId: args[3] }), + }); + if (response.type !== "question") + throw new Error("Invalid question response"); + return response.selected; + }; + } + if (key === "askForm") { + return async ( + ...args: Parameters> + ) => { + const hooks = route(args[0]); + if (hooks === undefined) { + if (client.askForm === undefined) + throw new Error("Forms are not supported"); + return client.askForm(...args); + } + const response = await hooks.prompt({ + type: "form", + ...args[1], + }); + if (response.type !== "form") + throw new Error("Invalid form response"); + return response.value; + }; + } + if (key === "proposeAction") { + return async ( + ...args: Parameters + ) => { + const hooks = route(args[0]); + if (hooks === undefined) + return client.proposeAction(...args); + const template = args[1]; + const response = await hooks.prompt({ + type: "proposal", + templateAgentName: template.templateAgentName, + templateName: template.templateName, + schema: template.defaultTemplate, + data: template.templateData, + templates: template, + }); + if (response.type !== "proposal") + throw new Error("Invalid proposal response"); + return response.accepted ? response.data : undefined; + }; + } + if (key === "getUserContext") { + return ( + ...args: Parameters< + NonNullable + > + ) => + route(args[0]) === undefined + ? client.getUserContext?.(...args) + : Promise.resolve(undefined); + } + return Reflect.get(target, key, receiver); + }, + }); +} diff --git a/ts/packages/dispatcher/dispatcher/src/structuredAction/validation.ts b/ts/packages/dispatcher/dispatcher/src/structuredAction/validation.ts new file mode 100644 index 0000000000..9c7523de96 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/structuredAction/validation.ts @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { + StructuredActionPrompt, + StructuredActionResponse, +} from "@typeagent/dispatcher-types"; +import type { QuestionForm, TemplateSchema } from "@typeagent/agent-sdk"; + +export function object( + value: unknown, +): asserts value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Expected an object"); + } +} + +export function keys( + value: Record, + allowed: readonly string[], +) { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) + throw new Error(`Unexpected field '${key}'`); + } +} + +export function nonempty( + 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`); +} + +/** Reject non-wire values and externally supplied implicit binding syntax. */ +export function validateJson( + value: unknown, + bindings = false, + depth = 0, +): void { + if (depth > 64) throw new Error("Input nesting exceeds 64 levels"); + if (typeof value === "string") { + if (bindings && /\$\{(?:entity|result)-/.test(value)) + throw new Error( + "External entity/result references are not supported", + ); + return; + } + if (value === null || typeof value === "boolean") return; + if (typeof value === "number" && Number.isFinite(value)) return; + if (Array.isArray(value)) { + for (const item of value) validateJson(item, bindings, depth + 1); + return; + } + object(value); + // structuredClone and embedding hosts can supply objects from another realm. + const prototype = Object.getPrototypeOf(value); + if (prototype !== null && Object.getPrototypeOf(prototype) !== null) + throw new Error("Expected plain JSON data"); + for (const [key, descriptor] of Object.entries( + Object.getOwnPropertyDescriptors(value), + )) { + if (!("value" in descriptor)) + throw new Error("Accessors are not supported"); + if ( + key === "__proto__" || + key === "constructor" || + key === "prototype" || + (bindings && key === "$result") + ) + throw new Error(`Unsupported field '${key}'`); + validateJson(descriptor.value, bindings, depth + 1); + } +} + +export function immutable(value: T): T { + const copy = structuredClone(value); + const freeze = (item: unknown): void => { + if (item !== null && typeof item === "object") { + for (const child of Object.values(item)) freeze(child); + Object.freeze(item); + } + }; + freeze(copy); + return copy; +} + +function index(value: unknown, count: number): asserts value is number { + if ( + !Number.isSafeInteger(value) || + (value as number) < 0 || + (value as number) >= count + ) + throw new Error("Choice index is out of range"); +} + +function indexes(value: unknown, count: number): void { + if (!Array.isArray(value)) throw new Error("Expected choice indexes"); + if (new Set(value).size !== value.length) + throw new Error("Duplicate choice indexes"); + for (const selected of value) index(selected, count); +} + +function bool(value: unknown): void { + if (typeof value !== "boolean") + throw new Error("Expected a boolean response"); +} + +function validateForm(form: QuestionForm, value: unknown): void { + object(value); + keys(value, ["answers", "cancelled"]); + if (value.cancelled !== undefined && value.cancelled !== false) + throw new Error("Use cancelAction to cancel a form"); + object(value.answers); + keys( + value.answers, + form.fields.map((field) => field.id), + ); + for (const field of form.fields) { + const answer = value.answers[field.id]; + object(answer); + if (answer.kind !== field.kind) + throw new Error(`Invalid answer kind for '${field.id}'`); + if (field.kind === "yesNo") { + keys(answer, ["kind", "value"]); + bool(answer.value); + continue; + } + keys(answer, ["kind", "selected", "text"]); + if (answer.text !== undefined) { + if (!field.allowFreeText) + throw new Error("Free text is not permitted"); + nonempty(answer.text, "text"); + } + if (field.kind === "pick") { + if (answer.selected === -1) { + if (!field.allowFreeText) + throw new Error("A choice is required"); + nonempty(answer.text, "text"); + } else { + index(answer.selected, field.choices.length); + if (answer.text !== undefined) + throw new Error("Free text requires selected -1"); + } + } else { + indexes(answer.selected, field.choices.length); + } + } +} + +export function validateResponse( + prompt: StructuredActionPrompt, + value: unknown, +): asserts value is StructuredActionResponse { + object(value); + if (value.type !== prompt.type) + throw new Error("Response does not match the pending prompt"); + switch (prompt.type) { + case "confirmation": + keys(value, ["type", "approved"]); + bool(value.approved); + break; + case "question": + keys(value, ["type", "selected"]); + index(value.selected, prompt.choices.length); + break; + case "yesNo": + keys(value, ["type", "value"]); + bool(value.value); + break; + case "multiChoice": + keys(value, ["type", "selected"]); + indexes(value.selected, prompt.choices.length); + break; + case "pickRemember": + keys(value, ["type", "selected", "remember"]); + index(value.selected, prompt.choices.length); + bool(value.remember); + break; + case "form": + keys(value, ["type", "value"]); + validateForm(prompt, value.value); + break; + case "proposal": + keys(value, ["type", "accepted", "data"]); + bool(value.accepted); + if (value.accepted) { + validateJson(value.data, true); + const templates = prompt.templates.templateData; + if (Array.isArray(templates)) { + if (!Array.isArray(value.data) || value.data.length > 100) + throw new Error( + "Expected at most 100 proposed actions", + ); + for (let i = 0; i < value.data.length; i++) { + validateTemplate( + templates[i]?.schema ?? prompt.schema, + value.data[i], + ); + } + } else { + validateTemplate(templates.schema, value.data); + } + } else if (value.data !== undefined) + throw new Error("Rejected proposal cannot contain data"); + break; + } +} + +function validateTemplate(schema: TemplateSchema, value: unknown): void { + // Implemented against the SDK's declarative template fields, not a model. + validateTemplateField(schema, value); +} + +function validateTemplateField(schema: unknown, value: unknown): void { + object(schema); + const field = schema as unknown as Record; + if (field.type === "object") { + object(value); + object(field.fields); + keys(value, Object.keys(field.fields)); + for (const [name, child] of Object.entries(field.fields)) { + object(child); + if (value[name] === undefined && child.optional === true) continue; + validateTemplateField(child.type, value[name]); + } + } else if (field.type === "array") { + if (!Array.isArray(value)) throw new Error("Expected an array"); + for (const item of value) + validateTemplateField(field.elementType, item); + } else if (field.type === "string-union") { + if ( + typeof value !== "string" || + !Array.isArray(field.typeEnum) || + !field.typeEnum.includes(value) + ) + throw new Error("Invalid proposal discriminator"); + } else if ( + field.type === "string" || + field.type === "number" || + field.type === "boolean" + ) { + if (typeof value !== field.type) + throw new Error(`Expected ${field.type}`); + } else { + throw new Error("Unsupported proposal field schema"); + } +} diff --git a/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaFileCache.ts b/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaFileCache.ts index 31ec9797bc..be8d19bff4 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaFileCache.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/actionSchemaFileCache.ts @@ -221,7 +221,18 @@ export class ActionSchemaFileCache { parsedActionSchema: parseActionSchemaSource( source, actionConfig.schemaName, - actionConfig.schemaType, + typeof actionConfig.schemaType === "string" + ? actionConfig.schemaType + : { + ...actionConfig.schemaType, + ...(actionConfig.schemaType.entities === + undefined + ? {} + : { + entity: actionConfig.schemaType + .entities, + }), + }, fullPath, config ? JSON.parse(config) : undefined, true, diff --git a/ts/packages/dispatcher/dispatcher/test/pendingInteractionManager.spec.ts b/ts/packages/dispatcher/dispatcher/test/pendingInteractionManager.spec.ts index 63d20c9c72..a90f735ac6 100644 --- a/ts/packages/dispatcher/dispatcher/test/pendingInteractionManager.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/pendingInteractionManager.spec.ts @@ -64,6 +64,20 @@ describe("PendingInteractionManager", () => { manager = new PendingInteractionManager(); }); + it("strict cancellation rejects even an affirmative default and removes the entry", async () => { + const request = makeQuestionRequest({ + interactionId: "strict", + defaultId: 0, + }); + const result = manager.create(request, undefined, { + rejectOnCancel: true, + }); + const rejected = expect(result).rejects.toThrow("cancelled"); + manager.cancel(request.interactionId, new Error("cancelled")); + await rejected; + expect(manager.size).toBe(0); + }); + // --------------------------------------------------------------- // 1. create + resolve: resolves promise with the given value // --------------------------------------------------------------- diff --git a/ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts b/ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts new file mode 100644 index 0000000000..9f1372493b --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/structuredActionExecution.spec.ts @@ -0,0 +1,1413 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { jest } from "@jest/globals"; +import type { + ActionContext, + ActionResult, + AppAgent, + AppAgentManifest, + PendingChoice, + ReadinessReport, +} from "@typeagent/agent-sdk"; +import { ChoiceManager } from "@typeagent/agent-sdk/helpers/action"; +import type { + Dispatcher, + ExecuteActionRequest, + StructuredActionExecutionResult, + StructuredActionResponse, +} from "@typeagent/dispatcher-types"; +import { createDispatcherFromContext } from "../src/dispatcher.js"; +import { + initializeCommandHandlerContext, + closeCommandHandlerContext, + type CommandHandlerContext, +} from "../src/context/commandHandlerContext.js"; +import { nullClientIO } from "../src/context/interactiveIO.js"; +import type { AppAgentProvider } from "../src/agentProvider/agentProvider.js"; +import { closeStructuredActions } from "../src/structuredAction/executionHooks.js"; +import type { FlowDefinition } from "../src/execute/flowInterpreter.js"; +import { createAgentRpcClient } from "@typeagent/agent-rpc/client"; +import { createAgentRpcServer } from "@typeagent/agent-rpc/server"; +import { + createChannelProviderAdapter, + type ChannelProviderAdapter, +} from "@typeagent/agent-rpc/channel"; + +const manifest: AppAgentManifest = { + description: "Offline structured execution fixture", + emojiChar: "", + schema: { + description: "Guarded actions", + schemaType: { action: "Actions", entities: "Entities" }, + schemaFile: { + format: "ts", + content: ` + export type Actions = Write | Read | ConfirmedRead | Resolve; + export type Entities = Item; + type Item = string; + type Resolve = { actionName: "resolve"; parameters: { value: Item; mode?: string } }; + type Write = { actionName: "write"; parameters: { value: string; mode?: string } }; + type Read = { actionName: "read"; parameters: { value: string; mode?: string } }; + type ConfirmedRead = { actionName: "confirmedRead"; parameters: { value: string; mode?: string } }; + `, + }, + actionPolicies: { + read: { effects: "read-only" }, + confirmedRead: { effects: "read-only", confirmation: "required" }, + }, + }, +}; + +function requirePrompt(result: StructuredActionExecutionResult) { + if (result.status !== "requires_interaction") + throw new Error(`Expected prompt: ${JSON.stringify(result)}`); + return result; +} + +describe("real structured dispatcher execution", () => { + let context: CommandHandlerContext; + let dispatcher: Dispatcher; + let choices: ChoiceManager; + let readiness: ReadinessReport; + let entered: string[]; + let callbacks: number; + let resolutions: number; + let holdResolution: boolean; + let liveContext: ActionContext | undefined; + let release: (() => void) | undefined; + let held: Promise; + let scope: object; + let active: boolean; + let executionAllowed: boolean | undefined; + let closeRpc: (() => void) | undefined; + const broadcasts: string[] = []; + const setup = jest.fn>(); + + const complete = (): ActionResult => ({ + entities: [{ name: "saved", type: ["Item"], uniqueId: "stable-1" }], + resultEntity: { name: "saved", type: ["Item"], uniqueId: "stable-1" }, + resultValue: { ids: ["stable-1"], count: 42 }, + historyText: "Saved item", + displayContent: { type: "html", content: "Saved" }, + }); + const form = { + message: "Every field matters", + paged: true, + fields: [ + { + id: "pick", + kind: "pick" as const, + prompt: "Pick", + choices: ["a", "b"], + allowFreeText: true, + }, + { + id: "many", + kind: "multiChoice" as const, + prompt: "Many", + choices: ["a", "b"], + allowFreeText: true, + }, + { + id: "yes", + kind: "yesNo" as const, + prompt: "Yes?", + defaultValue: true, + }, + ], + }; + const formAnswer: StructuredActionResponse = { + type: "form", + value: { + answers: { + pick: { kind: "pick", selected: -1, text: "other" }, + many: { kind: "multiChoice", selected: [0, 1] }, + yes: { kind: "yesNo", value: false }, + }, + }, + }; + + beforeEach(async () => { + choices = new ChoiceManager(); + readiness = { state: "ready" }; + entered = []; + callbacks = 0; + resolutions = 0; + holdResolution = false; + active = true; + executionAllowed = undefined; + scope = {}; + broadcasts.length = 0; + setup.mockClear(); + held = new Promise((resolve) => { + release = resolve; + }); + const agent: AppAgent = { + checkReadiness: async () => readiness, + setup, + resolveEntity: async (type, name) => { + resolutions++; + if (holdResolution) await held; + return { + match: "exact", + entities: [{ name, type: [type], uniqueId: "resolved-id" }], + }; + }, + cancelChoice: async (id) => { + choices.cancelChoice(id); + }, + handleChoice: (id, response, actionContext) => + choices.handleChoice(id, response, actionContext), + executeAction: async (action, actionContext) => { + liveContext = actionContext; + const params = action.parameters as { + value: string; + mode?: string; + }; + entered.push(params.value); + expect(actionContext.activityContext).toBeUndefined(); + switch (params.mode) { + case "parallelQuestions": + await Promise.all( + ["first", "second"].map((message) => + actionContext.sessionContext.popupQuestion( + message, + ["yes", "no"], + 0, + ), + ), + ); + callbacks++; + return complete(); + case "choiceChild": + return { + entities: [], + additionalActions: [ + { + actionName: "write", + parameters: { value: "initial-child" }, + }, + ], + pendingChoice: { + type: "yesNo", + message: "Continue", + choiceId: choices.registerChoice(async () => { + callbacks++; + return { + ...complete(), + additionalActions: [ + { + actionName: "write", + parameters: { + value: "callback-child", + }, + }, + ], + }; + }), + }, + }; + case "hold": + await held; + return complete(); + case "throw": + throw new Error("handler failed"); + case "fallback": + return { + error: "No model retry", + fallbackToReasoning: true, + } as ActionResult; + case "empty": + return undefined; + case "parentChild": + return { + ...complete(), + resultValue: { ids: ["source-id"] }, + historyText: "Parent result", + additionalActions: [ + { + actionName: "write", + parameters: { + value: "child", + mode: "distinctChild", + }, + }, + ], + }; + case "distinctChild": + return { + ...complete(), + resultValue: { ids: ["child-id"] }, + historyText: "Child result", + }; + case "question": { + const selected = + await actionContext.sessionContext.popupQuestion( + "Choose", + ["yes", "no"], + 0, + ); + callbacks += selected === 0 ? 1 : 10; + return complete(); + } + case "blockingForm": + await context.clientIO.askForm!( + context.currentRequestId, + form, + "guarded", + ); + callbacks++; + return complete(); + case "proposal": + await context.clientIO.proposeAction( + context.currentRequestId!, + { + templateAgentName: "guarded", + templateName: "edit", + defaultTemplate: { + type: "object", + fields: { + value: { type: { type: "string" } }, + }, + }, + templateData: { + schema: { + type: "object", + fields: { + value: { type: { type: "string" } }, + }, + }, + data: { value: "old" }, + }, + }, + "guarded", + ); + callbacks++; + return complete(); + case "child": + return { + ...complete(), + additionalActions: [ + { + actionName: "write", + parameters: { value: "child" }, + }, + ], + }; + case "reason": + return { + ...complete(), + additionalActions: [ + { + schemaName: "dispatcher", + actionName: "reasoningAction", + parameters: { request: "do not run" }, + }, + ], + }; + } + if ( + ["yesNo", "multiChoice", "pickRemember", "form"].includes( + params.mode ?? "", + ) + ) { + const choiceId = choices.registerChoice( + async (_response, callbackContext) => { + callbacks++; + callbackContext.actionIO.appendDisplay( + "callback output", + ); + return complete(); + }, + ); + const pendingChoice: PendingChoice = + params.mode === "form" + ? { type: "form", choiceId, ...form } + : params.mode === "yesNo" + ? { type: "yesNo", choiceId, message: "Really?" } + : params.mode === "pickRemember" + ? { + type: "pickRemember", + choiceId, + message: "Pick", + choices: ["a", "b"], + checkboxLabel: "Remember", + } + : { + type: "multiChoice", + choiceId, + message: "Many", + choices: ["a", "b"], + }; + return { entities: [], pendingChoice }; + } + return complete(); + }, + }; + const provider: AppAgentProvider = { + getAppAgentNames: () => ["guarded"], + getAppAgentManifest: async () => manifest, + loadAppAgent: async () => agent, + unloadAppAgent: async () => {}, + }; + context = await initializeCommandHandlerContext( + "structured-execution-test", + { + agents: { + schemas: ["guarded", "system.config"], + actions: ["guarded", "system.config"], + }, + translation: { enabled: false }, + explainer: { enabled: false }, + cache: { enabled: false }, + appAgentProviders: [provider], + collectCommandResult: true, + metrics: true, + conversationMemorySettings: { + requestKnowledgeExtraction: false, + actionResultEntityStorage: false, + actionResultKnowledgeExtraction: false, + }, + clientIO: { + ...nullClientIO, + question: async () => { + broadcasts.push("question"); + return 0; + }, + askForm: async () => { + broadcasts.push("form"); + return { answers: {} }; + }, + proposeAction: async () => { + broadcasts.push("proposal"); + }, + requestChoice: () => { + broadcasts.push("choice"); + }, + requestForm: () => { + broadcasts.push("form"); + }, + requestInteraction: () => { + broadcasts.push("interaction"); + }, + }, + }, + ); + dispatcher = createDispatcherFromContext( + context, + "owner", + undefined, + () => ({ + scope, + canDiscoverSchema: () => true, + ...(executionAllowed === undefined + ? {} + : { canExecute: executionAllowed }), + isActive: () => active, + }), + ); + }); + + afterEach(async () => { + release?.(); + await closeCommandHandlerContext(context); + closeRpc?.(); + closeRpc = undefined; + }); + + async function useAgentRpc() { + let clientProvider: ChannelProviderAdapter; + let serverProvider: ChannelProviderAdapter; + clientProvider = createChannelProviderAdapter( + "client", + (message, callback) => { + setImmediate(() => + serverProvider.notifyMessage(structuredClone(message)), + ); + callback?.(null); + }, + ); + serverProvider = createChannelProviderAdapter( + "server", + (message, callback) => { + setImmediate(() => + clientProvider.notifyMessage(structuredClone(message)), + ); + callback?.(null); + }, + ); + const original = context.agents.getAppAgent.bind(context.agents); + const server = createAgentRpcServer( + "guarded", + original("guarded"), + serverProvider, + ); + const client = await createAgentRpcClient( + "guarded", + clientProvider, + server.agentInterface, + ); + const spy = jest + .spyOn(context.agents, "getAppAgent") + .mockImplementation((name) => + name === "guarded" ? client : original(name), + ); + closeRpc = () => { + spy.mockRestore(); + server.closeFn(); + clientProvider.notifyDisconnected(); + serverProvider.notifyDisconnected(); + }; + return () => { + clientProvider.notifyDisconnected(); + serverProvider.notifyDisconnected(); + }; + } + + async function request( + actionName = "write", + mode?: string, + ): Promise { + const found = await dispatcher.getActionContract({ + schemaName: "guarded", + actionName, + }); + if (found.status !== "found") + throw new Error("Missing fixture contract"); + return { + protocolVersion: 1, + scopeId: found.scopeId, + schemaName: "guarded", + actionName, + fingerprint: found.contract.fingerprint, + parameters: { + value: "original", + ...(mode === undefined ? {} : { mode }), + }, + }; + } + + async function answer( + result: StructuredActionExecutionResult, + response: StructuredActionResponse, + ) { + const prompt = requirePrompt(result); + return dispatcher.continueAction({ + protocolVersion: 1, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + interactionId: prompt.interactionId, + response, + }); + } + + it("confirms an immutable action, preserves real result data, and never broadcasts prompts", async () => { + const input = await request(); + const pending = dispatcher.executeAction(input); + input.parameters!.value = "mutated"; + const prompt = requirePrompt(await pending); + expect(prompt.prompt).toMatchObject({ + type: "confirmation", + action: { parameters: { value: "original" } }, + }); + expect(entered).toEqual([]); + expect(context.currentRequestId?.requestId).toBe(prompt.operationId); + expect(context.activeRequests.has(prompt.operationId)).toBe(true); + expect(context.currentAbortSignal).toBeDefined(); + expect((await dispatcher.getQueueSnapshot()).running?.blockedOn).toBe( + "interaction", + ); + const result = await answer(prompt, { + type: "confirmation", + approved: true, + }); + expect(result.status).toBe("completed"); + expect(entered).toEqual(["original"]); + expect(result.results[0].result).toMatchObject(complete()); + expect(result.output).toContain("Saved item"); + expect(broadcasts).toEqual([]); + }); + + it.each([ + "stale", + "scope", + "parameter", + "approval", + "reference", + "resultReference", + ])("rejects %s before any agent effect", async (kind) => { + const input = await request(); + if (kind === "stale") input.fingerprint = "stale"; + if (kind === "scope") input.scopeId = "wrong"; + if (kind === "parameter") input.parameters = { value: 1 }; + if (kind === "approval") Object.assign(input, { approved: true }); + if (kind === "reference") input.parameters!.value = "${entity-1}"; + if (kind === "resultReference") + input.parameters!.value = { $result: "1" }; + const result = await dispatcher.executeAction(input); + expect(result.status).not.toBe("completed"); + expect(result.status).not.toBe("requires_interaction"); + expect(entered).toEqual([]); + expect(setup).not.toHaveBeenCalled(); + expect(resolutions).toBe(0); + }); + + it.each(["read", "write", "resolve"])( + "allows discovery but denies %s execution without opt-in", + async (actionName) => { + executionAllowed = false; + const input = await request(actionName); + const result = await dispatcher.executeAction(input); + expect(result).toMatchObject({ + status: "unavailable", + operationId: "", + error: { code: "unavailable" }, + }); + expect(context.requestQueue.getSnapshot().running).toBeNull(); + expect(entered).toEqual([]); + expect(resolutions).toBe(0); + expect(setup).not.toHaveBeenCalled(); + }, + ); + + it("denies continuation without consuming the prompt when execution access is revoked", async () => { + const prompt = requirePrompt( + await dispatcher.executeAction(await request()), + ); + executionAllowed = false; + expect( + (await answer(prompt, { type: "confirmation", approved: true })) + .status, + ).toBe("unavailable"); + expect( + ( + await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + }) + ).status, + ).toBe("unavailable"); + expect(entered).toEqual([]); + executionAllowed = true; + expect( + (await answer(prompt, { type: "confirmation", approved: true })) + .status, + ).toBe("completed"); + expect(entered).toEqual(["original"]); + }); + + it("rechecks execution access after asynchronous entity preparation", async () => { + holdResolution = true; + const prompt = requirePrompt( + await dispatcher.executeAction(await request("resolve")), + ); + const result = answer(prompt, { type: "confirmation", approved: true }); + for (let ticks = 0; resolutions === 0 && ticks < 20; ticks++) + await new Promise((resolve) => setImmediate(resolve)); + expect(resolutions).toBe(1); + executionAllowed = false; + release!(); + expect((await result).status).toBe("unavailable"); + expect(entered).toEqual([]); + expect(setup).not.toHaveBeenCalled(); + }); + + it("does not execute or run setup while unready", async () => { + const input = await request(); + readiness = { state: "setup-required", message: "Configure fixture" }; + await context.agents.refreshReadiness("guarded"); + const result = await dispatcher.executeAction(input); + expect(result.status).toBe("unavailable"); + expect(entered).toEqual([]); + expect(setup).not.toHaveBeenCalled(); + }); + + it("requires explicit read-only policy and honors required confirmation", async () => { + expect( + (await dispatcher.executeAction(await request("read"))).status, + ).toBe("completed"); + const prompt = await dispatcher.executeAction( + await request("confirmedRead"), + ); + expect(requirePrompt(prompt).prompt.type).toBe("confirmation"); + await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + }); + expect(entered).toEqual(["original"]); + }); + + it("does not consume invalid or duplicate concurrent responses", async () => { + const prompt = requirePrompt( + await dispatcher.executeAction(await request()), + ); + expect( + (await answer(prompt, { type: "yesNo", value: true })).status, + ).toBe("failed"); + const [first, second] = await Promise.all([ + answer(prompt, { type: "confirmation", approved: true }), + answer(prompt, { type: "confirmation", approved: true }), + ]); + expect(first.status).toBe("completed"); + expect(second.status).toBe("failed"); + expect(entered).toEqual(["original"]); + }); + + it("rechecks changed contracts after waiting without invoking the handler", async () => { + const prompt = await dispatcher.executeAction(await request()); + const config = context.agents.getActionConfig("guarded"); + config.actionPolicies = { + ...config.actionPolicies, + write: { effects: "state-changing" }, + }; + expect( + (await answer(prompt, { type: "confirmation", approved: true })) + .status, + ).toBe("contract_stale"); + expect(entered).toEqual([]); + }); + + it.each([ + "yesNo", + "multiChoice", + "pickRemember", + "form", + "question", + "blockingForm", + "proposal", + ])( + "resumes the same %s operation without rerunning the action", + async (mode) => { + const prompt = requirePrompt( + await dispatcher.executeAction(await request("read", mode)), + ); + const responses: Record = { + yesNo: { type: "yesNo", value: true }, + multiChoice: { type: "multiChoice", selected: [0, 1] }, + pickRemember: { + type: "pickRemember", + selected: 1, + remember: false, + }, + form: formAnswer, + blockingForm: formAnswer, + question: { type: "question", selected: 0 }, + proposal: { + type: "proposal", + accepted: true, + data: { value: "edited" }, + }, + }; + expect( + (await answer(prompt, { type: "question", selected: 99 })) + .status, + ).toBe("failed"); + const result = await answer(prompt, responses[mode]); + if (result.status !== "completed") + throw new Error(JSON.stringify(result)); + expect(result).toMatchObject({ status: "completed" }); + expect(entered).toEqual(["original"]); + expect(callbacks).toBe(1); + expect(broadcasts).toEqual([]); + expect(() => liveContext!.actionIO).toThrow("Context is closed"); + }, + ); + + it("requires every form answer and rejects duplicates without consuming the prompt", async () => { + const prompt = await dispatcher.executeAction( + await request("read", "form"), + ); + expect( + (await answer(prompt, { type: "form", value: { answers: {} } })) + .status, + ).toBe("failed"); + const invalid = structuredClone(formAnswer); + if (invalid.type === "form") + invalid.value.answers.many = { + kind: "multiChoice", + selected: [0, 0], + }; + expect((await answer(prompt, invalid)).status).toBe("failed"); + expect((await answer(prompt, formAnswer)).status).toBe("completed"); + expect(callbacks).toBe(1); + }); + + it.each(["write", "question", "yesNo"])( + "cancellation of %s never chooses a default", + async (mode) => { + const prompt = requirePrompt( + await dispatcher.executeAction( + await request(mode === "write" ? "write" : "read", mode), + ), + ); + const result = await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + }); + expect(result.status).toBe( + mode === "write" ? "cancelled" : "execution_uncertain", + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(callbacks).toBe(0); + expect( + (choices as unknown as { callbacks: Map }) + .callbacks.size, + ).toBe(0); + }, + ); + + it("guards additional actions independently", async () => { + const prompt = requirePrompt( + await dispatcher.executeAction(await request("read", "child")), + ); + expect(prompt.prompt).toMatchObject({ + type: "confirmation", + action: { parameters: { value: "child" } }, + }); + expect(entered).toEqual(["original"]); + expect( + (await answer(prompt, { type: "confirmation", approved: true })) + .status, + ).toBe("completed"); + expect(entered).toEqual(["original", "child"]); + }); + + it("serializes concurrent blocking prompts without dropping an answer", async () => { + const first = requirePrompt( + await dispatcher.executeAction( + await request("read", "parallelQuestions"), + ), + ); + expect(first.prompt).toMatchObject({ + type: "question", + message: "first", + }); + const second = requirePrompt( + await answer(first, { type: "question", selected: 1 }), + ); + expect(second.prompt).toMatchObject({ + type: "question", + message: "second", + }); + expect( + (await answer(second, { type: "question", selected: 0 })).status, + ).toBe("completed"); + expect(callbacks).toBe(1); + expect(broadcasts).toEqual([]); + }); + + it("preserves additional actions from both a pending choice and its callback", async () => { + const choice = requirePrompt( + await dispatcher.executeAction( + await request("read", "choiceChild"), + ), + ); + const first = requirePrompt( + await answer(choice, { type: "yesNo", value: true }), + ); + expect(first.prompt).toMatchObject({ + action: { parameters: { value: "initial-child" } }, + }); + const second = requirePrompt( + await answer(first, { type: "confirmation", approved: true }), + ); + expect(second.prompt).toMatchObject({ + action: { parameters: { value: "callback-child" } }, + }); + expect( + (await answer(second, { type: "confirmation", approved: true })) + .status, + ).toBe("completed"); + expect(entered).toEqual([ + "original", + "initial-child", + "callback-child", + ]); + expect(callbacks).toBe(1); + }); + + it.each(["question", "yesNo"])( + "resumes a real agent-RPC %s without legacy broadcasting", + async (mode) => { + await useAgentRpc(); + const prompt = requirePrompt( + await dispatcher.executeAction(await request("read", mode)), + ); + expect(broadcasts).toEqual([]); + const result = await answer( + prompt, + mode === "question" + ? { type: "question", selected: 0 } + : { type: "yesNo", value: true }, + ); + expect(result.status).toBe("completed"); + expect(callbacks).toBe(1); + expect(entered).toEqual(["original"]); + expect(result.results.at(-1)?.result).toMatchObject({ + resultValue: { ids: ["stable-1"], count: 42 }, + }); + }, + ); + + it("retains the queue lock after aborting an uncooperative agent-RPC handler", async () => { + await useAgentRpc(); + const input = await request("read", "hold"); + const running = dispatcher.executeAction(input); + for (let ticks = 0; entered.length === 0 && ticks < 50; ticks++) + await new Promise((resolve) => setImmediate(resolve)); + expect(entered).toEqual(["original"]); + const cancelled = await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: input.scopeId, + operationId: context.currentRequestId!.requestId, + }); + expect(cancelled.status).toBe("execution_uncertain"); + expect((await running).status).toBe("execution_uncertain"); + const next = dispatcher.executeAction(await request("read")); + for (let ticks = 0; ticks < 10; ticks++) + await new Promise((resolve) => setImmediate(resolve)); + expect(entered).toHaveLength(1); + release!(); + expect((await next).status).toBe("completed"); + expect(entered).toHaveLength(2); + }); + + it("returns uncertainty and stops admission when the agent transport is lost", async () => { + const disconnect = await useAgentRpc(); + const input = await request("read", "hold"); + const running = dispatcher.executeAction(input); + for (let ticks = 0; entered.length === 0 && ticks < 50; ticks++) + await new Promise((resolve) => setImmediate(resolve)); + expect(entered).toEqual(["original"]); + disconnect(); + expect((await running).status).toBe("execution_uncertain"); + expect((await dispatcher.executeAction(input)).status).toBe( + "unavailable", + ); + expect(context.currentRequestId).toBeDefined(); + }); + + it("chains same-operation resultValue through guarded flow actions and their children", async () => { + const registry = ( + context.agents as unknown as { + flowRegistry: Map; + } + ).flowRegistry; + registry.set("guarded/read", { + name: "read", + description: "Chained flow", + parameters: {}, + steps: [ + { + id: "first", + schemaName: "guarded", + actionName: "write", + parameters: { value: "first" }, + }, + { + id: "second", + schemaName: "guarded", + actionName: "write", + parameters: { value: "${first.data.ids.0}", mode: "child" }, + }, + ], + }); + const first = requirePrompt( + await dispatcher.executeAction(await request("read")), + ); + const second = requirePrompt( + await answer(first, { type: "confirmation", approved: true }), + ); + expect(second.prompt).toMatchObject({ + action: { parameters: { value: "stable-1" } }, + }); + const child = requirePrompt( + await answer(second, { type: "confirmation", approved: true }), + ); + expect(child.prompt).toMatchObject({ + action: { parameters: { value: "child" } }, + }); + expect( + (await answer(child, { type: "confirmation", approved: true })) + .status, + ).toBe("completed"); + expect(entered).toEqual(["first", "stable-1", "child"]); + }); + + it("guards each nested flow step, including entity preparation", async () => { + const registry = ( + context.agents as unknown as { + flowRegistry: Map; + } + ).flowRegistry; + registry.set("guarded/read", { + name: "read", + description: "Flow fixture", + parameters: {}, + steps: [ + { + id: "resolve", + schemaName: "guarded", + actionName: "resolve", + parameters: { value: "${value}" }, + }, + ], + }); + const prompt = requirePrompt( + await dispatcher.executeAction(await request("read")), + ); + expect(prompt.prompt).toMatchObject({ + type: "confirmation", + action: { actionName: "resolve" }, + }); + expect(entered).toEqual([]); + expect(resolutions).toBe(0); + expect( + (await answer(prompt, { type: "confirmation", approved: true })) + .status, + ).toBe("completed"); + expect(entered).toEqual(["original"]); + expect(resolutions).toBe(1); + }); + + it.each(["disabled", "inactive"])( + "does not enter a %s schema", + async (state) => { + const input = await request("resolve"); + if (state === "disabled") { + const agents = context.agents as unknown as { + agents: Map }>; + }; + agents.agents.get("guarded")!.actions.delete("guarded"); + } else { + const agents = context.agents as unknown as { + transientAgents: Record; + }; + agents.transientAgents.guarded = false; + } + expect((await dispatcher.executeAction(input)).status).toBe( + "unavailable", + ); + expect(entered).toEqual([]); + expect(resolutions).toBe(0); + expect(setup).not.toHaveBeenCalled(); + }, + ); + + it("does not let legacy choice responses consume a structured SDK choice", async () => { + const prompt = requirePrompt( + await dispatcher.executeAction(await request("read", "yesNo")), + ); + const choiceResult = prompt.results[0].result; + if ( + choiceResult.error !== undefined || + choiceResult.pendingChoice === undefined + ) + throw new Error("Missing SDK choice"); + await expect( + dispatcher.respondToChoice( + choiceResult.pendingChoice.choiceId, + true, + ), + ).rejects.toThrow("Choice not found or expired"); + expect(callbacks).toBe(0); + expect( + (await answer(prompt, { type: "yesNo", value: true })).status, + ).toBe("completed"); + expect(callbacks).toBe(1); + }); + + it.each(["throw", "fallback", "reason"])( + "returns %s failures without model retry", + async (mode) => { + const result = await dispatcher.executeAction( + await request("read", mode), + ); + expect(result.status).toBe("failed"); + expect(entered).toEqual(["original"]); + }, + ); + + it("accepts an empty handler result as completion", async () => { + expect( + (await dispatcher.executeAction(await request("read", "empty"))) + .status, + ).toBe("completed"); + }); + + it("returns nested built-in command errors instead of synthesized success", async () => { + const identity = { + schemaName: "system.config", + actionName: "toggleAgent", + }; + const found = await dispatcher.getActionContract(identity); + if (found.status !== "found") + throw new Error("Expected built-in action"); + const prompt = requirePrompt( + await dispatcher.executeAction({ + protocolVersion: found.protocolVersion, + scopeId: found.scopeId, + ...identity, + fingerprint: found.contract.fingerprint, + parameters: { + enable: true, + agentNames: ["review-no-such-agent"], + }, + }), + ); + const result = await answer(prompt, { + type: "confirmation", + approved: true, + }); + expect(result.status).toBe("failed"); + expect(result.results[0].result.error).toContain("Invalid agent name"); + expect(result.output.join("\n")).toContain("review-no-such-agent"); + expect(result.output.join("\n")).not.toContain("completed."); + + const legacy = await dispatcher.submitCommand( + "@config agent review-no-such-agent", + ); + if (!legacy.ok) throw new Error("Expected legacy submission"); + expect((await legacy.entry.completion)?.disposition?.status).toBe( + "failed", + ); + }); + + it.each([ + ["${first.data.ids.0}", "source-id"], + ["${first.text}", "Parent result"], + ])( + "binds a flow step's own %s without replaying its additional actions", + async (binding, expected) => { + const registry = ( + context.agents as unknown as { + flowRegistry: Map; + } + ).flowRegistry; + registry.set("guarded/read", { + name: "read", + description: "Distinct parent and child results", + parameters: {}, + steps: [ + { + id: "first", + schemaName: "guarded", + actionName: "write", + parameters: { value: "first", mode: "parentChild" }, + }, + { + id: "last", + schemaName: "guarded", + actionName: "write", + parameters: { + value: binding, + mode: "parentChild", + }, + }, + ], + }); + let result = await dispatcher.executeAction(await request("read")); + for (const value of ["first", "child", expected, "child"]) { + const current = requirePrompt(result); + expect(current.prompt).toMatchObject({ + type: "confirmation", + action: { parameters: { value } }, + }); + result = await answer(current, { + type: "confirmation", + approved: true, + }); + } + expect(result.status).toBe("completed"); + expect(entered).toEqual(["first", "child", expected, "child"]); + const root = result.results.find( + ({ action }) => action.actionName === "read", + ); + expect(root?.result).toMatchObject({ + resultValue: { ids: ["source-id"] }, + }); + expect(root?.result).not.toHaveProperty("additionalActions"); + }, + ); + + it("retains uncertainty and serialization while an uncooperative handler runs", async () => { + const input = await request("read", "hold"); + const running = dispatcher.executeAction(input); + while (entered.length === 0) + await new Promise((resolve) => setImmediate(resolve)); + const id = context.currentRequestId!.requestId; + const result = await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: input.scopeId, + operationId: id, + }); + expect(result.status).toBe("execution_uncertain"); + expect((await running).status).toBe("execution_uncertain"); + const next = dispatcher.executeAction(await request("read")); + await new Promise((resolve) => setImmediate(resolve)); + expect(entered).toHaveLength(1); + release!(); + expect((await next).status).toBe("completed"); + expect(entered).toHaveLength(2); + expect( + ( + await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: input.scopeId, + operationId: id, + }) + ).status, + ).toBe("execution_uncertain"); + }); + + it("supports trusted reconnect takeover but rejects stale and foreign facades", async () => { + const prompt = requirePrompt( + await dispatcher.executeAction(await request()), + ); + const ownerScope = scope; + const resumed = createDispatcherFromContext( + context, + "new-owner", + undefined, + () => ({ scope: ownerScope, canDiscoverSchema: () => true }), + ); + const foreign = createDispatcherFromContext( + context, + "foreign", + undefined, + () => ({ scope: {}, canDiscoverSchema: () => true }), + ); + const input = { + protocolVersion: 1 as const, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + interactionId: prompt.interactionId, + response: { type: "confirmation" as const, approved: true }, + }; + active = false; + expect((await dispatcher.continueAction(input)).status).toBe("failed"); + expect((await foreign.continueAction(input)).status).toBe("failed"); + expect((await resumed.continueAction(input)).status).toBe("completed"); + expect(entered).toEqual(["original"]); + }); + + it("rebinds a denied-scope facade to the current lease before resuming each wait", async () => { + const logicalScope = scope; + const deniedScope = {}; + let current = true; + const original = createDispatcherFromContext( + context, + "old-lease", + undefined, + () => ({ + scope: current ? logicalScope : deniedScope, + canDiscoverSchema: () => current, + }), + ); + const prompt = requirePrompt( + await original.executeAction(await request("write", "yesNo")), + ); + current = false; + const incoming = createDispatcherFromContext( + context, + "new-lease", + undefined, + () => ({ + scope: logicalScope, + canDiscoverSchema: () => !current, + }), + ); + const continuation = { + protocolVersion: 1 as const, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + interactionId: prompt.interactionId, + response: { type: "confirmation" as const, approved: true }, + }; + expect((await original.continueAction(continuation)).status).toBe( + "failed", + ); + const choice = requirePrompt( + await incoming.continueAction(continuation), + ); + expect(choice.prompt.type).toBe("yesNo"); + expect( + ( + await incoming.continueAction({ + ...continuation, + interactionId: choice.interactionId, + response: { type: "yesNo", value: true }, + }) + ).status, + ).toBe("completed"); + expect(entered).toEqual(["original"]); + expect(callbacks).toBe(1); + }); + + it.each([ + ["write", undefined, "user", "cancelled"], + ["read", "question", "no_clients", "execution_uncertain"], + ["read", "yesNo", "no_clients", "execution_uncertain"], + ] as const)( + "host cancellation handles %s/%s without legacy interaction entries", + async (action, mode, reason, status) => { + const prompt = requirePrompt( + await dispatcher.executeAction(await request(action, mode)), + ); + expect(context.requestQueue.getSnapshot().running).toMatchObject({ + requestId: prompt.operationId, + blockedOn: "interaction", + }); + // The host's existing supersession path performs these two steps + // even when its legacy interaction manager has no matching entry. + expect( + context.requestQueue.cancelRunning(prompt.operationId, reason), + ).toBe(true); + const controller = context.activeRequests.get(prompt.operationId); + expect(controller).toBeDefined(); + controller!.abort(); + expect( + context.requestQueue.cancelRunning(prompt.operationId, reason), + ).toBe(false); + const result = await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + }); + expect(result.status).toBe(status); + expect(callbacks).toBe(0); + expect(broadcasts).toEqual([]); + expect( + (await dispatcher.executeAction(await request("read"))).status, + ).toBe("completed"); + }, + ); + + it("uses concrete owning-agent resolution without conversation memory or prior activity", async () => { + const memory = context.conversationMemory; + const search = + memory === undefined + ? undefined + : jest.spyOn(memory, "searchKnowledge"); + context.activityContext = { + appAgentName: "guarded", + activityName: "old", + context: { value: "past" }, + } as unknown as NonNullable; + const prompt = await dispatcher.executeAction(await request("resolve")); + expect(resolutions).toBe(0); + const result = await answer(prompt, { + type: "confirmation", + approved: true, + }); + expect(result.status).toBe("completed"); + expect(resolutions).toBe(1); + expect(entered).toEqual(["original"]); + expect(search?.mock.calls ?? []).toEqual([]); + search?.mockRestore(); + }); + + it("rechecks the contract after asynchronous entity preparation", async () => { + holdResolution = true; + const prompt = await dispatcher.executeAction(await request("resolve")); + const result = answer(prompt, { type: "confirmation", approved: true }); + for (let i = 0; i < 20 && resolutions === 0; i++) + await new Promise((resolve) => setImmediate(resolve)); + expect(resolutions).toBe(1); + const config = context.agents.getActionConfig("guarded"); + config.actionPolicies = { + ...config.actionPolicies, + resolve: { effects: "state-changing" }, + }; + release!(); + expect((await result).status).toBe("contract_stale"); + expect(entered).toEqual([]); + }); + + it("honors normal queue/controller cancellation of a blocked structured request", async () => { + const prompt = await dispatcher.executeAction(await request()); + await dispatcher.cancelCommand(prompt.operationId); + expect( + ( + await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + }) + ).status, + ).toBe("cancelled"); + expect(entered).toEqual([]); + expect( + (await dispatcher.executeAction(await request("read"))).status, + ).toBe("completed"); + }); + + it("expires a prompt without selecting its affirmative default", async () => { + jest.useFakeTimers({ doNotFake: ["nextTick", "setImmediate"] }); + try { + const prompt = requirePrompt( + await dispatcher.executeAction( + await request("read", "question"), + ), + ); + await jest.advanceTimersByTimeAsync(10 * 60_000); + const result = await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: prompt.scopeId, + operationId: prompt.operationId, + }); + expect(result.status).toBe("execution_uncertain"); + expect(callbacks).toBe(0); + closeStructuredActions(context); + } finally { + jest.useRealTimers(); + } + }); + + it("bounds live operations at the existing queue capacity without evicting a prompt", async () => { + const input = await request(); + const first = requirePrompt(await dispatcher.executeAction(input)); + const queued = Array.from({ length: 99 }, () => + dispatcher.executeAction(input), + ); + expect((await dispatcher.executeAction(input)).status).toBe( + "unavailable", + ); + expect((await dispatcher.getQueueSnapshot()).queued).toHaveLength(99); + expect(entered).toEqual([]); + closeStructuredActions(context); + expect( + (await Promise.all(queued)).every( + (result) => result.status === "cancelled", + ), + ).toBe(true); + expect( + ( + await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: first.scopeId, + operationId: first.operationId, + }) + ).status, + ).not.toBe("completed"); + }); + + it("retains only the latest 100 terminal outcomes and never replays lost state", async () => { + const input = await request("read"); + const first = await dispatcher.executeAction(input); + for (let i = 0; i < 101; i++) await dispatcher.executeAction(input); + const result = await dispatcher.cancelAction({ + protocolVersion: 1, + scopeId: first.scopeId, + operationId: first.operationId, + }); + expect(result.status).toBe("execution_uncertain"); + if (result.status === "execution_uncertain") + expect(result.error.code).toBe("execution_state_lost"); + expect(entered).toHaveLength(102); + }); +}); diff --git a/ts/packages/dispatcher/rpc/src/dispatcherClient.ts b/ts/packages/dispatcher/rpc/src/dispatcherClient.ts index 3b0fcee870..8ab28babd4 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherClient.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherClient.ts @@ -217,6 +217,15 @@ export function createDispatcherRpcClient( async getActionContract(...args) { return rpc.invoke("getActionContract", ...args); }, + async executeAction(...args) { + return rpc.invoke("executeAction", ...args); + }, + async continueAction(...args) { + return rpc.invoke("continueAction", ...args); + }, + async cancelAction(...args) { + return rpc.invoke("cancelAction", ...args); + }, async respondToChoice(...args) { return rpc.invoke("respondToChoice", ...args); }, diff --git a/ts/packages/dispatcher/rpc/src/dispatcherServer.ts b/ts/packages/dispatcher/rpc/src/dispatcherServer.ts index 23a6cf663f..3915562a20 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherServer.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherServer.ts @@ -103,6 +103,9 @@ export function createDispatcherRpcServer( getActionContract: async (...args) => { return dispatcher.getActionContract(...args); }, + executeAction: async (...args) => dispatcher.executeAction(...args), + continueAction: async (...args) => dispatcher.continueAction(...args), + cancelAction: async (...args) => dispatcher.cancelAction(...args), respondToChoice: async (...args) => { return dispatcher.respondToChoice(...args); }, diff --git a/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts b/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts index 77480c7c5c..ea225caf35 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts @@ -13,6 +13,10 @@ import type { ActionIdentity, ActionSearchRequest, ActionSearchResult, + ExecuteActionRequest, + ContinueActionRequest, + CancelActionRequest, + StructuredActionExecutionResult, CancelResult, CommandCompletionResult, CommandResult, @@ -107,6 +111,16 @@ export type DispatcherInvokeFunctions = { getActionContract(identity: ActionIdentity): Promise; + executeAction( + request: ExecuteActionRequest, + ): Promise; + continueAction( + request: ContinueActionRequest, + ): Promise; + cancelAction( + request: CancelActionRequest, + ): 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 728a951a22..adec4ed970 100644 --- a/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts +++ b/ts/packages/dispatcher/rpc/test/dispatcherRpc.spec.ts @@ -12,6 +12,7 @@ import type { Dispatcher, QueuedRequest, SubmitResult, + StructuredActionExecutionResult, } from "@typeagent/dispatcher-types"; import { ServerStoppingError } from "@typeagent/dispatcher-types"; import type { PendingInteractionResponse } from "@typeagent/dispatcher-types"; @@ -76,6 +77,9 @@ function makeStubDispatcher(overrides: Partial = {}): Dispatcher & { getAgentSchemas: notImplemented("getAgentSchemas") as any, searchActions: notImplemented("searchActions"), getActionContract: notImplemented("getActionContract"), + executeAction: notImplemented("executeAction"), + continueAction: notImplemented("continueAction"), + cancelAction: notImplemented("cancelAction"), respondToChoice: notImplemented("respondToChoice") as any, getDisplayHistory: notImplemented("getDisplayHistory") as any, async cancelCommand(...args) { @@ -154,6 +158,93 @@ describe("dispatcher RPC lifecycle options", () => { }); describe("dispatcher RPC structured discovery", () => { + it("roundtrips structured execution, continuation and cancellation without losing result data", async () => { + const { serverChannel, clientChannel } = createChannelPair(); + const seen: unknown[] = []; + const result: StructuredActionExecutionResult = { + protocolVersion: 1, + scopeId: "scope", + operationId: "operation", + status: "completed", + output: ["Saved"], + results: [ + { + action: { + schemaName: "test", + actionName: "save", + parameters: { name: "item" }, + }, + result: { + displayContent: { + type: "html", + content: "Saved", + }, + entities: [ + { + name: "item", + type: ["Item"], + uniqueId: "stable", + }, + ], + resultEntity: { + name: "item", + type: ["Item"], + uniqueId: "stable", + }, + resultValue: { + songs: [{ id: "stable", title: "song" }], + }, + }, + }, + ], + }; + createDispatcherRpcServer( + makeStubDispatcher({ + executeAction: async (request) => { + seen.push(request); + return result; + }, + continueAction: async (request) => { + seen.push(request); + return result; + }, + cancelAction: async (request) => { + seen.push(request); + return result; + }, + }), + serverChannel, + ); + const { dispatcher } = createDispatcherRpcClient( + clientChannel, + undefined, + ); + const request = { + protocolVersion: 1 as const, + scopeId: "scope", + schemaName: "test", + actionName: "save", + fingerprint: "exact", + parameters: { name: "item" }, + }; + const continuation = { + protocolVersion: 1 as const, + scopeId: "scope", + operationId: "operation", + interactionId: "opaque", + response: { type: "confirmation" as const, approved: true }, + }; + const cancellation = { + protocolVersion: 1 as const, + scopeId: "scope", + operationId: "operation", + }; + expect(await dispatcher.executeAction(request)).toEqual(result); + expect(await dispatcher.continueAction(continuation)).toEqual(result); + expect(await dispatcher.cancelAction(cancellation)).toEqual(result); + expect(seen).toEqual([request, continuation, cancellation]); + }); + it("forwards exact identities and the complete versioned contract", async () => { const identity = { schemaName: "test.sub", actionName: "select" }; const summary = { diff --git a/ts/packages/dispatcher/types/src/dispatcher.ts b/ts/packages/dispatcher/types/src/dispatcher.ts index 40eb68dac7..3e58ff053b 100644 --- a/ts/packages/dispatcher/types/src/dispatcher.ts +++ b/ts/packages/dispatcher/types/src/dispatcher.ts @@ -23,6 +23,10 @@ import type { ActionIdentity, ActionSearchRequest, ActionSearchResult, + ExecuteActionRequest, + ContinueActionRequest, + CancelActionRequest, + StructuredActionExecutionResult, } from "./structuredAction.js"; export const DispatcherName = "dispatcher"; @@ -518,6 +522,16 @@ export interface Dispatcher { getActionContract(identity: ActionIdentity): Promise; + executeAction( + request: ExecuteActionRequest, + ): Promise; + continueAction( + request: ContinueActionRequest, + ): Promise; + cancelAction( + request: CancelActionRequest, + ): 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/structuredAction.ts b/ts/packages/dispatcher/types/src/structuredAction.ts index 958995e5af..6534de57e7 100644 --- a/ts/packages/dispatcher/types/src/structuredAction.ts +++ b/ts/packages/dispatcher/types/src/structuredAction.ts @@ -1,7 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { ActionEffect, ReadinessReport } from "@typeagent/agent-sdk"; +import type { + ActionEffect, + ActionResult, + PendingChoice, + QuestionForm, + QuestionFormResponse, + ReadinessReport, + TemplateSchema, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import type { TemplateEditConfig } from "./clientIO.js"; export const structuredActionProtocolVersion = 1; @@ -95,3 +105,94 @@ export type ActionContractResult = StructuredActionEnvelope & // Deliberately does not distinguish absent and unauthorized identities. | { status: "not-found" } ); + +export type ExecuteActionRequest = StructuredActionEnvelope & + ActionIdentity & { + fingerprint: string; + parameters?: Record; + }; + +export type StructuredActionPrompt = + | { + type: "confirmation"; + action: ExecuteActionRequest; + contract: ActionContract; + } + | { + type: "question"; + message: string; + choices: string[]; + defaultId?: number; + } + | Omit, "choiceId"> + | Omit, "choiceId"> + | Omit, "choiceId"> + | ({ type: "form" } & QuestionForm) + | { + type: "proposal"; + templateAgentName: string; + templateName: string; + schema: TemplateSchema; + data: unknown; + templates: TemplateEditConfig; + }; + +export type StructuredActionResponse = + | { type: "confirmation"; approved: boolean } + | { type: "question"; selected: number } + | { type: "yesNo"; value: boolean } + | { type: "multiChoice"; selected: number[] } + | { type: "pickRemember"; selected: number; remember: boolean } + | { type: "form"; value: QuestionFormResponse } + | { type: "proposal"; accepted: boolean; data?: unknown }; + +export type ContinueActionRequest = StructuredActionEnvelope & { + operationId: string; + interactionId: string; + response: StructuredActionResponse; +}; + +export type CancelActionRequest = StructuredActionEnvelope & { + operationId: string; + interactionId?: string; +}; + +export type StructuredActionError = { + code: + | "invalid_request" + | "invalid_response" + | "invalid_scope" + | "contract_stale" + | "unavailable" + | "interaction_consumed" + | "interaction_expired" + | "execution_state_lost" + | "queue_full" + | "server_stopping" + | "cancelled" + | "execution_failed"; + message: string; +}; + +export type StructuredActionExecutionResult = StructuredActionEnvelope & { + operationId: string; + output: string[]; + results: { action: TypeAgentAction; result: ActionResult }[]; +} & ( + | { + status: "requires_interaction"; + interactionId: string; + expiresAt: number; + prompt: StructuredActionPrompt; + } + | { status: "completed" } + | { + status: + | "failed" + | "cancelled" + | "contract_stale" + | "unavailable" + | "execution_uncertain"; + error: StructuredActionError; + } + ); diff --git a/ts/tools/scripts/jestSetupSignalExit.cjs b/ts/tools/scripts/jestSetupSignalExit.cjs index c0988fc9c1..37035a8a3e 100644 --- a/ts/tools/scripts/jestSetupSignalExit.cjs +++ b/ts/tools/scripts/jestSetupSignalExit.cjs @@ -22,7 +22,7 @@ // Pre-installing the emitter as a NON-enumerable property before any test // module loads makes signal-exit reuse it (its `if (process.__signal_exit_emitter__)` // branch) instead of creating an enumerable one. Because it is never -// enumerable, `Object.keys(process)` stays stable and the race cannot happen. +// enumerable, it cannot change the synthetic module's export names. const { EventEmitter } = require("node:events"); @@ -39,3 +39,17 @@ if (!process.__signal_exit_emitter__) { configurable: true, }); } + +// Registering a signal-exit callback also wraps process.emit. It is normally +// inherited from EventEmitter, so that assignment adds an enumerable own key. +// Materialize it before Jest snapshots the exports; keep it writable so real +// signal-exit registration and cleanup still work. Preserve an existing own +// property (including any instrumentation and its descriptor). +if (!Object.prototype.hasOwnProperty.call(process, "emit")) { + Object.defineProperty(process, "emit", { + value: process.emit, + enumerable: true, + writable: true, + configurable: true, + }); +} diff --git a/ts/tools/scripts/test/jestSetupSignalExit.spec.mjs b/ts/tools/scripts/test/jestSetupSignalExit.spec.mjs new file mode 100644 index 0000000000..0e6ee39b02 --- /dev/null +++ b/ts/tools/scripts/test/jestSetupSignalExit.spec.mjs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; + +const setupSource = readFileSync( + new URL("../jestSetupSignalExit.cjs", import.meta.url), + "utf8", +); +const dispatcherRequire = createRequire( + new URL( + "../../../packages/dispatcher/dispatcher/package.json", + import.meta.url, + ), +); +const lockfileRequire = createRequire( + dispatcherRequire.resolve("proper-lockfile"), +); +const signalExitPath = lockfileRequire.resolve("signal-exit"); +const signalExitSource = readFileSync(signalExitPath, "utf8"); +const signalExitRequire = createRequire(signalExitPath); + +for (const ownEmit of [false, true]) { + test(`keeps process exports stable with ${ownEmit ? "own" : "inherited"} emit`, () => { + // Never install signal handlers on the host process or invoke real exit. + const fakeProcess = Object.assign(new EventEmitter(), { + platform: process.platform, + pid: 1, + kill() { + assert.fail("Unexpected signal"); + }, + reallyExit() { + assert.fail("Unexpected exit"); + }, + }); + if (ownEmit) { + fakeProcess.emit = fakeProcess.emit; + } + const originalEmit = fakeProcess.emit; + const originalReallyExit = fakeProcess.reallyExit; + const sandbox = { + process: fakeProcess, + require: signalExitRequire, + module: { exports: {} }, + }; + sandbox.global = sandbox; + const setup = () => + runInNewContext(`(function () {\n${setupSource}\n})();`, sandbox); + setup(); + const emitter = fakeProcess.__signal_exit_emitter__; + const exportNames = Object.keys(fakeProcess); + const assertStableExports = () => + assert.deepEqual(Object.keys(fakeProcess), exportNames); + + runInNewContext(signalExitSource, sandbox); + let exitCalls = 0; + const remove = sandbox.module.exports(() => exitCalls++); + assert.notEqual(fakeProcess.emit, originalEmit); + assertStableExports(); + + // Loading setup again must preserve installed wrappers and the emitter. + const wrappedEmit = fakeProcess.emit; + setup(); + assert.equal(fakeProcess.emit, wrappedEmit); + assert.equal(fakeProcess.__signal_exit_emitter__, emitter); + assertStableExports(); + + let forwarded; + fakeProcess.on("fixture", (value) => (forwarded = value)); + fakeProcess.emit("fixture", 42); + assert.equal(forwarded, 42); + assert.equal(exitCalls, 0); + fakeProcess.emit("exit"); + assert.equal(exitCalls, 1); + remove(); + assert.equal(fakeProcess.emit, originalEmit); + assert.equal(fakeProcess.reallyExit, originalReallyExit); + assert.equal(emitter.count, 0); + assertStableExports(); + }); +}