Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/website/content/docs/ag-ui/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions apps/website/content/docs/langgraph/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions libs/ag-ui/src/lib/to-agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }).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<string, unknown> }).state;
toAgent(stub as unknown as AbstractAgent);
expect((stub as unknown as { state?: Record<string, unknown> }).state).toBe(before);
});

it('starts with idle status and no messages', () => {
const stub = new StubAgent();
const a = toAgent(stub as unknown as AbstractAgent);
Expand Down
17 changes: 17 additions & 0 deletions libs/ag-ui/src/lib/to-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -102,6 +110,15 @@ export interface AgUiAgent<TState = Record<string, unknown>> extends Agent<TStat
* ```
*/
export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): AgUiAgent {
// Advertise A2UI capabilities via the AG-UI shared state so every
// RunAgentInput.state carries them (transport metadata, A2UI v0.9).
if (options.a2uiClientCapabilities) {
source.state = {
...((source.state as Record<string, unknown>) ?? {}),
a2ui_client_capabilities: options.a2uiClientCapabilities,
};
}

let generationSequence = 0;
const allocateDeliveryGeneration = (scope: string): string =>
`${scope}-${++generationSequence}-${Math.random().toString(36).slice(2, 10)}`;
Expand Down
6 changes: 5 additions & 1 deletion libs/langgraph/src/lib/agent.fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { buildBranchTree } from './internals/branch-tree';
import { extractCitations } from './internals/extract-citations';
import {
createClientToolsCapability,
mergeA2uiClientCapabilities,
mergeClientTools,
mergeStagedToolMessages,
} from './client-tools';
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions libs/langgraph/src/lib/agent.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,15 @@ export interface AgentOptions<T, _ResolvedBag extends BagTemplate> {
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.
Expand Down
32 changes: 32 additions & 0 deletions libs/langgraph/src/lib/client-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { signal } from '@angular/core';
import type { CompleteOutcome, ToolCall } from '@threadplane/chat';
import {
createClientToolsCapability,
mergeA2uiClientCapabilities,
mergeClientTools,
mergeStagedToolMessages,
} from './client-tools';
Expand Down Expand Up @@ -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<string, unknown>;
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', () => {
Expand Down
19 changes: 19 additions & 0 deletions libs/langgraph/src/lib/client-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,25 @@ export function mergeClientTools(
return { ...(payload as Record<string, unknown>), 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<string, unknown>), 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -12,6 +13,7 @@
)

__all__ = [
"a2ui_client_capabilities",
"bind_client_tools",
"client_tool_names",
"client_tool_specs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
15 changes: 15 additions & 0 deletions packages/threadplane-middleware/tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading