Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <OpenBot's OPENTAG_SHARED_SECRET>". Adds the
# openbot.sender context entry OpenBot requires. See setup.md.
# export OPENBOT_PAIRING=true
9 changes: 8 additions & 1 deletion app/channel-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
};
}

Expand Down
80 changes: 79 additions & 1 deletion app/channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -227,6 +231,7 @@ function makeChannel(
"opentag",
agent,
options.agentDisplayName,
{ openbotPairing: options.openbotPairing },
);
channel.ɵruntime.addAdapter(adapter);
channels.push(channel);
Expand Down Expand Up @@ -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" }];
Expand Down
20 changes: 18 additions & 2 deletions app/channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
31 changes: 31 additions & 0 deletions app/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ describe("readEnvironment", () => {
"intelligenceApiUrl",
"intelligenceGatewayWsUrl",
"learningContainerId",
"openbotPairing",
"port",
"publicUrl",
]);
Expand All @@ -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"');
});
});
});
14 changes: 14 additions & 0 deletions app/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
};
}
1 change: 1 addition & 0 deletions app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function createOpenTagApplication(
environment.channelName,
agent,
environment.agentDisplayName,
{ openbotPairing: environment.openbotPairing },
),
];
const runtimeHost = createOpenTagRuntime({ environment, channels });
Expand Down
126 changes: 126 additions & 0 deletions app/openbot-sender.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading
Loading