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
22 changes: 22 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,28 @@ 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

# Feishu application credentials live in a server-local JSON file, not process environment or any
# browser/chat configuration path. One long connection is started for each array entry. See
# docs/feishu.md. Leave unset to disable Feishu.
# OPENBOT_FEISHU_APPS_FILE=/etc/openbot/feishu-apps.json

# 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/
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,21 @@ upgrade is stamped as already onboarded by the migration and sees nothing.
Bound across the signed-in app, shown under **Settings → Keyboard shortcuts**, and inert while you
are typing in a field. Handoff work is also picked up the moment it is queued rather than at the
next poll, so an answer's round trip no longer pays up to two seconds per leg.
### A coworker named in the message is routed to without asking a model

Naming a coworker in the text — "ask Risk Analyst to review this" — went to the intent router like
any other message, so the deployment paid a model call to be told what the person had already said,
and sometimes was told something else. A name that matches exactly one coworker on that person's
roster now routes straight to them, recorded as `named by the person asking` on the same
`channel.routed` row. A name that matches more than one is refused with both names rather than
guessed at, and a name nobody on the roster answers to falls through to the router as before.

### Routing refuses rather than routes on a connector read it could not make

Which systems a coworker can reach is weighed by the router alongside what the coworker is for. A
failed read of that used to be treated as "reaches nothing", which is a statement about the
deployment rather than an absence of one: a database that blinked quietly re-routed messages away
from the coworker that could actually do the work. It now fails the request instead.
### A Bot's shell can no longer reach the embedded database without a password

In the all-in-one image the cluster was `trust`-auth on loopback, and the Bot's shell runs in the
Expand Down
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