@mastra/core
Version:
1 lines • 12.6 kB
Source Map (JSON)
{"version":3,"file":"channels-DpBkVl9k.cjs","names":["AgentChannels"],"sources":["../src/channels/agent-controller-channels.ts"],"sourcesContent":["import type { Message, Thread } from 'chat';\n\nimport type { Agent } from '../agent/agent';\nimport type { MastraProviderMetadata } from '../agent/message-list/state/types';\nimport type { AgentSignalContents } from '../agent/signals';\nimport type { AgentController } from '../agent-controller/agent-controller';\nimport type { Session } from '../agent-controller/session';\nimport type { Mastra } from '../mastra';\nimport type { StorageThreadType } from '../memory/types';\nimport type { RequestContext } from '../request-context';\n\nimport { AgentChannels } from './agent-channels';\nimport type { ChannelConfig } from './types';\n\n/** Configuration for {@link AgentControllerChannels}. Same shape as agent channels. */\nexport type AgentControllerChannelsConfig = ChannelConfig;\n\n/**\n * Runs an AgentController inside chat channels (Slack, Discord, ...).\n *\n * Extends {@link AgentChannels} so all inbound machinery (thread mapping,\n * history, attachments, event context) and all outbound rendering\n * (`ChatChannelOutputProcessor` with native streaming, tool cards, typing\n * status) are reused unchanged. Only the dispatch seams differ: instead of\n * routing into a bare agent, inbound messages route into a controller\n * `Session` — one durable session per chat thread, keyed by the mapped\n * Mastra thread's `resourceId`.\n *\n * V1 targets long-lived servers: controller sessions are in-memory objects\n * and do not survive process restarts.\n */\nexport class AgentControllerChannels extends AgentChannels {\n private controller: AgentController<any> | null = null;\n\n /**\n * Session resourceIds whose adapter can't render approval buttons, so their\n * runs must auto-approve tools (`requireToolApproval: false`) instead of\n * parking forever on an approval nobody can answer. Kept outside session\n * state on purpose: state is validated against the controller's\n * `stateSchema`, which would strip (or reject) an injected flag. Refreshed\n * on every inbound message; in-memory only, matching the v1 long-lived\n * server scope.\n */\n private autoApproveResourceIds = new Set<string>();\n\n /** @internal Called by AgentController's constructor to bind itself. */\n __setController(controller: AgentController<any>): void {\n this.controller = controller;\n }\n\n /**\n * @internal Consulted by the controller's run-option builder: `true` when\n * the session's channel adapter can't render approval buttons and tool\n * calls must auto-approve (the session-side equivalent of the base agent\n * path's `autoResumeSuspendedTools`).\n */\n __isAutoApproveResource(resourceId: string): boolean {\n return this.autoApproveResourceIds.has(resourceId);\n }\n\n /**\n * @internal No-op override. The controller attaches this instance to its\n * mode agents via `Agent.setChannels`, which calls `__setAgent(agent)` —\n * with multiple mode agents the last one would win. Every `this.agent` use\n * is overridden in this subclass, so keep the base field unset rather than\n * holding a misleading ref.\n */\n override __setAgent(_agent: Agent<any, any, any, any>): void {}\n\n protected override getOwnerId(): string | null {\n return this.controller?.id ?? null;\n }\n\n protected override getWebhookBasePath(): string {\n return `/api/agent-controllers/${this.getOwnerId()}`;\n }\n\n protected override getMastra(): Mastra | undefined {\n return this.controller?.getMastra();\n }\n\n /**\n * One session per chat thread: unless the user supplied a custom\n * `resolveResourceId`, key new Mastra threads (and therefore controller\n * sessions) off the platform + external thread id.\n */\n protected override resolveChannelResourceId(args: {\n platform: string;\n chatThread: Thread;\n message: Message;\n defaultResourceId: string;\n }): string | (() => string | Promise<string>) {\n const base = super.resolveChannelResourceId(args);\n // The base returns a thunk only when a custom resolveResourceId was\n // configured — honor it. Otherwise derive the channel-thread key. The\n // adapter's thread id is already platform-prefixed (e.g. `slack:C123:ts`),\n // so don't prepend the platform again or the key double-prefixes.\n if (typeof base === 'function') return base;\n return `channel:${args.chatThread.id}`;\n }\n\n /**\n * Route an inbound chat message into the controller session bound to this\n * chat thread. Output renders back to the platform through the channels\n * output processor: the `requestContext` built by the base class (carrying\n * the channel context and render context) flows through the session into\n * the run.\n */\n protected override async dispatchInboundMessage(args: {\n signalContents: AgentSignalContents;\n attributes: Record<string, string | undefined>;\n providerOptions: MastraProviderMetadata;\n requestContext: RequestContext;\n thread: StorageThreadType;\n memory: { thread: string; resource: string };\n autoResumeSuspendedTools: true | undefined;\n }): Promise<void> {\n const { signalContents, attributes, providerOptions, requestContext, thread, autoResumeSuspendedTools } = args;\n\n // The tenant, when there is one, is already stamped on `requestContext` by\n // the channel handler that accepted this message — a host that maps\n // platform senders to Mastra users writes `user` before calling\n // `defaultHandler`, and gating an unlinked sender means not calling it at\n // all. Core dispatches whatever reaches it.\n const session = await this.getSessionForThread(thread, requestContext);\n\n // The session equivalent of the base path's `autoResumeSuspendedTools`:\n // controller runs set `requireToolApproval` from this marker, so on\n // adapters that can't render approval buttons the run auto-approves\n // instead of parking forever on an approval nobody can answer. Tracked\n // outside session state so the controller's `stateSchema` (which would\n // strip or reject an injected key) never sees it.\n const sessionResourceId = thread.resourceId;\n if (autoResumeSuspendedTools) {\n this.autoApproveResourceIds.add(sessionResourceId);\n } else {\n this.autoApproveResourceIds.delete(sessionResourceId);\n }\n\n const result = session.sendSignal({\n content: signalContents,\n ifActive: { attributes },\n ifIdle: { attributes },\n requestContext,\n providerOptions,\n });\n await result.accepted;\n }\n\n /**\n * Resolve an approval-card \"approve\" action against the controller session's\n * parked tool-approval gate. The run engine — awaiting the gate inside its\n * stream-consumer loop — performs the actual resume itself and keeps\n * consuming, so the continuation renders through the output processor.\n */\n protected override async dispatchApproval(args: {\n runId: string;\n toolCallId: string;\n requestContext: RequestContext;\n memory: { thread: string; resource: string };\n }): Promise<void> {\n await this.respondToSessionApproval({ decision: 'approve', ...args });\n }\n\n /**\n * Resolve an approval-card \"deny\" action against the controller session's\n * parked tool-approval gate (see {@link dispatchApproval}).\n */\n protected override async dispatchDecline(args: {\n runId: string;\n toolCallId: string;\n requestContext: RequestContext;\n memory: { thread: string; resource: string };\n }): Promise<void> {\n await this.respondToSessionApproval({ decision: 'decline', ...args });\n }\n\n /**\n * Shared approve/decline path. Never calls the session's internal\n * `approveToolCall`/`declineToolCall` executors directly — the engine parked\n * at the gate owns the resume. `respondToToolApproval` is a silent no-op\n * when nothing is armed or the toolCallId mismatches, so staleness is\n * pre-checked explicitly (an armed gate does not survive process restarts,\n * so restart-recovered approvals are always stale — consistent with the\n * v1 long-lived-server scope).\n */\n private async respondToSessionApproval({\n decision,\n toolCallId,\n requestContext,\n memory,\n }: {\n decision: 'approve' | 'decline';\n toolCallId: string;\n requestContext: RequestContext;\n memory: { thread: string; resource: string };\n }): Promise<void> {\n const session = await this.getSessionForThread({ id: memory.thread, resourceId: memory.resource });\n if (!session.approval.isArmed() || session.approval.getToolCallId() !== toolCallId) {\n this.log(\n 'info',\n `Ignoring stale tool ${decision === 'approve' ? 'approval' : 'denial'} action (no matching parked approval for toolCallId=${toolCallId})`,\n );\n return;\n }\n // The requestContext carries the channel render context, so the resumed\n // stream renders back to the platform through the output processor.\n session.respondToToolApproval({ decision, toolCallId, requestContext });\n }\n\n /**\n * Get-or-create the durable controller session for a mapped channel thread\n * and bind it to that thread. Keyed off the thread's own `resourceId` so\n * pre-existing threads (custom resolveResourceId, or created before this\n * feature) always pass the session's thread-ownership check.\n */\n protected async getSessionForThread(\n thread: Pick<StorageThreadType, 'id' | 'resourceId'>,\n requestContext?: RequestContext,\n ): Promise<Session<any>> {\n const controller = this.requireController();\n const channelResourceId = thread.resourceId;\n // `createSession` is get-or-create keyed by resourceId, so follow-up messages\n // on the same thread reuse the cached session bound to this thread. The\n // dispatch requestContext must flow in: a dynamic workspace factory is\n // resolved once at session creation with THIS context, and it may need the\n // stamped tenant (`user`) to authorize a repo-backed session workspace.\n const session = await controller.createSession({\n resourceId: channelResourceId,\n id: channelResourceId,\n ownerId: controller.id,\n requestContext,\n });\n // Bind the mapped thread. Guard is mandatory: `switch` aborts any active\n // run, so never re-switch when the session is already on this thread.\n if (session.thread.getId() !== thread.id) {\n await session.thread.switch({ threadId: thread.id });\n }\n return session;\n }\n\n private requireController(): AgentController<any> {\n if (!this.controller) {\n throw new Error(\n 'AgentControllerChannels is not bound to an AgentController. Pass it via `channels` in AgentControllerConfig.',\n );\n }\n return this.controller;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;AA+BA,IAAa,0BAAb,cAA6CA,cAAAA,cAAc;CACzD,aAAkD;;;;;;;;;;CAWlD,yCAAiC,IAAI,IAAY;;CAGjD,gBAAgB,YAAwC;EACtD,KAAK,aAAa;CACpB;;;;;;;CAQA,wBAAwB,YAA6B;EACnD,OAAO,KAAK,uBAAuB,IAAI,UAAU;CACnD;;;;;;;;CASA,WAAoB,QAAyC,CAAC;CAE9D,aAA+C;EAC7C,OAAO,KAAK,YAAY,MAAM;CAChC;CAEA,qBAAgD;EAC9C,OAAO,0BAA0B,KAAK,WAAW;CACnD;CAEA,YAAmD;EACjD,OAAO,KAAK,YAAY,UAAU;CACpC;;;;;;CAOA,yBAA4C,MAKE;EAC5C,MAAM,OAAO,MAAM,yBAAyB,IAAI;EAKhD,IAAI,OAAO,SAAS,YAAY,OAAO;EACvC,OAAO,WAAW,KAAK,WAAW;CACpC;;;;;;;;CASA,MAAyB,uBAAuB,MAQ9B;EAChB,MAAM,EAAE,gBAAgB,YAAY,iBAAiB,gBAAgB,QAAQ,6BAA6B;EAO1G,MAAM,UAAU,MAAM,KAAK,oBAAoB,QAAQ,cAAc;EAQrE,MAAM,oBAAoB,OAAO;EACjC,IAAI,0BACF,KAAK,uBAAuB,IAAI,iBAAiB;OAEjD,KAAK,uBAAuB,OAAO,iBAAiB;EAUtD,MAPe,QAAQ,WAAW;GAChC,SAAS;GACT,UAAU,EAAE,WAAW;GACvB,QAAQ,EAAE,WAAW;GACrB;GACA;EACF,CACW,CAAC,CAAC;CACf;;;;;;;CAQA,MAAyB,iBAAiB,MAKxB;EAChB,MAAM,KAAK,yBAAyB;GAAE,UAAU;GAAW,GAAG;EAAK,CAAC;CACtE;;;;;CAMA,MAAyB,gBAAgB,MAKvB;EAChB,MAAM,KAAK,yBAAyB;GAAE,UAAU;GAAW,GAAG;EAAK,CAAC;CACtE;;;;;;;;;;CAWA,MAAc,yBAAyB,EACrC,UACA,YACA,gBACA,UAMgB;EAChB,MAAM,UAAU,MAAM,KAAK,oBAAoB;GAAE,IAAI,OAAO;GAAQ,YAAY,OAAO;EAAS,CAAC;EACjG,IAAI,CAAC,QAAQ,SAAS,QAAQ,KAAK,QAAQ,SAAS,cAAc,MAAM,YAAY;GAClF,KAAK,IACH,QACA,uBAAuB,aAAa,YAAY,aAAa,SAAS,sDAAsD,WAAW,EACzI;GACA;EACF;EAGA,QAAQ,sBAAsB;GAAE;GAAU;GAAY;EAAe,CAAC;CACxE;;;;;;;CAQA,MAAgB,oBACd,QACA,gBACuB;EACvB,MAAM,aAAa,KAAK,kBAAkB;EAC1C,MAAM,oBAAoB,OAAO;EAMjC,MAAM,UAAU,MAAM,WAAW,cAAc;GAC7C,YAAY;GACZ,IAAI;GACJ,SAAS,WAAW;GACpB;EACF,CAAC;EAGD,IAAI,QAAQ,OAAO,MAAM,MAAM,OAAO,IACpC,MAAM,QAAQ,OAAO,OAAO,EAAE,UAAU,OAAO,GAAG,CAAC;EAErD,OAAO;CACT;CAEA,oBAAkD;EAChD,IAAI,CAAC,KAAK,YACR,MAAM,IAAI,MACR,8GACF;EAEF,OAAO,KAAK;CACd;AACF"}