Skip to content

Commit ce4e414

Browse files
committed
feat(sdk): auto-inject A2UI tool in CopilotKitMiddleware
Prebuilt agents get dynamic A2UI with no extra wiring — adding the middleware is enough. When the frontend registers an A2UI catalog (surfaced by the runtime into state["ag-ui"].a2ui_schema), the middleware infers the agent's own model, advertises the generate_a2ui tool in the model-call hook, and executes it in the tool-call hook. No catalog → the tool is never advertised. Covers both @copilotkit/sdk-js and the copilotkit Python SDK. Bumps the A2UI tool-factory dependency to where get_a2ui_tools ships (@ag-ui/langgraph 0.0.35, ag-ui-langgraph >=0.0.37).
1 parent 14bfd3b commit ce4e414

7 files changed

Lines changed: 393 additions & 14 deletions

File tree

packages/sdk-js/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
"attw": "attw --pack . --profile node16"
6060
},
6161
"dependencies": {
62-
"@ag-ui/langgraph": "0.0.34",
62+
"@ag-ui/langgraph": "0.0.35",
6363
"@copilotkit/shared": "workspace:*"
6464
},
6565
"devDependencies": {

packages/sdk-js/src/langgraph/__tests__/middleware.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,114 @@ describe("afterAgent", () => {
498498
});
499499
});
500500

501+
// ---------------------------------------------------------------------------
502+
// Auto-A2UI — middleware injects + executes generate_a2ui when the frontend
503+
// registered a catalog (surfaced into state["ag-ui"].a2ui_schema)
504+
// ---------------------------------------------------------------------------
505+
//
506+
// Contract: the developer passes nothing — using the middleware is enough.
507+
// generate_a2ui is advertised to the model only when an A2UI catalog is
508+
// present, is built from the agent's own (inferred) model, and is executed by
509+
// the middleware itself (it is never in the agent's static tool registry).
510+
511+
async function runWrapTool(middleware: any, request: any) {
512+
let received: any = null;
513+
const handler = async (req: any) => {
514+
received = req;
515+
return { content: "tool-ok" } as any;
516+
};
517+
await middleware.wrapToolCall(request, handler);
518+
return received;
519+
}
520+
521+
describe("auto-A2UI injection", () => {
522+
it("does NOT advertise generate_a2ui when there is no A2UI catalog", async () => {
523+
const request = makeRequest({
524+
state: { messages: [], thread_id: "a2ui-off" },
525+
tools: [{ name: "backend" }],
526+
});
527+
528+
const { received } = await runWrap(copilotkitMiddleware, request);
529+
530+
expect(received.tools.map((t: any) => t.name)).toEqual(["backend"]);
531+
});
532+
533+
it("advertises generate_a2ui (alongside existing tools) when a catalog is present", async () => {
534+
const request = makeRequest({
535+
state: {
536+
messages: [],
537+
thread_id: "a2ui-on",
538+
"ag-ui": { a2ui_schema: "<components/>" },
539+
},
540+
tools: [{ name: "backend" }],
541+
});
542+
543+
const { received } = await runWrap(copilotkitMiddleware, request);
544+
545+
const names = received.tools.map((t: any) => t.name);
546+
expect(names).toContain("backend");
547+
expect(names).toContain("generate_a2ui");
548+
});
549+
550+
it("executes generate_a2ui via wrapToolCall using the inferred model", async () => {
551+
const state = {
552+
messages: [],
553+
thread_id: "a2ui-exec",
554+
"ag-ui": { a2ui_schema: "<components/>" },
555+
};
556+
// First the model call infers the model + stashes the built tool.
557+
await runWrap(copilotkitMiddleware, makeRequest({ state, tools: [] }));
558+
559+
const received = await runWrapTool(copilotkitMiddleware, {
560+
toolCall: { name: "generate_a2ui", id: "1", args: {} },
561+
tool: undefined,
562+
state,
563+
runtime: {},
564+
});
565+
566+
expect(received.tool).toBeDefined();
567+
expect(received.tool.name).toBe("generate_a2ui");
568+
});
569+
570+
it("leaves non-A2UI tool calls untouched", async () => {
571+
const state = {
572+
messages: [],
573+
thread_id: "a2ui-other",
574+
"ag-ui": { a2ui_schema: "<components/>" },
575+
};
576+
await runWrap(copilotkitMiddleware, makeRequest({ state, tools: [] }));
577+
578+
const backendTool = { name: "backend" };
579+
const received = await runWrapTool(copilotkitMiddleware, {
580+
toolCall: { name: "backend", id: "1", args: {} },
581+
tool: backendTool,
582+
state,
583+
runtime: {},
584+
});
585+
586+
expect(received.tool).toBe(backendTool);
587+
});
588+
589+
it("stops executing generate_a2ui after the run ends (afterAgent clears the bridge)", async () => {
590+
const state = {
591+
messages: [],
592+
thread_id: "a2ui-clean",
593+
"ag-ui": { a2ui_schema: "<components/>" },
594+
};
595+
await runWrap(copilotkitMiddleware, makeRequest({ state, tools: [] }));
596+
copilotkitMiddleware.afterAgent(state, {} as any);
597+
598+
const received = await runWrapTool(copilotkitMiddleware, {
599+
toolCall: { name: "generate_a2ui", id: "1", args: {} },
600+
tool: undefined,
601+
state,
602+
runtime: {},
603+
});
604+
605+
expect(received.tool).toBeUndefined();
606+
});
607+
});
608+
501609
// ---------------------------------------------------------------------------
502610
// zodState — Standard-Schema JSON-schema augmentation
503611
// ---------------------------------------------------------------------------

