.NET: A2UI (Agent-to-UI) toolkit, adapter, and AG-UI streaming#6494
.NET: A2UI (Agent-to-UI) toolkit, adapter, and AG-UI streaming#6494ranst91 wants to merge 24 commits into
Conversation
Adds the Microsoft.Agents.AI.AGUI.A2UI project (framework-agnostic A2UI generation helpers ported from @ag-ui/a2ui-toolkit and ag-ui-a2ui-toolkit) and its unit test project. Cross-language wire-contract constants are implemented and pinned by tests; operation builders are stubbed and their ported tests fail by design until the implementation lands. Signed-off-by: ran <ran@copilotkit.ai>
Ports all test classes from the sibling toolkits (Python tests/test_toolkit.py, tests/test_validate.py, tests/test_recovery.py and the TypeScript mirror suite) to xunit: validator semantics, recovery-loop semantics, prompt assembly, prior-surface reconstruction (11 ordering/delete cases), envelope assembly and untrusted-output narrowing, and tool-parameter resolution. The public API surface is stubbed (NotImplementedException), so 74 of 78 tests fail by design; constants pins are green. Duck-typing cases covered by the .NET type system (dict-shaped messages, non-list components) are documented instead of ported. Signed-off-by: ran <ran@copilotkit.ai>
Implements the full toolkit ported from @ag-ui/a2ui-toolkit (TypeScript) and ag-ui-a2ui-toolkit (Python): semantic component validation, the validate-and- retry generation recovery loop, operation builders, prompt assembly with verbatim default generation/design guidelines, prior-surface reconstruction, request preparation, envelope assembly with untrusted-output narrowing, and tool-parameter resolution. All 78 ported unit tests pass on net10.0; net472 test execution is a Windows-CI concern (zero tests run locally on macOS for sibling AGUI test projects as well). Signed-off-by: ran <ran@copilotkit.ai>
Adds the Agent Framework adapter over the framework-agnostic A2UI toolkit: A2UIAgent wraps any AIAgent and injects a per-run generate_a2ui AIFunction that reads the run's conversation history (prior-surface lookup) and the AG-UI context forwarded by MapAGUI (component catalog), delegates surface generation to a subagent chat client with a forced render_a2ui structured tool call, and returns a validated A2UI operations envelope through the shared recovery loop. Mirrors the LangGraph adapters' getA2UITools factory shape. Includes unit tests over a scripted subagent chat client. Signed-off-by: ran <ran@copilotkit.ai>
Adds three AG-UI dojo demos exercising the A2UI toolkit and adapter: - /a2ui_fixed_schema — author-owned card layouts (flights/hotels) where plain backend tools wrap agent-supplied data in A2UI operations envelopes. - /a2ui_dynamic_schema — A2UIAgent adapter path: a subagent designs the UI via the generate_a2ui tool against the dojo's dynamic catalog. - /a2ui_recovery — same path with the validate-and-retry recovery loop capped at three attempts for the failure showcase. Also makes the sample's model configuration dual-mode: the existing AZURE_OPENAI_ENDPOINT path, or OPENAI_API_KEY with an optional OPENAI_BASE_URL override (enables deterministic end-to-end tests against a local mock server). Signed-off-by: ran <ran@copilotkit.ai>
A string function result is JSON-serialized a second time on its way into the AG-UI tool-result event, double-encoding the A2UI envelope and breaking the middleware's hard-failure detection. Returning the parsed JsonElement keeps the envelope single-encoded on the wire. Signed-off-by: ran <ran@copilotkit.ai>
… events MEAI chat clients attach the typed FunctionCallContent only once a tool call's arguments are complete, so the AG-UI wire carried a single atomic TOOL_CALL_ARGS event per call — starving streaming consumers such as generative-UI middlewares that paint arguments progressively. The OpenAI argument fragments are still observable on each update's RawRepresentation: the hosting-side event conversion now surfaces them as incremental TOOL_CALL_ARGS events (one ToolCallStart per call, args deltas as they arrive) and suppresses the duplicate atomic emission when the coalesced FunctionCallContent follows, emitting only the closing event. Providers without raw OpenAI fragments keep the existing behavior. Verified live: a 36-fragment streamed call now yields 35 incremental TOOL_CALL_ARGS events (previously 1). Adds five unit tests; no regressions across the AGUI, hosting, and integration suites. Signed-off-by: ran <ran@copilotkit.ai>
AGUIContextAgent surfaces forwarded AG-UI context entries (the A2UI middleware's injected render-tool usage guide and component schema) to the model as a system message — the piece that makes the zero-configuration A2UI path work end to end: the client middleware injects the render_a2ui tool, the hosting layer binds it automatically, and with this wrapper a plain chat agent renders A2UI surfaces with progressively streamed tool arguments and no A2UI-specific agent code. Adds the /a2ui_chat endpoint to the AGUIDojoServer sample. Signed-off-by: ran <ran@copilotkit.ai>
…ls at end of stream Fixes for the raw argument-streaming tap: - Per-call state is released when the coalesced FunctionCallContent closes a raw-streamed call. OpenAI restarts tool-call indexes at 0 for each assistant turn, so a stale index entry silently absorbed a later round's fragments into the finished call and the new call's coalesced content re-emitted a duplicate atomic Start/Args/End. - An end-of-stream sweep closes any raw-streamed call whose coalesced content never arrived, so the wire never carries Start/Args without End. - The raw-path ToolCallStartEvent's possibly-null ParentMessageId is documented (schema-legal; ignored by the inbound builder). Call-site enumeration: - rawToolCallIdsByIndex: TryGetValue start-gate — holds, now sees a fresh index after a close; assignment site unchanged; new removal scoped to the just-closed call only. - rawStreamedToolCallIds: Add unchanged; dedupe-branch Contains became Remove (single consumer, End-only semantics preserved); new end-of-stream sweep reads then clears; Clear is idempotent. Tests: +3 (index reuse across rounds, end-of-stream sweep, parallel calls now assert one End per started call). Signed-off-by: ran <ran@copilotkit.ai>
Fixes: - FindPriorSurface deep-clones the captured component/data nodes: the public A2UIPriorSurface record exposes them, and a parent-attached JsonNode throws when a caller re-attaches it elsewhere. - ReadAgentState is now internal and shared by AGUIContextAgent, so both wrappers route the catalog schema entry into the canonical '## Available Components' section instead of diverging on the same input. - ToHistoryMessage walks every FunctionResultContent on a tool message (parallel calls) and accepts JsonNode-shaped results. Call-site enumeration: - ReadAgentState: A2UIAgent.PrepareRun (unchanged semantics) and the new AGUIContextAgent.WithContextPrompt caller — identical input contract. - ToHistoryMessage: single caller CreateGenerateA2UIFunction — only broader content acceptance. - FindPriorSurface captures: all existing consumers were read-only; clones preserve their behavior. Tests: +7 (JsonElement argument marshalling, schema/context routing, AGUIContextAgent behaviors, update-with-data envelope, prior-node detachability). Signed-off-by: ran <ran@copilotkit.ai>
Fixes: - Agent descriptions no longer claim Azure OpenAI: the dual-mode initialization made those claims false on the OpenAI-compatible path. - Endpoint configuration values are validated with Uri.TryCreate and fail with the offending key named instead of a bare UriFormatException. - UserSecretsId is a valid GUID (the previous value contained non-hex characters, breaking the dotnet user-secrets workflow). - The fixed-schema A2UI tools pass the source-generated serializer options like every other tool in the sample (JsonArray/JsonObject registered in the context); verified live against the mock server. Signed-off-by: ran <ran@copilotkit.ai>
…streaming sweep A2UIAgent.PrepareRun rebuilt the run options from scratch, silently dropping the base AgentRunOptions members a caller may have set (ContinuationToken, AllowBackgroundResponses, AdditionalProperties, ResponseFormat). The options are now cloned — via ChatClientAgentRunOptions.Clone() when the caller passed that type, or by copying the base members otherwise — and only the ChatOptions tool list is augmented. Callers' options objects are no longer mutated. In the AG-UI streaming conversion, the end-of-stream sweep that closes raw-streamed tool calls now walks calls in tool-call index order so the emitted ToolCallEnd events are deterministic, and the coalesced-call branch closes any open reasoning block before emitting ToolCallEnd, matching the other tool-event paths. Also: derive RenderA2UIToolDeclaration.Description from the canonical tool definition instead of a duplicated literal, pin the remaining wire-contract constants in tests, document the deliberate pricePerNight/price cross-naming in the fixed-schema sample, clarify the recovery sample's validation scope, and add tests for run-option preservation, caller-tool retention, the streaming entry point, and update-intent prior-surface lookup through conversation history. Signed-off-by: ran <ran@copilotkit.ai>
Tighten the parallel tool-call streaming test to assert the deterministic index-ordered close sweep and the per-call start events on the wire instead of comparing a re-sorted list, document the a2ui_recovery activity-type constant's role in the cross-language contract, reference the default attempt cap by its constant in the recovery sample, and drop an unused FluentAssertions package reference from the toolkit test project. Signed-off-by: ran <ran@copilotkit.ai>
The recovery status channel rides the a2ui-surface lifecycle activity today; this constant is reserved wire vocabulary mirroring the TypeScript toolkit's A2UI_RECOVERY_ACTIVITY_TYPE, not something the middleware currently emits as an activity type. Say so instead of implying active emission. Signed-off-by: ran <ran@copilotkit.ai>
The streaming entry point ran generate_a2ui through automatic function invocation, so the planner's tool call and the render subagent's output were both swallowed inside one inner-agent turn: the wire carried a single atomic tool result and the surface painted in one bulk update with no loading state. A2UIAgent.RunCoreStreamingAsync now runs the invocation loop at the agent level. generate_a2ui is advertised as a schema-only declaration so the planner's call surfaces on the update stream; the adapter then runs the render subagent with a streaming chat call and forwards its updates, so the hosting layer emits the inner render_a2ui argument fragments incrementally and the surface paints one component at a time. A streaming twin of the recovery loop makes each retry a fresh, visible subagent call. The validated envelope is fed back to the planner as a tool result and the conversation continues — the same event shape the LangGraph adapters produce. The non-streaming RunCoreAsync path is unchanged. Tests: +2 (subagent updates forwarded with the result fed back to the planner; an invalid first attempt retries with a second visible subagent call), and the existing injection test now asserts a declaration rather than an invocable function. Signed-off-by: ran <ran@copilotkit.ai>
Streaming the subagent's render_a2ui call onto the wire makes it part of the persisted conversation, but its output was consumed internally to build the generate_a2ui envelope, so the call had no matching tool result. A later turn replaying that history failed (e.g. OpenAI rejects an assistant tool call with no responding tool message), so the next user message produced no response. Each forwarded render_a2ui call now emits a bare acknowledgement tool result, keeping the conversation valid across turns. The painted surface still comes from the streamed arguments, so the result carries no operations. Tests: the two streaming tests now assert every forwarded render call is balanced with its own result. Signed-off-by: ran <ran@copilotkit.ai>
…e the planner-round cap, share attempt validation Refinements to the streaming generate_a2ui path: - The render subagent's arguments were read off each streamed update and overwritten, which keeps only the last fragment if a chat client streams a tool call's arguments across updates. The updates are now accumulated and coalesced via ToChatResponse() before the complete render_a2ui call is read, matching the non-streaming path. - When the planner kept requesting generations through the round cap, the loop ended on an unanswered tool result with no closing assistant turn. It now runs one final planner turn with the generate tool withheld, so the planner narrates and cannot request another surface. - The per-attempt validate-and-record step is extracted into A2UIGenerationRecovery.ValidateAttempt and shared by the streaming loop and the non-streaming recovery loop, so the two cannot drift on attempt semantics. Tests: +3 streaming cases (recovery exhaustion with the OnAttempt callback, update intent finding a prior surface through the streamed history, and the planner-round cap closing with the generate tool withheld). Signed-off-by: ran <ran@copilotkit.ai>
…, isolate the closing-turn options, guard a non-positive attempt cap Refinements to the streaming generate_a2ui path: - The per-round assistant message fed back to the planner was reconstructed from only its generate_a2ui calls, dropping any narration the planner streamed in the same turn. The planner's text content is now preserved alongside the calls so the resent history is not lossy. - The round-cap closing turn mutated the loop's shared chat options to withhold the generate tool; it now runs against a fresh options instance. - A non-positive A2UIRecoveryConfig.MaxAttempts skipped the loop entirely and emitted a confusing 'after 0 attempt(s)' envelope. A new shared A2UIGenerationRecovery.ResolveMaxAttempts treats a non-positive cap as unset and falls back to the default, used by both generation paths. Tests: +1 (planner narration survives into the next round's history). Signed-off-by: ran <ran@copilotkit.ai>
Add a README for AGUIDojoServer listing its per-feature AG-UI endpoints (including the four A2UI demos), the dual Azure-OpenAI / OpenAI-compatible configuration, and how to run it against the AG-UI Dojo viewer, and reference it from the parent AGUIClientServer README. Signed-off-by: ran <ran@copilotkit.ai>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds progressive tool-call argument streaming support (via OpenAI raw updates) and introduces a new A2UI toolkit library with comprehensive unit tests and sample endpoints.
Changes:
- Emit incremental
ToolCallArgsEventframes from OpenAIRawRepresentationfragments and suppress duplicate atomic args emission. - Add
Microsoft.Agents.AI.AGUI.A2UItoolkit (prompting, envelope building, validation, recovery loop) plus extensive unit coverage. - Expand the Dojo sample server with multiple A2UI endpoints and OpenAI/Azure OpenAI configuration support.
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/StreamingToolCallArgsTests.cs | Adds unit tests validating incremental tool-call arg streaming behavior. |
| dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests.csproj | Introduces a new unit test project for the A2UI toolkit. |
| dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/AGUIContextAgentTests.cs | Tests that forwarded AG-UI context is surfaced to the model. |
| dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIPromptBuildingTests.cs | Tests prompt assembly for context + subagent prompts. |
| dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIOperationBuilderTests.cs | Tests v0.9 operation builder output shapes. |
| dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIGenerationRecoveryTests.cs | Tests validate-and-retry recovery loop semantics and envelopes. |
| dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIFindPriorSurfaceTests.cs | Tests conversation-history surface reconstruction rules. |
| dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIEnvelopeTests.cs | Tests envelope assembly, tool defs, and param defaulting. |
| dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIConstantsTests.cs | Pins cross-language constants to prevent protocol drift. |
| dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs | Tests structural/semantic validation behavior. |
| dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs | Tests A2UIAgent tool injection and streaming/non-streaming behavior. |
| dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj | Adds OpenAI dependency needed for raw-update tap on ASP.NET Core build. |
| dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs | Implements incremental tool-call arg streaming from OpenAI raw updates. |
| dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj | Adds new internal A2UI toolkit project and dependencies. |
| dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/AGUIContextAgent.cs | Adds agent wrapper to prepend forwarded AG-UI context as a system message. |
| dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs | Adds A2UI validator, error codes, and catalog model. |
| dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UITypes.cs | Adds shared A2UI types (state, history, prompts, prior surface, etc.). |
| dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolkit.cs | Adds prompt building, surface-walk, request prep, and envelope helpers. |
| dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolParams.cs | Adds shared tool params and canonical tool definitions/defaults. |
| dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIOperationBuilder.cs | Adds builders for create/update operations (v0.9). |
| dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs | Adds async recovery loop and exhaustion envelope logic. |
| dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs | Adds protocol constants shared cross-language. |
| dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs | Adds Agent Framework adapter that injects generate_a2ui tool per run. |
| dotnet/samples/05-end-to-end/AGUIClientServer/README.md | Documents new Dojo server project within the sample. |
| dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/README.md | Adds endpoint list and configuration docs for Dojo server. |
| dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs | Maps additional A2UI endpoints in Dojo server. |
| dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs | Adds A2UI demo agent factories and OpenAI-compatible configuration. |
| dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs | Adds JsonNode types to source-gen serialization context. |
| dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj | Fixes BOM/metadata; references new A2UI toolkit project. |
| dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UIFixedSchemaTools.cs | Adds fixed-schema A2UI tools emitting operations envelopes. |
| dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UICompositionGuides.cs | Adds Dojo-specific composition guides for A2UI subagent prompting. |
| dotnet/agent-framework-dotnet.slnx | Adds new A2UI project + test project to the solution. |
Comments suppressed due to low confidence (1)
dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests.csproj:1
- This test project file is missing the usual test-project configuration (e.g.,
TargetFramework(s),IsTestProject, and test dependencies like xUnit + test SDK), unless those are injected via shared repo-wide props/targets. If they are not centrally provided, the project won’t compile or run tests; consider aligning it with the structure of existing*.UnitTests.csprojfiles in the repo.
| var closingChatOptions = chatOptions.Clone(); | ||
| closingChatOptions.Tools = (chatOptions.Tools ?? Enumerable.Empty<AITool>()) | ||
| .Where(t => !string.Equals(t.Name, generateTool.Name, StringComparison.Ordinal)) | ||
| .ToList(); | ||
| var closingOptions = new ChatClientAgentRunOptions { ChatOptions = closingChatOptions, ChatClientFactory = runOptions.ChatClientFactory }; | ||
| await foreach (AgentResponseUpdate update in this.InnerAgent | ||
| .RunStreamingAsync(history, pendingSession, closingOptions, cancellationToken) | ||
| .ConfigureAwait(false)) |
| var ids = new HashSet<string>(StringComparer.Ordinal); | ||
| foreach (JsonNode? node in components) | ||
| { | ||
| if (node is JsonObject component && component["id"] is JsonValue idValue && | ||
| idValue.TryGetValue(out string? id) && !ids.Add(id)) | ||
| { | ||
| errors.Add(new A2UIValidationError( | ||
| A2UIValidationErrorCodes.DuplicateId, | ||
| $"components[id={id}]", | ||
| $"Duplicate component id '{id}'")); | ||
| } | ||
| } |
| if (component is not null) | ||
| { | ||
| foreach (string reference in CollectChildReferences(component["children"])) | ||
| { | ||
| if (!ids.Contains(reference)) | ||
| { | ||
| errors.Add(new A2UIValidationError( | ||
| A2UIValidationErrorCodes.UnresolvedChild, | ||
| $"components[{i}].children", | ||
| $"Child reference '{reference}' does not match any component id")); | ||
| } | ||
| } |
| if (rawStreamedToolCallIds.Remove(functionCallContent.CallId)) | ||
| { | ||
| int? closedIndex = null; | ||
| foreach (KeyValuePair<int, string> entry in rawToolCallIdsByIndex) | ||
| { | ||
| if (string.Equals(entry.Value, functionCallContent.CallId, StringComparison.Ordinal)) | ||
| { | ||
| closedIndex = entry.Key; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| if (closedIndex is int index) | ||
| { | ||
| rawToolCallIdsByIndex.Remove(index); | ||
| } |
| <ItemGroup> | ||
| <!-- Raw-representation tap for progressive tool-call argument streaming. --> | ||
| <PackageReference Include="OpenAI" /> | ||
| </ItemGroup> |
There was a problem hiding this comment.
The repo uses Central Package Management (ManagePackageVersionsCentrally=true in Directory.Packages.props), so versions are pinned centrally, not on the PackageReference. OpenAI is pinned there at 2.10.0 (Directory.Packages.props:120). An unversioned PackageReference is the required pattern under CPM adding a version here would actually error
| var ids = new HashSet<string>(StringComparer.Ordinal); | ||
| foreach (JsonNode? node in components) | ||
| { | ||
| if (node is JsonObject component && component["id"] is JsonValue idValue && | ||
| idValue.TryGetValue(out string? id) && !ids.Add(id)) | ||
| { | ||
| errors.Add(new A2UIValidationError( | ||
| A2UIValidationErrorCodes.DuplicateId, | ||
| $"components[id={id}]", | ||
| $"Duplicate component id '{id}'")); | ||
| } | ||
| } |
The round-cap closing turn rebuilt the run options as a partial ChatClientAgentRunOptions carrying only ChatOptions and ChatClientFactory, dropping the base AgentRunOptions members (continuation token, background-response opt-in, additional properties, response format) for that final planner turn. Clone the run options instead, then strip only the generate tool from the cloned chat options, so all members survive. Tests: the planner-round-cap test now passes base run-option members and asserts they reach the closing turn. Signed-off-by: ran <ran@copilotkit.ai>
The duplicate-id pass added every string id to its set, so two components with an empty-string id flagged each other as duplicate_id on top of (or instead of) the intended missing_id. Skip empty ids in the duplicate pass so they are reported once as a missing id. Tests: empty-string ids report missing_id and not duplicate_id. Signed-off-by: ran <ran@copilotkit.ai>
The validator collected child references from the plural `children` field only, so a dangling singular `child` reference (the one-child container shape the default prompt uses for Card and Button) passed validation and surfaced as a render-time failure the recovery loop could not catch. It now validates both `child` and `children`, and CollectChildReferences handles a bare string id (the singular shape) in addition to arrays and templates. Scope: dangling-reference resolution only; cycle/self-reference detection remains a separate gap. Tests: a dangling singular `child` reports unresolved_child; a resolved one is valid. Signed-off-by: ran <ran@copilotkit.ai>
The OpenAI streaming tap recovered a finished tool call's fragment index with a linear scan over the index map. Track a callId-to-index map alongside the index-to-callId map so the close is a direct lookup, and keep the two in lockstep on open and close. The end-of-stream sweep still emits closes in tool-call index order. No change to the emitted event sequence. Signed-off-by: ran <ran@copilotkit.ai>
A component whose child/children references form a cycle (a self-reference like id 'avatar' with child 'avatar', or a longer loop) passed validation and never terminated at render time, even though the generation prompt tells the model the child tree must be a DAG. The validator now walks the child graph and reports a child_cycle error, feeding the failure back into the recovery loop instead of letting it reach the renderer. The walk is an iterative (explicit-stack) depth-first search so a pathologically deep child chain in untrusted model output cannot overflow the call stack. Each cycle is canonicalised (rotated so the lexicographically smallest id leads) and reported once; the error code and message format match the TypeScript and Python toolkits for cross-language parity. Tests: self-reference and multi-component cycles report child_cycle (once), an acyclic graph does not, and a 20k-deep linear chain validates without overflow. Signed-off-by: ran <ran@copilotkit.ai>
Ports the A2UI generation toolkit to .NET, matching the existing TypeScript
(
@ag-ui/a2ui-toolkit) and Python (ag-ui-a2ui-toolkit) implementations, so a .NET agentcan produce A2UI v0.9 surfaces (agent-generated UI) over the AG-UI protocol. The
cross-language wire contract (envelope shape, validation codes, recovery semantics, prompt
defaults) stays aligned.
What's added
Microsoft.Agents.AI.AGUI.A2UI(new,IsPackable=false, internal building block):component validator, validate-and-retry recovery loop (returns a structured
a2ui_recovery_exhaustedenvelope on exhaustion rather than throwing), prior-surface walk,and envelope assembly with untrusted-output narrowing. Ported behavior-for-behavior from the
siblings.
A2UIAgent/AGUIContextAgent, the Agent Framework adapter (the .NETgetA2UIToolsequivalent).
A2UIAgentinjects a per-rungenerate_a2uitool backed by a render subagent.AGUIContextAgentreplays forwardedag_ui_contextinto the prompt, the piece that makesthe zero-config path work.
surfaces paint component-by-component instead of in one bulk update. On the subagent path the
generation loop runs at the agent level so the inner
render_a2uiargs reach the wireincrementally, the same event shape the LangGraph adapters produce.
AGUIDojoServersample (plus README):/a2ui_fixed_schema,/a2ui_dynamic_schema,/a2ui_recovery,/a2ui_chat.Design notes (flagged for direction)
OpenAIdependency onHosting.AGUI.AspNetCore.Microsoft.Extensions.AIcoalesces tool-call arguments; the per-fragment deltas a progressive UI needs survive only
on the provider's
RawRepresentation, so the tap reaches intoOpenAI.Chat.StreamingChatCompletionUpdate. It is#if ASPNETCORE-gated and fails soft(non-OpenAI providers keep atomic args, so surfaces still render). This couples hosting to
one SDK, which we would rather avoid. The preferred fix is a provider-neutral
incremental-tool-call type in MEAI, after which the tap deletes itself. Guidance welcome.
AGUIContextAgentexists only because
MapAGUIforwardsag_ui_contextbut nothing replays it into the modelprompt. This could arguably be a hosting default. Flagging it.
Testing
Microsoft.Agents.AI.AGUI.A2UI.UnitTests(103) plus hosting streaming-tap cases; noregressions in
AGUI.UnitTests(216) or hosting integration tests (32). End-to-end verifiedagainst the AG-UI Dojo: fixed/dynamic/recovery/zero-config surfaces stream progressively,
recovery recovers, multi-turn stays valid. (Local
net10.0; full TFM matrix in CI.)