UNPKG

eve

Version:

Filesystem-first framework for durable backend AI agents that run anywhere.

486 lines (485 loc) 23.8 kB
import type { CrossChannelReceiveOptions } from "#channel/cross-channel-receive.js"; import type { Session } from "#channel/session.js"; import type { CancelTurnResult, SessionAuthContext } from "#channel/types.js"; import type { CardElement } from "#compiled/chat/index.js"; import type { SessionContext } from "#public/definitions/callback-context.js"; import type { ChannelSessionOps } from "#public/definitions/channel.js"; import type { HandleMessageStreamEvent } from "#protocol/message.js"; import { type SlackBotToken, type SlackHandle, type SlackThread, type SlackWorkspaceHandle } from "#public/channels/slack/api.js"; import { type SlackEvent, type SlackEventEnvelope, type SlackMessage } from "#public/channels/slack/inbound.js"; import { type LoadThreadContextMessagesOptions } from "#public/channels/slack/thread.js"; import { type UploadPolicyInput } from "#public/channels/upload-policy.js"; import { type SlackWebhookVerifier } from "#public/channels/slack/verify.js"; import { type Channel } from "#public/definitions/channel.js"; type EventData<T extends HandleMessageStreamEvent["type"]> = Extract<HandleMessageStreamEvent, { type: T; }> extends { data: infer D; } ? D : undefined; /** * Base Slack context for inbound webhook handlers. These hooks run before the * runtime hydrates session state, so `state` is absent here. * {@link thread} owns thread-scoped operations (`post`, `postEphemeral`, * `startTyping`, `refresh`, `listParticipants`, `recentMessages`, * `mentionUser`); {@link slack} owns Slack identity (`channelId`, `threadTs`, * `teamId`) plus the raw-API escape hatch (`request`, `uploadFiles`). */ export interface SlackContext { readonly thread: SlackThread; readonly slack: SlackHandle; } /** * {@link SlackContext} plus the persisted per-session * {@link SlackChannelState}. Built by the channel's `context()` hook and * extended by {@link SlackEventContext}. */ export interface SlackChannelContext extends SlackContext { state: SlackChannelState; } /** * Slack context handed to `events[type]` handlers. Extends * {@link SlackChannelContext} (`thread`, `slack`, hydrated `state`) with * session operations ({@link ChannelSessionOps}). Unlike the pre-dispatch * {@link SlackContext}, `state` is hydrated here. */ export interface SlackEventContext extends SlackChannelContext, ChannelSessionOps { } export type { SlackApiResponse, SlackBotToken, SlackHandle, SlackThread, SlackWorkspaceHandle, } from "#public/channels/slack/api.js"; export type { SlackWebhookVerifier } from "#public/channels/slack/verify.js"; type SlackEventHandler<T extends HandleMessageStreamEvent["type"]> = (data: EventData<T>, channel: SlackEventContext, ctx: SessionContext) => void | Promise<void>; /** * Delivery surface handed to `authorization.required` overrides. The * connection challenge is a credential: anyone who completes the sign-in * binds their identity to this session's connection. So the only * delivery capabilities here are private ones, an ephemeral reply in the * thread or a direct message. There is deliberately no public `post`, * no raw `slack.request` escape hatch, and no full thread handle. An * override can change the words, not the audience. */ export interface SlackAuthorizationEventContext { /** * Ephemeral message in the current thread, visible only to `userId`. * Same contract as {@link SlackThread.postEphemeral}. */ readonly postEphemeral: SlackThread["postEphemeral"]; /** * Direct message to `userId`'s IM conversation with the bot. Same * contract as {@link SlackThread.postDirectMessage} (requires the * `im:write` scope). */ readonly postDirectMessage: SlackThread["postDirectMessage"]; /** * Hydrated per-session channel state — read `triggeringUserId` to * target the delivery. */ readonly state: SlackChannelState; } /** * Signature of an `authorization.required` override. Unlike every other * event handler, it receives {@link SlackAuthorizationEventContext} * instead of the full {@link SlackEventContext} — see the context type * for why. */ export type SlackAuthorizationRequiredHandler = (data: EventData<"authorization.required">, channel: SlackAuthorizationEventContext, ctx: SessionContext) => void | Promise<void>; type SlackSessionFailedHandler = (data: EventData<"session.failed">, channel: SlackEventContext) => void | Promise<void>; /** * JSON-serializable per-session state, stored verbatim across workflow * step boundaries. Anything written here must round-trip through * `JSON.stringify` / `JSON.parse`. */ export interface SlackChannelState { /** Slack channel id seeded by the inbound mention. */ channelId: string | null; /** Slack thread root ts. */ threadTs: string | null; /** Slack team id, when the inbound event carried one. */ teamId: string | null; /** * Slack user id of the actor that triggered the current session/turn. * Captured on every inbound mention so default handlers (e.g. * `authorization.required`) can target ephemeral feedback at the right * user without re-parsing the mention payload. */ triggeringUserId?: string | null; /** * Buffered text from a `message.completed` event whose `finishReason` * was `"tool-calls"`. The default `actions.requested` handler uses the * first non-empty line as the next typing indicator, surfacing the * model's pre-tool narration instead of the action label. Cleared at * `turn.started` and after use. */ pendingToolCallMessage?: string | null; /** * Last reasoning-derived typing indicator sent by the default * `reasoning.appended` handler. Used to surface substantial progressive * extensions immediately while throttling smaller streamed deltas. */ lastReasoningTypingAtMs?: number | null; lastReasoningTypingStatus?: string | null; /** * Connection name to Slack message ts. Each entry is the public * link-free status post created by the default * `authorization.required` handler; the matching * `authorization.completed` handler edits it in place to surface the * resolution outcome. */ pendingAuthMessageTs?: Record<string, string>; } /** * Per-session metadata attached to tracing spans, projected by the * channel's `metadata(state)` hook. Fields mirror the inbound mention * (channel, team, thread, triggering user) and are `null` until an inbound * event seeds them. Open-ended (`Record<string, unknown>`) so deployments * can attach extra span attributes. */ export interface SlackInstrumentationMetadata extends Record<string, unknown> { readonly channelId: string | null; readonly teamId: string | null; readonly threadTs: string | null; readonly triggeringUserId: string | null; } /** * Slack channel credentials: outbound bot token plus inbound webhook * verification. Any field may be omitted to fall back to its env-var / * signing-secret default. */ export interface SlackChannelCredentials { /** * Bot token for all outbound Slack Web API calls. Falls back to * `process.env.SLACK_BOT_TOKEN` when omitted. */ readonly botToken?: SlackBotToken; /** * Signing secret used to HMAC-verify inbound webhook requests. Falls * back to `process.env.SLACK_SIGNING_SECRET` when neither this nor * `webhookVerifier` is supplied. */ readonly signingSecret?: string; /** * Custom inbound webhook verifier. When supplied, eve skips the * `SLACK_SIGNING_SECRET` fallback and delegates to it. Typically set by * integrations (e.g. Connect) that authenticate webhooks out-of-band. */ readonly webhookVerifier?: SlackWebhookVerifier; } /** Target accepted by `receive(slack, { target })` for proactive sessions. */ export interface SlackReceiveTarget { readonly channelId: string; readonly threadTs?: string; /** * Optional message posted into the Slack channel before the agent runs. * The post becomes the thread root and the first turn is threaded under * it, giving cross-channel handoffs a visible context anchor. Mutually * exclusive with {@link threadTs}. */ readonly initialMessage?: SlackInitialMessage; } /** * Pre-agent post issued by `slackChannel().receive` when the caller * provides `target.initialMessage`. Mirrors `ctx.thread.post`'s card * variant so the same `Card({...})` construction can be reused. */ export interface SlackInitialMessage { readonly card: CardElement; readonly fallbackText?: string; } /** * One imperative turn start requested by a generic Slack event handler. * The schedule API's `receive(slack, options)` payload with the Slack * channel already bound by the inbound webhook. */ export type SlackEventReceiveOptions = CrossChannelReceiveOptions<SlackReceiveTarget>; /** * Starts a session on the current Slack channel from `onEvent`. Call it zero, * one, or many times; each invocation returns the resulting session. */ export type SlackEventReceiveFn = (options: SlackEventReceiveOptions) => Promise<Session>; /** * Slack thread identity used by workspace-scoped inbound helpers. */ export interface SlackSessionTarget { readonly channelId: string; readonly threadTs: string; } /** * Options for cancelling the turn bound to a message or interaction context. * `turnId` guards against a stale request cancelling a newer turn. */ export interface SlackCancelOptions { readonly turnId?: string; } /** * Target and optional stale-turn guard accepted by `onEvent`'s cancellation * helper. */ export interface SlackEventCancelOptions extends SlackSessionTarget, SlackCancelOptions { } /** * Imperative surface handed to `slackChannel({ onEvent })`. Generic Events API * payloads are not necessarily tied to one thread, so the context exposes a * workspace API handle plus a Slack-bound `receive` function rather than the * thread-scoped {@link SlackContext} used by message handlers. */ export interface SlackInboundEventContext { /** * Cancels the active turn for a Slack thread. Both `"accepted"` and * `"no_active_turn"` are successful outcomes. */ readonly cancel: (options: SlackEventCancelOptions) => Promise<CancelTurnResult>; /** The complete signed Events API callback envelope. */ readonly envelope: SlackEventEnvelope; /** Starts a turn on this Slack channel using the proactive receive contract. */ readonly receive: SlackEventReceiveFn; /** Resolves the active eve session for a Slack channel thread. */ readonly resolveActiveSession: (target: SlackSessionTarget) => Promise<{ readonly sessionId: string; } | undefined>; /** Workspace-scoped Slack identity and raw Web API escape hatch. */ readonly slack: SlackWorkspaceHandle; /** Keeps detached handler work alive after the Slack webhook is acknowledged. */ readonly waitUntil: (task: Promise<unknown>) => void; } /** * Message-scoped context handed to `onMessage`, `onAppMention`, and * `onDirectMessage`. */ export interface SlackInboundMessageContext extends SlackContext { /** * Cancels the active turn in this message's thread. Both `"accepted"` and * `"no_active_turn"` are successful outcomes. */ cancel(options?: SlackCancelOptions): Promise<CancelTurnResult>; /** Returns whether this message belongs to a thread with an active eve session. */ isSubscribed(): Promise<boolean>; /** Returns whether the inbound event explicitly mentions this bot. */ isBotMentioned(): boolean; } /** Interaction-scoped context handed to `slackChannel({ onInteraction })`. */ export interface SlackInteractionContext extends SlackContext { /** * Cancels the active turn in the interaction's thread. Both `"accepted"` and * `"no_active_turn"` are successful outcomes. */ cancel(options?: SlackCancelOptions): Promise<CancelTurnResult>; } export interface SlackInteractionAction { readonly actionId: string; readonly value?: string; readonly blockId?: string; /** * `selected_option.value` for radio / select / external_select * widgets. `undefined` for buttons and multi-select widgets. */ readonly selectedOptionValue?: string; /** * `ts` of the Slack message hosting the clicked component. Required to * update that message in place via `chat.update`, since `ctx.slack.threadTs` * resolves to the thread root (not the clicked message) for components * inside thread replies. */ readonly messageTs?: string; /** * Display label of the clicked widget: `text.text` for buttons, * `selected_option.text.text` for radio/static_select. Renders the * "answered" card without re-fetching the original request. */ readonly label?: string; /** * Slack actor who triggered the interaction, letting `onInteraction` * handlers attribute resolutions back to the clicker without re-parsing * the raw payload. Always present, since Slack requires `user` on every * `block_actions` payload. */ readonly user: SlackInteractionUser; } /** Slack actor on {@link SlackInteractionAction.user}, mirroring `body.user`. */ export interface SlackInteractionUser { readonly id: string; /** Modern canonical display handle. */ readonly username?: string; /** Legacy display handle, kept for older workspaces. */ readonly name?: string; } /** * Result of an `onAppMention` or `onDirectMessage` callback. Return an * object (auth may be `null`) to dispatch a turn, or `null` to drop the * inbound message. `context` strings are appended as user messages to * session history before the delivery message. */ export type SlackMentionResult = { readonly auth: SessionAuthContext | null; readonly context?: readonly string[]; } | null; export type SlackMentionResultOrPromise = SlackMentionResult | Promise<SlackMentionResult>; /** * Alias of {@link SlackMentionResult} for the `onDirectMessage` signature, * so DM handlers do not read in terms of "mention". */ export type SlackInboundResult = SlackMentionResult; /** {@link SlackInboundResult}, or a promise resolving to one. */ export type SlackInboundResultOrPromise = SlackMentionResultOrPromise; /** * Per-event Slack handlers keyed by harness stream-event type, passed to * `slackChannel({ events })`. Each key is optional; supplying one replaces * only that event's built-in default (see {@link defaultEvents}). Handlers * receive the event data, the {@link SlackEventContext}, and the session * {@link SessionContext}; `session.failed` receives only data and context. */ export interface SlackChannelEvents { readonly "turn.started"?: SlackEventHandler<"turn.started">; readonly "actions.requested"?: SlackEventHandler<"actions.requested">; readonly "action.result"?: SlackEventHandler<"action.result">; readonly "message.completed"?: SlackEventHandler<"message.completed">; readonly "message.appended"?: SlackEventHandler<"message.appended">; readonly "reasoning.appended"?: SlackEventHandler<"reasoning.appended">; readonly "reasoning.completed"?: SlackEventHandler<"reasoning.completed">; readonly "input.requested"?: SlackEventHandler<"input.requested">; readonly "turn.failed"?: SlackEventHandler<"turn.failed">; readonly "turn.completed"?: SlackEventHandler<"turn.completed">; readonly "turn.cancelled"?: SlackEventHandler<"turn.cancelled">; readonly "session.failed"?: SlackSessionFailedHandler; readonly "session.completed"?: SlackEventHandler<"session.completed">; readonly "session.waiting"?: SlackEventHandler<"session.waiting">; /** * Override receives {@link SlackAuthorizationEventContext}, a * private-delivery context (ephemeral or DM), not the full * {@link SlackEventContext}. The challenge is a credential, so a * public post is not expressible here. */ readonly "authorization.required"?: SlackAuthorizationRequiredHandler; readonly "authorization.completed"?: SlackEventHandler<"authorization.completed">; } /** * Full-context variant of {@link SlackChannelEvents} consumed by the * channel internals. The framework's default `authorization.required` * handler keeps the full {@link SlackEventContext} because it owns the * public link-free status while user overrides remain private-only. The * factory adapts user overrides into this shape with * {@link constrainAuthorizationRequired}. */ export interface SlackChannelInternalEvents extends Omit<SlackChannelEvents, "authorization.required"> { readonly "authorization.required"?: SlackEventHandler<"authorization.required">; } export interface SlackChannelConfig { readonly credentials?: SlackChannelCredentials; readonly botName?: string; /** Override the default webhook route path (`/eve/v1/slack`). */ readonly route?: string; /** * Inbound upload policy applied to file attachments before they reach * the harness. Violating attachments are dropped with a warning so the * mention's text portion still gets delivered. Pass `"disabled"` to * reject every attachment. Defaults to the framework's 25 MB cap with * unrestricted media types. */ readonly uploadPolicy?: UploadPolicyInput; /** * Adds earlier replies from the current Slack thread to each triggering * turn. Messages are rendered with their Slack sender ids attached so a * multi-user transcript retains unambiguous speaker attribution. Omit this * option to avoid fetching thread history. */ readonly threadContext?: LoadThreadContextMessagesOptions; /** * Handles human-authored Slack messages. Specialized `onAppMention` and * `onDirectMessage` handlers take precedence for their event types. Other * channel messages are ignored when this hook is omitted. */ onMessage?(ctx: SlackInboundMessageContext, message: SlackMessage): SlackInboundResultOrPromise; /** * Invoked when a Slack `app_mention` event arrives (only `app_mention`; * other event types are ignored). Decides whether to dispatch and with * what auth, and may run pre-dispatch side effects (e.g. * `ctx.thread.startTyping("Thinking...")`) on the inbound webhook side * before the runtime cold-starts. * * Return `{ auth }` to dispatch with that session auth context, or `null` * to drop the mention. May be sync or async; the result is awaited before * dispatching. Thrown errors are caught and logged and the mention is * dropped; wrap best-effort side effects in `try/catch` to keep them * non-fatal. Defaults to a workspace-scoped auth derivation that posts a * `"Thinking..."` typing indicator; replacing this replaces both. */ onAppMention?(ctx: SlackInboundMessageContext, message: SlackMessage): SlackMentionResultOrPromise; /** * Invoked on a direct message: a Slack `message` event with * `channel_type: "im"`. Subtype messages (edits, deletes, joins, etc.) * and bot messages (`bot_id` set, including the bot's own replies) are * filtered out first, so handlers only see plain user-authored DMs. * Decides whether to dispatch and with what auth, and may run * pre-dispatch side effects on the inbound webhook side before cold-start. * * Return `{ auth }` to dispatch with that session auth context, or `null` * to drop the message. May be sync or async; the result is awaited before * dispatching. Thrown errors are caught and logged and the message is * dropped; wrap best-effort side effects in `try/catch` to keep them * non-fatal. Defaults to a workspace-scoped auth derivation that posts a * `"Thinking..."` typing indicator; replacing this replaces both. * Requires the bot's Slack app to subscribe to `message.im` with the * `im:history` scope. */ onDirectMessage?(ctx: SlackInboundMessageContext, message: SlackMessage): SlackInboundResultOrPromise; /** * Fallback handler for signed Slack Events API callbacks. An authored * `onAppMention` or `onDirectMessage` takes precedence for events accepted * by that specialized handler; otherwise the raw event arrives here. When * neither a specialized handler nor `onEvent` is authored, mentions and DMs * retain their built-in defaults and other event types are ignored. * * The handler owns control flow. Call `ctx.receive(...)` zero, one, or many * times to start turns on Slack, and use `ctx.waitUntil(...)` for detached * work. The return value is ignored. Runs after the webhook has been * acknowledged through the host's `waitUntil` mechanism. Errors are caught * and logged and never fall through to another handler. * * URL verification, slash commands, and interactive payloads are not Events * API callbacks and do not reach this handler. */ onEvent?(ctx: SlackInboundEventContext, event: SlackEvent): void | Promise<void>; /** * Handler for Slack `block_actions` interactive callbacks (button * clicks, select changes, etc.) **not** consumed by the framework's * HITL pipeline. Slack POSTs interactive payloads to the same webhook * route as mentions; the framework decodes them, routes any action whose * `action_id` starts with `eve_input:` to the runtime as an HITL * response (resuming a paused session), and forwards everything else * here, one invocation per non-HITL action. * * Runs on the inbound webhook side via `waitUntil()`, so the channel * returns `200 OK` immediately. Errors are caught and logged; they do * not affect the webhook response or sibling invocations. * * The `SlackContext` here is rebuilt from the interaction payload * (channel id, thread ts, team id), **not** the persisted thread state * used by event handlers. Use `ctx.slack.request(...)` for arbitrary * Slack Web API calls and `action.messageTs` to target `chat.update`. */ onInteraction?(action: SlackInteractionAction, ctx: SlackInteractionContext): void | Promise<void>; readonly events?: SlackChannelEvents; } /** * Concrete return type of {@link slackChannel}. Named so consumers can * default-export a `slackChannel(...)` call under `declaration: true` * without TypeScript emitting an internal path for `Channel`. */ export interface SlackChannel extends Channel<SlackChannelState, SlackReceiveTarget, SlackInstrumentationMetadata> { } /** * Slack channel factory. Wires up the webhook route, mention dispatch, * interaction handling, and a baseline set of typing / error / * connection-auth event handlers. Defaults apply per field: pass * `onAppMention` to fully replace the default mention pipeline (auth * derivation plus `"Thinking..."` typing), or an `events[type]` handler to * replace only that one event. When `onEvent` is authored it becomes the * fallback ahead of unsupplied mention and DM defaults; otherwise unsupplied * fields keep their defaults. */ export declare function slackChannel(config?: SlackChannelConfig): SlackChannel; /** * Adapts a user-supplied `authorization.required` override to the full * internal event signature while handing it only the private-delivery * surface ({@link SlackAuthorizationEventContext}). Override code never * receives `thread.post` or the raw `slack.request` escape hatch, so the * challenge it renders cannot be addressed to the shared thread. */ export declare function constrainAuthorizationRequired(handler: SlackAuthorizationRequiredHandler): NonNullable<SlackChannelInternalEvents["authorization.required"]>;