Skip to content

Commit 1bd944b

Browse files
committed
feat(sdk): source A2UI catalog wherever the frontend registered it
The auto-injected generate_a2ui tool now resolves the A2UI catalog from both delivery paths: the CopilotKit runtime proxy (a copilotkit.context entry) and the AG-UI native endpoint (state["ag-ui"].a2ui_schema). The registered catalog id is extracted and bound to generated surfaces so BYOC custom catalogs render their own components instead of the basic catalog. Covers @copilotkit/sdk-js and the copilotkit Python SDK.
1 parent ce4e414 commit 1bd944b

4 files changed

Lines changed: 216 additions & 22 deletions

File tree

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,32 @@ describe("auto-A2UI injection", () => {
547547
expect(names).toContain("generate_a2ui");
548548
});
549549

550+
it("advertises generate_a2ui when the catalog arrives via copilotkit.context (runtime-proxy path)", async () => {
551+
const request = makeRequest({
552+
state: {
553+
messages: [],
554+
thread_id: "a2ui-ctx",
555+
copilotkit: {
556+
context: [
557+
{
558+
description:
559+
"A2UI catalog capabilities: available catalog IDs and custom component definitions.",
560+
value:
561+
"Available A2UI catalog:\n- declarative-gen-ui-catalog\n - Card: {...}\n - Metric: {...}",
562+
},
563+
],
564+
},
565+
},
566+
tools: [{ name: "backend" }],
567+
});
568+
569+
const { received } = await runWrap(copilotkitMiddleware, request);
570+
571+
const names = received.tools.map((t: any) => t.name);
572+
expect(names).toContain("backend");
573+
expect(names).toContain("generate_a2ui");
574+
});
575+
550576
it("executes generate_a2ui via wrapToolCall using the inferred model", async () => {
551577
const state = {
552578
messages: [],

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

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,45 @@ const A2UI_DEFAULT_THREAD_KEY = "__copilotkit_a2ui_default__";
1919
const a2uiThreadKey = (state: any): string =>
2020
(state?.thread_id as string) || A2UI_DEFAULT_THREAD_KEY;
2121

22+
/**
23+
* Find the frontend-registered A2UI catalog wherever it was passed. Returns
24+
* `{ compositionGuide?, catalogId? }` when a catalog is present, else `null`
25+
* (so the tool is never advertised when the client can't render A2UI). Two
26+
* delivery paths, depending on how the agent is served:
27+
* - AG-UI native endpoint → `state["ag-ui"].a2ui_schema` (JSON
28+
* `{ catalogId, components }`); the toolkit reads it from state itself.
29+
* - CopilotKit runtime proxy → a `state.copilotkit.context` entry describing
30+
* the A2UI catalog (catalog id + component schemas as text), passed to the
31+
* subagent via `compositionGuide`.
32+
* `catalogId` binds generated surfaces to the frontend's catalog so BYOC
33+
* custom catalogs render their own components (not the basic one).
34+
*/
35+
const resolveA2uiCatalog = (
36+
state: any,
37+
): { compositionGuide?: string; catalogId?: string } | null => {
38+
const a2uiSchema = state?.["ag-ui"]?.a2ui_schema;
39+
if (a2uiSchema) {
40+
let catalogId: string | undefined;
41+
try {
42+
const parsed =
43+
typeof a2uiSchema === "string" ? JSON.parse(a2uiSchema) : a2uiSchema;
44+
catalogId = parsed?.catalogId;
45+
} catch {
46+
// non-JSON schema — fall back to the toolkit's basic catalog
47+
}
48+
return { catalogId };
49+
}
50+
const context = state?.copilotkit?.context;
51+
for (const entry of Array.isArray(context) ? context : []) {
52+
const description = entry?.description ?? "";
53+
const value = entry?.value ?? "";
54+
if (!description.includes("A2UI catalog") || !value) continue;
55+
const match = /^\s*-\s+(\S+)/m.exec(value);
56+
return { compositionGuide: value, catalogId: match?.[1] };
57+
}
58+
return null;
59+
};
60+
2261
type WithJsonSchema<T> = T extends { "~standard": infer S }
2362
? Omit<T, "~standard"> & {
2463
"~standard": S &
@@ -335,16 +374,24 @@ const buildMiddlewareInput = (exposeState: ExposeStateOption) => ({
335374
}
336375

337376
// 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.
377+
// catalog — sourced wherever the FE passed it (CopilotKit runtime proxy via
378+
// copilotkit.context, or AG-UI native via ag-ui.a2ui_schema). The catalog's
379+
// presence is the signal that the client can render A2UI surfaces, so this
380+
// never advertises a tool that would render nowhere while staying fully
381+
// zero-config. The model is inferred from request.model; the catalog id
382+
// binds surfaces to the FE's catalog; the built tool is stashed for
383+
// wrapToolCall to execute.
344384
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);
385+
const a2uiCatalog =
386+
typeof getA2UITools === "function"
387+
? resolveA2uiCatalog(request.state)
388+
: null;
389+
if (a2uiCatalog) {
390+
const opts: { defaultCatalogId?: string; compositionGuide?: string } = {};
391+
if (a2uiCatalog.catalogId) opts.defaultCatalogId = a2uiCatalog.catalogId;
392+
if (a2uiCatalog.compositionGuide)
393+
opts.compositionGuide = a2uiCatalog.compositionGuide;
394+
a2uiTool = getA2UITools(request.model, opts);
348395
a2uiToolsByThread.set(a2uiThreadKey(request.state), a2uiTool);
349396
}
350397

sdk-python/copilotkit/copilotkit_lg_middleware.py

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -318,28 +318,92 @@ def _apply_state_note(self, request: ModelRequest) -> ModelRequest:
318318
# Auto-A2UI tool injection
319319
# ------------------------------------------------------------------
320320

321+
@staticmethod
322+
def _resolve_a2ui_catalog(state: dict) -> "tuple[str | None, str | None] | None":
323+
"""Find the frontend-registered A2UI catalog wherever it was passed.
324+
325+
Returns ``(component_schema, catalog_id)`` when a catalog is present,
326+
else ``None`` (so the tool is never advertised when the client can't
327+
render A2UI). Two delivery paths are supported, because the catalog
328+
lands in different places depending on how the agent is served:
329+
330+
- **AG-UI native endpoint** → ``state["ag-ui"]["a2ui_schema"]``, a JSON
331+
string ``{"catalogId": ..., "components": [...]}``.
332+
- **CopilotKit runtime proxy** → a ``state["copilotkit"]["context"]``
333+
entry describing the A2UI catalog (catalog id + component schemas as
334+
text).
335+
336+
``component_schema`` is the text/JSON the subagent should compose from;
337+
``catalog_id`` binds generated surfaces to the frontend's catalog (so
338+
BYOC custom catalogs render their own components, not the basic one).
339+
"""
340+
# AG-UI native path.
341+
ag_ui = state.get("ag-ui") or {}
342+
a2ui_schema = ag_ui.get("a2ui_schema")
343+
if a2ui_schema:
344+
catalog_id = None
345+
try:
346+
parsed = (
347+
json.loads(a2ui_schema)
348+
if isinstance(a2ui_schema, str)
349+
else a2ui_schema
350+
)
351+
if isinstance(parsed, dict):
352+
catalog_id = parsed.get("catalogId")
353+
except (TypeError, ValueError):
354+
pass
355+
# Native path: the toolkit reads ``a2ui_schema`` from state itself,
356+
# so no composition_guide is needed — just surface the catalog id.
357+
return None, catalog_id
358+
359+
# CopilotKit runtime-proxy path: the catalog arrives as a context entry.
360+
context = (state.get("copilotkit") or {}).get("context") or []
361+
for entry in context:
362+
if not isinstance(entry, dict):
363+
continue
364+
description = entry.get("description") or ""
365+
value = entry.get("value") or ""
366+
if "A2UI catalog" not in description or not value:
367+
continue
368+
# The value lists catalogs as "- <catalogId>" lines; the first is
369+
# the custom catalog the client registered.
370+
match = re.search(r"(?m)^\s*-\s+(\S+)", value)
371+
catalog_id = match.group(1) if match else None
372+
return value, catalog_id
373+
374+
return None
375+
321376
def _maybe_build_a2ui_tool(self, request: ModelRequest) -> Any | None:
322377
"""Build a ``generate_a2ui`` tool bound to the agent's own model when
323-
the frontend has registered an A2UI catalog.
378+
the frontend has registered an A2UI catalog — wherever it was passed.
324379
325-
The catalog is surfaced by the AG-UI runtime into
326-
``state["ag-ui"]["a2ui_schema"]``; its presence is the signal that the
327-
client can actually render A2UI surfaces. Gating on it means agents
328-
whose frontend has no catalog never see the tool — so we never advertise
329-
a tool that would render nowhere — while keeping the feature fully
330-
zero-config: the developer only uses the middleware, nothing else.
380+
The catalog's presence is the signal that the client can actually render
381+
A2UI surfaces, so agents whose frontend has no catalog never see the
382+
tool (we never advertise a tool that would render nowhere) while the
383+
feature stays fully zero-config: the developer only uses the middleware.
331384
332385
The model is inferred from ``request.model`` (the bound agent model);
333-
the built tool is stashed for the tool-call hook to execute. Returns the
334-
tool (also appended to the advertised tools) or ``None`` when A2UI is
335-
not applicable.
386+
the component schema and catalog id come from the registered catalog so
387+
the subagent composes the right components and surfaces bind to the
388+
frontend's catalog. The built tool is stashed for the tool-call hook to
389+
execute. Returns the tool or ``None`` when A2UI is not applicable.
336390
"""
337391
if get_a2ui_tools is None:
338392
return None
339-
ag_ui = request.state.get("ag-ui") or {}
340-
if not ag_ui.get("a2ui_schema"):
393+
resolved = self._resolve_a2ui_catalog(request.state or {})
394+
if resolved is None:
341395
return None
342-
tool = get_a2ui_tools(request.model)
396+
component_schema, catalog_id = resolved
397+
398+
kwargs: dict[str, Any] = {}
399+
if catalog_id:
400+
kwargs["default_catalog_id"] = catalog_id
401+
# Feed the registered component schema to the subagent so it composes
402+
# only catalog components (the toolkit appends this to its prompt).
403+
if component_schema:
404+
kwargs["composition_guide"] = component_schema
405+
406+
tool = get_a2ui_tools(request.model, **kwargs)
343407
_a2ui_tools_by_thread[_current_thread_id() or _DEFAULT_THREAD_KEY] = tool
344408
return tool
345409

sdk-python/tests/test_copilotkit_lg_middleware.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,3 +1172,60 @@ def test_bridge_cleared_after_agent_stops_execution(self):
11721172
)
11731173

11741174
assert received.tool is None
1175+
1176+
# --- catalog sourced from wherever the frontend passed it ----------------
1177+
1178+
def test_injected_from_copilotkit_context(self):
1179+
"""CopilotKit runtime-proxy path: catalog arrives as a context entry."""
1180+
middleware = CopilotKitMiddleware()
1181+
state = {
1182+
"messages": [],
1183+
"copilotkit": {
1184+
"context": [
1185+
{
1186+
"description": "A2UI catalog capabilities: available "
1187+
"catalog IDs and custom component definitions.",
1188+
"value": "Available A2UI catalog:\n- my-custom-catalog\n"
1189+
" - Card: {...}\n - Metric: {...}",
1190+
}
1191+
]
1192+
},
1193+
}
1194+
request = _make_request(state=state, tools=[{"name": "backend"}])
1195+
1196+
seen, _ = _run_wrap(middleware, request)
1197+
1198+
names = [getattr(t, "name", None) or t.get("name") for t in seen.tools]
1199+
assert "generate_a2ui" in names
1200+
assert "backend" in names
1201+
1202+
def test_resolve_catalog_from_context_extracts_catalog_id(self):
1203+
schema, catalog_id = CopilotKitMiddleware._resolve_a2ui_catalog(
1204+
{
1205+
"copilotkit": {
1206+
"context": [
1207+
{
1208+
"description": "A2UI catalog capabilities",
1209+
"value": "Available A2UI catalog:\n- declarative-gen-ui-catalog\n ...",
1210+
}
1211+
]
1212+
}
1213+
}
1214+
)
1215+
assert catalog_id == "declarative-gen-ui-catalog"
1216+
assert schema and "declarative-gen-ui-catalog" in schema
1217+
1218+
def test_resolve_catalog_from_native_schema_extracts_catalog_id(self):
1219+
schema, catalog_id = CopilotKitMiddleware._resolve_a2ui_catalog(
1220+
{
1221+
"ag-ui": {
1222+
"a2ui_schema": json.dumps({"catalogId": "cat-x", "components": []})
1223+
}
1224+
}
1225+
)
1226+
# Native path: toolkit reads a2ui_schema from state, so no guide needed.
1227+
assert schema is None
1228+
assert catalog_id == "cat-x"
1229+
1230+
def test_resolve_catalog_none_when_absent(self):
1231+
assert CopilotKitMiddleware._resolve_a2ui_catalog({"messages": []}) is None

0 commit comments

Comments
 (0)