Skip to content
Merged
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
111 changes: 91 additions & 20 deletions agent-langgraph/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,21 +235,35 @@ const TOOL_URL =
const TOOL_TOKEN = process.env.AGENT_TOOL_TOKEN ?? "";

async function callTool(
botId: string,
run: string,
name: string,
args: Record<string, unknown>,
): Promise<string> {
if (!TOOL_TOKEN) {
return "Refused. This Bot has no credential for calling tools back through its deployment.";
}
if (!run) {
/*
* No statement from the deployment about whose run this is, so there is nothing to act on behalf
* of. Reported as a result rather than thrown: the run continues and says what it could not do.
*/
return "Refused. This run carried no signed statement of which Bot and person it is for.";
}
try {
const response = await fetch(TOOL_URL, {
method: "POST",
headers: {
"content-type": "application/json",
"x-openbot-agent-token": TOOL_TOKEN,
},
body: JSON.stringify({ botId, name, args }),
/*
* The deployment's own statement, handed straight back.
*
* The Bot and the actor used to be sent from here, which meant this process asserted who it was
* acting for. It is not in a position to know, and anything holding the token could claim
* anything, so the deployment says it and this only carries the note.
*/
body: JSON.stringify({ name, args, run }),
});
const body = (await response.json()) as { text?: string };
return body.text ?? "The tool returned nothing.";
Expand All @@ -261,10 +275,43 @@ async function callTool(
}
}

/** Which Bot is running, so the deployment can attribute the call it is about to be asked for. */
function botIdOf(input: RunAgentInput): string {
const props = input.forwardedProps as { openbotBotId?: unknown } | undefined;
return typeof props?.openbotBotId === "string" ? props.openbotBotId : "";
/**
* The deployment's signed statement of what this run is.
*
* Opaque here on purpose: this process cannot read it and has no reason to. It carries it back when it
* calls a tool, and the deployment that signed it is the only thing that can open it.
*/
function runAssertionOf(input: RunAgentInput): string {
const props = input.forwardedProps as { openbotRun?: unknown } | undefined;
return typeof props?.openbotRun === "string" ? props.openbotRun : "";
}

/**
* The tools this deployment runs, as opposed to the ones the surface draws.
*
* Both arrive in the same list and no naming rule separates them, so the deployment names its own.
* Absent, nothing is treated as the deployment's: a Bot that guessed wrong would either apologise for
* a component it did show, or quietly report a governed tool as drawn without ever calling it. The
* first is embarrassing and the second is a lie about governance, so an unmarked run does neither.
*/
function deploymentToolsOf(input: RunAgentInput): Set<string> {
const props = input.forwardedProps as
| { openbotDeploymentTools?: unknown }
| undefined;
const names = props?.openbotDeploymentTools;
return new Set(
Array.isArray(names)
? names.filter((name) => typeof name === "string")
: [],
);
}

/** Did the model reach for something the surface owns rather than something this deployment runs? */
function callsTheSurface(
calls: { name: string }[],
ours: Set<string>,
): boolean {
return calls.some((call) => !ours.has(call.name));
}

/**
Expand All @@ -279,10 +326,11 @@ function botIdOf(input: RunAgentInput): string {
*/
function buildGraph(input: RunAgentInput) {
const model = buildModel();
const botId = botIdOf(input);
const run = runAssertionOf(input);

const tools = toBoundTools(input);
const bound = tools.length > 0 ? model.bindTools(tools) : model;
const ours = deploymentToolsOf(input);

return new StateGraph(MessagesAnnotation)
.addNode("answer", async (state) => ({
Expand All @@ -291,25 +339,48 @@ function buildGraph(input: RunAgentInput) {
.addNode("tools", async (state) => {
const last = state.messages.at(-1) as AIMessage;
const results = await Promise.all(
(last.tool_calls ?? []).map(async (call) => {
const text = await callTool(
botId,
call.name,
(call.args ?? {}) as Record<string, unknown>,
);
return new ToolMessage({
content: text,
tool_call_id: call.id ?? call.name,
name: call.name,
});
}),
/*
* Only this deployment's own tools. A component is drawn by the surface, and a decision is
* answered there by a person, so neither is executed here and neither gets a result invented
* here. The run ends instead, and the surface starts the next one carrying what it produced.
*/
(last.tool_calls ?? [])
.filter((call) => ours.has(call.name))
.map(async (call) => {
const text = await callTool(
run,
call.name,
(call.args ?? {}) as Record<string, unknown>,
);
return new ToolMessage({
content: text,
tool_call_id: call.id ?? call.name,
name: call.name,
});
}),
);
return { messages: results };
})
.addEdge(START, "answer")
.addConditionalEdges("answer", (state) => {
const last = state.messages.at(-1) as AIMessage | undefined;
return (last?.tool_calls?.length ?? 0) > 0 ? "tools" : END;
const calls = last?.tool_calls ?? [];
if (calls.length === 0) return END;
/*
* A call the surface owns ends the run.
*
* This is how a tool that lives in the browser is supposed to work: the Bot asks for it, the
* run finishes, the surface draws it or puts the question to a person, and the surface begins
* the next run with the answer in hand. Running the loop through it here instead invents a
* result: the Bot apologises for a chart the person is looking at, and an approval card that
* has already been answered on its behalf sits waiting for a click that can never land.
*
* A turn that asks for both kinds at once ends too, and the model asks again for what it still
* has no answer to. That is the rarer case and the safe way round: the alternative runs a
* governed tool whose result nobody is waiting for.
*/
if (callsTheSurface(calls, ours)) return END;
return "tools";
})
.addEdge("tools", "answer")
.compile();
Expand Down
12 changes: 12 additions & 0 deletions app/src/components/agents/agent-profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useNavigate } from "@tanstack/react-router";
import { type ReactNode, useState } from "react";
import { AbstractAvatar } from "@/components/agents/abstract-avatar";
import { AgentFields } from "@/components/agents/agent-fields";
import { CallbackTokenPanel } from "@/components/agents/callback-token-panel";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
Expand Down Expand Up @@ -142,6 +143,17 @@ export function AgentProfile({ agentId }: { agentId: string }) {
</section>
)}

{/*
* Only for a coworker that runs somewhere else, and only for somebody who may change it.
* The Bot in the box has no endpoint and nothing to authenticate as.
*/}
{!isEditing && profile.endpoint && profile.canManage ? (
<CallbackTokenPanel
agentId={agentId}
hasToken={profile.hasCallbackToken}
/>
) : null}

{actionError ? (
<p className="text-sm text-destructive" role="alert">
{actionError.message}
Expand Down
103 changes: 103 additions & 0 deletions app/src/components/agents/callback-token-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
issueCallbackTokenMutationOptions,
revokeCallbackTokenMutationOptions,
} from "@/lib/agents/mutations";

/**
* The credential this coworker presents when it calls a tool back.
*
* Only for a coworker that runs somewhere else. The Bot in the box has no endpoint and nothing to
* authenticate as, and offering it a token would imply otherwise.
*
* Shown once, and said plainly. This deployment keeps a hash, so there is no screen that can show the
* token again: an operator who loses it rotates, and rotating retires the old one, which is also how a
* leak is handled.
*
* A coworker with no token can still hold a conversation. What it cannot do is reach anything outside
* one, which is the right default for a URL somebody pasted.
*/
export function CallbackTokenPanel({
agentId,
hasToken,
}: {
agentId: string;
hasToken: boolean;
}) {
const queryClient = useQueryClient();
const issue = useMutation(issueCallbackTokenMutationOptions(queryClient));
const revoke = useMutation(revokeCallbackTokenMutationOptions(queryClient));

/** Held in state, not in a query cache: it must not survive a refetch or a navigation. */
const [token, setToken] = useState<string | null>(null);
const error = issue.error ?? revoke.error;

return (
<section className="mt-6 grid gap-2">
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Calling tools back
</h2>

<p className="text-muted-foreground text-sm">
{hasToken
? "This coworker holds a credential, so it can use the tools it has been granted. Rotating replaces it, and the old one stops working straight away."
: "This coworker has no credential, so it can hold a conversation but cannot use any tool it has been granted. Generate one and put it in that agent's configuration."}
</p>

{token ? (
<div className="grid gap-2 rounded-lg border border-border bg-card p-3">
<p className="font-medium text-sm">
Copy this now. It will not be shown again.
</p>
{/*
* Selectable and wrapped rather than a copy button alone: somebody pasting this into a
* deployment config on another machine may not have a clipboard between the two.
*/}
<code className="block break-all rounded bg-foreground/5 p-2 font-mono text-xs">
{token}
</code>
<p className="text-muted-foreground text-xs">
The deployment keeps only a hash of it, so nothing here can show it
to you a second time.
</p>
<Button onClick={() => setToken(null)} size="sm" variant="outline">
Done
</Button>
</div>
) : (
<div className="flex flex-wrap gap-2">
<Button
disabled={issue.isPending}
onClick={async () => setToken(await issue.mutateAsync(agentId))}
size="sm"
variant="outline"
>
{issue.isPending
? "Generating…"
: hasToken
? "Rotate token"
: "Generate token"}
</Button>
{hasToken ? (
<Button
disabled={revoke.isPending}
onClick={() => revoke.mutate(agentId)}
size="sm"
variant="outline"
>
{revoke.isPending ? "Revoking…" : "Revoke"}
</Button>
) : null}
</div>
)}

{error ? (
<p className="text-destructive text-sm" role="alert">
{error.message}
</p>
) : null}
</section>
);
}
32 changes: 32 additions & 0 deletions app/src/lib/agents/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,35 @@ export function deleteAgentMutationOptions(queryClient: QueryClient) {
onSuccess: () => invalidateAgents(queryClient),
});
}

/**
* Issue this coworker a credential for calling tools back, and hand it over once.
*
* The token is in this response and nowhere else, ever again, so the caller has to show it to the
* person immediately. Calling this on a coworker that already has one rotates it, which is how a
* leaked token is retired.
*/
export function issueCallbackTokenMutationOptions(queryClient: QueryClient) {
return mutationOptions({
mutationFn: async (agentId: string): Promise<string> => {
const response = await agentRequest(
`/api/agents/${agentId}/callback-token`,
{ method: "POST" },
);
return ((await response.json()) as { token: string }).token;
},
onSuccess: () => invalidateAgents(queryClient),
});
}

/** Take the credential away. The coworker may still talk; it may not reach anything outside a chat. */
export function revokeCallbackTokenMutationOptions(queryClient: QueryClient) {
return mutationOptions({
mutationFn: async (agentId: string) => {
await agentRequest(`/api/agents/${agentId}/callback-token`, {
method: "DELETE",
});
},
onSuccess: () => invalidateAgents(queryClient),
});
}
7 changes: 7 additions & 0 deletions app/src/lib/agents/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ export type AgentProfile = {
endpoint: string | null;
/** Whether a key is set for it. Never the key itself. */
hasAuth: boolean;
/**
* Whether this coworker holds a credential for calling tools back.
*
* A boolean, because the token is readable exactly once: in the response that issued it. The
* surface needs this only to decide between offering "generate" and "rotate".
*/
hasCallbackToken: boolean;
hidden: boolean;
systemOwned: boolean;
canManage: boolean;
Expand Down
2 changes: 2 additions & 0 deletions server/drizzle/0001_swift_morph.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "agent_profiles" ADD COLUMN "callback_token_hash" text;--> statement-breakpoint
ALTER TABLE "agent_profiles" ADD COLUMN "callback_token_issued_at" timestamp with time zone;
Loading