Skip to content

Commit 7d537fa

Browse files
authored
feat(sdk): auto-inject A2UI tool in CopilotKitMiddleware (CopilotKit#5161)
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).
2 parents 669f60f + 37577a2 commit 7d537fa

38 files changed

Lines changed: 1966 additions & 1736 deletions

File tree

.npmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
minimum-release-age=1440
22
minimum-release-age-exclude[]=@ag-ui/langgraph
3+
minimum-release-age-exclude[]=@ag-ui/a2ui-middleware
34
block-exotic-subdeps=true

packages/runtime/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,11 @@
7777
"attw": "attw --pack . --profile node16"
7878
},
7979
"dependencies": {
80-
"@ag-ui/a2ui-middleware": "0.0.5",
80+
"@ag-ui/a2ui-middleware": "0.0.6",
8181
"@ag-ui/client": "0.0.53",
8282
"@ag-ui/core": "0.0.53",
8383
"@ag-ui/encoder": "0.0.53",
84-
"@ag-ui/langgraph": "0.0.34",
84+
"@ag-ui/langgraph": "0.0.37",
8585
"@ag-ui/mcp-apps-middleware": "0.0.3",
8686
"@ai-sdk/anthropic": "^3.0.49",
8787
"@ai-sdk/google": "^3.0.33",

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.37",
6363
"@copilotkit/shared": "workspace:*"
6464
},
6565
"devDependencies": {

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

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,231 @@ 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 the inject flag is absent", 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("does NOT advertise generate_a2ui when a catalog is present but the flag is absent (opt-in)", async () => {
534+
const request = makeRequest({
535+
state: {
536+
messages: [],
537+
thread_id: "a2ui-noflag",
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).not.toContain("generate_a2ui");
547+
expect(names).toContain("backend");
548+
});
549+
550+
it("advertises generate_a2ui (alongside existing tools) when the flag is on", async () => {
551+
const request = makeRequest({
552+
state: {
553+
messages: [],
554+
thread_id: "a2ui-on",
555+
"ag-ui": { a2ui_schema: "<components/>", inject_a2ui_tool: true },
556+
},
557+
tools: [{ name: "backend" }],
558+
});
559+
560+
const { received } = await runWrap(copilotkitMiddleware, request);
561+
562+
const names = received.tools.map((t: any) => t.name);
563+
expect(names).toContain("backend");
564+
expect(names).toContain("generate_a2ui");
565+
});
566+
567+
it("binds to the catalog from copilotkit.context when the flag is on (runtime-proxy path)", async () => {
568+
const request = makeRequest({
569+
state: {
570+
messages: [],
571+
thread_id: "a2ui-ctx",
572+
"ag-ui": { inject_a2ui_tool: true },
573+
copilotkit: {
574+
context: [
575+
{
576+
description:
577+
"A2UI catalog capabilities: available catalog IDs and custom component definitions.",
578+
value:
579+
"Available A2UI catalog:\n- declarative-gen-ui-catalog\n - Card: {...}\n - Metric: {...}",
580+
},
581+
],
582+
},
583+
},
584+
tools: [{ name: "backend" }],
585+
});
586+
587+
const { received } = await runWrap(copilotkitMiddleware, request);
588+
589+
const names = received.tools.map((t: any) => t.name);
590+
expect(names).toContain("backend");
591+
expect(names).toContain("generate_a2ui");
592+
});
593+
594+
it("executes generate_a2ui via wrapToolCall using the inferred model", async () => {
595+
const state = {
596+
messages: [],
597+
thread_id: "a2ui-exec",
598+
"ag-ui": { a2ui_schema: "<components/>", inject_a2ui_tool: true },
599+
};
600+
// First the model call infers the model + stashes the built tool.
601+
await runWrap(copilotkitMiddleware, makeRequest({ state, tools: [] }));
602+
603+
const received = await runWrapTool(copilotkitMiddleware, {
604+
toolCall: { name: "generate_a2ui", id: "1", args: {} },
605+
tool: undefined,
606+
state,
607+
runtime: {},
608+
});
609+
610+
expect(received.tool).toBeDefined();
611+
expect(received.tool.name).toBe("generate_a2ui");
612+
});
613+
614+
it("leaves non-A2UI tool calls untouched", async () => {
615+
const state = {
616+
messages: [],
617+
thread_id: "a2ui-other",
618+
"ag-ui": { a2ui_schema: "<components/>", inject_a2ui_tool: true },
619+
};
620+
await runWrap(copilotkitMiddleware, makeRequest({ state, tools: [] }));
621+
622+
const backendTool = { name: "backend" };
623+
const received = await runWrapTool(copilotkitMiddleware, {
624+
toolCall: { name: "backend", id: "1", args: {} },
625+
tool: backendTool,
626+
state,
627+
runtime: {},
628+
});
629+
630+
expect(received.tool).toBe(backendTool);
631+
});
632+
633+
it("stops executing generate_a2ui after the run ends (afterAgent clears the bridge)", async () => {
634+
const state = {
635+
messages: [],
636+
thread_id: "a2ui-clean",
637+
"ag-ui": { a2ui_schema: "<components/>", inject_a2ui_tool: true },
638+
};
639+
await runWrap(copilotkitMiddleware, makeRequest({ state, tools: [] }));
640+
copilotkitMiddleware.afterAgent(state, {} as any);
641+
642+
const received = await runWrapTool(copilotkitMiddleware, {
643+
toolCall: { name: "generate_a2ui", id: "1", args: {} },
644+
tool: undefined,
645+
state,
646+
runtime: {},
647+
});
648+
649+
expect(received.tool).toBeUndefined();
650+
});
651+
652+
// --- A2UI injectA2UITool flag (forwarded → ag-ui state) ------------------
653+
654+
it("does NOT advertise generate_a2ui when inject_a2ui_tool is false", async () => {
655+
const request = makeRequest({
656+
state: {
657+
messages: [],
658+
thread_id: "a2ui-optout",
659+
"ag-ui": { a2ui_schema: "<components/>", inject_a2ui_tool: false },
660+
},
661+
tools: [{ name: "backend" }],
662+
});
663+
664+
const { received } = await runWrap(copilotkitMiddleware, request);
665+
666+
const names = received.tools.map((t: any) => t.name);
667+
expect(names).not.toContain("generate_a2ui");
668+
expect(names).toContain("backend");
669+
});
670+
671+
it("advertises generate_a2ui when inject_a2ui_tool is true", async () => {
672+
const request = makeRequest({
673+
state: {
674+
messages: [],
675+
thread_id: "a2ui-optin",
676+
"ag-ui": { a2ui_schema: "<components/>", inject_a2ui_tool: true },
677+
},
678+
tools: [{ name: "backend" }],
679+
});
680+
681+
const { received } = await runWrap(copilotkitMiddleware, request);
682+
683+
expect(received.tools.map((t: any) => t.name)).toContain("generate_a2ui");
684+
});
685+
686+
it("drops the runtime's render_a2ui when injecting our generate_a2ui", async () => {
687+
const request = makeRequest({
688+
state: {
689+
messages: [],
690+
thread_id: "a2ui-drop",
691+
"ag-ui": { a2ui_schema: "<components/>", inject_a2ui_tool: true },
692+
copilotkit: {
693+
actions: [{ name: "render_a2ui" }, { name: "fe_tool" }],
694+
},
695+
},
696+
tools: [],
697+
});
698+
699+
const { received } = await runWrap(copilotkitMiddleware, request);
700+
701+
const names = received.tools.map((t: any) => t.name);
702+
expect(names).toContain("generate_a2ui");
703+
expect(names).toContain("fe_tool");
704+
expect(names).not.toContain("render_a2ui");
705+
});
706+
707+
it("does NOT double-inject when the agent already defines generate_a2ui", async () => {
708+
const request = makeRequest({
709+
state: {
710+
messages: [],
711+
thread_id: "a2ui-dup",
712+
"ag-ui": { a2ui_schema: "<components/>", inject_a2ui_tool: true },
713+
},
714+
tools: [{ name: "generate_a2ui" }],
715+
});
716+
717+
const { received } = await runWrap(copilotkitMiddleware, request);
718+
719+
const count = received.tools.filter(
720+
(t: any) => t.name === "generate_a2ui",
721+
).length;
722+
expect(count).toBe(1);
723+
});
724+
});
725+
501726
// ---------------------------------------------------------------------------
502727
// zodState — Standard-Schema JSON-schema augmentation
503728
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)