From 7e9e0bd11525ea7c2869ac628c5dedb383a26f83 Mon Sep 17 00:00:00 2001 From: David McKay Date: Tue, 18 Aug 2026 22:44:18 -0700 Subject: [PATCH] Make LangChain the default harness, with the tool loop governed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agent-bot` hand-writes the AG-UI protocol and leaves the tool loop to whatever is watching. That is a fine reference and a bad thing to build a deployment on, so the default teammate endpoint is now `agent-langgraph` on 4201: a real framework, running a real loop. The loop moves into the agent, and stays governed. A framework Bot that called an MCP server directly would be a Bot that walked around the grant, the policy and the audit row, which are the product. So it calls back to `/api/agent-tools/call`, authenticated by a shared secret because the caller is a service with no person behind it, and that route goes through the same plugin store as everything else. No secret configured means the route does not exist and the Bot is told so, rather than a deployment quietly accepting anybody who can reach the port. The runtime now sends a remote agent the tools its Bot was granted, on every run rather than configured once, so a grant added or revoked applies to the next run and not after a restart. Two defects fixed on the way. `BOT_MODEL` arrived from compose as an empty string, which is a value, so the agent asked its provider for a model named "" and every run died with "you must provide a model parameter" — read as a broken Bot rather than missing configuration. And the run emitted its answer before the tool calls that informed it, so the surface drew a Bot that spoke first and worked afterwards; tool events are now sent as they happen, with each stretch of prose owning its own message id. Driven in a browser: the LangGraph Bot answered a question about the expense policy, the transcript drew the tool line and then the answer with its source link, and the audit row names risk-analyst calling through the deployment rather than the vendor. --- .env.example | 12 ++- agent-langgraph/src/index.ts | 178 +++++++++++++++++++++++++++++------ docker-compose.yml | 8 +- examples/fintech/agents.yaml | 2 +- server/src/app.ts | 49 ++++++++++ server/src/config.ts | 15 +++ server/src/copilot.ts | 42 ++++++++- 7 files changed, 273 insertions(+), 33 deletions(-) diff --git a/.env.example b/.env.example index 3205c40..5ad0165 100644 --- a/.env.example +++ b/.env.example @@ -172,7 +172,11 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true # The managed coworker AG-UI endpoint. Required: use an HTTP(S) URL. -MANAGED_AGENT_AG_UI_URL=http://localhost:4200/ag-ui +# +# Defaults to agent-langgraph on 4201, which runs a real framework and its own tool loop. The +# proof-of-concept on 4200 hand-writes the protocol and leaves the loop to whatever is watching, so +# it is a reference rather than something to build a deployment on. +MANAGED_AGENT_AG_UI_URL=http://localhost:4201/ag-ui # The second Bot in the box runs on http://localhost:4201/ag-ui, on a framework rather than # proof of concept, and is reached the same way: point MANAGED_AGENT_AG_UI_URL at it, or add it as a @@ -192,3 +196,9 @@ SUPERVISOR_TOKEN= # Set to runsc to run every computer under gVisor, if the host has it. Unset, a computer is an # ordinary container and shares the host kernel, which is worth knowing when the Bot is not ours. COMPUTER_RUNTIME= + +# The secret a framework Bot presents when it calls a tool back through this server. A Bot runs its +# own loop, in its own process, and still may not reach a vendor directly: it calls the deployment +# that granted the tool, which is where the grant, the policy and the audit row are. Absent, no Bot +# may call tools back and it is told so rather than being quietly allowed. +AGENT_TOOL_TOKEN= diff --git a/agent-langgraph/src/index.ts b/agent-langgraph/src/index.ts index c8a0040..fd747f8 100644 --- a/agent-langgraph/src/index.ts +++ b/agent-langgraph/src/index.ts @@ -55,7 +55,15 @@ const PORT = Number.parseInt(process.env.PORT ?? "4201", 10); * The default is unchanged so the two shipped Bots stay comparable out of the box. */ const PROVIDER = (process.env.BOT_PROVIDER ?? "openai").toLowerCase(); -const MODEL = process.env.BOT_MODEL ?? defaultModelFor(PROVIDER); +/* + * An unset model and an empty one are the same thing. + * + * `??` only catches undefined, and a compose file passing `BOT_MODEL: ${BOT_MODEL:-}` hands this an + * empty string, which is a value. The agent then asked its provider for a model named "" and the + * run died with "you must provide a model parameter", which reads as a broken Bot rather than as + * missing configuration. + */ +const MODEL = process.env.BOT_MODEL?.trim() || defaultModelFor(PROVIDER); /** OpenAI only. Its newer models require the Responses API, which the integration handles. */ const USE_RESPONSES_API = process.env.BOT_RESPONSES_API === "true"; /** @@ -216,13 +224,62 @@ function buildModel() { } /** - * The graph. + * Where this Bot runs a tool. * - * One node is enough while the tool loop lives on the client. The graph provides model orchestration - * without changing the AG-UI contract. + * Not the vendor: this deployment. A Bot that called an MCP server directly would be a Bot that + * walked around the grant, the policy and the audit row, and those are the product. So the loop runs + * here, in this process, and every call it makes goes back through the deployment that granted it. + */ +const TOOL_URL = + process.env.OPENBOT_TOOL_URL ?? "http://localhost:3001/api/agent-tools/call"; +const TOOL_TOKEN = process.env.AGENT_TOOL_TOKEN ?? ""; + +async function callTool( + botId: string, + name: string, + args: Record, +): Promise { + if (!TOOL_TOKEN) { + return "Refused. This Bot has no credential for calling tools back through its deployment."; + } + 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 }), + }); + const body = (await response.json()) as { text?: string }; + return body.text ?? "The tool returned nothing."; + } catch (error) { + // Reported to the model as a result rather than thrown: the run continues and says what broke. + return `That tool could not be called: ${ + error instanceof Error ? error.message : "unknown error" + }`; + } +} + +/** 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 graph, with the tool loop where it belongs. + * + * The loop used to run in the browser: this emitted a call, ended the run, and waited for a surface + * to execute it and start another. That made a watching browser a requirement for a Bot to do + * anything, which rules out an embed, a schedule, and anything unattended. + * + * Now it answers, calls what it needs, reads the results and answers again, which is what a harness + * is for. `recursionLimit` bounds a model that would otherwise call tools in a circle. */ function buildGraph(input: RunAgentInput) { const model = buildModel(); + const botId = botIdOf(input); const tools = toBoundTools(input); const bound = tools.length > 0 ? model.bindTools(tools) : model; @@ -231,8 +288,30 @@ function buildGraph(input: RunAgentInput) { .addNode("answer", async (state) => ({ messages: [await bound.invoke(state.messages)], })) + .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, + ); + return new ToolMessage({ + content: text, + tool_call_id: call.id ?? call.name, + name: call.name, + }); + }), + ); + return { messages: results }; + }) .addEdge(START, "answer") - .addEdge("answer", END) + .addConditionalEdges("answer", (state) => { + const last = state.messages.at(-1) as AIMessage | undefined; + return (last?.tool_calls?.length ?? 0) > 0 ? "tools" : END; + }) + .addEdge("tools", "answer") .compile(); } @@ -250,8 +329,23 @@ async function runAgent(input: RunAgentInput): Promise { runId: input.runId, } as BaseEvent); - const messageId = `msg_${input.runId}`; + /* + * One message id per stretch of prose. + * + * A run is several turns now: the Bot may speak, call a tool, read the result and speak + * again. Reusing one id reopens a message the surface has already closed, and the second half + * of the answer is dropped. + */ + let messageIndex = 0; + let messageId = `msg_${input.runId}_0`; let textOpen = false; + const closeText = () => { + if (!textOpen) return; + send({ type: "TEXT_MESSAGE_END", messageId } as BaseEvent); + textOpen = false; + messageIndex += 1; + messageId = `msg_${input.runId}_${messageIndex}`; + }; try { const graph = buildGraph(input); @@ -264,6 +358,11 @@ async function runAgent(input: RunAgentInput): Promise { // fragments and AG-UI wants one call. The framework hands back assembled `tool_calls` on the // final message, which is precisely the plumbing agent-bot does by hand. let finalMessage: AIMessage | null = null; + /** Calls seen on the way past, so a result can be paired with the arguments it answered. */ + const pending = new Map< + string, + { name: string; args: Record } + >(); for await (const event of events) { if (event.event === "on_chat_model_stream") { @@ -291,31 +390,56 @@ async function runAgent(input: RunAgentInput): Promise { if (event.event === "on_chat_model_end") { const output = event.data?.output as AIMessage | undefined; - if (output) finalMessage = output; + if (output) { + finalMessage = output; + for (const call of output.tool_calls ?? []) { + pending.set(call.id ?? call.name, { + name: call.name, + args: (call.args ?? {}) as Record, + }); + } + } } - } - if (textOpen) { - send({ type: "TEXT_MESSAGE_END", messageId } as BaseEvent); + /* + * The tools node finished. Reported here, in order, rather than collected for the end: the + * surface draws a conversation, and a call arriving after the answer it informed reads as + * though the Bot spoke first and did the work afterwards. + */ + if (event.event === "on_chain_end" && event.name === "tools") { + const output = event.data?.output as + | { messages?: { tool_call_id?: string; content?: unknown }[] } + | undefined; + // Prose and tool calls cannot interleave inside one message. + closeText(); + for (const message of output?.messages ?? []) { + const id = message.tool_call_id ?? ""; + const call = pending.get(id); + if (!call) continue; + send({ + type: "TOOL_CALL_START", + toolCallId: id, + toolCallName: call.name, + } as BaseEvent); + send({ + type: "TOOL_CALL_ARGS", + toolCallId: id, + delta: JSON.stringify(call.args), + } as BaseEvent); + send({ type: "TOOL_CALL_END", toolCallId: id } as BaseEvent); + send({ + type: "TOOL_CALL_RESULT", + messageId: `${id}-result`, + toolCallId: id, + content: String(message.content ?? ""), + role: "tool", + } as BaseEvent); + pending.delete(id); + } + } } - for (const call of finalMessage?.tool_calls ?? []) { - send({ - type: "TOOL_CALL_START", - toolCallId: call.id ?? `call_${input.runId}_${call.name}`, - toolCallName: call.name, - parentMessageId: messageId, - } as BaseEvent); - send({ - type: "TOOL_CALL_ARGS", - toolCallId: call.id ?? `call_${input.runId}_${call.name}`, - delta: JSON.stringify(call.args ?? {}), - } as BaseEvent); - send({ - type: "TOOL_CALL_END", - toolCallId: call.id ?? `call_${input.runId}_${call.name}`, - } as BaseEvent); - } + closeText(); send({ type: "RUN_FINISHED", diff --git a/docker-compose.yml b/docker-compose.yml index 5baf210..9f38be9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -190,8 +190,14 @@ services: ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-} GOOGLE_API_KEY: ${GOOGLE_API_KEY:-} GOOGLE_GENERATIVE_AI_BASE_URL: ${GOOGLE_GENERATIVE_AI_BASE_URL:-} - BOT_MODEL: ${BOT_MODEL:-} + BOT_MODEL: ${BOT_MODEL:-gpt-5.5} BOT_RESPONSES_API: ${BOT_RESPONSES_API:-false} + # Where this Bot runs a tool: back through the deployment that granted it, never at the vendor. + # `host.docker.internal` because the API server runs on the host, not in this network. + OPENBOT_TOOL_URL: ${OPENBOT_TOOL_URL:-http://host.docker.internal:3001/api/agent-tools/call} + AGENT_TOOL_TOKEN: ${AGENT_TOOL_TOKEN:-} + extra_hosts: + - "host.docker.internal:host-gateway" healthcheck: test: ["CMD-SHELL", "bun -e \"await fetch('http://localhost:4201/health')\""] interval: 10s diff --git a/examples/fintech/agents.yaml b/examples/fintech/agents.yaml index 8116156..979454d 100644 --- a/examples/fintech/agents.yaml +++ b/examples/fintech/agents.yaml @@ -24,4 +24,4 @@ agents: role_description: Investigate policies, transaction monitoring, and control evidence. avatar_seed: risk-analyst type: remote-ag-ui - endpoint: ${MANAGED_AGENT_AG_UI_URL:-http://localhost:4200/ag-ui} + endpoint: ${MANAGED_AGENT_AG_UI_URL:-http://localhost:4201/ag-ui} diff --git a/server/src/app.ts b/server/src/app.ts index 7675d47..4248909 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -27,6 +27,7 @@ import type { DeploymentConfig } from "./config"; import type { ConnectorAdminService } from "./connectors"; import type { CredentialAdminService, CredentialInput } from "./credentials"; import { createPluginRoutes } from "./plugins/routes"; +import { REFUSAL_MARKER } from "./plugins/tools"; import type { PluginStore } from "./plugins/store"; import type { PackageStatusReader } from "./tenant-package"; @@ -344,6 +345,54 @@ export function createApp( app.route("/api/plugins", createPluginRoutes(pluginStore, requireUser)); } + /* + * Where a framework Bot runs a tool. + * + * A Bot that runs its own loop, in its own process, is the honest shape: the run does not need a + * browser and does not stop when one closes. What it must not have is a route to a vendor that + * goes around this deployment, so it calls here and this calls the plugin store, which asks the + * same two questions it asks of everything else and writes the same audit row. + * + * Authenticated by a shared secret rather than a session, because the caller is a service and has + * no person behind it. Absent secret means the route does not exist: a deployment that has not + * configured this refuses rather than accepting anybody who can reach the port. + */ + if (pluginStore && config.agentToolToken) { + const token = config.agentToolToken; + app.post("/api/agent-tools/call", async (context) => { + if (context.req.header("x-openbot-agent-token") !== token) { + return context.json({ error: "Not authorised." }, 401); + } + const body = (await context.req.json().catch(() => null)) as { + botId?: string; + actorId?: string; + name?: string; + args?: Record; + } | null; + if (!body?.botId || !body.name) { + return context.json({ error: "A Bot and a tool are required." }, 400); + } + try { + const result = await pluginStore.callTool({ + // The model is offered `mcp__server__tool`; the store speaks `server/tool`. + ref: body.name.replace(/^mcp__/, "").replace("__", "/"), + args: body.args ?? {}, + botId: body.botId, + actorId: body.actorId ?? "agent", + }); + return context.json({ text: result.text, isError: result.isError }); + } catch (error) { + // A refusal is an answer, not a failure: the Bot says what was blocked and carries on. The + // marker leads it for the same reason it does on the in-process path, so a transcript can + // draw a refusal as one without reading the wording. + return context.json({ + text: `${REFUSAL_MARKER} ${error instanceof Error ? error.message : "That tool could not be called."}`, + isError: true, + }); + } + }); + } + if (sandboxedStore) { app.route( "/api/sandboxed", diff --git a/server/src/config.ts b/server/src/config.ts index 9e9c2c8..a85e8f0 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -83,6 +83,18 @@ export type DeploymentConfig = { */ policy?: ActionPolicy; }; + /** + * The secret a Bot presents when it calls a tool back through this server. + * + * A framework Bot runs its own tool loop, in its own process, which is what makes it a real + * harness rather than a shape the browser drives. It still may not reach a vendor directly: it + * calls here, and here is where the grant, the policy and the audit row are. This is what tells + * that call apart from anybody else on the network. + * + * Absent means no Bot may call tools back, and a deployment that wanted them gets a refusal rather + * than an open door. + */ + agentToolToken?: string; }; type Environment = Record; @@ -369,5 +381,8 @@ export function loadConfig( auth: authConfig(environment, google), devNoAuth: devAuthEnabled(environment), computer: computerConfig(environment), + ...(optional(environment, "AGENT_TOOL_TOKEN") + ? { agentToolToken: optional(environment, "AGENT_TOOL_TOKEN") as string } + : {}), }; } diff --git a/server/src/copilot.ts b/server/src/copilot.ts index ac4d934..cbdd98a 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -9,6 +9,7 @@ import { createCopilotHonoHandler } from "@copilotkit/runtime/v2/hono"; import type { AgentActor } from "./agents/profile-types"; import type { StallGuard } from "./channels/stall-guard"; import type { DeploymentConfig } from "./config"; +import { z } from "zod"; import type { GrantedTool } from "./plugins/tools"; /** @@ -257,7 +258,11 @@ async function buildAgent( if (agent.type === "unavailable") { return new UnavailableAgent(agent); } - return remoteAgentWithStandingRole(agent, stallGuard); + return remoteAgentWithStandingRole( + agent, + stallGuard, + await loadTools(agent.id), + ); } /** @@ -274,7 +279,15 @@ async function buildAgent( */ function remoteAgentWithStandingRole( agent: RegisteredRemoteAgent, - stallGuard?: StallGuard, + stallGuard: StallGuard | undefined, + /** + * What this Bot was granted, described rather than executable. + * + * A framework Bot runs its own loop and calls these back through `/api/agent-tools/call`, so what + * it needs from here is the offer: the name, what the tool is for, and the arguments it takes. + * The executing half stays on this side, where the grant and the policy are. + */ + tools: GrantedTool[] = [], ) { const remote = new HttpAgent({ url: agent.endpoint, @@ -295,7 +308,30 @@ function remoteAgentWithStandingRole( (message) => message.id !== agent.standingMessage.id, ), ], - }), + /* + * The Bot's own grants, added to whatever the surface offered. + * + * Sent on every run rather than configured once on the endpoint, because a grant an + * administrator adds or revokes has to apply to the next run and the endpoint is somebody + * else's process. + */ + tools: [ + ...(input.tools ?? []), + ...tools.map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: z.toJSONSchema(tool.parameters) as Record< + string, + unknown + >, + })), + ], + // Who the Bot is calling back as, so the audit row names it rather than "an agent". + forwardedProps: { + ...(input.forwardedProps ?? {}), + openbotBotId: agent.id, + }, + } as never), ); return remote; }