From a2547850af2f0f142b7b1b4a4888c599c4bc604f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 12:58:36 -0700 Subject: [PATCH] feat(a2ui): advertise client capabilities through the transports @threadplane/langgraph gains AgentOptions.a2uiClientCapabilities (merged into plain-object run payloads as a2ui_client_capabilities, mirroring client_tools); @threadplane/ag-ui seeds the same key into shared state via ToAgentOptions; threadplane-middleware adds the a2ui_client_capabilities(state) reader for graphs. Co-Authored-By: Claude Fable 5 --- .../content/docs/ag-ui/api/api-docs.json | 6 ++++ .../content/docs/langgraph/api/api-docs.json | 6 ++++ libs/ag-ui/src/lib/to-agent.spec.ts | 14 ++++++++ libs/ag-ui/src/lib/to-agent.ts | 17 ++++++++++ libs/langgraph/src/lib/agent.fn.ts | 6 +++- libs/langgraph/src/lib/agent.types.ts | 9 ++++++ libs/langgraph/src/lib/client-tools.spec.ts | 32 +++++++++++++++++++ libs/langgraph/src/lib/client-tools.ts | 19 +++++++++++ .../middleware/langgraph/__init__.py | 2 ++ .../middleware/langgraph/middleware.py | 18 +++++++++++ .../tests/test_middleware.py | 15 +++++++++ 11 files changed, 143 insertions(+), 1 deletion(-) diff --git a/apps/website/content/docs/ag-ui/api/api-docs.json b/apps/website/content/docs/ag-ui/api/api-docs.json index 34580a8ea..7bddde37c 100644 --- a/apps/website/content/docs/ag-ui/api/api-docs.json +++ b/apps/website/content/docs/ag-ui/api/api-docs.json @@ -525,6 +525,12 @@ "kind": "interface", "description": "", "properties": [ + { + "name": "a2uiClientCapabilities", + "type": "object", + "description": "A2UI client capabilities (catalog negotiation) to advertise to the agent.\nWhen set, they are seeded once into the AG-UI shared state under the\n`a2ui_client_capabilities` key, so every RunAgentInput.state carries them.\nUse `@threadplane/chat`'s `a2uiClientCapabilities()` for the renderer's\nstandard value.", + "optional": true + }, { "name": "telemetry", "type": "false | AgentRuntimeTelemetrySink", diff --git a/apps/website/content/docs/langgraph/api/api-docs.json b/apps/website/content/docs/langgraph/api/api-docs.json index 8a727c72a..ebc1ad96c 100644 --- a/apps/website/content/docs/langgraph/api/api-docs.json +++ b/apps/website/content/docs/langgraph/api/api-docs.json @@ -1022,6 +1022,12 @@ "kind": "interface", "description": "", "properties": [ + { + "name": "a2uiClientCapabilities", + "type": "object", + "description": "A2UI client capabilities (catalog negotiation) to advertise to the graph.\nWhen set, every plain-object run payload carries them under the\n`a2ui_client_capabilities` state key — mirroring how `client_tools`\nrides the payload. Read server-side via threadplane-middleware's\n`a2ui_client_capabilities(state)`. Use `@threadplane/chat`'s\n`a2uiClientCapabilities()` for the renderer's standard value.", + "optional": true + }, { "name": "apiUrl", "type": "string", diff --git a/libs/ag-ui/src/lib/to-agent.spec.ts b/libs/ag-ui/src/lib/to-agent.spec.ts index c2fe16dc2..27735185a 100644 --- a/libs/ag-ui/src/lib/to-agent.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.spec.ts @@ -100,6 +100,20 @@ function deferNextRun(source: StubAgent): { resolve: () => void; reject: (error: } describe('toAgent', () => { + it('seeds a2ui_client_capabilities into the source state when configured', () => { + const stub = new StubAgent(); + const caps = { supportedCatalogIds: ['https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json'] }; + toAgent(stub as unknown as AbstractAgent, { a2uiClientCapabilities: caps }); + expect((stub as unknown as { state: Record }).state['a2ui_client_capabilities']).toEqual(caps); + }); + + it('leaves the source state untouched when capabilities are not configured', () => { + const stub = new StubAgent(); + const before = (stub as unknown as { state?: Record }).state; + toAgent(stub as unknown as AbstractAgent); + expect((stub as unknown as { state?: Record }).state).toBe(before); + }); + it('starts with idle status and no messages', () => { const stub = new StubAgent(); const a = toAgent(stub as unknown as AbstractAgent); diff --git a/libs/ag-ui/src/lib/to-agent.ts b/libs/ag-ui/src/lib/to-agent.ts index aa0017e60..fdeb07804 100644 --- a/libs/ag-ui/src/lib/to-agent.ts +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -33,6 +33,14 @@ import { createClientToolsCapability } from './client-tools'; export interface ToAgentOptions { /** Optional app-owned telemetry sink. No telemetry is emitted unless this is provided. */ telemetry?: AgentRuntimeTelemetrySink | false; + /** + * A2UI client capabilities (catalog negotiation) to advertise to the agent. + * When set, they are seeded once into the AG-UI shared state under the + * `a2ui_client_capabilities` key, so every RunAgentInput.state carries them. + * Use `@threadplane/chat`'s `a2uiClientCapabilities()` for the renderer's + * standard value. + */ + a2uiClientCapabilities?: { supportedCatalogIds: string[]; inlineCatalogs?: unknown[] }; } function captureAgentRuntimeTelemetry( @@ -102,6 +110,15 @@ export interface AgUiAgent> extends Agent) ?? {}), + a2ui_client_capabilities: options.a2uiClientCapabilities, + }; + } + let generationSequence = 0; const allocateDeliveryGeneration = (scope: string): string => `${scope}-${++generationSequence}-${Math.random().toString(36).slice(2, 10)}`; diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index ccd39f08a..96208488e 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -69,6 +69,7 @@ import { buildBranchTree } from './internals/branch-tree'; import { extractCitations } from './internals/extract-citations'; import { createClientToolsCapability, + mergeA2uiClientCapabilities, mergeClientTools, mergeStagedToolMessages, } from './client-tools'; @@ -519,7 +520,10 @@ export function agent< const withStaged = staged.length > 0 ? mergeStagedToolMessages(request.payload, staged) : request.payload; - const payload = mergeClientTools(withStaged, clientToolsCap.catalog()); + const payload = mergeA2uiClientCapabilities( + mergeClientTools(withStaged, clientToolsCap.catalog()), + options.a2uiClientCapabilities, + ); const createsQueuedRun = request.options?.multitaskStrategy === 'enqueue' && isLoading(); if (!createsQueuedRun) { diff --git a/libs/langgraph/src/lib/agent.types.ts b/libs/langgraph/src/lib/agent.types.ts index 598343902..57b86a1db 100644 --- a/libs/langgraph/src/lib/agent.types.ts +++ b/libs/langgraph/src/lib/agent.types.ts @@ -283,6 +283,15 @@ export interface AgentOptions { filterSubagentMessages?: boolean; /** Tool names that indicate a subagent invocation. */ subagentToolNames?: string[]; + /** + * A2UI client capabilities (catalog negotiation) to advertise to the graph. + * When set, every plain-object run payload carries them under the + * `a2ui_client_capabilities` state key — mirroring how `client_tools` + * rides the payload. Read server-side via threadplane-middleware's + * `a2ui_client_capabilities(state)`. Use `@threadplane/chat`'s + * `a2uiClientCapabilities()` for the renderer's standard value. + */ + a2uiClientCapabilities?: { supportedCatalogIds: string[]; inlineCatalogs?: unknown[] }; /** * LangGraph node names whose `messages-tuple` LLM chunks should be projected * into the main chat transcript. Omit to accept all top-level message chunks. diff --git a/libs/langgraph/src/lib/client-tools.spec.ts b/libs/langgraph/src/lib/client-tools.spec.ts index bb1f535d5..add8bb8ed 100644 --- a/libs/langgraph/src/lib/client-tools.spec.ts +++ b/libs/langgraph/src/lib/client-tools.spec.ts @@ -4,6 +4,7 @@ import { signal } from '@angular/core'; import type { CompleteOutcome, ToolCall } from '@threadplane/chat'; import { createClientToolsCapability, + mergeA2uiClientCapabilities, mergeClientTools, mergeStagedToolMessages, } from './client-tools'; @@ -67,6 +68,37 @@ const STOCK_SPEC = { parameters: { type: 'object', properties: { ticker: { type: 'string' } } }, } as const; +// ─── mergeA2uiClientCapabilities helper ────────────────────────────────────── + +describe('mergeA2uiClientCapabilities', () => { + const CAPS = { supportedCatalogIds: ['https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json'] }; + + it('returns payload unchanged when capabilities are undefined', () => { + const payload = { messages: [] }; + expect(mergeA2uiClientCapabilities(payload, undefined)).toBe(payload); + }); + + it('returns null unchanged (command resume) even with capabilities set', () => { + expect(mergeA2uiClientCapabilities(null, CAPS)).toBeNull(); + }); + + it('merges a2ui_client_capabilities into a plain object payload without mutating it', () => { + const payload = { messages: [{ type: 'human', content: 'hi' }] }; + const result = mergeA2uiClientCapabilities(payload, CAPS) as Record; + expect(result).toEqual({ + messages: [{ type: 'human', content: 'hi' }], + a2ui_client_capabilities: CAPS, + }); + expect('a2ui_client_capabilities' in payload).toBe(false); + }); + + it('passes non-record payloads through unchanged', () => { + expect(mergeA2uiClientCapabilities('raw', CAPS)).toBe('raw'); + const arr = [1]; + expect(mergeA2uiClientCapabilities(arr, CAPS)).toBe(arr); + }); +}); + // ─── mergeClientTools helper ───────────────────────────────────────────────── describe('mergeClientTools', () => { diff --git a/libs/langgraph/src/lib/client-tools.ts b/libs/langgraph/src/lib/client-tools.ts index 04903cbab..78b1f350e 100644 --- a/libs/langgraph/src/lib/client-tools.ts +++ b/libs/langgraph/src/lib/client-tools.ts @@ -77,6 +77,25 @@ export function mergeClientTools( return { ...(payload as Record), client_tools: catalog }; } +/** + * Merge A2UI client capabilities into a run payload under the + * `a2ui_client_capabilities` state key. Same payload semantics as + * {@link mergeClientTools}: null/undefined payloads (command resumes, + * regenerates) and non-record payloads pass through untouched, and the + * original object is never mutated. Because LangGraph thread state + * persists across runs, the capabilities stamped by any run remain + * readable by later runs on the same thread. + */ +export function mergeA2uiClientCapabilities( + payload: unknown, + capabilities: { supportedCatalogIds: string[]; inlineCatalogs?: unknown[] } | undefined, +): unknown { + if (!capabilities) return payload; + if (payload === null || payload === undefined) return payload; + if (typeof payload !== 'object' || Array.isArray(payload)) return payload; + return { ...(payload as Record), a2ui_client_capabilities: capabilities }; +} + /** * Wire shape for a settled client-tool result awaiting durability. `id` is * deterministic for the tool-call ID so overlapping handoffs and retries diff --git a/packages/threadplane-middleware/src/threadplane/middleware/langgraph/__init__.py b/packages/threadplane-middleware/src/threadplane/middleware/langgraph/__init__.py index 90cfd5cb1..b17fae23a 100644 --- a/packages/threadplane-middleware/src/threadplane/middleware/langgraph/__init__.py +++ b/packages/threadplane-middleware/src/threadplane/middleware/langgraph/__init__.py @@ -2,6 +2,7 @@ """threadplane-middleware — LangGraph middleware for client-declared tools.""" from threadplane.middleware.langgraph.middleware import ( + a2ui_client_capabilities, bind_client_tools, client_tool_names, client_tool_specs, @@ -12,6 +13,7 @@ ) __all__ = [ + "a2ui_client_capabilities", "bind_client_tools", "client_tool_names", "client_tool_specs", diff --git a/packages/threadplane-middleware/src/threadplane/middleware/langgraph/middleware.py b/packages/threadplane-middleware/src/threadplane/middleware/langgraph/middleware.py index b24efe7cd..aad1f2c73 100644 --- a/packages/threadplane-middleware/src/threadplane/middleware/langgraph/middleware.py +++ b/packages/threadplane-middleware/src/threadplane/middleware/langgraph/middleware.py @@ -97,6 +97,24 @@ def agent_node(state): return llm.bind_tools([*server_tools, *client_tool_specs(state)]) +def a2ui_client_capabilities(state: dict) -> dict | None: + """Read the A2UI client capabilities advertised by the frontend. + + ``@threadplane/langgraph`` merges them into run payloads under the + ``a2ui_client_capabilities`` state key when the host configures + ``a2uiClientCapabilities`` on the agent. Returns the capabilities dict + (``{"supportedCatalogIds": [...], "inlineCatalogs": [...]}``) or ``None`` + when the client did not advertise any — use it to gate A2UI emission or + pick a catalog the renderer actually supports:: + + caps = a2ui_client_capabilities(state) + if caps and BASIC_CATALOG_ID in caps.get("supportedCatalogIds", []): + ...emit A2UI envelopes... + """ + caps = state.get("a2ui_client_capabilities") + return caps if isinstance(caps, dict) else None + + def route_after_agent( state: dict, server_tool_names: Iterable[str], diff --git a/packages/threadplane-middleware/tests/test_middleware.py b/packages/threadplane-middleware/tests/test_middleware.py index dc301a982..dacf791c5 100644 --- a/packages/threadplane-middleware/tests/test_middleware.py +++ b/packages/threadplane-middleware/tests/test_middleware.py @@ -350,3 +350,18 @@ def test_route_after_agent_pure_client_call_custom_end(): } result = route_after_agent(state, [], end="END") assert result == "END" + + +def test_a2ui_client_capabilities_reads_dict(): + from threadplane.middleware.langgraph import a2ui_client_capabilities + + caps = {"supportedCatalogIds": ["https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"]} + assert a2ui_client_capabilities({"a2ui_client_capabilities": caps}) == caps + + +def test_a2ui_client_capabilities_missing_or_malformed_is_none(): + from threadplane.middleware.langgraph import a2ui_client_capabilities + + assert a2ui_client_capabilities({}) is None + assert a2ui_client_capabilities({"a2ui_client_capabilities": "nope"}) is None + assert a2ui_client_capabilities({"a2ui_client_capabilities": ["x"]}) is None