diff --git a/.env.example b/.env.example index 3873c5c..0be2ed8 100644 --- a/.env.example +++ b/.env.example @@ -146,3 +146,9 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope # checked once this holds a value, so leaving it out changes nothing else. # Quote it — the value contains a space. # export AGENT_AUTH_HEADER="Bearer generate-a-long-random-string" + +# -- OpenBot pairing (Optional) -- +# Set when AGENT_URL is OpenBot's /api/delivery/webhooks/opentag/agent and +# AGENT_AUTH_HEADER is "Bearer ". Adds the +# openbot.sender context entry OpenBot requires. See setup.md. +# export OPENBOT_PAIRING=true diff --git a/app/channel-helpers.ts b/app/channel-helpers.ts index db0393e..0b81f16 100644 --- a/app/channel-helpers.ts +++ b/app/channel-helpers.ts @@ -28,10 +28,17 @@ export function promptFromMessage( export function managedRunInput( message: IncomingMessage, conditionalTools: ChannelTool[] = [], + extraContext: ContextEntry[] = [], ) { + const input = platformRunInput( + message.platform, + message.actor, + conditionalTools, + ); return { prompt: promptFromMessage(message), - ...platformRunInput(message.platform, message.actor, conditionalTools), + ...input, + context: [...input.context, ...extraContext], }; } diff --git a/app/channel.test.ts b/app/channel.test.ts index 2e2aa49..c54cd42 100644 --- a/app/channel.test.ts +++ b/app/channel.test.ts @@ -217,7 +217,11 @@ afterEach(async () => { }); function makeChannel( - options: { agent?: FakeAgent; agentDisplayName?: string } = {}, + options: { + agent?: FakeAgent; + agentDisplayName?: string; + openbotPairing?: boolean; + } = {}, ) { const adapter = new FakeAdapter({ platform: "intelligence" }); const stateStore = new MemoryStore(); @@ -227,6 +231,7 @@ function makeChannel( "opentag", agent, options.agentDisplayName, + { openbotPairing: options.openbotPairing }, ); channel.ɵruntime.addAdapter(adapter); channels.push(channel); @@ -760,6 +765,79 @@ describe("createOpenTagChannel", () => { ]); }); + it("tells OpenBot who the turn is from, and where, when paired", async () => { + const { adapter, agent, channel } = makeChannel({ openbotPairing: true }); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "hello", + platform: "slack", + eventId: "Ev0001", + actor: { id: "U1", kind: "human", name: "Ada" }, + identityContext: { + tenant: { id: "T0123" }, + installation: { id: "A0INSTALL" }, + conversation: { id: "D0ABCDEFGH", kind: "im" }, + trigger: "message", + event: { id: "Ev0001" }, + raw: {}, + }, + operation: { + kind: "created", + logicalMessageId: "m1", + revisionId: "m1", + mentioned: true, + }, + }); + + const context = (agent as CapturingAgent).calls[0]?.context ?? []; + // The readable label stays: it is the agent-facing half, and OpenBot reads + // only the structured entry. + expect(context).toContainEqual({ + description: "Requesting slack user", + value: "Ada (slack id U1)", + }); + const senders = context.filter( + ({ description }) => description === "openbot.sender", + ); + expect(senders).toHaveLength(1); + expect(JSON.parse(senders[0]!.value)).toEqual({ + user: "slack:T0123:U1", + event: "Ev0001", + mentioned: true, + conversation: { id: "D0ABCDEFGH", kind: "im" }, + }); + }); + + it("adds nothing for OpenBot when not paired", async () => { + const { adapter, agent, channel } = makeChannel(); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "hello", + platform: "slack", + eventId: "Ev0002", + actor: { id: "U1", kind: "human", name: "Ada" }, + identityContext: { + tenant: { id: "T0123" }, + installation: { id: "A0INSTALL" }, + conversation: { id: "D0ABCDEFGH", kind: "im" }, + trigger: "message", + event: { id: "Ev0002" }, + raw: {}, + }, + }); + + const context = (agent as CapturingAgent).calls[0]?.context ?? []; + expect( + context.some(({ description }) => description === "openbot.sender"), + ).toBe(false); + }); + it("injects managed content parts as the current agent prompt", async () => { const { adapter, agent, channel } = makeChannel(); const parts = [{ type: "text" as const, text: "from content parts" }]; diff --git a/app/channel.tsx b/app/channel.tsx index 99bd573..63b5547 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -15,6 +15,10 @@ import { appCommands } from "./commands/index.js"; import { IssueCard, IssueList, PageList } from "./components/index.js"; import { createAppContext } from "./context/app-context.js"; import { DEFAULT_AGENT_DISPLAY_NAME } from "./env.js"; +import { + createTurnConversations, + openbotSenderContext, +} from "./openbot-sender.js"; import { ConnectAccount } from "./human-in-the-loop/index.js"; import { createConfirmWrite } from "./human-in-the-loop/confirm-write.js"; import { createApprovalDecisions } from "./human-in-the-loop/approval-decisions.js"; @@ -122,7 +126,11 @@ export function createOpenTagChannel( name: string, agent: ChannelAgent, agentDisplayName = DEFAULT_AGENT_DISPLAY_NAME, + options: { openbotPairing?: boolean } = {}, ): Channel { + // Only when paired: unpaired, the Channel keeps the stock `"platform"` + // strategy and every other agent sees exactly the context it always did. + const turns = options.openbotPairing ? createTurnConversations() : undefined; const decisions = createApprovalDecisions(() => { const store = channel.adapters.find( (adapter) => adapter.stateStore, @@ -134,7 +142,7 @@ export function createOpenTagChannel( const channel = createChannel({ name, agent, - identifyUser: "platform", + identifyUser: turns ? turns.identifyUser : "platform", tools: createAppTools(agentDisplayName), context: [...createAppContext(agentDisplayName)], commands: appCommands, @@ -163,7 +171,15 @@ export function createOpenTagChannel( conditionalTools: ChannelTool[], ) => { try { - await thread.runAgent(managedRunInput(message, conditionalTools)); + await thread.runAgent( + managedRunInput( + message, + conditionalTools, + turns + ? openbotSenderContext(message, turns.take(message.eventId)) + : [], + ), + ); } catch (error) { try { await thread.post( diff --git a/app/env.test.ts b/app/env.test.ts index 4bfc5e8..9c70d40 100644 --- a/app/env.test.ts +++ b/app/env.test.ts @@ -222,6 +222,7 @@ describe("readEnvironment", () => { "intelligenceApiUrl", "intelligenceGatewayWsUrl", "learningContainerId", + "openbotPairing", "port", "publicUrl", ]); @@ -246,4 +247,34 @@ describe("parsePort", () => { expect(() => parsePort(raw)).toThrow(`Invalid PORT: "${raw}"`); }, ); + + describe("OPENBOT_PAIRING", () => { + it("is off unless set", () => { + expect(readEnvironment(requiredEnvironment).openbotPairing).toBe(false); + expect( + readEnvironment({ ...requiredEnvironment, OPENBOT_PAIRING: " " }) + .openbotPairing, + ).toBe(false); + }); + + it.each(["true", "TRUE", "1", " true\n"])("is on for %j", (value) => { + expect( + readEnvironment({ ...requiredEnvironment, OPENBOT_PAIRING: value }) + .openbotPairing, + ).toBe(true); + }); + + it.each(["false", "0"])("is off for %j", (value) => { + expect( + readEnvironment({ ...requiredEnvironment, OPENBOT_PAIRING: value }) + .openbotPairing, + ).toBe(false); + }); + + it("refuses a value that is neither, rather than guessing", () => { + expect(() => + readEnvironment({ ...requiredEnvironment, OPENBOT_PAIRING: "yes" }), + ).toThrow('Invalid OPENBOT_PAIRING: "yes"'); + }); + }); }); diff --git a/app/env.ts b/app/env.ts index a20fc5a..18eb76b 100644 --- a/app/env.ts +++ b/app/env.ts @@ -25,6 +25,11 @@ export interface AppEnvironment { * a second thing to configure. */ publicUrl?: string; + /** + * OpenBot is the agent at `AGENT_URL`, so each turn also carries the + * structured `openbot.sender` entry OpenBot requires (`OPENBOT_PAIRING`). + */ + openbotPairing?: boolean; } /** @@ -55,6 +60,14 @@ function required(env: NodeJS.ProcessEnv, name: string): string { return value; } +/** `true`/`1` turn a flag on; unset or blank leaves it off; anything else is a typo. */ +function readFlag(env: NodeJS.ProcessEnv, name: string): boolean { + const value = env[name]?.trim().toLowerCase(); + if (!value || value === "false" || value === "0") return false; + if (value === "true" || value === "1") return true; + throw new Error(`Invalid ${name}: "${env[name]}". Use true or false.`); +} + export function parsePort( raw: string | undefined, defaultPort = 3000, @@ -103,5 +116,6 @@ export function readEnvironment( // default every other variable here falls back to. port: parsePort(env.PORT?.trim() || undefined), publicUrl: readPublicUrl(env), + openbotPairing: readFlag(env, "OPENBOT_PAIRING"), }; } diff --git a/app/index.ts b/app/index.ts index 5a52af1..ff551ce 100644 --- a/app/index.ts +++ b/app/index.ts @@ -59,6 +59,7 @@ export function createOpenTagApplication( environment.channelName, agent, environment.agentDisplayName, + { openbotPairing: environment.openbotPairing }, ), ]; const runtimeHost = createOpenTagRuntime({ environment, channels }); diff --git a/app/openbot-sender.test.ts b/app/openbot-sender.test.ts new file mode 100644 index 0000000..59516c0 --- /dev/null +++ b/app/openbot-sender.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import type { ChannelIdentityContext } from "@copilotkit/channels"; +import { + OPENBOT_SENDER_CONTEXT, + createTurnConversations, + openbotSenderContext, +} from "./openbot-sender.js"; + +function identity( + overrides: Partial<{ + provider: string; + tenant: string; + actor: { id: string; kind: string; name?: string; handle?: string }; + conversation: { id: string; kind?: string }; + event: string; + }> = {}, +): ChannelIdentityContext { + return { + provider: overrides.provider ?? "slack", + tenant: { id: overrides.tenant ?? "T0123" }, + installation: { id: "inst" }, + actor: overrides.actor ?? { id: "U42", kind: "human", name: "Ada" }, + conversation: overrides.conversation ?? { id: "D0ABCDEFGH", kind: "im" }, + trigger: "message", + event: { id: overrides.event ?? "Ev1" }, + } as unknown as ChannelIdentityContext; +} + +describe("createTurnConversations", () => { + it("names a person exactly as the Channel's platform strategy does", async () => { + const turns = createTurnConversations(); + expect(await turns.identifyUser(identity())).toEqual({ + id: "slack:T0123:U42", + name: "Ada", + }); + }); + + it("falls back to the handle, then the id, for the name", async () => { + const turns = createTurnConversations(); + expect( + await turns.identifyUser( + identity({ actor: { id: "U1", kind: "human", handle: "ada" } }), + ), + ).toEqual({ id: "slack:T0123:U1", name: "ada" }); + expect( + await turns.identifyUser( + identity({ actor: { id: "U2", kind: "human" } }), + ), + ).toEqual({ id: "slack:T0123:U2", name: "U2" }); + }); + + it("names nobody for anything that is not a person", async () => { + const turns = createTurnConversations(); + expect( + await turns.identifyUser( + identity({ actor: { id: "B1", kind: "bot", name: "Some bot" } }), + ), + ).toBeNull(); + }); + + it("hands back the conversation of the turn it was told about, once", async () => { + const turns = createTurnConversations(); + await turns.identifyUser( + identity({ event: "Ev7", conversation: { id: "C0XYZ", kind: "channel" } }), + ); + expect(turns.take("Ev7")).toEqual({ id: "C0XYZ", kind: "channel" }); + expect(turns.take("Ev7")).toBeUndefined(); + }); + + it("knows nothing about a turn without an event id", async () => { + const turns = createTurnConversations(); + expect(turns.take(undefined)).toBeUndefined(); + }); + + it("forgets the oldest turns past its limit, so unanswered turns cannot pile up", async () => { + const turns = createTurnConversations(2); + await turns.identifyUser(identity({ event: "a" })); + await turns.identifyUser(identity({ event: "b" })); + await turns.identifyUser(identity({ event: "c" })); + expect(turns.take("a")).toBeUndefined(); + expect(turns.take("b")).toBeDefined(); + expect(turns.take("c")).toBeDefined(); + }); +}); + +describe("openbotSenderContext", () => { + const message = { + user: { id: "slack:T0123:U42", name: "Ada" }, + eventId: "Ev1", + operation: { mentioned: true }, + }; + + it("is one structured entry under the name OpenBot reads", () => { + const entries = openbotSenderContext(message, { + id: "D0ABCDEFGH", + kind: "im", + }); + expect(entries).toHaveLength(1); + expect(entries[0]?.description).toBe(OPENBOT_SENDER_CONTEXT); + expect(OPENBOT_SENDER_CONTEXT).toBe("openbot.sender"); + expect(JSON.parse(entries[0]!.value)).toEqual({ + user: "slack:T0123:U42", + event: "Ev1", + mentioned: true, + conversation: { id: "D0ABCDEFGH", kind: "im" }, + }); + }); + + it("leaves the conversation out rather than inventing one", () => { + const [entry] = openbotSenderContext( + { ...message, operation: { mentioned: false } }, + undefined, + ); + expect(JSON.parse(entry!.value)).toEqual({ + user: "slack:T0123:U42", + event: "Ev1", + mentioned: false, + }); + }); + + it("vouches for nobody when the Channel could not name a person", () => { + expect( + openbotSenderContext({ ...message, user: null }, undefined), + ).toEqual([]); + }); +}); diff --git a/app/openbot-sender.ts b/app/openbot-sender.ts new file mode 100644 index 0000000..14fce3c --- /dev/null +++ b/app/openbot-sender.ts @@ -0,0 +1,94 @@ +import type { + ApplicationUser, + ChannelConversation, + ChannelIdentityContext, + ContextEntry, + IncomingMessage, +} from "@copilotkit/channels"; + +/** + * The context entry OpenBot reads to learn who a turn is from, when OpenBot is + * the agent at `AGENT_URL` (`OPENBOT_PAIRING`). + * + * OpenBot accepts exactly one entry under this name and refuses the turn + * without it, so the human-readable sender label stays beside it for every + * other agent. The shape is OpenBot's `senderFromContext` + * (`server/src/delivery/opentag.ts`): `user` is the Channel's own + * `provider:tenant:actor` id, and the conversation is what decides whether a + * reply may draw on the person's private context and whether a link code may + * be redeemed, so it is sent as the provider reported it or not at all. + */ +export const OPENBOT_SENDER_CONTEXT = "openbot.sender"; + +type TurnConversation = { id: string; kind?: string }; + +/** + * Remembers each turn's conversation between identity resolution and the + * handler. + * + * The managed Channel tells `identifyUser` which conversation a turn is in and + * then hands the handler a message without it. `identifyUser` therefore stands + * in for the `"platform"` strategy, returning the same person, and notes the + * conversation under the turn's event id for the handler to take. Turns the + * handler drops (a bot, an edit) are never taken, so the oldest are forgotten + * past `limit`. + */ +export function createTurnConversations(limit = 1_000) { + const conversations = new Map(); + + return { + async identifyUser( + context: ChannelIdentityContext, + ): Promise { + const eventId = context.event.id; + if (eventId && context.conversation.id) { + conversations.delete(eventId); + conversations.set(eventId, conversationOf(context.conversation)); + while (conversations.size > limit) { + const oldest = conversations.keys().next().value; + if (oldest === undefined) break; + conversations.delete(oldest); + } + } + // The `"platform"` strategy, unchanged: OpenTag's per-person identity and + // OpenBot's identity links are both keyed on this id. + if (context.actor.kind !== "human") return null; + return { + id: [context.provider, context.tenant.id, context.actor.id].join(":"), + name: context.actor.name ?? context.actor.handle ?? context.actor.id, + }; + }, + + take(eventId: string | undefined): TurnConversation | undefined { + if (!eventId) return undefined; + const conversation = conversations.get(eventId); + conversations.delete(eventId); + return conversation; + }, + }; +} + +function conversationOf(conversation: ChannelConversation): TurnConversation { + return conversation.kind + ? { id: conversation.id, kind: conversation.kind } + : { id: conversation.id }; +} + +export type TurnConversations = ReturnType; + +/** The `openbot.sender` entry for one turn, or none when no person was named. */ +export function openbotSenderContext( + message: Pick & { + operation?: { mentioned: boolean }; + }, + conversation: TurnConversation | undefined, +): ContextEntry[] { + if (!message.user) return []; + const sender = { + user: message.user.id, + ...(message.eventId ? { event: message.eventId } : {}), + mentioned: message.operation?.mentioned === true, + ...(conversation ? { conversation } : {}), + }; + return [{ description: OPENBOT_SENDER_CONTEXT, value: JSON.stringify(sender) }]; +} diff --git a/setup.md b/setup.md index 54765a8..7f5ca88 100644 --- a/setup.md +++ b/setup.md @@ -159,6 +159,7 @@ The AG-UI endpoint is `http://localhost:8123/`; `/health` reports the | `INTELLIGENCE_GATEWAY_WS_URL` | No | Defaults to `wss://realtime.intelligence.copilotkit.ai` | | `AGENT_AUTH_HEADER` | No | Shared secret between runtime and agent. Sent as `Authorization`; the agent checks it when set to a non-empty value, and **requires** a non-empty one before minting a Composio or Arcade connect link | | `PUBLIC_URL` | For personal Arcade only | Where a browser can reach this service. Falls back to `RAILWAY_PUBLIC_DOMAIN`. Composio deployments and shared-only Arcade need none | +| `OPENBOT_PAIRING` | No | `true` when `AGENT_URL` points at OpenBot. Adds the structured `openbot.sender` context entry OpenBot requires on every turn; see [Pairing with OpenBot](#pairing-with-openbot). Defaults to `false` | | `PORT` | No | Channel HTTP port; defaults to `3000` | | `LOG_LEVEL` | No | Defaults to `error`. Channel lifecycle breadcrumbs are emitted at `warn`, so set `warn` or lower to see them | | `MERMAID_URL` | No | Overrides the Mermaid browser bundle URL used by diagram rendering | @@ -309,6 +310,36 @@ Before a Linear or Notion mutation reaches MCP, a Python interceptor emits graph with the user's decision. The MCP handler runs only after approval. Reads and UI rendering are never gated. +## Pairing with OpenBot + +[OpenBot](https://github.com/CopilotKit/openbot) can be the agent behind this +Channel, so people talk to their own OpenBot Bot from Slack or Teams. OpenBot +holds no Slack app of its own; it answers OpenTag's AG-UI requests and decides +whose Bot a turn reaches from who sent it. + +Point the runtime at OpenBot and turn the pairing on: + +```bash +export AGENT_URL=https://openbot.example.com/api/delivery/webhooks/opentag/agent +export AGENT_AUTH_HEADER="Bearer " +export OPENBOT_PAIRING=true +``` + +The Python agent is not used in this setup. With the pairing on, each turn +carries one extra context entry, `openbot.sender`, a JSON object naming the +sender as the Channel's `provider:tenant:actor` user id, the platform event id, +whether the app was mentioned, and the conversation (`{ id, kind }`) as +Intelligence reported it. OpenBot reads the conversation to tell a direct +message from a shared one: link codes are only redeemed in a direct message, +and an answer that may draw on someone's private context is only posted there. + +A person links their Slack account from their Bot's page in OpenBot, which +shows a one-time `link ` message to send to this app in a direct message. + +Not yet implemented here: OpenBot's proactive sends (`POST /openbot/deliver`) +and channel-membership checks (`POST /openbot/membership`). Until they are, +replies OpenBot routes outside the live turn, and Slack triggers, do not work. + ## Optional sources ### Public web research