Skip to content
Open
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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai
INTELLIGENCE_API_KEY=
COPILOTKIT_LICENSE_TOKEN=

# Managed Slack uses this same Intelligence project. Sign the CLI in, select this existing project,
# then run the guided setup and choose Channel name `openbot` and Slack:
#
# npx copilotkit@latest login
# npx copilotkit@latest project select
# npx copilotkit@latest channels setup
#
# The setup CLI may temporarily read Slack attachment credentials from an ignored local .env, the
# shell, or a secret manager. Remove or unset those bootstrap copies before starting OpenBot.
# Intelligence owns them after attachment; the OpenBot runtime reads no Slack credential, so this
# example intentionally contains no Slack credential placeholders. See docs/slack.md.

# Single-workspace bridge for managed deliveries that omit canonical Slack tenant metadata. Set
# this to the workspace/team ID attached to this Channel. A conflicting known tenant is always
# rejected. Leave it unset when Channels supplies canonical tenant metadata for every delivery.
# OPENBOT_SLACK_TENANT_ID=T0123456789

# How long a Bot's stream may say nothing before this deployment gives up on the turn, in
# milliseconds. A Bot is any AG-UI endpoint, which means it will be redeployed mid-answer, its own
# upstream will time out, and it will sometimes accept a connection and then write nothing at all.
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ app/.tanstack/
# it is what makes that fetch reproducible, and ignoring it meant every build resolved the dependency
# afresh, so CI and a customer install could take different subchart versions with no diff to show it.
charts/*/charts/

# Added by CopilotKit CLI: the selected Intelligence project, the declared Channel, and the
# credentials its guided setup writes. All three are one deployment's, not the template's.
.copilotkit/
171 changes: 161 additions & 10 deletions agent-computer/src/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export type ControlState = {
* doing, with the reason the Bot gave, written for whoever asked and rendered to whoever looked.
*/
requestedAt?: string;
/** Opaque generation of the pending help request, used only for conditional cancellation. */
helpRequestId?: string;
/**
* A secret the Bot is waiting for, described by its label only.
*
Expand All @@ -47,6 +49,25 @@ export type ControlState = {
*/
secretRef?: string;
secretSnapshotId?: number;
/** When this exact secret generation was requested, so it expires like a help request. */
secretRequestedAt?: string;
/** Opaque generation of the pending secret request, used only for conditional cancellation. */
secretRequestId?: string;
};

export type AssistanceStatus =
| "pending"
| "human"
| "completed"
| "expired"
| "cancelled"
| "superseded"
| "unknown";

export type AssistanceCancellationResult = {
cancelled: boolean;
state: ControlState;
status: AssistanceStatus;
};

/** Refusal because a person is driving. Distinct from a failure, so the Bot can be told to wait. */
Expand Down Expand Up @@ -96,6 +117,66 @@ export function createControl(
since: now(),
requested: false,
};
const terminalAssistance = new Map<string, AssistanceStatus>();
let humanAssistanceId: string | undefined;

const remember = (
requestId: string | undefined,
status: AssistanceStatus,
) => {
if (!requestId) return;
terminalAssistance.delete(requestId);
terminalAssistance.set(requestId, status);
while (terminalAssistance.size > 128) {
const oldest = terminalAssistance.keys().next().value;
if (oldest === undefined) break;
terminalAssistance.delete(oldest);
}
};

const expirePending = () => {
const current = Date.parse(now());
if (
state.holder === "bot" &&
state.requested &&
state.requestedAt &&
current - Date.parse(state.requestedAt) > HELP_REQUEST_TTL_MS
) {
remember(state.helpRequestId, "expired");
const {
helpRequestId: _requestId,
reason: _reason,
requestedAt: _at,
...rest
} = state;
state = { ...rest, requested: false };
}
if (
state.holder === "bot" &&
state.secretWanted &&
state.secretRequestedAt &&
current - Date.parse(state.secretRequestedAt) > HELP_REQUEST_TTL_MS
) {
remember(state.secretRequestId, "expired");
const {
secretRequestId: _requestId,
secretWanted: _wanted,
secretRef: _ref,
secretSnapshotId: _snapshotId,
secretRequestedAt: _at,
...rest
} = state;
state = rest;
}
};

const assistanceRequestId = (candidate: unknown) =>
typeof candidate === "string" &&
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
candidate,
)
? candidate
: crypto.randomUUID();