packages/sdk-js/src/langgraph/middleware.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,20 @@ import type {
55
StandardSchemaV1,
66
} from "@standard-schema/spec";
77
import * as z from "zod";
8+
import { getA2UITools } from "@ag-ui/langgraph";
89
import { getForwardedHeaders } from "../header-propagation";
910

11+
// ---------------------------------------------------------------------------
12+
// Auto-A2UI: bridge the inferred model's generate_a2ui tool from wrapModelCall
13+
// (the only hook that exposes the bound model) to wrapToolCall (where the tool
14+
// actually executes but the model is absent). Keyed by the run's thread id so
15+
// concurrent runs don't clobber each other.
16+
// ---------------------------------------------------------------------------
17+
const a2uiToolsByThread = new Map<string, any>();
18+
const A2UI_DEFAULT_THREAD_KEY = "__copilotkit_a2ui_default__";
19+
const a2uiThreadKey = (state: any): string =>
20+
(state?.thread_id as string) || A2UI_DEFAULT_THREAD_KEY;
21+
1022
type WithJsonSchema<T> = T extends { "~standard": infer S }
1123
? Omit<T, "~standard"> & {
1224
"~standard": S &
@@ -322,25 +334,60 @@ const buildMiddlewareInput = (exposeState: ExposeStateOption) => ({
322334
};
323335
}
324336

337+
// Auto-inject generate_a2ui when the frontend has registered an A2UI
338+
// catalog. The AG-UI runtime surfaces that catalog into
339+
// state["ag-ui"].a2ui_schema; its presence is the signal that the client
340+
// can actually render A2UI surfaces. Gating on it keeps the feature fully
341+
// zero-config (the developer only uses the middleware) while never
342+
// advertising a tool that would render nowhere. The model is inferred from
343+
// request.model; the built tool is stashed for wrapToolCall to execute.
344+
let a2uiTool: any = null;
345+
const a2uiSchema = (request.state["ag-ui"] as any)?.a2ui_schema;
346+
if (a2uiSchema && typeof getA2UITools === "function") {
347+
a2uiTool = getA2UITools(request.model);
348+
a2uiToolsByThread.set(a2uiThreadKey(request.state), a2uiTool);
349+
}
350+
325351
const frontendTools = request.state["copilotkit"]?.actions ?? [];
326352

327-
if (frontendTools.length === 0) {
353+
if (frontendTools.length === 0 && !a2uiTool) {
328354
return handler(request);
329355
}
330356

331357
const existingTools = request.tools || [];
332-
const mergedTools = [...existingTools, ...frontendTools];
358+
const mergedTools = [
359+
...existingTools,
360+
...(a2uiTool ? [a2uiTool] : []),
361+
...frontendTools,
362+
];
333363

334364
return handler({
335365
...request,
336366
tools: mergedTools,
337367
});
338368
},
339369

370+
// Execute the dynamically-advertised generate_a2ui tool. It is not in the
371+
// agent's static tool registry, so the tool node cannot run it on its own;
372+
// we supply the implementation (built with the inferred model) for that one
373+
// tool. This hook's presence also disables createAgent's "unknown tool"
374+
// guard for dynamically-advertised tools.
375+
wrapToolCall: async (request: any, handler: (req: any) => Promise<any>) => {
376+
const tool = a2uiToolsByThread.get(a2uiThreadKey(request.state));
377+
if (tool && !request.tool && request.toolCall?.name === tool.name) {
378+
return handler({ ...request, tool });
379+
}
380+
return handler(request);
381+
},
382+
340383
beforeAgent: createAppContextBeforeAgent,
341384

342385
// Restore frontend tool calls to AIMessage before agent exits
343386
afterAgent: (state) => {
387+
// Drop the bridged A2UI tool for this run — all tool calls for the turn
388+
// have executed by now; the next model call re-stashes if needed.
389+
a2uiToolsByThread.delete(a2uiThreadKey(state));
390+
344391
const interceptedToolCalls = state["copilotkit"]?.interceptedToolCalls;
345392
const originalMessageId = state["copilotkit"]?.originalAIMessageId;
346393

pnpm-lock.yaml

Lines changed: 15 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)