return {
/**
Expand All @@ -110,15 +191,7 @@ export function createControl(
* than any stale prompt.
*/
get(): ControlState {
if (
state.requested &&
state.holder === "bot" &&
state.requestedAt &&
Date.parse(now()) - Date.parse(state.requestedAt) > HELP_REQUEST_TTL_MS
) {
const { reason: _reason, requestedAt: _at, ...rest } = state;
state = { ...rest, requested: false };
}
expirePending();
return { ...state };
},

Expand All @@ -128,11 +201,15 @@ export function createControl(
* It does not take control: it says it is stuck and why, and a person decides. A Bot that could
* hand itself to a human could also hand a human a page they never asked to see.
*/
requestHelp(reason: unknown): ControlState {
requestHelp(reason: unknown, requestId?: unknown): ControlState {
expirePending();
if (state.requested) remember(state.helpRequestId, "superseded");
const id = assistanceRequestId(requestId);
state = {
...state,
requested: true,
requestedAt: now(),
helpRequestId: id,
reason:
typeof reason === "string" && reason.trim()
? reason.trim()
Expand All @@ -146,12 +223,16 @@ export function createControl(
label?: unknown;
ref?: unknown;
snapshotId?: unknown;
requestId?: unknown;
}): ControlState {
expirePending();
if (typeof input.ref !== "string" || !input.ref.trim()) {
throw new ControlRequestError(
"Say which field the value goes in, using a ref from your snapshot.",
);
}
if (state.secretWanted) remember(state.secretRequestId, "superseded");
const requestedAt = now();
state = {
...state,
secretWanted:
Expand All @@ -161,17 +242,77 @@ export function createControl(
secretRef: input.ref.trim(),
secretSnapshotId:
typeof input.snapshotId === "number" ? input.snapshotId : undefined,
secretRequestId: assistanceRequestId(input.requestId),
secretRequestedAt: requestedAt,
};
return this.get();
},

/**
* Clear only the exact pending assistance generation while the Bot still owns the browser.
* A stale delivery timeout is therefore harmless after a newer request or human handoff.
*/
cancelAssistance(requestId: string): AssistanceCancellationResult {
expirePending();
if (humanAssistanceId === requestId) {
return { cancelled: false, state: this.get(), status: "human" };
}
if (state.holder !== "bot") {
return {
cancelled: false,
state: this.get(),
status: terminalAssistance.get(requestId) ?? "unknown",
};
}
if (state.helpRequestId === requestId && state.requested) {
const {
helpRequestId: _requestId,
reason: _reason,
requestedAt: _requestedAt,
...rest
} = state;
state = { ...rest, requested: false };
remember(requestId, "cancelled");
return { cancelled: true, state: this.get(), status: "cancelled" };
}
if (state.secretRequestId === requestId && state.secretWanted) {
const {
secretRequestId: _requestId,
secretWanted: _wanted,
secretRef: _ref,
secretSnapshotId: _snapshotId,
secretRequestedAt: _requestedAt,
...rest
} = state;
state = rest;
remember(requestId, "cancelled");
return { cancelled: true, state: this.get(), status: "cancelled" };
}
return {
cancelled: false,
state: this.get(),
status: terminalAssistance.get(requestId) ?? "unknown",
};
},

assistanceStatus(requestId: string): AssistanceStatus {
expirePending();
if (humanAssistanceId === requestId) return "human";
if (state.requested && state.helpRequestId === requestId)
return "pending";
if (state.secretWanted && state.secretRequestId === requestId)
return "pending";
return terminalAssistance.get(requestId) ?? "unknown";
},

/**
* The pending secret request, or null.
*
* Read before typing so the caller can refuse when nothing asked for one: this is what keeps the
* masked box from being a general-purpose way to type into the page.
*/
pendingSecret(): { ref: string; snapshotId?: number } | null {
expirePending();
if (!state.secretWanted || !state.secretRef) return null;
return { ref: state.secretRef, snapshotId: state.secretSnapshotId };
},
Expand All @@ -183,11 +324,14 @@ export function createControl(
* can try again.
*/
secretSupplied(): void {
remember(state.secretRequestId, "completed");
state = {
...state,
secretWanted: undefined,
secretRef: undefined,
secretSnapshotId: undefined,
secretRequestId: undefined,
secretRequestedAt: undefined,
};
},

Expand All @@ -199,6 +343,11 @@ export function createControl(
* box left open behind them no longer corresponds to an active request.
*/
take(): ControlState {
expirePending();
if (state.holder === "bot") {
humanAssistanceId = state.requested ? state.helpRequestId : undefined;
}
if (state.secretWanted) remember(state.secretRequestId, "cancelled");
state = {
holder: "human",
since: now(),
Expand All @@ -217,6 +366,8 @@ export function createControl(
* secret box left open afterwards is asking for a password nothing is waiting for.
*/
release(): ControlState {
remember(humanAssistanceId, "completed");
humanAssistanceId = undefined;
state = {
holder: "bot",
since: now(),
Expand Down
31 changes: 30 additions & 1 deletion agent-computer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,8 +621,9 @@ serve<StreamData>({
if (url.pathname === "/control/request" && request.method === "POST") {
const body = (await request.json().catch(() => null)) as {
reason?: unknown;
requestId?: unknown;
} | null;
return json(session.control.requestHelp(body?.reason));
return json(session.control.requestHelp(body?.reason, body?.requestId));
}

// The Bot asking for one value it must not be told. It has already focused the field.
Expand All @@ -642,6 +643,34 @@ serve<StreamData>({
}
}

// Conditional cleanup for an assistance request whose Slack handoff definitely failed. The
// state machine matches the opaque generation and refuses to change a human-owned browser.
if (
url.pathname === "/control/assistance/cancel" &&
request.method === "POST"
) {
const body = (await request.json().catch(() => null)) as {
requestId?: unknown;
} | null;
if (typeof body?.requestId !== "string" || !body.requestId) {
return json({ error: "An assistance request id is required." }, 400);
}
return json(session.control.cancelAssistance(body.requestId));
}

if (
url.pathname === "/control/assistance/status" &&
request.method === "POST"
) {
const body = (await request.json().catch(() => null)) as {
requestId?: unknown;
} | null;
if (typeof body?.requestId !== "string" || !body.requestId) {
return json({ error: "An assistance request id is required." }, 400);
}
return json({ status: session.control.assistanceStatus(body.requestId) });
}

/**
* A person supplying that value.
*
Expand Down
Loading