From 9fab0b9dd95ad61a341e80ec551dac3a913bdbb2 Mon Sep 17 00:00:00 2001 From: ran Date: Wed, 10 Jun 2026 16:19:58 +0200 Subject: [PATCH 01/24] .NET: Scaffold the AG-UI A2UI toolkit and its test project 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 --- dotnet/agent-framework-dotnet.slnx | 2 + .../A2UIConstants.cs | 56 ++++++++++++++ .../A2UIOperationBuilder.cs | 47 ++++++++++++ .../Microsoft.Agents.AI.AGUI.A2UI.csproj | 17 +++++ .../A2UIConstantsTests.cs | 42 +++++++++++ .../A2UIOperationBuilderTests.cs | 75 +++++++++++++++++++ ...osoft.Agents.AI.AGUI.A2UI.UnitTests.csproj | 11 +++ 7 files changed, 250 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIOperationBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIConstantsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIOperationBuilderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests.csproj diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 5c846f0def..d45c5f575d 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -598,6 +598,7 @@ + @@ -656,6 +657,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs new file mode 100644 index 0000000000..19f9a88ba9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.AGUI.A2UI; + +/// +/// Shared constants for the A2UI toolkit, mirroring the canonical values used by the +/// TypeScript (@ag-ui/a2ui-toolkit) and Python (ag-ui-a2ui-toolkit) implementations. +/// +/// +/// These values are part of the cross-language A2UI wire contract and must not diverge +/// from the sibling implementations. +/// +public static class A2UIConstants +{ + /// + /// The JSON key that wraps an array of A2UI operations in a tool result envelope. + /// AG-UI middlewares scan tool results for this key to detect renderable surfaces. + /// + public const string A2UIOperationsKey = "a2ui_operations"; + + /// + /// The catalog identifier of the A2UI v0.9 basic component catalog. + /// Used as the default catalog when the host does not configure one. + /// + public const string BasicCatalogId = "https://a2ui.org/specification/v0_9/basic_catalog.json"; + + /// + /// The fallback surface identifier used when the model output does not carry a usable one. + /// + public const string DefaultSurfaceId = "dynamic-surface"; + + /// + /// The protocol version stamped on every emitted A2UI operation. + /// + public const string ProtocolVersion = "v0.9"; + + /// + /// The default name of the planner-facing tool that delegates surface generation to a subagent. + /// + public const string GenerateA2UIToolName = "generate_a2ui"; + + /// + /// The name of the inner structured-output tool the subagent is forced to call. + /// + public const string RenderA2UIToolName = "render_a2ui"; + + /// + /// The default maximum number of generation attempts in the validate-and-retry recovery loop. + /// + public const int MaxA2UIAttempts = 3; + + /// + /// The activity type identifier for A2UI recovery lifecycle records. + /// + public const string A2UIRecoveryActivityType = "a2ui_recovery"; +} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIOperationBuilder.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIOperationBuilder.cs new file mode 100644 index 0000000000..f871419f4c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIOperationBuilder.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace Microsoft.Agents.AI.AGUI.A2UI; + +/// +/// Builders for the A2UI v0.9 operations that make up a surface lifecycle: +/// createSurface, updateComponents, and updateDataModel. +/// +/// +/// Each builder returns a single operation object of the shape +/// { "version": "v0.9", "<operation>": { ... } }, matching the A2UI v0.9 +/// envelope specification and the sibling TypeScript/Python toolkit implementations. +/// +public static class A2UIOperationBuilder +{ + /// + /// Builds a createSurface operation. + /// + /// The identifier of the surface to create. + /// The identifier of the component catalog the surface renders against. + /// The operation as a . + public static JsonObject CreateSurface(string surfaceId, string catalogId) + => throw new NotImplementedException(); + + /// + /// Builds an updateComponents operation carrying a flat component array. + /// + /// The identifier of the target surface. + /// The flat A2UI component array. + /// The operation as a . + public static JsonObject UpdateComponents(string surfaceId, IEnumerable components) + => throw new NotImplementedException(); + + /// + /// Builds an updateDataModel operation that writes at . + /// + /// The identifier of the target surface. + /// The value to write into the surface data model. + /// The JSON-pointer-style path to write at. Defaults to the root path "/". + /// The operation as a . + public static JsonObject UpdateDataModel(string surfaceId, JsonNode? value, string path = "/") + => throw new NotImplementedException(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj new file mode 100644 index 0000000000..0e7aa6f07c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj @@ -0,0 +1,17 @@ + + + + + Microsoft Agent Framework AG-UI A2UI Toolkit + Framework-agnostic helpers for building A2UI (Agent-to-UI) generation tools on top of the AG-UI protocol. + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIConstantsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIConstantsTests.cs new file mode 100644 index 0000000000..4e8358cbea --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIConstantsTests.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests; + +/// +/// Unit tests for . +/// +/// +/// These values are the cross-language A2UI wire contract shared with the TypeScript and +/// Python toolkits; the tests pin them so a refactor cannot silently change the protocol. +/// +public sealed class A2UIConstantsTests +{ + [Fact] + public void A2UIOperationsKey_MatchesCrossLanguageContract() + { + // Assert + Assert.Equal("a2ui_operations", A2UIConstants.A2UIOperationsKey); + } + + [Fact] + public void BasicCatalogId_MatchesCrossLanguageContract() + { + // Assert + Assert.Equal("https://a2ui.org/specification/v0_9/basic_catalog.json", A2UIConstants.BasicCatalogId); + } + + [Fact] + public void DefaultSurfaceId_MatchesCrossLanguageContract() + { + // Assert + Assert.Equal("dynamic-surface", A2UIConstants.DefaultSurfaceId); + } + + [Fact] + public void RecoveryDefaults_MatchCrossLanguageContract() + { + // Assert + Assert.Equal(3, A2UIConstants.MaxA2UIAttempts); + Assert.Equal("a2ui_recovery", A2UIConstants.A2UIRecoveryActivityType); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIOperationBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIOperationBuilderTests.cs new file mode 100644 index 0000000000..457864ba94 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIOperationBuilderTests.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Nodes; + +namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests; + +/// +/// Unit tests for . +/// +/// +/// Ported from the Python toolkit's TestOpBuilders and the TypeScript toolkit's +/// op-builder tests to guarantee byte-level envelope parity across languages. +/// +public sealed class A2UIOperationBuilderTests +{ + [Fact] + public void CreateSurface_ReturnsVersionedOperation() + { + // Act + JsonObject op = A2UIOperationBuilder.CreateSurface("s1", "catalog-1"); + + // Assert + Assert.Equal("v0.9", (string?)op["version"]); + JsonObject createSurface = Assert.IsType(op["createSurface"]); + Assert.Equal("s1", (string?)createSurface["surfaceId"]); + Assert.Equal("catalog-1", (string?)createSurface["catalogId"]); + } + + [Fact] + public void UpdateComponents_WrapsComponentArray() + { + // Arrange + var components = new JsonNode?[] + { + new JsonObject { ["id"] = "root", ["component"] = "Row" }, + }; + + // Act + JsonObject op = A2UIOperationBuilder.UpdateComponents("s1", components); + + // Assert + Assert.Equal("v0.9", (string?)op["version"]); + JsonObject updateComponents = Assert.IsType(op["updateComponents"]); + Assert.Equal("s1", (string?)updateComponents["surfaceId"]); + JsonArray array = Assert.IsType(updateComponents["components"]); + JsonObject component = Assert.IsType(Assert.Single(array)); + Assert.Equal("root", (string?)component["id"]); + } + + [Fact] + public void UpdateDataModel_DefaultsToRootPath() + { + // Act + JsonObject op = A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["items"] = new JsonArray() }); + + // Assert + Assert.Equal("v0.9", (string?)op["version"]); + JsonObject updateDataModel = Assert.IsType(op["updateDataModel"]); + Assert.Equal("s1", (string?)updateDataModel["surfaceId"]); + Assert.Equal("/", (string?)updateDataModel["path"]); + Assert.NotNull(updateDataModel["value"]); + } + + [Fact] + public void UpdateDataModel_HonorsCustomPath() + { + // Act + JsonObject op = A2UIOperationBuilder.UpdateDataModel("s1", JsonValue.Create(42), "/answer"); + + // Assert + JsonObject updateDataModel = Assert.IsType(op["updateDataModel"]); + Assert.Equal("/answer", (string?)updateDataModel["path"]); + Assert.Equal(42, (int?)updateDataModel["value"]); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests.csproj new file mode 100644 index 0000000000..d33a1153df --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + From 086bd9aaf3d72c0fba20dfc29fa5f90c43d4ea76 Mon Sep 17 00:00:00 2001 From: ran Date: Wed, 10 Jun 2026 16:37:09 +0200 Subject: [PATCH 02/24] .NET: Port the A2UI toolkit unit test suite ahead of the implementation 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 --- .../A2UIGenerationRecovery.cs | 98 +++++ .../A2UIToolParams.cs | 114 ++++++ .../A2UIToolkit.cs | 149 +++++++ .../A2UITypes.cs | 91 +++++ .../A2UIValidation.cs | 114 ++++++ .../Microsoft.Agents.AI.AGUI.A2UI.csproj | 1 + .../A2UIComponentValidatorTests.cs | 275 +++++++++++++ .../A2UIEnvelopeTests.cs | 371 ++++++++++++++++++ .../A2UIFindPriorSurfaceTests.cs | 242 ++++++++++++ .../A2UIGenerationRecoveryTests.cs | 204 ++++++++++ .../A2UIPromptBuildingTests.cs | 218 ++++++++++ 11 files changed, 1877 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolParams.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolkit.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UITypes.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIEnvelopeTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIFindPriorSurfaceTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIGenerationRecoveryTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIPromptBuildingTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs new file mode 100644 index 0000000000..d947b2c792 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.AGUI.A2UI; + +/// +/// One attempt of the A2UI validate-and-retry generation loop. +/// +/// The 1-based attempt number. +/// Whether the attempt produced a valid component tree. +/// The validation errors when is . +public sealed record A2UIAttemptRecord(int Attempt, bool Ok, IReadOnlyList Errors); + +/// +/// Configuration for the A2UI generation recovery loop. +/// +public sealed class A2UIRecoveryConfig +{ + /// + /// Gets the maximum number of generation attempts. Defaults to + /// when unset. + /// + public int? MaxAttempts { get; init; } +} + +/// +/// The outcome of the A2UI generation recovery loop. +/// +/// +/// The operations envelope on success, or a structured hard-failure envelope +/// (code: "a2ui_recovery_exhausted") when all attempts failed. +/// +/// The per-attempt records, in order. +/// Whether a valid surface was produced. +public sealed record A2UIRecoveryResult(string Envelope, IReadOnlyList Attempts, bool Ok); + +/// +/// The A2UI validate-and-retry generation loop, mirroring +/// runA2UIGenerationWithRecovery / run_a2ui_generation_with_recovery in the sibling toolkits. +/// +/// +/// Each attempt invokes the subagent, validates the structured tool output, and either +/// returns the built envelope (first valid attempt wins) or retries with the prior +/// attempt's errors appended to the prompt. A missing tool call counts as a failed, +/// retryable attempt. After the attempt cap is reached, a structured hard-failure +/// envelope is returned instead of throwing, so the conversation stays usable. +/// +public static class A2UIGenerationRecovery +{ + /// + /// Formats validation errors as a compact, model-readable list + /// (- [code] path: message per line). + /// + /// The errors to format. + /// The formatted block. + public static string FormatValidationErrors(IEnumerable errors) + => throw new NotImplementedException(); + + /// + /// Appends a fix-it block carrying to . + /// Returns the prompt unchanged when there are no errors. + /// + /// The base subagent prompt. + /// The prior attempt's validation errors. + /// The augmented prompt. + public static string AugmentPromptWithValidationErrors(string prompt, IReadOnlyList errors) + => throw new NotImplementedException(); + + /// + /// Runs the validate-and-retry loop until a valid surface is produced or the attempt cap is reached. + /// + /// The subagent system prompt produced by request preparation. + /// + /// Invokes the subagent with the (possibly error-augmented) prompt and the 1-based attempt + /// number, returning the structured render_a2ui tool arguments, or + /// when the model did not call the tool. + /// + /// Builds the final operations envelope from validated tool arguments. + /// The catalog used for semantic validation, when available. + /// Loop configuration overrides. + /// Observability callback invoked after each attempt is validated. + /// A token to cancel the loop between attempts. + /// The loop outcome. + public static Task RunAsync( + string basePrompt, + Func> invokeSubagentAsync, + Func buildEnvelope, + A2UIValidationCatalog? catalog = null, + A2UIRecoveryConfig? config = null, + Action? onAttempt = null, + CancellationToken cancellationToken = default) + => throw new NotImplementedException(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolParams.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolParams.cs new file mode 100644 index 0000000000..6bd28c3eed --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolParams.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Nodes; + +namespace Microsoft.Agents.AI.AGUI.A2UI; + +/// +/// Shared behavior knobs for A2UI tool factories. Every framework adapter accepts this +/// exact shape, so a new knob reaches all adapters without signature changes. +/// +/// +/// Mirrors A2UIToolParams in the sibling toolkits, minus the model field: +/// in .NET the subagent chat client is a framework concern owned by the adapter's own +/// factory signature, not by this parameter object. +/// +public sealed class A2UIToolParams +{ + /// Gets the prompt-section overrides. + public A2UIGuidelines? Guidelines { get; init; } + + /// Gets the fallback surface id. Empty or unset falls back to . + public string? DefaultSurfaceId { get; init; } + + /// Gets the catalog id for created surfaces. Empty or unset falls back to . + public string? DefaultCatalogId { get; init; } + + /// Gets the planner-facing tool name. Empty or unset falls back to . + public string? ToolName { get; init; } + + /// Gets the planner-facing tool description. Empty or unset falls back to the canonical description. + public string? ToolDescription { get; init; } + + /// Gets the catalog used for semantic validation in the recovery loop. + public A2UIValidationCatalog? Catalog { get; init; } + + /// Gets the recovery-loop configuration. + public A2UIRecoveryConfig? Recovery { get; init; } + + /// Gets the per-attempt observability callback. + public Action? OnAttempt { get; init; } +} + +/// +/// with every defaultable field resolved to its effective value. +/// +/// The prompt-section overrides, passed through. +/// The effective fallback surface id. +/// The effective default catalog id. +/// The effective planner-facing tool name. +/// The effective planner-facing tool description. +/// The validation catalog, passed through. +/// The recovery configuration, passed through. +/// The per-attempt callback, passed through. +public sealed record A2UIResolvedToolParams( + A2UIGuidelines? Guidelines, + string DefaultSurfaceId, + string DefaultCatalogId, + string ToolName, + string ToolDescription, + A2UIValidationCatalog? Catalog, + A2UIRecoveryConfig? Recovery, + Action? OnAttempt); + +/// +/// Canonical tool definitions and descriptions shared by all A2UI adapters. +/// +public static class A2UIToolDefinitions +{ + /// + /// Gets the planner-facing description of the generate_a2ui tool. + /// + public static string GenerateA2UIToolDescription + => "[[A2UI_GENERATE_TOOL_DESCRIPTION_PLACEHOLDER]]"; // TODO(a2ui-port): verbatim text from sibling toolkits. + + /// + /// Creates the OpenAI-style function definition of the inner render_a2ui + /// structured-output tool (surfaceId, components, data; + /// surfaceId and components required). + /// + /// A fresh, caller-owned with the tool definition. + public static JsonObject CreateRenderA2UIToolDefinition() + => throw new NotImplementedException(); + + /// + /// Fills canonical defaults for every unset or empty-string field of + /// . Empty strings fall back to defaults rather than + /// propagating into tool advertisements or emitted operations. + /// + /// The raw parameters, or for all defaults. + /// The resolved parameters. + public static A2UIResolvedToolParams ResolveA2UIToolParams(A2UIToolParams? parameters) + => throw new NotImplementedException(); +} + +/// +/// The built-in default prompt blocks shared by all adapters. The exact text is part of +/// the cross-language contract and is ported verbatim from the sibling toolkits. +/// +public static class A2UIPromptDefaults +{ + /// + /// Gets the default generation-guidelines block (A2UI protocol rules: ids, paths, + /// bindings, data model). + /// + public static string GenerationGuidelines + => "[[A2UI_DEFAULT_GENERATION_GUIDELINES_PLACEHOLDER]]"; // TODO(a2ui-port): verbatim text from sibling toolkits. + + /// + /// Gets the default design-guidelines block (visual hierarchy, layout patterns). + /// + public static string DesignGuidelines + => "[[A2UI_DEFAULT_DESIGN_GUIDELINES_PLACEHOLDER]]"; // TODO(a2ui-port): verbatim text from sibling toolkits. +} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolkit.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolkit.cs new file mode 100644 index 0000000000..b7ad976a3a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolkit.cs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace Microsoft.Agents.AI.AGUI.A2UI; + +/// +/// Pure helpers for building A2UI subagent tools: prompt assembly, conversation-history +/// surface reconstruction, request preparation, and operations-envelope assembly. +/// +/// +/// This is the .NET port of @ag-ui/a2ui-toolkit (TypeScript) and +/// ag-ui-a2ui-toolkit (Python). All behavior — section ordering, fallback rules, +/// surface-walk semantics, untrusted-output narrowing — is contract-tested for parity +/// with the sibling implementations. +/// +public static class A2UIToolkit +{ + /// + /// Builds the context section of the subagent prompt from the AG-UI agent state: + /// one markdown section per described context entry, followed by the component + /// catalog under an ## Available Components heading when present. + /// + /// The AG-UI agent state slice, or . + /// The context prompt, possibly empty. + public static string BuildContextPrompt(A2UIAgentState? state) + => throw new NotImplementedException(); + + /// + /// Walks the conversation history backwards to reconstruct the latest known state of + /// from prior a2ui_operations tool results. + /// + /// + /// Within a message, operations apply in order (the last operation per field wins, and + /// deleteSurface resets the accumulator). Across messages, the newest mention is + /// authoritative and older messages only fill fields the newer ones did not set. When the + /// newest mention ends with the surface deleted, the surface is gone — older state is not + /// resurrected and is returned. + /// + /// The conversation history, oldest first. + /// The surface to look for. + /// The reconstructed surface state, or when absent or deleted. + public static A2UIPriorSurface? FindPriorSurface(IEnumerable messages, string surfaceId) + => throw new NotImplementedException(); + + /// + /// Assembles the full subagent system prompt in the canonical section order: + /// generation guidelines, design guidelines, context, composition guide, and — + /// when editing — the prior-surface edit block. + /// + /// The context section produced by . + /// Per-section overrides; see for the fallback rules. + /// The prior-surface context when editing an existing surface. + /// The assembled prompt, possibly empty. + public static string BuildSubagentPrompt( + string contextPrompt, + A2UIGuidelines? guidelines = null, + A2UIEditContext? editContext = null) + => throw new NotImplementedException(); + + /// + /// Resolves the create/update intent, locates the prior surface for updates, and builds + /// the subagent prompt. + /// + /// "create" (default) or "update". + /// The surface to edit; required for the update intent. + /// An optional natural-language description of the requested changes. + /// The conversation history, oldest first. + /// The AG-UI agent state slice. + /// Prompt-section overrides. + /// + /// The prepared request. On the update path, a missing prior surface yields a result with + /// set and an empty prompt instead of throwing, so the + /// hosting tool can return a structured error envelope to the planner. + /// + public static A2UIPreparedRequest PrepareA2UIRequest( + string? intent, + string? targetSurfaceId, + string? changes, + IEnumerable messages, + A2UIAgentState? state, + A2UIGuidelines? guidelines = null) + => throw new NotImplementedException(); + + /// + /// Builds the final operations envelope from the subagent's structured tool output, + /// narrowing untrusted values to safe defaults. + /// + /// + /// The model output is untrusted: a missing, empty, or non-string surfaceId falls back + /// to the default; a non-array components becomes empty; a non-object data is + /// dropped. The catalog id is never taken from the model — it comes from the prior surface on + /// updates and from on creates. Empty-string defaults fall + /// back to the canonical constants. + /// + /// The structured render_a2ui tool arguments from the model. + /// Whether this is the update path. + /// The surface being updated; ignored on the create path. + /// The prior surface state on the update path. + /// The fallback surface id. + /// The catalog id used on the create path. + /// The serialized operations envelope. + public static string BuildA2UIEnvelope( + JsonObject args, + bool isUpdate, + string? targetSurfaceId, + A2UIPriorSurface? prior, + string defaultSurfaceId = A2UIConstants.DefaultSurfaceId, + string defaultCatalogId = A2UIConstants.BasicCatalogId) + => throw new NotImplementedException(); + + /// + /// Assembles the ordered operation list for a surface render: the create intent emits + /// createSurface + updateComponents (+ updateDataModel when data is + /// non-empty); the update intent omits createSurface so the renderer reconciles in place. + /// + /// "create" or "update". + /// The target surface id. + /// The catalog id stamped on createSurface. + /// The flat component array. + /// The initial data model; omitted from the envelope when null or empty. + /// The ordered operations. + public static IReadOnlyList AssembleOps( + string intent, + string surfaceId, + string catalogId, + JsonArray components, + JsonObject? data = null) + => throw new NotImplementedException(); + + /// + /// Serializes operations under the envelope key. + /// + /// The operations to wrap. + /// The serialized envelope. + public static string WrapAsOperationsEnvelope(IEnumerable operations) + => throw new NotImplementedException(); + + /// + /// Serializes a host-facing error as {"error": message} so the planner receives a + /// structured failure instead of an exception. + /// + /// The error message. + /// The serialized error envelope. + public static string WrapErrorEnvelope(string message) + => throw new NotImplementedException(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UITypes.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UITypes.cs new file mode 100644 index 0000000000..9d38f85b0b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UITypes.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace Microsoft.Agents.AI.AGUI.A2UI; + +/// +/// One AG-UI context entry as forwarded to the agent (description/value pair). +/// +/// The optional section heading for the entry. +/// The entry content. +public sealed record A2UIContextEntry(string? Description, string? Value); + +/// +/// The AG-UI slice of agent state the toolkit reads: forwarded context entries and +/// the component catalog schema, when the host supplied one. +/// +/// +/// Mirrors the state["ag-ui"] contract of the sibling toolkits +/// (context + a2ui_schema). Adapters populate this from the transport, +/// e.g. the AG-UI hosting layer's ag_ui_context additional property. +/// +public sealed class A2UIAgentState +{ + /// + /// Gets the forwarded AG-UI context entries, when present. + /// + public IReadOnlyList? Context { get; init; } + + /// + /// Gets the A2UI component catalog schema (serialized JSON), when present. + /// + public string? A2UISchema { get; init; } +} + +/// +/// A conversation-history message as seen by the surface walker. Adapters map their +/// framework's message type onto this shape; only tool-result messages with string +/// content participate in surface reconstruction. +/// +/// The message role; tool results carry "tool". +/// The raw message content. +public sealed record A2UIHistoryMessage(string? Role, string? Content); + +/// +/// The reconstructed end state of a previously rendered surface, used to seed +/// update-intent prompts and envelopes. +/// +/// The last known component array, when seen. +/// The last known data model, when seen. May be . +/// The catalog the surface was created against, when seen. +public sealed record A2UIPriorSurface(JsonArray? Components, JsonNode? Data, string? CatalogId); + +/// +/// Prompt-section overrides for the subagent system prompt. +/// +/// +/// Per-field semantics, identical across the sibling toolkits: applies +/// the built-in default block, the empty string suppresses the block entirely, and any other +/// value replaces the default. +/// +public sealed class A2UIGuidelines +{ + /// Gets the protocol/generation rules block override. + public string? GenerationGuidelines { get; init; } + + /// Gets the visual design rules block override. + public string? DesignGuidelines { get; init; } + + /// Gets the host-specific composition guide appended after the context. No built-in default. + public string? CompositionGuide { get; init; } +} + +/// +/// The prior-surface context injected into the prompt when editing an existing surface. +/// +/// The id of the surface being edited. +/// The reconstructed prior surface state. +/// An optional natural-language description of the requested changes. +public sealed record A2UIEditContext(string SurfaceId, A2UIPriorSurface Prior, string? Changes = null); + +/// +/// The outcome of preparing an A2UI generation request: the assembled subagent prompt +/// plus the resolved create/update intent. +/// +/// The subagent system prompt; empty when is set. +/// Whether the request edits an existing surface. +/// The prior surface state on the update path. +/// A host-facing error when preparation failed (e.g. update target not found). +public sealed record A2UIPreparedRequest(string Prompt, bool IsUpdate, A2UIPriorSurface? Prior, string? Error); diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs new file mode 100644 index 0000000000..2c87d57a34 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace Microsoft.Agents.AI.AGUI.A2UI; + +/// +/// Error codes emitted by . +/// +/// +/// The string values are part of the cross-language A2UI contract (shared with the +/// TypeScript and Python toolkits) and feed back into subagent retry prompts; they +/// must not diverge from the sibling implementations. +/// +public static class A2UIValidationErrorCodes +{ + /// The component set is missing or empty. + public const string EmptyComponents = "empty_components"; + + /// A component has no usable string id. + public const string MissingId = "missing_id"; + + /// A component has no usable string component type. + public const string MissingComponentType = "missing_component_type"; + + /// Two or more components share the same id. + public const string DuplicateId = "duplicate_id"; + + /// No component carries the mandatory id of "root". + public const string NoRoot = "no_root"; + + /// A component type is not present in the supplied catalog. + public const string UnknownComponent = "unknown_component"; + + /// A component lacks a property the catalog marks as required. + public const string MissingRequiredProp = "missing_required_prop"; + + /// A child reference points at a component id that does not exist. + public const string UnresolvedChild = "unresolved_child"; + + /// An absolute data binding path does not resolve in the data model. + public const string UnresolvedBinding = "unresolved_binding"; +} + +/// +/// A single semantic validation finding for an A2UI component tree. +/// +/// One of the values. +/// A JSON-pointer-style location, e.g. components[1].rating. +/// A human/model-readable description used in retry prompts. +public sealed record A2UIValidationError(string Code, string Path, string Message); + +/// +/// The outcome of validating an A2UI component tree. +/// +/// when no errors were found. +/// The findings, empty when is . +public sealed record A2UIValidationResult(bool Valid, IReadOnlyList Errors); + +/// +/// An inline component catalog used for semantic validation: component schemas +/// (standard JSON Schema fragments with an optional required array) keyed by component name. +/// +public sealed class A2UIValidationCatalog +{ + /// + /// Initializes a new instance of the class. + /// + /// Component schemas keyed by component name. + public A2UIValidationCatalog(JsonObject components) + { + this.Components = components ?? throw new ArgumentNullException(nameof(components)); + } + + /// + /// Gets the component schemas keyed by component name. + /// + public JsonObject Components { get; } +} + +/// +/// Semantic validator for A2UI v0.9 component trees, mirroring +/// validateA2UIComponents / validate_a2ui_components in the sibling toolkits. +/// +/// +/// Structural checks (ids, component types, root presence, child references) always run. +/// Catalog checks (component existence, required props) run only when a catalog is supplied. +/// Absolute data-binding checks run unless validateBindings is ; +/// relative binding paths are never validated globally because they resolve per item inside +/// repeated templates. +/// +public static class A2UIComponentValidator +{ + /// + /// Validates a flat A2UI component array against structural rules and, optionally, + /// a component catalog and a data model. + /// + /// The flat component array. or empty fails validation. + /// The surface data model used to resolve absolute binding paths. + /// The component catalog enabling semantic checks. + /// + /// When , absolute binding checks are deferred (used while the data + /// model has not finished streaming). + /// + /// The validation outcome. + public static A2UIValidationResult Validate( + JsonArray? components, + JsonObject? data = null, + A2UIValidationCatalog? catalog = null, + bool validateBindings = true) + => throw new NotImplementedException(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj index 0e7aa6f07c..857baf4f16 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj @@ -4,6 +4,7 @@ Microsoft Agent Framework AG-UI A2UI Toolkit Framework-agnostic helpers for building A2UI (Agent-to-UI) generation tools on top of the AG-UI protocol. + true diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs new file mode 100644 index 0000000000..d5eadbd61a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs @@ -0,0 +1,275 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; + +namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests; + +/// +/// Unit tests for . +/// +/// +/// Ported from the Python toolkit's tests/test_validate.py and the TypeScript toolkit's +/// validate.test.ts so all three languages agree on what counts as a valid A2UI surface. +/// The Python "non-list components" case is covered by the type system here (the parameter is a +/// ), so only the null/empty variants are ported. +/// +public sealed class A2UIComponentValidatorTests +{ + private static A2UIValidationCatalog CreateCatalog() => new(new JsonObject + { + ["Row"] = new JsonObject + { + ["type"] = "object", + ["required"] = new JsonArray("children"), + }, + ["HotelCard"] = new JsonObject + { + ["type"] = "object", + ["required"] = new JsonArray("name", "location", "rating", "pricePerNight"), + }, + }); + + private static JsonArray CreateValidComponents() => new( + new JsonObject + { + ["id"] = "root", + ["component"] = "Row", + ["children"] = new JsonObject { ["componentId"] = "card", ["path"] = "/items" }, + }, + new JsonObject + { + ["id"] = "card", + ["component"] = "HotelCard", + ["name"] = new JsonObject { ["path"] = "name" }, + ["location"] = new JsonObject { ["path"] = "location" }, + ["rating"] = new JsonObject { ["path"] = "rating" }, + ["pricePerNight"] = new JsonObject { ["path"] = "pricePerNight" }, + }); + + private static JsonObject CreateValidData() => new() + { + ["items"] = new JsonArray(new JsonObject + { + ["name"] = "Ritz", + ["location"] = "NYC", + ["rating"] = 4.8, + ["pricePerNight"] = "$450", + }), + }; + + private static HashSet Codes(A2UIValidationResult result) + => result.Errors.Select(e => e.Code).ToHashSet(); + + [Fact] + public void Validate_WellFormedSurface_IsValid() + { + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate( + CreateValidComponents(), CreateValidData(), CreateCatalog()); + + // Assert + Assert.True(result.Valid); + Assert.Empty(result.Errors); + } + + [Fact] + public void Validate_MissingRoot_ReportsNoRoot() + { + // Arrange: rename the root component so no component carries id "root". + JsonArray components = CreateValidComponents(); + components[0]!["id"] = "container"; + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate( + components, CreateValidData(), CreateCatalog()); + + // Assert + Assert.False(result.Valid); + Assert.Contains(A2UIValidationErrorCodes.NoRoot, Codes(result)); + } + + [Fact] + public void Validate_MissingId_ReportsMissingId() + { + // Arrange + var components = new JsonArray(new JsonObject + { + ["component"] = "Row", + ["children"] = new JsonArray(), + }); + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate(components); + + // Assert + Assert.Contains(A2UIValidationErrorCodes.MissingId, Codes(result)); + } + + [Fact] + public void Validate_MissingComponentType_ReportsMissingComponentType() + { + // Arrange + var components = new JsonArray(new JsonObject { ["id"] = "root" }); + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate(components); + + // Assert + Assert.Contains(A2UIValidationErrorCodes.MissingComponentType, Codes(result)); + } + + [Fact] + public void Validate_DuplicateId_ReportsDuplicateId() + { + // Arrange + var components = new JsonArray( + new JsonObject { ["id"] = "root", ["component"] = "Row", ["children"] = new JsonArray("x") }, + new JsonObject { ["id"] = "x", ["component"] = "Row", ["children"] = new JsonArray() }, + new JsonObject { ["id"] = "x", ["component"] = "Row", ["children"] = new JsonArray() }); + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate(components); + + // Assert + Assert.Contains(A2UIValidationErrorCodes.DuplicateId, Codes(result)); + } + + [Fact] + public void Validate_EmptyOrNullComponents_FailsLoud() + { + // Act + A2UIValidationResult emptyResult = A2UIComponentValidator.Validate(new JsonArray()); + A2UIValidationResult nullResult = A2UIComponentValidator.Validate(null); + + // Assert + Assert.False(emptyResult.Valid); + Assert.False(nullResult.Valid); + Assert.Contains(A2UIValidationErrorCodes.EmptyComponents, Codes(emptyResult)); + Assert.Contains(A2UIValidationErrorCodes.EmptyComponents, Codes(nullResult)); + } + + [Fact] + public void Validate_UnknownComponent_ReportsUnknownComponent() + { + // Arrange: point the card at a type the catalog does not define. + JsonArray components = CreateValidComponents(); + components[1]!["component"] = "MysteryCard"; + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate( + components, CreateValidData(), CreateCatalog()); + + // Assert + Assert.Contains(A2UIValidationErrorCodes.UnknownComponent, Codes(result)); + } + + [Fact] + public void Validate_MissingRequiredProp_ReportsPropNameInMessage() + { + // Arrange: drop a catalog-required property from the card. + JsonArray components = CreateValidComponents(); + ((JsonObject)components[1]!).Remove("pricePerNight"); + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate( + components, CreateValidData(), CreateCatalog()); + + // Assert + Assert.Contains(result.Errors, e => + e.Code == A2UIValidationErrorCodes.MissingRequiredProp && e.Message.Contains("pricePerNight")); + } + + [Fact] + public void Validate_WithoutCatalog_RunsStructuralChecksOnly() + { + // Arrange: an unknown component type is acceptable when no catalog is supplied. + JsonArray components = CreateValidComponents(); + components[1]!["component"] = "MysteryCard"; + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate(components, CreateValidData()); + + // Assert + Assert.DoesNotContain(A2UIValidationErrorCodes.UnknownComponent, Codes(result)); + Assert.True(result.Valid); + } + + [Fact] + public void Validate_StructuralChildUnresolved_ReportsReferencedId() + { + // Arrange: the repeated-template child references a component that does not exist. + var components = new JsonArray(new JsonObject + { + ["id"] = "root", + ["component"] = "Row", + ["children"] = new JsonObject { ["componentId"] = "ghost", ["path"] = "/items" }, + }); + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate( + components, CreateValidData(), CreateCatalog()); + + // Assert + Assert.Contains(result.Errors, e => + e.Code == A2UIValidationErrorCodes.UnresolvedChild && e.Message.Contains("ghost")); + } + + [Fact] + public void Validate_ArrayChildUnresolved_ReportsReferencedId() + { + // Arrange + var components = new JsonArray(new JsonObject + { + ["id"] = "root", + ["component"] = "Row", + ["children"] = new JsonArray("missing-1"), + }); + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate(components); + + // Assert + Assert.Contains(result.Errors, e => + e.Code == A2UIValidationErrorCodes.UnresolvedChild && e.Message.Contains("missing-1")); + } + + [Fact] + public void Validate_AbsoluteBindingUnresolved_ReportsPathInMessage() + { + // Arrange: empty data model cannot satisfy the absolute "/items" template path. + var emptyData = new JsonObject(); + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate( + CreateValidComponents(), emptyData, CreateCatalog()); + + // Assert + Assert.Contains(result.Errors, e => + e.Code == A2UIValidationErrorCodes.UnresolvedBinding && e.Message.Contains("/items")); + } + + [Fact] + public void Validate_RelativeBindings_AreNotValidated() + { + // Act: card props use relative paths (resolved per item inside the template). + A2UIValidationResult result = A2UIComponentValidator.Validate( + CreateValidComponents(), CreateValidData(), CreateCatalog()); + + // Assert + Assert.DoesNotContain(A2UIValidationErrorCodes.UnresolvedBinding, Codes(result)); + } + + [Fact] + public void Validate_ValidateBindingsFalse_DefersBindingChecks() + { + // Act: with binding checks deferred, an empty data model is acceptable. + A2UIValidationResult result = A2UIComponentValidator.Validate( + CreateValidComponents(), new JsonObject(), CreateCatalog(), validateBindings: false); + + // Assert + Assert.DoesNotContain(A2UIValidationErrorCodes.UnresolvedBinding, Codes(result)); + Assert.True(result.Valid); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIEnvelopeTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIEnvelopeTests.cs new file mode 100644 index 0000000000..60e7dac758 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIEnvelopeTests.cs @@ -0,0 +1,371 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; + +namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests; + +/// +/// Unit tests for , the envelope wrappers, +/// , , +/// , and parameter resolution. +/// +/// +/// Ported from the Python toolkit's TestAssembleOps, TestWrapAsOperationsEnvelope, +/// TestWrapErrorEnvelope, TestPrepareA2UIRequest, TestBuildA2UIEnvelope, +/// TestRenderToolDef, and TestResolveA2UIToolParams (mirrored in the TypeScript +/// suite). The Python model passthrough assertions are not ported: in .NET the subagent +/// chat client is owned by the adapter factory, not by . +/// +public sealed class A2UIEnvelopeTests +{ + private static readonly string[] s_renderToolRequiredFields = ["surfaceId", "components"]; + + private static JsonArray RowComponents() => new(new JsonObject { ["id"] = "root", ["component"] = "Row" }); + + private static A2UIHistoryMessage PriorSurfaceMessage(string surfaceId) => new( + "tool", + A2UIToolkit.WrapAsOperationsEnvelope( + [ + A2UIOperationBuilder.CreateSurface(surfaceId, "cat://x"), + A2UIOperationBuilder.UpdateComponents(surfaceId, RowComponents()), + A2UIOperationBuilder.UpdateDataModel(surfaceId, new JsonObject { ["items"] = new JsonArray(1, 2) }), + ])); + + private static JsonArray ParseOperations(string envelope) + { + JsonObject parsed = Assert.IsType(JsonNode.Parse(envelope)); + return Assert.IsType(parsed[A2UIConstants.A2UIOperationsKey]); + } + + private static JsonObject SingleOperation(JsonArray operations, string operationName) + { + JsonObject? match = operations + .OfType() + .SingleOrDefault(op => op.ContainsKey(operationName)); + Assert.NotNull(match); + return Assert.IsType(match[operationName]); + } + + [Fact] + public void CreateRenderA2UIToolDefinition_HasCanonicalShape() + { + // Act + JsonObject definition = A2UIToolDefinitions.CreateRenderA2UIToolDefinition(); + + // Assert + Assert.Equal("function", (string?)definition["type"]); + JsonObject function = Assert.IsType(definition["function"]); + Assert.Equal(A2UIConstants.RenderA2UIToolName, (string?)function["name"]); + JsonObject parameters = Assert.IsType(function["parameters"]); + JsonArray required = Assert.IsType(parameters["required"]); + Assert.Equal(s_renderToolRequiredFields, required.Select(n => (string?)n).ToArray()); + JsonObject properties = Assert.IsType(parameters["properties"]); + Assert.Equal(["surfaceId", "components", "data"], properties.Select(p => p.Key).ToArray()); + } + + [Fact] + public void AssembleOps_CreateIntent_EmitsFullEnvelope() + { + // Act + IReadOnlyList ops = A2UIToolkit.AssembleOps( + "create", "s1", "cat://x", RowComponents(), new JsonObject { ["items"] = new JsonArray("a") }); + + // Assert + Assert.Equal(3, ops.Count); + Assert.True(ops[0].ContainsKey("createSurface")); + Assert.True(ops[1].ContainsKey("updateComponents")); + Assert.True(ops[2].ContainsKey("updateDataModel")); + } + + [Fact] + public void AssembleOps_UpdateIntent_SkipsCreateSurface() + { + // Act + IReadOnlyList ops = A2UIToolkit.AssembleOps( + "update", "s1", "cat://x", RowComponents(), new JsonObject { ["items"] = new JsonArray("a") }); + + // Assert + Assert.Equal(2, ops.Count); + Assert.True(ops[0].ContainsKey("updateComponents")); + Assert.True(ops[1].ContainsKey("updateDataModel")); + } + + [Fact] + public void AssembleOps_NoData_OmitsDataModelOp() + { + // Act + IReadOnlyList ops = A2UIToolkit.AssembleOps("create", "s1", "cat://x", RowComponents()); + + // Assert + Assert.Equal(2, ops.Count); + Assert.True(ops[0].ContainsKey("createSurface")); + Assert.True(ops[1].ContainsKey("updateComponents")); + } + + [Fact] + public void AssembleOps_EmptyData_OmitsDataModelOp() + { + // Act + IReadOnlyList ops = A2UIToolkit.AssembleOps( + "create", "s1", "cat://x", RowComponents(), new JsonObject()); + + // Assert + Assert.Equal(2, ops.Count); + } + + [Fact] + public void WrapAsOperationsEnvelope_SerializesUnderOperationsKey() + { + // Act + string envelope = A2UIToolkit.WrapAsOperationsEnvelope([A2UIOperationBuilder.CreateSurface("s1", "c")]); + + // Assert + JsonArray ops = ParseOperations(envelope); + JsonObject op = Assert.IsType(Assert.Single(ops)); + Assert.Equal("v0.9", (string?)op["version"]); + Assert.Equal("s1", (string?)op["createSurface"]?["surfaceId"]); + } + + [Fact] + public void WrapAsOperationsEnvelope_EmptyOps_SerializesEmptyArray() + { + // Act & Assert + Assert.Empty(ParseOperations(A2UIToolkit.WrapAsOperationsEnvelope([]))); + } + + [Fact] + public void WrapErrorEnvelope_WrapsMessage() + { + // Act + JsonObject parsed = Assert.IsType(JsonNode.Parse(A2UIToolkit.WrapErrorEnvelope("boom"))); + + // Assert + Assert.Equal("boom", (string?)parsed["error"]); + Assert.Single(parsed); + } + + [Fact] + public void PrepareA2UIRequest_CreateIntent_BuildsPromptWithoutPrior() + { + // Arrange + var state = new A2UIAgentState { Context = [new A2UIContextEntry(null, "ctx")] }; + var guidelines = new A2UIGuidelines { CompositionGuide = "guide" }; + + // Act + A2UIPreparedRequest prep = A2UIToolkit.PrepareA2UIRequest( + "create", targetSurfaceId: null, changes: null, messages: [], state, guidelines); + + // Assert + Assert.Null(prep.Error); + Assert.False(prep.IsUpdate); + Assert.Null(prep.Prior); + Assert.Contains("ctx", prep.Prompt); + Assert.Contains("guide", prep.Prompt); + } + + [Fact] + public void PrepareA2UIRequest_MissingIntent_DefaultsToCreate() + { + // Act + A2UIPreparedRequest prep = A2UIToolkit.PrepareA2UIRequest( + intent: null, targetSurfaceId: null, changes: null, messages: [], state: null); + + // Assert + Assert.False(prep.IsUpdate); + Assert.Null(prep.Error); + } + + [Fact] + public void PrepareA2UIRequest_UpdateWithMatchingPrior_BuildsEditPrompt() + { + // Act + A2UIPreparedRequest prep = A2UIToolkit.PrepareA2UIRequest( + "update", "s1", "make it red", [PriorSurfaceMessage("s1")], state: null); + + // Assert + Assert.Null(prep.Error); + Assert.True(prep.IsUpdate); + Assert.Equal("cat://x", prep.Prior?.CatalogId); + Assert.Contains("Editing an existing surface", prep.Prompt); + Assert.Contains("make it red", prep.Prompt); + } + + [Fact] + public void PrepareA2UIRequest_UpdateWithoutPrior_ReturnsError() + { + // Act + A2UIPreparedRequest prep = A2UIToolkit.PrepareA2UIRequest( + "update", "missing", changes: null, [PriorSurfaceMessage("s1")], state: null); + + // Assert + Assert.Equal(string.Empty, prep.Prompt); + Assert.NotNull(prep.Error); + Assert.Contains("missing", prep.Error); + Assert.Contains("no prior render", prep.Error); + } + + [Fact] + public void BuildA2UIEnvelope_Create_UsesConfiguredCatalogNotArgs() + { + // Arrange + var args = new JsonObject + { + ["surfaceId"] = "from-args", + ["components"] = RowComponents(), + ["data"] = new JsonObject { ["items"] = new JsonArray(1) }, + }; + + // Act + JsonArray ops = ParseOperations(A2UIToolkit.BuildA2UIEnvelope( + args, isUpdate: false, targetSurfaceId: null, prior: null, + defaultCatalogId: "cat://configured")); + + // Assert + JsonObject createSurface = SingleOperation(ops, "createSurface"); + Assert.Equal("from-args", (string?)createSurface["surfaceId"]); + Assert.Equal("cat://configured", (string?)createSurface["catalogId"]); + JsonObject updateComponents = SingleOperation(ops, "updateComponents"); + Assert.Equal(RowComponents().ToJsonString(), updateComponents["components"]?.ToJsonString()); + JsonObject updateDataModel = SingleOperation(ops, "updateDataModel"); + Assert.Equal("""{"items":[1]}""", updateDataModel["value"]?.ToJsonString()); + } + + [Fact] + public void BuildA2UIEnvelope_MissingSurfaceId_FallsBackToDefault() + { + // Arrange + var args = new JsonObject { ["components"] = new JsonArray() }; + + // Act + JsonArray ops = ParseOperations(A2UIToolkit.BuildA2UIEnvelope( + args, isUpdate: false, targetSurfaceId: null, prior: null)); + + // Assert + Assert.Equal(A2UIConstants.DefaultSurfaceId, (string?)SingleOperation(ops, "createSurface")["surfaceId"]); + } + + [Fact] + public void BuildA2UIEnvelope_EmptyStringDefaults_FallBackToCanonical() + { + // Arrange: a misconfigured host passes empty-string defaults. Those must NOT + // propagate into the emitted ops — the renderer would surface + // "Catalog not found: " / a blank surface id, hiding the real cause. + var args = new JsonObject { ["components"] = RowComponents() }; + + // Act + JsonArray ops = ParseOperations(A2UIToolkit.BuildA2UIEnvelope( + args, isUpdate: false, targetSurfaceId: null, prior: null, + defaultSurfaceId: string.Empty, defaultCatalogId: string.Empty)); + + // Assert + JsonObject createSurface = SingleOperation(ops, "createSurface"); + Assert.Equal(A2UIConstants.DefaultSurfaceId, (string?)createSurface["surfaceId"]); + Assert.Equal(A2UIConstants.BasicCatalogId, (string?)createSurface["catalogId"]); + } + + [Theory] + [InlineData("42")] + [InlineData("[\"x\"]")] + [InlineData("null")] + [InlineData("{\"a\":1}")] + [InlineData("true")] + public void BuildA2UIEnvelope_NonStringSurfaceId_FallsBackToDefault(string badSurfaceIdJson) + { + // Arrange: the model is untrusted — surfaceId may come back as a number, array, + // null, object, or boolean. Without narrowing, a non-string value propagates into + // createSurface.surfaceId and the renderer crashes. Mirror of the TS/Python narrow. + var args = new JsonObject + { + ["surfaceId"] = JsonNode.Parse(badSurfaceIdJson), + ["components"] = new JsonArray(), + }; + + // Act + JsonArray ops = ParseOperations(A2UIToolkit.BuildA2UIEnvelope( + args, isUpdate: false, targetSurfaceId: null, prior: null)); + + // Assert + Assert.Equal(A2UIConstants.DefaultSurfaceId, (string?)SingleOperation(ops, "createSurface")["surfaceId"]); + } + + [Fact] + public void BuildA2UIEnvelope_UpdateWithEmptyTargetSurfaceId_FallsBackToDefault() + { + // Arrange: direct callers (bypassing PrepareA2UIRequest) may pass an empty target + // surface id on the update path. It must not propagate into updateComponents. + var args = new JsonObject { ["components"] = RowComponents() }; + var prior = new A2UIPriorSurface(new JsonArray(), null, "cat://prior"); + + // Act + JsonArray ops = ParseOperations(A2UIToolkit.BuildA2UIEnvelope( + args, isUpdate: true, targetSurfaceId: string.Empty, prior)); + + // Assert + Assert.Equal(A2UIConstants.DefaultSurfaceId, (string?)SingleOperation(ops, "updateComponents")["surfaceId"]); + } + + [Fact] + public void BuildA2UIEnvelope_Update_SkipsCreateSurfaceAndKeepsTarget() + { + // Arrange + var args = new JsonObject + { + ["surfaceId"] = "ignored", + ["components"] = new JsonArray(new JsonObject { ["id"] = "root", ["component"] = "Column" }), + }; + var prior = new A2UIPriorSurface(new JsonArray(), null, "cat://prior"); + + // Act + JsonArray ops = ParseOperations(A2UIToolkit.BuildA2UIEnvelope( + args, isUpdate: true, targetSurfaceId: "s1", prior)); + + // Assert + Assert.DoesNotContain(ops.OfType(), op => op.ContainsKey("createSurface")); + Assert.Equal("s1", (string?)SingleOperation(ops, "updateComponents")["surfaceId"]); + } + + [Fact] + public void ResolveA2UIToolParams_FillsCanonicalDefaults() + { + // Act + A2UIResolvedToolParams resolved = A2UIToolDefinitions.ResolveA2UIToolParams(new A2UIToolParams()); + + // Assert + Assert.Equal(A2UIConstants.DefaultSurfaceId, resolved.DefaultSurfaceId); + Assert.Equal(A2UIConstants.BasicCatalogId, resolved.DefaultCatalogId); + Assert.Equal(A2UIConstants.GenerateA2UIToolName, resolved.ToolName); + Assert.Equal(A2UIToolDefinitions.GenerateA2UIToolDescription, resolved.ToolDescription); + Assert.Null(resolved.Guidelines); + } + + [Fact] + public void ResolveA2UIToolParams_EmptyStringOverrides_FallBackToDefaults() + { + // Arrange + var parameters = new A2UIToolParams { ToolName = string.Empty, DefaultCatalogId = string.Empty }; + + // Act + A2UIResolvedToolParams resolved = A2UIToolDefinitions.ResolveA2UIToolParams(parameters); + + // Assert + Assert.Equal(A2UIConstants.GenerateA2UIToolName, resolved.ToolName); + Assert.Equal(A2UIConstants.BasicCatalogId, resolved.DefaultCatalogId); + } + + [Fact] + public void ResolveA2UIToolParams_Overrides_PassThrough() + { + // Arrange + var guidelines = new A2UIGuidelines { CompositionGuide = "g" }; + var parameters = new A2UIToolParams { ToolName = "custom_tool", Guidelines = guidelines }; + + // Act + A2UIResolvedToolParams resolved = A2UIToolDefinitions.ResolveA2UIToolParams(parameters); + + // Assert + Assert.Equal("custom_tool", resolved.ToolName); + Assert.Same(guidelines, resolved.Guidelines); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIFindPriorSurfaceTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIFindPriorSurfaceTests.cs new file mode 100644 index 0000000000..8486a19264 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIFindPriorSurfaceTests.cs @@ -0,0 +1,242 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Nodes; + +namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests; + +/// +/// Unit tests for . +/// +/// +/// Ported from the Python toolkit's TestFindPriorSurface (11 cases, mirrored in the +/// TypeScript suite). These pin the surface-walk semantics: within a message the last +/// operation per field wins and deleteSurface resets the accumulator; across +/// messages the newest mention is authoritative and older messages only fill gaps; a +/// surface whose newest end state is deleted is never resurrected. +/// The Python "accepts dict-style messages" duck-typing case is covered by the +/// type here and is not ported. +/// +public sealed class A2UIFindPriorSurfaceTests +{ + private static A2UIHistoryMessage ToolMessage(JsonObject content) + => new("tool", content.ToJsonString()); + + private static JsonObject Operations(params JsonObject[] operations) + => new() { [A2UIConstants.A2UIOperationsKey] = new JsonArray(operations) }; + + private static JsonObject DeleteSurface(string surfaceId) => new() + { + ["version"] = "v0.9", + ["deleteSurface"] = new JsonObject { ["surfaceId"] = surfaceId }, + }; + + private static JsonArray RowComponents() => new(new JsonObject { ["id"] = "root", ["component"] = "Row" }); + + private static JsonArray ColumnComponents() => new(new JsonObject { ["id"] = "root", ["component"] = "Column" }); + + [Fact] + public void FindPriorSurface_SurfaceNotInHistory_ReturnsNull() + { + // Arrange + A2UIHistoryMessage[] messages = [ToolMessage(Operations())]; + + // Act & Assert + Assert.Null(A2UIToolkit.FindPriorSurface(messages, "missing")); + } + + [Fact] + public void FindPriorSurface_SingleMessage_ReconstructsState() + { + // Arrange + A2UIHistoryMessage[] messages = + [ + ToolMessage(Operations( + A2UIOperationBuilder.CreateSurface("s1", "cat://x"), + A2UIOperationBuilder.UpdateComponents("s1", RowComponents()), + A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["items"] = new JsonArray(1, 2) }))), + ]; + + // Act + A2UIPriorSurface? prior = A2UIToolkit.FindPriorSurface(messages, "s1"); + + // Assert + Assert.NotNull(prior); + Assert.Equal("cat://x", prior.CatalogId); + Assert.Equal(RowComponents().ToJsonString(), prior.Components?.ToJsonString()); + Assert.Equal("""{"items":[1,2]}""", prior.Data?.ToJsonString()); + } + + [Fact] + public void FindPriorSurface_MultipleMessages_PrefersLatest() + { + // Arrange + A2UIHistoryMessage[] messages = + [ + ToolMessage(Operations( + A2UIOperationBuilder.CreateSurface("s1", "old-cat"), + A2UIOperationBuilder.UpdateComponents("s1", RowComponents()))), + ToolMessage(Operations( + A2UIOperationBuilder.UpdateComponents("s1", ColumnComponents()), + A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["changed"] = true }))), + ]; + + // Act + A2UIPriorSurface? prior = A2UIToolkit.FindPriorSurface(messages, "s1"); + + // Assert + Assert.NotNull(prior); + Assert.Equal(ColumnComponents().ToJsonString(), prior.Components?.ToJsonString()); + Assert.Equal("""{"changed":true}""", prior.Data?.ToJsonString()); + } + + [Fact] + public void FindPriorSurface_NonToolAndMalformedMessages_AreIgnored() + { + // Arrange + A2UIHistoryMessage[] messages = + [ + new("assistant", "not a tool"), + new("tool", "not json"), + ToolMessage(new JsonObject { ["unrelated"] = "payload" }), + ]; + + // Act & Assert + Assert.Null(A2UIToolkit.FindPriorSurface(messages, "s1")); + } + + [Fact] + public void FindPriorSurface_WithinMessage_LastOperationWins() + { + // Arrange: one envelope emits multiple ops for the same surface. The renderer + // applies them in order, so the surface ends at Column / {v:2} / cat-B. + A2UIHistoryMessage[] messages = + [ + ToolMessage(Operations( + A2UIOperationBuilder.CreateSurface("s1", "cat-A"), + A2UIOperationBuilder.UpdateComponents("s1", RowComponents()), + A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["v"] = 1 }), + A2UIOperationBuilder.CreateSurface("s1", "cat-B"), + A2UIOperationBuilder.UpdateComponents("s1", ColumnComponents()), + A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["v"] = 2 }))), + ]; + + // Act + A2UIPriorSurface? prior = A2UIToolkit.FindPriorSurface(messages, "s1"); + + // Assert + Assert.NotNull(prior); + Assert.Equal("cat-B", prior.CatalogId); + Assert.Equal(ColumnComponents().ToJsonString(), prior.Components?.ToJsonString()); + Assert.Equal("""{"v":2}""", prior.Data?.ToJsonString()); + } + + [Fact] + public void FindPriorSurface_FieldsAccumulateAcrossWalk() + { + // Arrange: turn 1 sets everything; turn 2 only updates data. The walker must + // surface components + catalogId from turn 1 plus the newer data from turn 2 — + // not blank components because the most recent message happened to omit them. + A2UIHistoryMessage[] messages = + [ + ToolMessage(Operations( + A2UIOperationBuilder.CreateSurface("s1", "cat://x"), + A2UIOperationBuilder.UpdateComponents("s1", RowComponents()), + A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["items"] = new JsonArray(1) }))), + ToolMessage(Operations( + A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["items"] = new JsonArray(1, 2, 3) }))), + ]; + + // Act + A2UIPriorSurface? prior = A2UIToolkit.FindPriorSurface(messages, "s1"); + + // Assert + Assert.NotNull(prior); + Assert.Equal("cat://x", prior.CatalogId); + Assert.Equal(RowComponents().ToJsonString(), prior.Components?.ToJsonString()); + Assert.Equal("""{"items":[1,2,3]}""", prior.Data?.ToJsonString()); + } + + [Fact] + public void FindPriorSurface_NewestDelete_ReturnsNull() + { + // Arrange: older message populated the surface; newer message deletes it. The + // renderer no longer shows it, so the stale state must not be resurrected. + A2UIHistoryMessage[] messages = + [ + ToolMessage(Operations( + A2UIOperationBuilder.CreateSurface("s1", "cat://x"), + A2UIOperationBuilder.UpdateComponents("s1", RowComponents()), + A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["items"] = new JsonArray(1, 2) }))), + ToolMessage(Operations(DeleteSurface("s1"))), + ]; + + // Act & Assert + Assert.Null(A2UIToolkit.FindPriorSurface(messages, "s1")); + } + + [Fact] + public void FindPriorSurface_OlderDelete_OverriddenByNewerCreate() + { + // Arrange: older message deleted the surface; newer message recreates it. The + // newer state must be returned — the older delete is dead history. + A2UIHistoryMessage[] messages = + [ + ToolMessage(Operations(DeleteSurface("s1"))), + ToolMessage(Operations( + A2UIOperationBuilder.CreateSurface("s1", "cat://new"), + A2UIOperationBuilder.UpdateComponents("s1", ColumnComponents()), + A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["items"] = new JsonArray(9) }))), + ]; + + // Act + A2UIPriorSurface? prior = A2UIToolkit.FindPriorSurface(messages, "s1"); + + // Assert + Assert.NotNull(prior); + Assert.Equal("cat://new", prior.CatalogId); + Assert.Equal(ColumnComponents().ToJsonString(), prior.Components?.ToJsonString()); + Assert.Equal("""{"items":[9]}""", prior.Data?.ToJsonString()); + } + + [Fact] + public void FindPriorSurface_IntraMessageDeleteThenCreate_ReturnsRecreatedState() + { + // Arrange: within one message, ops apply in order. Delete then create → the + // surface exists with the recreated content at end of message, data unset. + A2UIHistoryMessage[] messages = + [ + ToolMessage(Operations( + DeleteSurface("s1"), + A2UIOperationBuilder.CreateSurface("s1", "cat-recreated"), + A2UIOperationBuilder.UpdateComponents("s1", RowComponents()))), + ]; + + // Act + A2UIPriorSurface? prior = A2UIToolkit.FindPriorSurface(messages, "s1"); + + // Assert + Assert.NotNull(prior); + Assert.Equal("cat-recreated", prior.CatalogId); + Assert.Equal(RowComponents().ToJsonString(), prior.Components?.ToJsonString()); + Assert.Null(prior.Data); + } + + [Fact] + public void FindPriorSurface_IntraMessageCreateThenDelete_ReturnsNull() + { + // Arrange: within the newest message the surface is created then deleted — its end + // state is deleted, regardless of older accumulated state in prior messages. + A2UIHistoryMessage[] messages = + [ + ToolMessage(Operations( + A2UIOperationBuilder.CreateSurface("s1", "older-cat"), + A2UIOperationBuilder.UpdateComponents("s1", RowComponents()))), + ToolMessage(Operations( + A2UIOperationBuilder.CreateSurface("s1", "transient"), + DeleteSurface("s1"))), + ]; + + // Act & Assert + Assert.Null(A2UIToolkit.FindPriorSurface(messages, "s1")); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIGenerationRecoveryTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIGenerationRecoveryTests.cs new file mode 100644 index 0000000000..7e060c5423 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIGenerationRecoveryTests.cs @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Nodes; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests; + +/// +/// Unit tests for . +/// +/// +/// Ported from the Python toolkit's tests/test_recovery.py and the TypeScript toolkit's +/// recovery.test.ts. The .NET loop is asynchronous (idiomatic ), but the +/// attempt semantics — first valid attempt wins, error-augmented retries, structured exhaustion +/// envelope — are identical across languages. +/// +public sealed class A2UIGenerationRecoveryTests +{ + private static A2UIValidationCatalog CreateCatalog() => new(new JsonObject + { + ["Row"] = new JsonObject { ["required"] = new JsonArray("children") }, + ["HotelCard"] = new JsonObject { ["required"] = new JsonArray("name", "rating") }, + }); + + private static JsonObject CreateRoot() => new() + { + ["id"] = "root", + ["component"] = "Row", + ["children"] = new JsonObject { ["componentId"] = "card", ["path"] = "/items" }, + }; + + private static JsonObject CreateGoodCard() => new() + { + ["id"] = "card", + ["component"] = "HotelCard", + ["name"] = new JsonObject { ["path"] = "name" }, + ["rating"] = new JsonObject { ["path"] = "rating" }, + }; + + // Missing the catalog-required `rating` property. + private static JsonObject CreateBadCard() => new() + { + ["id"] = "card", + ["component"] = "HotelCard", + ["name"] = new JsonObject { ["path"] = "name" }, + }; + + private static JsonObject CreateArgs(JsonObject card) => new() + { + ["surfaceId"] = "s1", + ["components"] = new JsonArray(CreateRoot(), card), + ["data"] = new JsonObject + { + ["items"] = new JsonArray(new JsonObject { ["name"] = "Ritz", ["rating"] = 4.8 }), + }, + }; + + private static string BuildEnvelope(JsonObject args) + => new JsonObject { [A2UIConstants.A2UIOperationsKey] = args["components"]!.DeepClone() }.ToJsonString(); + + private static readonly IReadOnlyList s_sampleErrors = + [ + new(A2UIValidationErrorCodes.MissingRequiredProp, "components[1].rating", "missing required prop 'rating'"), + ]; + + [Fact] + public void AugmentPromptWithValidationErrors_NoErrors_ReturnsPromptUnchanged() + { + // Act + string result = A2UIGenerationRecovery.AugmentPromptWithValidationErrors("BASE", []); + + // Assert + Assert.Equal("BASE", result); + } + + [Fact] + public void AugmentPromptWithValidationErrors_WithErrors_AppendsFormattedFixBlock() + { + // Act + string result = A2UIGenerationRecovery.AugmentPromptWithValidationErrors("BASE", s_sampleErrors); + + // Assert + Assert.Contains("BASE", result); + Assert.Contains("rating", result); + Assert.Contains(A2UIGenerationRecovery.FormatValidationErrors(s_sampleErrors), result); + } + + [Fact] + public async Task RunAsync_ValidFirstAttempt_ReturnsImmediatelyAsync() + { + // Arrange + List calls = []; + + // Act + A2UIRecoveryResult result = await A2UIGenerationRecovery.RunAsync( + "P", + (prompt, attempt, ct) => + { + calls.Add(attempt); + return new ValueTask(CreateArgs(CreateGoodCard())); + }, + BuildEnvelope, + CreateCatalog()); + + // Assert + Assert.True(result.Ok); + A2UIAttemptRecord record = Assert.Single(result.Attempts); + Assert.True(record.Ok); + Assert.Equal([1], calls); + JsonObject envelope = Assert.IsType(JsonNode.Parse(result.Envelope)); + Assert.True(envelope.ContainsKey(A2UIConstants.A2UIOperationsKey)); + } + + [Fact] + public async Task RunAsync_InvalidFirstAttempt_RetriesWithErrorFeedbackAsync() + { + // Arrange + List prompts = []; + + // Act + A2UIRecoveryResult result = await A2UIGenerationRecovery.RunAsync( + "P", + (prompt, attempt, ct) => + { + prompts.Add(prompt); + return new ValueTask(CreateArgs(attempt == 1 ? CreateBadCard() : CreateGoodCard())); + }, + BuildEnvelope, + CreateCatalog()); + + // Assert + Assert.True(result.Ok); + Assert.Equal(2, result.Attempts.Count); + Assert.False(result.Attempts[0].Ok); + Assert.True(result.Attempts[1].Ok); + // The retry prompt carries the prior attempt's validation errors. + Assert.Contains("rating", prompts[1]); + } + + [Fact] + public async Task RunAsync_AllAttemptsInvalid_ReturnsStructuredHardFailureAsync() + { + // Arrange + List seen = []; + + // Act + A2UIRecoveryResult result = await A2UIGenerationRecovery.RunAsync( + "P", + (prompt, attempt, ct) => new ValueTask(CreateArgs(CreateBadCard())), + BuildEnvelope, + CreateCatalog(), + onAttempt: seen.Add); + + // Assert + Assert.False(result.Ok); + Assert.Equal(A2UIConstants.MaxA2UIAttempts, result.Attempts.Count); + Assert.Equal(A2UIConstants.MaxA2UIAttempts, seen.Count); + JsonObject envelope = Assert.IsType(JsonNode.Parse(result.Envelope)); + Assert.Equal("a2ui_recovery_exhausted", (string?)envelope["code"]); + Assert.False(string.IsNullOrEmpty((string?)envelope["error"])); + Assert.IsType(envelope["attempts"]); + } + + [Fact] + public async Task RunAsync_MaxAttemptsOverride_LimitsAttemptsAsync() + { + // Arrange + List calls = []; + + // Act + A2UIRecoveryResult result = await A2UIGenerationRecovery.RunAsync( + "P", + (prompt, attempt, ct) => + { + calls.Add(attempt); + return new ValueTask(CreateArgs(CreateBadCard())); + }, + BuildEnvelope, + CreateCatalog(), + new A2UIRecoveryConfig { MaxAttempts = 2 }); + + // Assert + Assert.False(result.Ok); + Assert.Equal([1, 2], calls); + } + + [Fact] + public async Task RunAsync_MissingToolCall_IsRetryableAsync() + { + // Act + A2UIRecoveryResult result = await A2UIGenerationRecovery.RunAsync( + "P", + (prompt, attempt, ct) => new ValueTask( + attempt == 1 ? null : CreateArgs(CreateGoodCard())), + BuildEnvelope, + CreateCatalog()); + + // Assert + Assert.True(result.Ok); + Assert.Equal(2, result.Attempts.Count); + Assert.False(result.Attempts[0].Ok); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIPromptBuildingTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIPromptBuildingTests.cs new file mode 100644 index 0000000000..2472a8c05d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIPromptBuildingTests.cs @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Nodes; + +namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests; + +/// +/// Unit tests for and +/// . +/// +/// +/// Ported from the Python toolkit's TestBuildContextPrompt and +/// TestBuildSubagentPrompt (mirrored in the TypeScript suite). +/// +public sealed class A2UIPromptBuildingTests +{ + /// + /// Suppresses both built-in default blocks so structural tests can assert exact output + /// without the (large) default text. The empty string is the documented escape hatch + /// ( → default; "" → block omitted). + /// + private static A2UIGuidelines SuppressDefaults() => new() + { + GenerationGuidelines = string.Empty, + DesignGuidelines = string.Empty, + }; + + [Fact] + public void BuildContextPrompt_EmptyState_ReturnsEmpty() + { + // Act & Assert + Assert.Equal(string.Empty, A2UIToolkit.BuildContextPrompt(new A2UIAgentState())); + Assert.Equal(string.Empty, A2UIToolkit.BuildContextPrompt(null)); + } + + [Fact] + public void BuildContextPrompt_DescribedEntry_BecomesMarkdownSection() + { + // Arrange + var state = new A2UIAgentState + { + Context = [new A2UIContextEntry("Style guide", "use cards")], + }; + + // Act + string prompt = A2UIToolkit.BuildContextPrompt(state); + + // Assert + Assert.Contains("## Style guide", prompt); + Assert.Contains("use cards", prompt); + } + + [Fact] + public void BuildContextPrompt_ValueOnlyEntry_HasNoHeading() + { + // Arrange + var state = new A2UIAgentState + { + Context = [new A2UIContextEntry(null, "free-form note")], + }; + + // Act + string prompt = A2UIToolkit.BuildContextPrompt(state); + + // Assert + Assert.Contains("free-form note", prompt); + Assert.DoesNotContain("##", prompt); + } + + [Fact] + public void BuildContextPrompt_Schema_RendersAvailableComponentsSection() + { + // Arrange + var state = new A2UIAgentState { A2UISchema = "" }; + + // Act + string prompt = A2UIToolkit.BuildContextPrompt(state); + + // Assert + Assert.Contains("## Available Components", prompt); + Assert.Contains("", prompt); + } + + [Fact] + public void BuildContextPrompt_EmptyEntries_AreDropped() + { + // Arrange + var state = new A2UIAgentState { Context = [new A2UIContextEntry(null, null)] }; + + // Act & Assert + Assert.Equal(string.Empty, A2UIToolkit.BuildContextPrompt(state)); + } + + [Fact] + public void BuildSubagentPrompt_NoGuidelines_AppliesBuiltInDefaults() + { + // Act + string prompt = A2UIToolkit.BuildSubagentPrompt("ctx"); + + // Assert + Assert.Contains(A2UIPromptDefaults.GenerationGuidelines, prompt, StringComparison.Ordinal); + Assert.Contains("## Design Guidelines", prompt, StringComparison.Ordinal); + Assert.Contains(A2UIPromptDefaults.DesignGuidelines, prompt, StringComparison.Ordinal); + Assert.Contains("ctx", prompt, StringComparison.Ordinal); + } + + [Fact] + public void BuildSubagentPrompt_Sections_AppearInCanonicalOrder() + { + // Arrange: generation → design → context → composition. + var guidelines = new A2UIGuidelines + { + GenerationGuidelines = "GENMARK", + DesignGuidelines = "DESMARK", + CompositionGuide = "COMPMARK", + }; + + // Act + string prompt = A2UIToolkit.BuildSubagentPrompt("CTXMARK", guidelines); + + // Assert + Assert.True(prompt.IndexOf("GENMARK", StringComparison.Ordinal) < prompt.IndexOf("DESMARK", StringComparison.Ordinal)); + Assert.True(prompt.IndexOf("DESMARK", StringComparison.Ordinal) < prompt.IndexOf("CTXMARK", StringComparison.Ordinal)); + Assert.True(prompt.IndexOf("CTXMARK", StringComparison.Ordinal) < prompt.IndexOf("COMPMARK", StringComparison.Ordinal)); + } + + [Fact] + public void BuildSubagentPrompt_PerFieldOverride_KeepsOtherDefault() + { + // Arrange: override generation only → design still falls back to its default. + var guidelines = new A2UIGuidelines { GenerationGuidelines = "CUSTOM_GEN" }; + + // Act + string prompt = A2UIToolkit.BuildSubagentPrompt("ctx", guidelines); + + // Assert + Assert.Contains("CUSTOM_GEN", prompt, StringComparison.Ordinal); + Assert.DoesNotContain(A2UIPromptDefaults.GenerationGuidelines, prompt, StringComparison.Ordinal); + Assert.Contains(A2UIPromptDefaults.DesignGuidelines, prompt, StringComparison.Ordinal); + } + + [Fact] + public void BuildSubagentPrompt_EmptyStringOverride_SuppressesBlock() + { + // Act + string prompt = A2UIToolkit.BuildSubagentPrompt("ctx", SuppressDefaults()); + + // Assert + Assert.DoesNotContain(A2UIPromptDefaults.GenerationGuidelines, prompt, StringComparison.Ordinal); + Assert.DoesNotContain(A2UIPromptDefaults.DesignGuidelines, prompt, StringComparison.Ordinal); + Assert.DoesNotContain("## Design Guidelines", prompt, StringComparison.Ordinal); + } + + [Fact] + public void BuildSubagentPrompt_ContextOnly_ReturnsContextVerbatim() + { + // Act & Assert + Assert.Equal("ctx", A2UIToolkit.BuildSubagentPrompt("ctx", SuppressDefaults())); + } + + [Fact] + public void BuildSubagentPrompt_CompositionGuide_IsAppendedAfterContext() + { + // Arrange + var guidelines = new A2UIGuidelines + { + GenerationGuidelines = string.Empty, + DesignGuidelines = string.Empty, + CompositionGuide = "guide", + }; + + // Act & Assert + Assert.Equal("ctx\nguide", A2UIToolkit.BuildSubagentPrompt("ctx", guidelines)); + } + + [Fact] + public void BuildSubagentPrompt_EditContext_RendersPriorStateAndChanges() + { + // Arrange + var prior = new A2UIPriorSurface( + new JsonArray(new JsonObject { ["id"] = "root", ["component"] = "Row" }), + new JsonObject { ["x"] = 1 }, + CatalogId: null); + var edit = new A2UIEditContext("s1", prior, "make the title bigger"); + + // Act + string prompt = A2UIToolkit.BuildSubagentPrompt("ctx", SuppressDefaults(), edit); + + // Assert + Assert.Contains("Editing an existing surface", prompt, StringComparison.Ordinal); + Assert.Contains("'s1'", prompt, StringComparison.Ordinal); + Assert.Contains("\"id\": \"root\"", prompt, StringComparison.Ordinal); + Assert.Contains("\"x\": 1", prompt, StringComparison.Ordinal); + Assert.Contains("Requested changes", prompt, StringComparison.Ordinal); + Assert.Contains("make the title bigger", prompt, StringComparison.Ordinal); + } + + [Fact] + public void BuildSubagentPrompt_NoChanges_OmitsRequestedChangesSection() + { + // Arrange + var edit = new A2UIEditContext("s1", new A2UIPriorSurface(new JsonArray(), null, null)); + + // Act + string prompt = A2UIToolkit.BuildSubagentPrompt("ctx", SuppressDefaults(), edit); + + // Assert + Assert.DoesNotContain("Requested changes", prompt, StringComparison.Ordinal); + } + + [Fact] + public void BuildSubagentPrompt_EmptyEverything_ReturnsEmpty() + { + // Act & Assert: empty context AND both default blocks suppressed → empty prompt. + Assert.Equal(string.Empty, A2UIToolkit.BuildSubagentPrompt(string.Empty, SuppressDefaults())); + } +} From cf198580cf9d09b72d45be168bef9b7e59e80ba9 Mon Sep 17 00:00:00 2001 From: ran Date: Wed, 10 Jun 2026 16:55:22 +0200 Subject: [PATCH 03/24] .NET: Implement the A2UI toolkit 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 --- .../A2UIGenerationRecovery.cs | 98 +++++- .../A2UIOperationBuilder.cs | 38 ++- .../A2UIToolParams.cs | 202 +++++++++++- .../A2UIToolkit.cs | 309 +++++++++++++++++- .../A2UIValidation.cs | 233 ++++++++++++- .../Microsoft.Agents.AI.AGUI.A2UI.csproj | 1 + 6 files changed, 850 insertions(+), 31 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs index d947b2c792..10175d67da 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs @@ -2,9 +2,11 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.AGUI.A2UI; @@ -52,6 +54,11 @@ public sealed record A2UIRecoveryResult(string Envelope, IReadOnlyList public static class A2UIGenerationRecovery { + private static readonly A2UIValidationError s_noToolCallError = new( + A2UIValidationErrorCodes.EmptyComponents, + "components", + "Sub-agent did not call render_a2ui"); + /// /// Formats validation errors as a compact, model-readable list /// (- [code] path: message per line). @@ -59,7 +66,10 @@ public static class A2UIGenerationRecovery /// The errors to format. /// The formatted block. public static string FormatValidationErrors(IEnumerable errors) - => throw new NotImplementedException(); + { + Throw.IfNull(errors); + return string.Join("\n", errors.Select(e => $"- [{e.Code}] {e.Path}: {e.Message}")); + } /// /// Appends a fix-it block carrying to . @@ -69,7 +79,12 @@ public static string FormatValidationErrors(IEnumerable err /// The prior attempt's validation errors. /// The augmented prompt. public static string AugmentPromptWithValidationErrors(string prompt, IReadOnlyList errors) - => throw new NotImplementedException(); + { + Throw.IfNull(errors); + return errors.Count == 0 + ? prompt + : $"{prompt}\n\n## Previous attempt was invalid — fix these and regenerate:\n{FormatValidationErrors(errors)}\n"; + } /// /// Runs the validate-and-retry loop until a valid surface is produced or the attempt cap is reached. @@ -86,7 +101,7 @@ public static string AugmentPromptWithValidationErrors(string prompt, IReadOnlyL /// Observability callback invoked after each attempt is validated. /// A token to cancel the loop between attempts. /// The loop outcome. - public static Task RunAsync( + public static async Task RunAsync( string basePrompt, Func> invokeSubagentAsync, Func buildEnvelope, @@ -94,5 +109,80 @@ public static Task RunAsync( A2UIRecoveryConfig? config = null, Action? onAttempt = null, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); + { + Throw.IfNull(basePrompt); + Throw.IfNull(invokeSubagentAsync); + Throw.IfNull(buildEnvelope); + + int maxAttempts = config?.MaxAttempts ?? A2UIConstants.MaxA2UIAttempts; + List attempts = []; + IReadOnlyList lastErrors = []; + + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + string prompt = AugmentPromptWithValidationErrors(basePrompt, lastErrors); + JsonObject? args = await invokeSubagentAsync(prompt, attempt, cancellationToken).ConfigureAwait(false); + + A2UIAttemptRecord record; + if (args is null) + { + record = new A2UIAttemptRecord(attempt, Ok: false, [s_noToolCallError]); + attempts.Add(record); + onAttempt?.Invoke(record); + lastErrors = record.Errors; + continue; + } + + // The model output is untrusted: narrow components/data to the expected shapes. + JsonArray? components = args["components"] as JsonArray; + JsonObject? data = args["data"] as JsonObject; + A2UIValidationResult result = A2UIComponentValidator.Validate(components, data, catalog); + record = new A2UIAttemptRecord(attempt, result.Valid, result.Errors); + attempts.Add(record); + onAttempt?.Invoke(record); + + if (result.Valid) + { + return new A2UIRecoveryResult(buildEnvelope(args), attempts, Ok: true); + } + + lastErrors = result.Errors; + } + + return new A2UIRecoveryResult(WrapRecoveryExhaustedEnvelope(maxAttempts, attempts), attempts, Ok: false); + } + + private static string WrapRecoveryExhaustedEnvelope(int maxAttempts, IReadOnlyList attempts) + { + var attemptsArray = new JsonArray(); + foreach (A2UIAttemptRecord attempt in attempts) + { + var errorsArray = new JsonArray(); + foreach (A2UIValidationError error in attempt.Errors) + { + errorsArray.Add((JsonNode)new JsonObject + { + ["code"] = error.Code, + ["path"] = error.Path, + ["message"] = error.Message, + }); + } + + attemptsArray.Add((JsonNode)new JsonObject + { + ["attempt"] = attempt.Attempt, + ["ok"] = attempt.Ok, + ["errors"] = errorsArray, + }); + } + + return new JsonObject + { + ["error"] = $"Failed to generate valid A2UI after {maxAttempts} attempt(s)", + ["code"] = "a2ui_recovery_exhausted", + ["attempts"] = attemptsArray, + }.ToJsonString(); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIOperationBuilder.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIOperationBuilder.cs index f871419f4c..0fda2deb43 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIOperationBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIOperationBuilder.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json.Nodes; namespace Microsoft.Agents.AI.AGUI.A2UI; @@ -14,6 +14,8 @@ namespace Microsoft.Agents.AI.AGUI.A2UI; /// Each builder returns a single operation object of the shape /// { "version": "v0.9", "<operation>": { ... } }, matching the A2UI v0.9 /// envelope specification and the sibling TypeScript/Python toolkit implementations. +/// Node inputs are deep-cloned, so callers may pass nodes that are already attached +/// to another document. /// public static class A2UIOperationBuilder { @@ -23,8 +25,15 @@ public static class A2UIOperationBuilder /// The identifier of the surface to create. /// The identifier of the component catalog the surface renders against. /// The operation as a . - public static JsonObject CreateSurface(string surfaceId, string catalogId) - => throw new NotImplementedException(); + public static JsonObject CreateSurface(string surfaceId, string catalogId) => new() + { + ["version"] = A2UIConstants.ProtocolVersion, + ["createSurface"] = new JsonObject + { + ["surfaceId"] = surfaceId, + ["catalogId"] = catalogId, + }, + }; /// /// Builds an updateComponents operation carrying a flat component array. @@ -32,8 +41,15 @@ public static JsonObject CreateSurface(string surfaceId, string catalogId) /// The identifier of the target surface. /// The flat A2UI component array. /// The operation as a . - public static JsonObject UpdateComponents(string surfaceId, IEnumerable components) - => throw new NotImplementedException(); + public static JsonObject UpdateComponents(string surfaceId, IEnumerable components) => new() + { + ["version"] = A2UIConstants.ProtocolVersion, + ["updateComponents"] = new JsonObject + { + ["surfaceId"] = surfaceId, + ["components"] = new JsonArray(components.Select(c => c?.DeepClone()).ToArray()), + }, + }; /// /// Builds an updateDataModel operation that writes at . @@ -42,6 +58,14 @@ public static JsonObject UpdateComponents(string surfaceId, IEnumerableThe value to write into the surface data model. /// The JSON-pointer-style path to write at. Defaults to the root path "/". /// The operation as a . - public static JsonObject UpdateDataModel(string surfaceId, JsonNode? value, string path = "/") - => throw new NotImplementedException(); + public static JsonObject UpdateDataModel(string surfaceId, JsonNode? value, string path = "/") => new() + { + ["version"] = A2UIConstants.ProtocolVersion, + ["updateDataModel"] = new JsonObject + { + ["surfaceId"] = surfaceId, + ["path"] = path, + ["value"] = value?.DeepClone(), + }, + }; } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolParams.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolParams.cs index 6bd28c3eed..0610ef885f 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolParams.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolParams.cs @@ -70,8 +70,34 @@ public static class A2UIToolDefinitions /// /// Gets the planner-facing description of the generate_a2ui tool. /// - public static string GenerateA2UIToolDescription - => "[[A2UI_GENERATE_TOOL_DESCRIPTION_PLACEHOLDER]]"; // TODO(a2ui-port): verbatim text from sibling toolkits. + public static string GenerateA2UIToolDescription => + "Generate or update a dynamic A2UI surface based on the conversation. " + + "A secondary LLM designs the UI components and data. " + + "Use intent='create' (default) when the user requests new visual content " + + "(cards, forms, lists, dashboards, comparisons, etc.). " + + "Use intent='update' with target_surface_id to modify a surface you " + + "previously rendered (e.g. 'change the second card's price', " + + "'add a Buy button', 'use red instead of blue')."; + + /// + /// Gets the planner-facing description of the generate_a2ui tool's intent argument. + /// + public static string IntentArgumentDescription => + "'create' to render a new surface; 'update' to modify a surface " + + "previously rendered in this conversation. Defaults to 'create'."; + + /// + /// Gets the planner-facing description of the generate_a2ui tool's + /// target_surface_id argument. + /// + public static string TargetSurfaceIdArgumentDescription => + "Required when intent='update'. The surface id of the prior render to modify."; + + /// + /// Gets the planner-facing description of the generate_a2ui tool's changes argument. + /// + public static string ChangesArgumentDescription => + "Optional natural-language description of the changes to apply when intent='update'."; /// /// Creates the OpenAI-style function definition of the inner render_a2ui @@ -79,8 +105,45 @@ public static string GenerateA2UIToolDescription /// surfaceId and components required). /// /// A fresh, caller-owned with the tool definition. - public static JsonObject CreateRenderA2UIToolDefinition() - => throw new NotImplementedException(); + public static JsonObject CreateRenderA2UIToolDefinition() => new() + { + ["type"] = "function", + ["function"] = new JsonObject + { + ["name"] = A2UIConstants.RenderA2UIToolName, + ["description"] = + "Render a dynamic A2UI v0.9 surface. The root component must have " + + "id 'root'. Use components from the available catalog only.", + ["parameters"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["surfaceId"] = new JsonObject + { + ["type"] = "string", + ["description"] = "Unique surface identifier.", + }, + ["components"] = new JsonObject + { + ["type"] = "array", + ["description"] = + "A2UI v0.9 component array (flat format). The root " + + "component must have id 'root'.", + ["items"] = new JsonObject { ["type"] = "object" }, + }, + ["data"] = new JsonObject + { + ["type"] = "object", + ["description"] = + "Optional initial data model for the surface (form " + + "values, list items for data-bound components, etc.).", + }, + }, + ["required"] = new JsonArray("surfaceId", "components"), + }, + }, + }; /// /// Fills canonical defaults for every unset or empty-string field of @@ -89,8 +152,18 @@ public static JsonObject CreateRenderA2UIToolDefinition() /// /// The raw parameters, or for all defaults. /// The resolved parameters. - public static A2UIResolvedToolParams ResolveA2UIToolParams(A2UIToolParams? parameters) - => throw new NotImplementedException(); + public static A2UIResolvedToolParams ResolveA2UIToolParams(A2UIToolParams? parameters) => new( + Guidelines: parameters?.Guidelines, + DefaultSurfaceId: DefaultOr(parameters?.DefaultSurfaceId, A2UIConstants.DefaultSurfaceId), + DefaultCatalogId: DefaultOr(parameters?.DefaultCatalogId, A2UIConstants.BasicCatalogId), + ToolName: DefaultOr(parameters?.ToolName, A2UIConstants.GenerateA2UIToolName), + ToolDescription: DefaultOr(parameters?.ToolDescription, GenerateA2UIToolDescription), + Catalog: parameters?.Catalog, + Recovery: parameters?.Recovery, + OnAttempt: parameters?.OnAttempt); + + private static string DefaultOr(string? value, string fallback) + => string.IsNullOrEmpty(value) ? fallback : value!; } /// @@ -101,14 +174,121 @@ public static class A2UIPromptDefaults { /// /// Gets the default generation-guidelines block (A2UI protocol rules: ids, paths, - /// bindings, data model). + /// bindings, data model). Ported verbatim from the sibling toolkits. /// - public static string GenerationGuidelines - => "[[A2UI_DEFAULT_GENERATION_GUIDELINES_PLACEHOLDER]]"; // TODO(a2ui-port): verbatim text from sibling toolkits. + public static string GenerationGuidelines => """ + Generate A2UI v0.9 JSON. + + ## A2UI Protocol Instructions + + A2UI (Agent to UI) is a protocol for rendering rich UI surfaces from agent responses. + + CRITICAL: You MUST call the render_a2ui tool with ALL of these arguments: + - surfaceId: A unique ID for the surface (e.g. "product-comparison") + - components: REQUIRED — the A2UI component array. NEVER omit this. Use a List with + children: { componentId: "card-id", path: "/items" } for repeating cards. + - data: OPTIONAL — a JSON object written to the root of the surface data model. + Use for pre-filling form values or providing data for path-bound components. + - every component must have the "component" field specifying the component type (e.g. "Text", "Image", "Row", "Column", "List", "Button", etc.) + + COMPONENT ID RULES: + - Every component ID must be unique within the surface. + - A component MUST NOT reference itself as child/children. This causes a + circular dependency error. For example, if a component has id="avatar", + its child must be a DIFFERENT id (e.g. "avatar-img"), never "avatar". + - The child/children tree must be a DAG — no cycles allowed. + + PATH RULES FOR TEMPLATES: + Components inside a repeating List use RELATIVE paths (no leading slash). + The path is resolved relative to each array item automatically. + If List has children: { componentId: "card", path: "/items" } and item has key "name", + use { "path": "name" } (NO leading slash — relative to item). + CRITICAL: Do NOT use "/name" (absolute) inside templates — use "name" (relative). + The List's own path ("/items") uses a leading slash (absolute), but all + components INSIDE the template card use paths WITHOUT leading slash. + Do NOT use "/items/0/name" or "/items/{@key}/name" — just "name". + + DATA MODEL: + The "data" key in the tool args is a plain JSON object that initializes the surface + data model. Components bound to paths (e.g. "value": { "path": "/form/name" }) + read from and write to this data model. Examples: + For forms: "data": { "form": { "name": "Alice", "email": "" } } + For lists: "data": { "items": [{"name": "Product A"}, {"name": "Product B"}] } + For mixed: "data": { "form": { "query": "" }, "results": [...] } + + FORMS AND TWO-WAY DATA BINDING: + To create editable forms, bind input components to data model paths using { "path": "..." }. + The client automatically writes user input back to the data model at the bound path. + CRITICAL: Using a literal value (e.g. "value": "") makes the field READ-ONLY. + You MUST use { "path": "..." } to make inputs editable. + + All input components use "value" as the binding property: + - TextField: "value": { "path": "/form/fieldName" } + - CheckBox: "value": { "path": "/form/isChecked" } + - Slider: "value": { "path": "/form/sliderVal" } + - DateTimeInput: "value": { "path": "/form/date" } + - ChoicePicker: "value": { "path": "/form/choices" } + + To retrieve form values when a button is clicked, include "context" with path references + in the button's action. Paths are resolved to their current values at click time: + "action": { "event": { "name": "submit", "context": { "userName": { "path": "/form/name" } } } } + + To pre-fill form values, pass initial data via the "data" tool argument: + "data": { "form": { "name": "Markus" } } + + FORM EXAMPLE (editable text field with pre-filled value + submit button): + "components": [ + { "id": "root", "component": "Card", "child": "form-col" }, + { "id": "form-col", "component": "Column", "children": ["name-field", "submit-row"] }, + { "id": "name-field", "component": "TextField", "label": "Name", "value": { "path": "/form/name" } }, + { "id": "submit-row", "component": "Row", "justify": "end", "children": ["submit-btn"] }, + { "id": "submit-btn", "component": "Button", "child": "btn-text", "variant": "primary", + "action": { "event": { "name": "submit", "context": { "userName": { "path": "/form/name" } } } } }, + { "id": "btn-text", "component": "Text", "text": "Submit" } + ], + "data": { "form": { "name": "Markus" } } + """; /// /// Gets the default design-guidelines block (visual hierarchy, layout patterns). + /// Ported verbatim from the sibling toolkits. /// - public static string DesignGuidelines - => "[[A2UI_DEFAULT_DESIGN_GUIDELINES_PLACEHOLDER]]"; // TODO(a2ui-port): verbatim text from sibling toolkits. + public static string DesignGuidelines => """ + Create polished, visually appealing interfaces: + - Always include a title heading (h2) for the surface, outside the List. + Wrap in a Column: [title, list] as root. + - For card templates, create clear visual hierarchy: + - h3 for primary text (names, titles) + - h2 for featured numbers (prices, scores) — makes them stand out + - caption for secondary info (ratings, categories, metadata) + - body for descriptions + - Use Divider between logical sections within cards. + - Use Row with justify="spaceBetween" for label-value pairs + (e.g. "Rating" on left, "4.5/5" on right). + - Include images when relevant (logos, icons, product photos): + - Use Image component with variant="smallFeature" or "avatar" + - Prefer company logos for branded products — Google favicons are reliable: + https://www.google.com/s2/favicons?domain=sony.com&sz=128 + https://www.google.com/s2/favicons?domain=bose.com&sz=128 + - For generic icons: https://placehold.co/128x128/EEE/999?text=🎧 + - Do NOT invent Unsplash photo-IDs — they will 404. Only use real, known URLs. + - Use horizontal List direction for side-by-side comparison cards. + - Keep cards clean — avoid clutter. Whitespace is good. + - Use consistent surfaceIds (lowercase, hyphenated). + - NEVER use the same ID for a component and its child — this creates a + circular dependency. E.g. if id="avatar", child must NOT be "avatar". + - Both Row and Column support "justify" and "align". + - Add Button for interactivity. Button needs child (Text ID) + action. + Action MUST use this exact nested format: + "action": { "event": { "name": "myAction", "context": { "key": "value" } } } + The "event" key holds an OBJECT with "name" (required) and "context" (optional). + Do NOT use a flat format like {"event": "name"} — "event" must be an object. + Use variant="primary" for main action buttons, variant="borderless" for links. + - For forms: wrap fields in a Card with a Column. Place the submit button in a + Row with justify="end". Every input MUST use path binding on the "value" property + (e.g. "value": { "path": "/form/name" }) to be editable. The submit button's action + context MUST reference the same paths to capture the user's input. + + Use the SAME surfaceId as the main surface. Match action names to Button action event names. + """; } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolkit.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolkit.cs index b7ad976a3a..c906998975 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolkit.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolkit.cs @@ -2,7 +2,11 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; using System.Text.Json.Nodes; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.AGUI.A2UI; @@ -18,6 +22,8 @@ namespace Microsoft.Agents.AI.AGUI.A2UI; /// public static class A2UIToolkit { + private static readonly JsonSerializerOptions s_indentedOptions = new() { WriteIndented = true }; + /// /// Builds the context section of the subagent prompt from the AG-UI agent state: /// one markdown section per described context entry, followed by the component @@ -26,7 +32,31 @@ public static class A2UIToolkit /// The AG-UI agent state slice, or . /// The context prompt, possibly empty. public static string BuildContextPrompt(A2UIAgentState? state) - => throw new NotImplementedException(); + { + List parts = []; + + foreach (A2UIContextEntry entry in state?.Context ?? []) + { + // A null value with a description must not leak a literal "null" + // into the subagent prompt — coerce to the empty string first. + string value = entry.Value ?? string.Empty; + if (!string.IsNullOrEmpty(entry.Description)) + { + parts.Add($"## {entry.Description}\n{value}\n"); + } + else if (value.Length > 0) + { + parts.Add($"{value}\n"); + } + } + + if (!string.IsNullOrEmpty(state?.A2UISchema)) + { + parts.Add($"## Available Components\n{state!.A2UISchema}\n"); + } + + return string.Join("\n", parts); + } /// /// Walks the conversation history backwards to reconstruct the latest known state of @@ -43,7 +73,144 @@ public static string BuildContextPrompt(A2UIAgentState? state) /// The surface to look for. /// The reconstructed surface state, or when absent or deleted. public static A2UIPriorSurface? FindPriorSurface(IEnumerable messages, string surfaceId) - => throw new NotImplementedException(); + { + Throw.IfNull(messages); + Throw.IfNull(surfaceId); + + JsonArray? components = null; + JsonNode? data = null; + bool dataSeen = false; + string? catalogId = null; + bool matched = false; + + foreach (A2UIHistoryMessage message in messages.Reverse()) + { + if (message.Role is not ("tool" or "ToolMessage") || message.Content is null) + { + continue; + } + + JsonNode? parsed; + try + { + parsed = JsonNode.Parse(message.Content); + } + catch (JsonException) + { + // Conversation history is untrusted input — skip malformed content. + continue; + } + + if (parsed is not JsonObject parsedObject || + parsedObject[A2UIConstants.A2UIOperationsKey] is not JsonArray operations) + { + continue; + } + + // Compute this message's end state for the surface by walking ops forward. + // deleteSurface resets the per-message accumulator; subsequent create/update + // ops in the same message restore it. + bool messageMentions = false; + bool messageDeleted = false; + string? messageCatalogId = null; + JsonArray? messageComponents = null; + JsonNode? messageData = null; + bool messageDataSeen = false; + + foreach (JsonNode? operationNode in operations) + { + if (operationNode is not JsonObject operation) + { + continue; + } + + if (operation["deleteSurface"] is JsonObject deleteSurface && + TryGetString(deleteSurface["surfaceId"]) == surfaceId) + { + messageMentions = true; + messageDeleted = true; + messageCatalogId = null; + messageComponents = null; + messageData = null; + messageDataSeen = false; + continue; + } + + if (operation["createSurface"] is JsonObject createSurface && + TryGetString(createSurface["surfaceId"]) == surfaceId) + { + messageMentions = true; + messageDeleted = false; + if (TryGetString(createSurface["catalogId"]) is string opCatalogId) + { + messageCatalogId = opCatalogId; + } + } + + if (operation["updateComponents"] is JsonObject updateComponents && + TryGetString(updateComponents["surfaceId"]) == surfaceId) + { + messageMentions = true; + messageDeleted = false; + if (updateComponents["components"] is JsonArray opComponents) + { + messageComponents = opComponents; + } + } + + if (operation["updateDataModel"] is JsonObject updateDataModel && + TryGetString(updateDataModel["surfaceId"]) == surfaceId) + { + messageMentions = true; + messageDeleted = false; + messageData = updateDataModel["value"]; + messageDataSeen = true; + } + } + + if (!messageMentions) + { + continue; + } + + if (!matched) + { + // Newest message that mentions the surface — its end state is authoritative. + if (messageDeleted) + { + return null; + } + + matched = true; + catalogId = messageCatalogId; + components = messageComponents; + data = messageData; + dataSeen = messageDataSeen; + } + else if (!messageDeleted) + { + // Older message: fill in only the fields not yet set. A delete here is + // overridden by the newer state already recorded. + catalogId ??= messageCatalogId; + components ??= messageComponents; + if (!dataSeen && messageDataSeen) + { + data = messageData; + dataSeen = true; + } + } + + // Early-exit once every field is populated — nothing older can override. + if (matched && components is not null && catalogId is not null && dataSeen) + { + break; + } + } + + return matched + ? new A2UIPriorSurface(components ?? [], data, catalogId) + : null; + } /// /// Assembles the full subagent system prompt in the canonical section order: @@ -58,7 +225,60 @@ public static string BuildSubagentPrompt( string contextPrompt, A2UIGuidelines? guidelines = null, A2UIEditContext? editContext = null) - => throw new NotImplementedException(); + { + Throw.IfNull(contextPrompt); + + // Per-field fallback: null → built-in default; "" → the host explicitly + // suppressed the block. + string generation = guidelines?.GenerationGuidelines ?? A2UIPromptDefaults.GenerationGuidelines; + string design = guidelines?.DesignGuidelines ?? A2UIPromptDefaults.DesignGuidelines; + string? compositionGuide = guidelines?.CompositionGuide; + + List parts = []; + if (generation.Length > 0) + { + parts.Add(generation); + } + + if (design.Length > 0) + { + parts.Add($"## Design Guidelines\n{design}"); + } + + if (contextPrompt.Length > 0) + { + parts.Add(contextPrompt); + } + + if (!string.IsNullOrEmpty(compositionGuide)) + { + parts.Add(compositionGuide!); + } + + if (editContext is not null) + { + string componentsJson = (editContext.Prior.Components ?? []).ToJsonString(s_indentedOptions); + string dataJson = editContext.Prior.Data?.ToJsonString(s_indentedOptions) ?? "null"; + + var editBlock = new StringBuilder() + .Append("## Editing an existing surface\n") + .Append("You are editing surface '").Append(editContext.SurfaceId).Append("'. Produce the FULL ") + .Append("updated components array and data model — not just a diff. ") + .Append("Preserve component ids that the user has not asked to change so ") + .Append("the renderer can reconcile them. Reuse the same catalogId.\n\n") + .Append("### Previous components\n").Append(componentsJson).Append("\n\n") + .Append("### Previous data\n").Append(dataJson).Append('\n'); + + if (!string.IsNullOrEmpty(editContext.Changes)) + { + editBlock.Append("\n### Requested changes\n").Append(editContext.Changes).Append('\n'); + } + + parts.Add(editBlock.ToString()); + } + + return string.Join("\n", parts.Where(p => p.Length > 0)); + } /// /// Resolves the create/update intent, locates the prior surface for updates, and builds @@ -82,7 +302,29 @@ public static A2UIPreparedRequest PrepareA2UIRequest( IEnumerable messages, A2UIAgentState? state, A2UIGuidelines? guidelines = null) - => throw new NotImplementedException(); + { + Throw.IfNull(messages); + + bool isUpdate = (intent ?? "create") == "update" && !string.IsNullOrEmpty(targetSurfaceId); + A2UIPriorSurface? prior = isUpdate ? FindPriorSurface(messages, targetSurfaceId!) : null; + + if (isUpdate && prior is null) + { + return new A2UIPreparedRequest( + Prompt: string.Empty, + IsUpdate: isUpdate, + Prior: null, + Error: $"intent='update' requested target_surface_id='{targetSurfaceId}' " + + "but no prior render of that surface was found in conversation history"); + } + + string prompt = BuildSubagentPrompt( + BuildContextPrompt(state), + guidelines, + prior is not null ? new A2UIEditContext(targetSurfaceId!, prior, changes) : null); + + return new A2UIPreparedRequest(prompt, isUpdate, prior, Error: null); + } /// /// Builds the final operations envelope from the subagent's structured tool output, @@ -109,7 +351,33 @@ public static string BuildA2UIEnvelope( A2UIPriorSurface? prior, string defaultSurfaceId = A2UIConstants.DefaultSurfaceId, string defaultCatalogId = A2UIConstants.BasicCatalogId) - => throw new NotImplementedException(); + { + Throw.IfNull(args); + + // Treat empty-string defaults as unset. Without this, a misconfigured host + // passing "" would propagate the empty string into the emitted ops and surface + // as "Catalog not found: " / blank surface ids at render time, hiding the cause. + string safeDefaultSurfaceId = string.IsNullOrEmpty(defaultSurfaceId) ? A2UIConstants.DefaultSurfaceId : defaultSurfaceId; + string safeDefaultCatalogId = string.IsNullOrEmpty(defaultCatalogId) ? A2UIConstants.BasicCatalogId : defaultCatalogId; + + string argSurfaceId = TryGetString(args["surfaceId"]) is string raw && raw.Length > 0 ? raw : string.Empty; + string surfaceId = isUpdate + ? (string.IsNullOrEmpty(targetSurfaceId) ? safeDefaultSurfaceId : targetSurfaceId!) + : (argSurfaceId.Length > 0 ? argSurfaceId : safeDefaultSurfaceId); + string catalogId = string.IsNullOrEmpty(prior?.CatalogId) ? safeDefaultCatalogId : prior!.CatalogId!; + + JsonArray components = args["components"] as JsonArray ?? []; + JsonObject? data = args["data"] as JsonObject; + + IReadOnlyList ops = AssembleOps( + isUpdate ? "update" : "create", + surfaceId, + catalogId, + components, + data); + + return WrapAsOperationsEnvelope(ops); + } /// /// Assembles the ordered operation list for a surface render: the create intent emits @@ -128,7 +396,23 @@ public static IReadOnlyList AssembleOps( string catalogId, JsonArray components, JsonObject? data = null) - => throw new NotImplementedException(); + { + Throw.IfNull(components); + + List ops = []; + if (!string.Equals(intent, "update", StringComparison.Ordinal)) + { + ops.Add(A2UIOperationBuilder.CreateSurface(surfaceId, catalogId)); + } + + ops.Add(A2UIOperationBuilder.UpdateComponents(surfaceId, components)); + if (data is { Count: > 0 }) + { + ops.Add(A2UIOperationBuilder.UpdateDataModel(surfaceId, data)); + } + + return ops; + } /// /// Serializes operations under the envelope key. @@ -136,7 +420,13 @@ public static IReadOnlyList AssembleOps( /// The operations to wrap. /// The serialized envelope. public static string WrapAsOperationsEnvelope(IEnumerable operations) - => throw new NotImplementedException(); + { + Throw.IfNull(operations); + return new JsonObject + { + [A2UIConstants.A2UIOperationsKey] = new JsonArray(operations.Select(JsonNode? (op) => op.DeepClone()).ToArray()), + }.ToJsonString(); + } /// /// Serializes a host-facing error as {"error": message} so the planner receives a @@ -145,5 +435,8 @@ public static string WrapAsOperationsEnvelope(IEnumerable operations /// The error message. /// The serialized error envelope. public static string WrapErrorEnvelope(string message) - => throw new NotImplementedException(); + => new JsonObject { ["error"] = message }.ToJsonString(); + + private static string? TryGetString(JsonNode? node) + => node is JsonValue value && value.TryGetValue(out string? text) ? text : null; } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs index 2c87d57a34..a377d3405f 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs @@ -110,5 +110,236 @@ public static A2UIValidationResult Validate( JsonObject? data = null, A2UIValidationCatalog? catalog = null, bool validateBindings = true) - => throw new NotImplementedException(); + { + // Fail loud on a missing/empty payload. + if (components is null || components.Count == 0) + { + return new A2UIValidationResult(false, + [ + new A2UIValidationError( + A2UIValidationErrorCodes.EmptyComponents, + "components", + "A2UI components must be a non-empty array"), + ]); + } + + List errors = []; + + // First pass: collect ids and flag duplicates. + var ids = new HashSet(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}'")); + } + } + + for (int i = 0; i < components.Count; i++) + { + JsonObject? component = components[i] as JsonObject; + string? id = TryGetString(component?["id"]); + string? componentType = TryGetString(component?["component"]); + + if (string.IsNullOrEmpty(id)) + { + errors.Add(new A2UIValidationError( + A2UIValidationErrorCodes.MissingId, + $"components[{i}].id", + $"Component at index {i} is missing a string 'id'")); + } + + if (string.IsNullOrEmpty(componentType)) + { + errors.Add(new A2UIValidationError( + A2UIValidationErrorCodes.MissingComponentType, + $"components[{i}].component", + $"Component at index {i} is missing a string 'component' type")); + } + + if (catalog is not null && componentType is not null) + { + if (catalog.Components[componentType] is not JsonObject schema) + { + errors.Add(new A2UIValidationError( + A2UIValidationErrorCodes.UnknownComponent, + $"components[{i}].component", + $"Component type '{componentType}' is not in the catalog")); + } + else if (schema["required"] is JsonArray required) + { + foreach (JsonNode? requiredNode in required) + { + if (TryGetString(requiredNode) is string requiredProp && + component?.ContainsKey(requiredProp) != true) + { + errors.Add(new A2UIValidationError( + A2UIValidationErrorCodes.MissingRequiredProp, + $"components[{i}].{requiredProp}", + $"Component '{componentType}' (index {i}) is missing required prop '{requiredProp}'")); + } + } + } + } + + 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 (validateBindings) + { + List bindingPaths = []; + CollectAbsoluteBindingPaths(component, bindingPaths); + foreach (string path in bindingPaths) + { + if (!AbsolutePathResolves(path, data)) + { + errors.Add(new A2UIValidationError( + A2UIValidationErrorCodes.UnresolvedBinding, + $"components[{i}]", + $"Binding path '{path}' does not resolve in the data model")); + } + } + } + } + } + + bool hasRoot = false; + foreach (JsonNode? node in components) + { + if (node is JsonObject component && TryGetString(component["id"]) == "root") + { + hasRoot = true; + break; + } + } + + if (!hasRoot) + { + errors.Add(new A2UIValidationError( + A2UIValidationErrorCodes.NoRoot, + "components", + "No component has id 'root'")); + } + + return new A2UIValidationResult(errors.Count == 0, errors); + } + + private static string? TryGetString(JsonNode? node) + => node is JsonValue value && value.TryGetValue(out string? text) ? text : null; + + private static bool AbsolutePathResolves(string path, JsonNode? data) + { + JsonNode? cursor = data; + foreach (string segment in path.Split('/')) + { + if (segment.Length == 0) + { + continue; + } + + switch (cursor) + { + case JsonArray array: + if (!int.TryParse(segment, out int index) || index < 0 || index >= array.Count) + { + return false; + } + + cursor = array[index]; + break; + + case JsonObject obj: + if (!obj.TryGetPropertyValue(segment, out JsonNode? next)) + { + return false; + } + + cursor = next; + break; + + default: + return false; + } + } + + return true; + } + + private static List CollectChildReferences(JsonNode? children) + { + List references = []; + + void Push(JsonNode? node) + { + if (TryGetString(node) is string id) + { + references.Add(id); + } + else if (node is JsonObject obj && TryGetString(obj["componentId"]) is string componentId) + { + references.Add(componentId); + } + } + + if (children is JsonArray array) + { + foreach (JsonNode? child in array) + { + Push(child); + } + } + else if (children is JsonObject) + { + Push(children); + } + + return references; + } + + private static void CollectAbsoluteBindingPaths(JsonNode? node, List accumulator) + { + switch (node) + { + case JsonArray array: + foreach (JsonNode? item in array) + { + CollectAbsoluteBindingPaths(item, accumulator); + } + + break; + + case JsonObject obj: + if (TryGetString(obj["path"]) is string path && path.Length > 0 && path[0] == '/') + { + accumulator.Add(path); + } + + foreach (KeyValuePair property in obj) + { + if (!string.Equals(property.Key, "path", StringComparison.Ordinal)) + { + CollectAbsoluteBindingPaths(property.Value, accumulator); + } + } + + break; + + default: + break; + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj index 857baf4f16..3acc01dbe6 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj @@ -5,6 +5,7 @@ Microsoft Agent Framework AG-UI A2UI Toolkit Framework-agnostic helpers for building A2UI (Agent-to-UI) generation tools on top of the AG-UI protocol. true + true From f8c61c9328aeefa95a6711ed5ed1c0b9a86fa0d7 Mon Sep 17 00:00:00 2001 From: ran Date: Wed, 10 Jun 2026 17:19:41 +0200 Subject: [PATCH 04/24] .NET: Add A2UIAgent adapter exposing the generate_a2ui tool 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 --- .../A2UIAgent.cs | 281 ++++++++++++++++++ .../A2UIConstants.cs | 9 + .../A2UIToolParams.cs | 8 +- .../Microsoft.Agents.AI.AGUI.A2UI.csproj | 5 + .../A2UIAgentTests.cs | 229 ++++++++++++++ 5 files changed, 528 insertions(+), 4 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs new file mode 100644 index 0000000000..e6e2497800 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs @@ -0,0 +1,281 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.AGUI.A2UI; + +/// +/// Wraps an agent with A2UI surface-generation support: every run gets a +/// generate_a2ui tool that delegates UI generation to a subagent chat client +/// and returns a validated A2UI operations envelope as its tool result. +/// +/// +/// +/// This is the Agent Framework adapter over the framework-agnostic toolkit +/// (, ), mirroring the +/// LangGraph adapters' getA2UITools/get_a2ui_tools factories. The tool +/// must be constructed per run because it reads the run's conversation history (for +/// prior-surface lookup) and the AG-UI context forwarded by the hosting layer (for the +/// component catalog) — hence the agent wrapper rather than a static tool. +/// +/// +/// The component catalog is read from the ag_ui_context additional property that +/// MapAGUI stamps onto , using the +/// schema context entry injected by the AG-UI A2UI middleware. +/// +/// +public sealed class A2UIAgent : DelegatingAIAgent +{ + private readonly IChatClient _subagentChatClient; + private readonly A2UIResolvedToolParams _parameters; + + /// + /// Initializes a new instance of the class. + /// + /// The agent to wrap. + /// + /// The chat client used to run the UI-generation subagent. Must be a raw client + /// (no automatic function invocation) — the adapter reads the forced + /// render_a2ui call's arguments directly. + /// + /// Behavior knobs; defaults are filled per the shared toolkit rules. + public A2UIAgent(AIAgent innerAgent, IChatClient subagentChatClient, A2UIToolParams? parameters = null) + : base(innerAgent) + { + this._subagentChatClient = Throw.IfNull(subagentChatClient); + this._parameters = A2UIToolDefinitions.ResolveA2UIToolParams(parameters); + } + + /// + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + (List messageList, AgentRunOptions runOptions) = this.PrepareRun(messages, options); + return this.InnerAgent.RunAsync(messageList, session, runOptions, cancellationToken); + } + + /// + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + (List messageList, AgentRunOptions runOptions) = this.PrepareRun(messages, options); + return this.InnerAgent.RunStreamingAsync(messageList, session, runOptions, cancellationToken); + } + + /// + /// Builds the per-run options: clones the incoming chat options and appends a + /// freshly constructed generate_a2ui tool that captures this run's + /// conversation history and AG-UI state. + /// + private (List Messages, AgentRunOptions Options) PrepareRun( + IEnumerable messages, + AgentRunOptions? options) + { + List messageList = messages.ToList(); + + ChatClientAgentRunOptions? original = options as ChatClientAgentRunOptions; + ChatOptions chatOptions = original?.ChatOptions?.Clone() ?? new ChatOptions(); + + AIFunction generateTool = this.CreateGenerateA2UIFunction(messageList, ReadAgentState(chatOptions.AdditionalProperties)); + chatOptions.Tools = (chatOptions.Tools ?? Enumerable.Empty()) + .Where(t => !string.Equals(t.Name, generateTool.Name, StringComparison.Ordinal)) + .Append(generateTool) + .ToList(); + + var runOptions = new ChatClientAgentRunOptions + { + ChatOptions = chatOptions, + ChatClientFactory = original?.ChatClientFactory, + }; + + return (messageList, runOptions); + } + + /// + /// Builds the per-run generate_a2ui tool with the run's conversation history and + /// AG-UI state captured in its closure. + /// + private AIFunction CreateGenerateA2UIFunction(IReadOnlyList messages, A2UIAgentState state) + { + List history = messages.Select(ToHistoryMessage).ToList(); + + async Task GenerateA2UIAsync( + [Description(A2UIToolDefinitions.IntentArgumentDescription)] string? intent = null, + [Description(A2UIToolDefinitions.TargetSurfaceIdArgumentDescription)] string? target_surface_id = null, + [Description(A2UIToolDefinitions.ChangesArgumentDescription)] string? changes = null, + CancellationToken cancellationToken = default) + { + A2UIPreparedRequest prep = A2UIToolkit.PrepareA2UIRequest( + intent, target_surface_id, changes, history, state, this._parameters.Guidelines); + if (prep.Error is not null) + { + return A2UIToolkit.WrapErrorEnvelope(prep.Error); + } + + A2UIRecoveryResult result = await A2UIGenerationRecovery.RunAsync( + prep.Prompt, + (prompt, attempt, ct) => this.InvokeRenderSubagentAsync(prompt, messages, ct), + args => A2UIToolkit.BuildA2UIEnvelope( + args, + prep.IsUpdate, + target_surface_id, + prep.Prior, + this._parameters.DefaultSurfaceId, + this._parameters.DefaultCatalogId), + this._parameters.Catalog, + this._parameters.Recovery, + this._parameters.OnAttempt, + cancellationToken).ConfigureAwait(false); + + return result.Envelope; + } + + return AIFunctionFactory.Create( + GenerateA2UIAsync, + this._parameters.ToolName, + this._parameters.ToolDescription); + } + + /// + /// Runs the UI-generation subagent with a forced render_a2ui tool call and + /// returns the call's structured arguments, or when the model + /// did not call the tool (a retryable failure in the recovery loop). + /// + private async ValueTask InvokeRenderSubagentAsync( + string prompt, + IReadOnlyList messages, + CancellationToken cancellationToken) + { + List subagentMessages = [new ChatMessage(ChatRole.System, prompt), .. messages]; + var subagentOptions = new ChatOptions + { + Tools = [new RenderA2UIToolDeclaration()], + ToolMode = ChatToolMode.RequireSpecific(A2UIConstants.RenderA2UIToolName), + }; + + ChatResponse response = await this._subagentChatClient + .GetResponseAsync(subagentMessages, subagentOptions, cancellationToken) + .ConfigureAwait(false); + + FunctionCallContent? call = response.Messages + .SelectMany(m => m.Contents) + .OfType() + .FirstOrDefault(c => string.Equals(c.Name, A2UIConstants.RenderA2UIToolName, StringComparison.Ordinal)); + + return call?.Arguments is { } arguments ? ToJsonObject(arguments) : null; + } + + /// + /// Reads the AG-UI state slice from the additional properties stamped by the AG-UI + /// hosting layer: forwarded context entries plus the A2UI component catalog entry + /// injected by the A2UI middleware. + /// + private static A2UIAgentState ReadAgentState(AdditionalPropertiesDictionary? properties) + { + if (properties is null || + !properties.TryGetValue("ag_ui_context", out object? contextValue) || + contextValue is not IEnumerable> entries) + { + return new A2UIAgentState(); + } + + List context = []; + string? schema = null; + foreach (KeyValuePair entry in entries) + { + if (string.Equals(entry.Key, A2UIConstants.A2UISchemaContextDescription, StringComparison.Ordinal)) + { + schema = entry.Value; + } + else + { + context.Add(new A2UIContextEntry(entry.Key, entry.Value)); + } + } + + return new A2UIAgentState { Context = context, A2UISchema = schema }; + } + + /// + /// Maps a chat message onto the toolkit's history shape: the role name plus the + /// message's textual content (for tool results, the function result payload). + /// + private static A2UIHistoryMessage ToHistoryMessage(ChatMessage message) + { + string? content = message.Text; + if (string.IsNullOrEmpty(content) && message.Role == ChatRole.Tool) + { + FunctionResultContent? result = message.Contents.OfType().FirstOrDefault(); + content = result?.Result switch + { + string text => text, + JsonElement { ValueKind: JsonValueKind.String } element => element.GetString(), + JsonElement element => element.GetRawText(), + _ => null, + }; + } + + return new A2UIHistoryMessage(message.Role.Value, content); + } + + private static JsonObject ToJsonObject(IDictionary arguments) + { + var result = new JsonObject(); + foreach (KeyValuePair argument in arguments) + { + result[argument.Key] = argument.Value switch + { + null => null, + JsonNode node => node.DeepClone(), + JsonElement element => JsonNode.Parse(element.GetRawText()), + string text => JsonValue.Create(text), + bool flag => JsonValue.Create(flag), + int number => JsonValue.Create(number), + long number => JsonValue.Create(number), + double number => JsonValue.Create(number), + _ => JsonNode.Parse(JsonSerializer.Serialize(argument.Value, AIJsonUtilities.DefaultOptions.GetTypeInfo(argument.Value.GetType()))), + }; + } + + return result; + } + + /// + /// The schema-only declaration of the inner render_a2ui structured-output tool. + /// The subagent is forced to call it; the adapter reads the arguments instead of invoking it. + /// + private sealed class RenderA2UIToolDeclaration : AIFunctionDeclaration + { + private static readonly JsonElement s_schema = ParseSchema(); + + private static JsonElement ParseSchema() + { + using var document = JsonDocument.Parse( + A2UIToolDefinitions.CreateRenderA2UIToolDefinition()["function"]!["parameters"]!.ToJsonString()); + return document.RootElement.Clone(); + } + + public override string Name => A2UIConstants.RenderA2UIToolName; + + public override string Description => + "Render a dynamic A2UI v0.9 surface. The root component must have " + + "id 'root'. Use components from the available catalog only."; + + public override JsonElement JsonSchema => s_schema; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs index 19f9a88ba9..56c8c8bea6 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs @@ -53,4 +53,13 @@ public static class A2UIConstants /// The activity type identifier for A2UI recovery lifecycle records. /// public const string A2UIRecoveryActivityType = "a2ui_recovery"; + + /// + /// The description the AG-UI A2UI middleware uses for the context entry that carries + /// the component catalog schema. Adapters match this description to route the catalog + /// into the subagent prompt's "Available Components" section. + /// + public const string A2UISchemaContextDescription = + "A2UI Component Schema — available components for generating UI surfaces. " + + "Use these component names and properties when creating A2UI operations."; } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolParams.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolParams.cs index 0610ef885f..e5322ba2ce 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolParams.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolParams.cs @@ -70,7 +70,7 @@ public static class A2UIToolDefinitions /// /// Gets the planner-facing description of the generate_a2ui tool. /// - public static string GenerateA2UIToolDescription => + public const string GenerateA2UIToolDescription = "Generate or update a dynamic A2UI surface based on the conversation. " + "A secondary LLM designs the UI components and data. " + "Use intent='create' (default) when the user requests new visual content " + @@ -82,7 +82,7 @@ public static class A2UIToolDefinitions /// /// Gets the planner-facing description of the generate_a2ui tool's intent argument. /// - public static string IntentArgumentDescription => + public const string IntentArgumentDescription = "'create' to render a new surface; 'update' to modify a surface " + "previously rendered in this conversation. Defaults to 'create'."; @@ -90,13 +90,13 @@ public static class A2UIToolDefinitions /// Gets the planner-facing description of the generate_a2ui tool's /// target_surface_id argument. /// - public static string TargetSurfaceIdArgumentDescription => + public const string TargetSurfaceIdArgumentDescription = "Required when intent='update'. The surface id of the prior render to modify."; /// /// Gets the planner-facing description of the generate_a2ui tool's changes argument. /// - public static string ChangesArgumentDescription => + public const string ChangesArgumentDescription = "Optional natural-language description of the changes to apply when intent='update'."; /// diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj index 3acc01dbe6..af34bed491 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj @@ -9,9 +9,14 @@ + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs new file mode 100644 index 0000000000..9245725ea0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests; + +/// +/// Unit tests for : per-run generate_a2ui tool injection and +/// the tool's end-to-end behavior over a scripted subagent chat client. +/// +public sealed class A2UIAgentTests +{ + private static readonly JsonObject s_validRenderArgs = new() + { + ["surfaceId"] = "s1", + ["components"] = new JsonArray( + new JsonObject + { + ["id"] = "root", + ["component"] = "Row", + ["children"] = new JsonObject { ["componentId"] = "card", ["path"] = "/items" }, + }, + new JsonObject + { + ["id"] = "card", + ["component"] = "HotelCard", + ["name"] = new JsonObject { ["path"] = "name" }, + }), + ["data"] = new JsonObject + { + ["items"] = new JsonArray(new JsonObject { ["name"] = "Ritz" }), + }, + }; + + [Fact] + public async Task RunStreamingAsync_InjectsGenerateA2UIToolIntoRunOptionsAsync() + { + // Arrange + var inner = new RecordingAgent(); + var agent = new A2UIAgent(inner, new ScriptedChatClient(_ => s_validRenderArgs)); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "hi")]); + + // Assert + ChatClientAgentRunOptions options = Assert.IsType(inner.LastOptions); + AITool tool = Assert.Single(options.ChatOptions?.Tools ?? []); + Assert.Equal(A2UIConstants.GenerateA2UIToolName, tool.Name); + Assert.Equal(A2UIToolDefinitions.GenerateA2UIToolDescription, tool.Description); + } + + [Fact] + public async Task RunStreamingAsync_CustomToolName_IsHonoredAsync() + { + // Arrange + var inner = new RecordingAgent(); + var agent = new A2UIAgent( + inner, + new ScriptedChatClient(_ => s_validRenderArgs), + new A2UIToolParams { ToolName = "custom_a2ui" }); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "hi")]); + + // Assert + ChatClientAgentRunOptions options = Assert.IsType(inner.LastOptions); + Assert.Contains(options.ChatOptions?.Tools ?? [], t => t.Name == "custom_a2ui"); + } + + [Fact] + public async Task GenerateA2UITool_CreateIntent_ReturnsOperationsEnvelopeAsync() + { + // Arrange + ChatOptions? subagentOptions = null; + var inner = new RecordingAgent(); + var agent = new A2UIAgent(inner, new ScriptedChatClient(options => + { + subagentOptions = options; + return s_validRenderArgs; + })); + await agent.RunAsync([new ChatMessage(ChatRole.User, "show hotels")]); + + // Act + string envelope = await InvokeGenerateToolAsync(inner, new() { ["intent"] = "create" }); + + // Assert + JsonObject parsed = Assert.IsType(JsonNode.Parse(envelope)); + JsonArray ops = Assert.IsType(parsed[A2UIConstants.A2UIOperationsKey]); + Assert.Equal(3, ops.Count); + // The subagent is forced to call render_a2ui. + Assert.NotNull(subagentOptions); + Assert.Equal( + ChatToolMode.RequireSpecific(A2UIConstants.RenderA2UIToolName), + subagentOptions!.ToolMode); + AITool renderTool = Assert.Single(subagentOptions.Tools ?? []); + Assert.Equal(A2UIConstants.RenderA2UIToolName, renderTool.Name); + } + + [Fact] + public async Task GenerateA2UITool_UpdateWithoutPrior_ReturnsErrorEnvelopeAsync() + { + // Arrange + var inner = new RecordingAgent(); + var agent = new A2UIAgent(inner, new ScriptedChatClient(_ => s_validRenderArgs)); + await agent.RunAsync([new ChatMessage(ChatRole.User, "update it")]); + + // Act + string envelope = await InvokeGenerateToolAsync( + inner, new() { ["intent"] = "update", ["target_surface_id"] = "missing" }); + + // Assert + JsonObject parsed = Assert.IsType(JsonNode.Parse(envelope)); + Assert.Contains("no prior render", (string?)parsed["error"]); + } + + [Fact] + public async Task GenerateA2UITool_SubagentNeverCallsTool_ReturnsRecoveryExhaustedEnvelopeAsync() + { + // Arrange + int calls = 0; + var inner = new RecordingAgent(); + var agent = new A2UIAgent(inner, new ScriptedChatClient(_ => + { + calls++; + return null; // no render_a2ui call + })); + await agent.RunAsync([new ChatMessage(ChatRole.User, "show hotels")]); + + // Act + string envelope = await InvokeGenerateToolAsync(inner, []); + + // Assert + JsonObject parsed = Assert.IsType(JsonNode.Parse(envelope)); + Assert.Equal("a2ui_recovery_exhausted", (string?)parsed["code"]); + Assert.Equal(A2UIConstants.MaxA2UIAttempts, calls); + } + + /// + /// Pulls the injected generate_a2ui function from the recorded run options and + /// invokes it the way a function-invoking chat client would. + /// + private static async Task InvokeGenerateToolAsync(RecordingAgent inner, Dictionary arguments) + { + ChatClientAgentRunOptions options = Assert.IsType(inner.LastOptions); + AIFunction function = Assert.IsType( + (options.ChatOptions?.Tools ?? []).Single(t => t is AIFunction), + exactMatch: false); + + object? result = await function.InvokeAsync(new AIFunctionArguments(arguments)); + return result switch + { + string text => text, + JsonElement element when element.ValueKind == JsonValueKind.String => element.GetString()!, + JsonElement element => element.GetRawText(), + _ => result?.ToString() ?? string.Empty, + }; + } + + /// An inner agent that records the options it was run with and returns an empty response. + private sealed class RecordingAgent : AIAgent + { + public AgentRunOptions? LastOptions { get; private set; } + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + this.LastOptions = options; + return Task.FromResult(new AgentResponse()); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + this.LastOptions = options; + await Task.CompletedTask.ConfigureAwait(false); + yield break; + } + } + + /// + /// A chat client scripted per call: returns a render_a2ui function call with the + /// supplied arguments, or a plain text message when the script yields . + /// + private sealed class ScriptedChatClient : IChatClient + { + private readonly Func _script; + + public ScriptedChatClient(Func script) => this._script = script; + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + JsonObject? args = this._script(options); + ChatMessage message = args is null + ? new ChatMessage(ChatRole.Assistant, "no tool call") + : new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent( + "call-1", + A2UIConstants.RenderA2UIToolName, + args.ToDictionary(p => p.Key, object? (p) => p.Value?.DeepClone())), + ]); + return Task.FromResult(new ChatResponse(message)); + } + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } +} From e11a7fa674526ec724fda1eff19e2ac623e8a13f Mon Sep 17 00:00:00 2001 From: ran Date: Wed, 10 Jun 2026 17:48:00 +0200 Subject: [PATCH 05/24] .NET: Add A2UI demo agents to the AGUIDojoServer sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../A2UI/A2UICompositionGuides.cs | 83 +++++++++++ .../A2UI/A2UIFixedSchemaTools.cs | 131 ++++++++++++++++++ .../AGUIDojoServer/AGUIDojoServer.csproj | 1 + .../AGUIDojoServer/ChatClientAgentFactory.cs | 106 +++++++++++--- .../AGUIDojoServer/Program.cs | 6 + 5 files changed, 310 insertions(+), 17 deletions(-) create mode 100644 dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UICompositionGuides.cs create mode 100644 dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UIFixedSchemaTools.cs diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UICompositionGuides.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UICompositionGuides.cs new file mode 100644 index 0000000000..ba90ff664d --- /dev/null +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UICompositionGuides.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace AGUIDojoServer.A2UI; + +/// +/// Project-specific composition rules for the A2UI subagent — tells it how to use the +/// pre-made domain components shipped in the dojo's dynamic catalog. Mirrors the +/// LangGraph dojo examples so all integrations exercise the same demos. +/// +internal static class A2UICompositionGuides +{ + /// The catalog id of the dojo's dynamic component catalog. + public const string DynamicCatalogId = "https://a2ui.org/demos/dojo/dynamic_catalog.json"; + + /// The planner system prompt for the dynamic-schema and recovery demos. + public const string PlannerInstructions = """ + You are a helpful assistant that creates rich visual UI on the fly. + + When the user asks for visual content (product comparisons, dashboards, lists, cards, etc.), + use the generate_a2ui tool to create a dynamic A2UI surface. + IMPORTANT: After calling the tool, do NOT repeat the data in your text response. The tool renders UI automatically. Just confirm what was rendered. + """; + + /// The composition guide for the dynamic-schema demo. + public const string DynamicSchema = """ + ## Available Pre-made Components + + You have 4 components. Use Row as the root with structural children to repeat a card per item. + + ### Row + Layout container. Use structural children to repeat a card template: + {"id":"root","component":"Row","children":{"componentId":"card","path":"/items"}} + + ### HotelCard + Props: name, location, rating (number 0-5), pricePerNight, amenities (optional), action + Example: + {"id":"card","component":"HotelCard","name":{"path":"name"},"location":{"path":"location"}, + "rating":{"path":"rating"},"pricePerNight":{"path":"pricePerNight"}, + "action":{"event":{"name":"book","context":{"name":{"path":"name"}}}}} + + ### ProductCard + Props: name, price, rating (number 0-5), description (optional), badge (optional), action + Example: + {"id":"card","component":"ProductCard","name":{"path":"name"},"price":{"path":"price"}, + "rating":{"path":"rating"},"description":{"path":"description"}, + "action":{"event":{"name":"select","context":{"name":{"path":"name"}}}}} + + ### TeamMemberCard + Props: name, role, department (optional), email (optional), avatarUrl (optional), action + Example: + {"id":"card","component":"TeamMemberCard","name":{"path":"name"},"role":{"path":"role"}, + "department":{"path":"department"},"email":{"path":"email"}, + "action":{"event":{"name":"contact","context":{"name":{"path":"name"}}}}} + + ## RULES + - Root is ALWAYS a Row with structural children: {"componentId":"","path":"/items"} + - Inside templates, use RELATIVE paths (no leading slash): {"path":"name"} not {"path":"/name"} + - Always provide data in the "data" argument as {"items":[...]} + - Pick the card type that best matches the user's request + - Generate 3-4 realistic items with diverse data + """; + + /// The composition guide for the recovery demo (structural validation showcase). + public const string Recovery = """ + ## Available Pre-made Components + + Use Row as the root with structural children to repeat a card per item. + + ### Row + Layout container. Repeat a card template via structural children: + {"id":"root","component":"Row","children":{"componentId":"card","path":"/items"}} + + ### HotelCard / ProductCard / TeamMemberCard + Card components bound to per-item data (relative paths inside the template). + + ## RULES + - Root is ALWAYS a Row with structural children: {"componentId":"","path":"/items"} + - ALWAYS include the referenced card component in the components array. + - Inside templates, use RELATIVE paths (no leading slash): {"path":"name"} not {"path":"/name"} + - Always provide data in the "data" argument as {"items":[...]} + - Generate 3-4 realistic items with diverse data. + """; +} diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UIFixedSchemaTools.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UIFixedSchemaTools.cs new file mode 100644 index 0000000000..8409297b85 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UIFixedSchemaTools.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using System.Text.Json.Nodes; +using Microsoft.Agents.AI.AGUI.A2UI; +using Microsoft.Extensions.AI; + +namespace AGUIDojoServer.A2UI; + +/// +/// Fixed-schema A2UI tools: pre-built component layouts for flight and hotel cards. +/// The agent only supplies the data; layout/styling is fixed in code. Demonstrates the +/// "controlled gen-UI" pattern — the author owns the UI shape, the agent owns the data. +/// +internal static class A2UIFixedSchemaTools +{ + private const string CatalogId = "https://a2ui.org/demos/dojo/fixed_catalog.json"; + private const string FlightSurfaceId = "flight-search-results"; + private const string HotelSurfaceId = "hotel-search-results"; + + /// Creates the search_flights tool. + public static AIFunction CreateSearchFlightsTool() => AIFunctionFactory.Create( + SearchFlights, + "search_flights", + "Search for flights and display the results as rich cards. Each flight " + + "must have: id, airline (e.g. 'United Airlines'), airlineLogo (use Google " + + "favicon API like 'https://www.google.com/s2/favicons?domain=united.com&sz=128'), " + + "flightNumber, origin, destination, date (e.g. 'Tue, Mar 18'), departureTime, " + + "arrivalTime, duration (e.g. '4h 25m'), status ('On Time' or 'Delayed'), " + + "and price (e.g. '$289')."); + + /// Creates the search_hotels tool. + public static AIFunction CreateSearchHotelsTool() => AIFunctionFactory.Create( + SearchHotels, + "search_hotels", + "Search for hotels and display the results as rich cards with star ratings. " + + "Each hotel must have: id, name (e.g. 'The Plaza'), location " + + "(e.g. 'Midtown Manhattan, NYC'), rating (float 0-5, e.g. 4.5), and " + + "price (per night, e.g. '$350'). Generate 3-4 realistic results."); + + private static string SearchFlights( + [Description("Array of flight result objects.")] JsonArray flights) + => RenderOperations(FlightSurfaceId, FlightSchema(), "flights", flights); + + private static string SearchHotels( + [Description("Array of hotel result objects.")] JsonArray hotels) + => RenderOperations(HotelSurfaceId, HotelSchema(), "hotels", hotels); + + /// + /// Wraps the fixed layout + agent-supplied data as the A2UI operations envelope the + /// AG-UI A2UI middleware detects in tool results. + /// + private static string RenderOperations(string surfaceId, JsonArray schema, string dataKey, JsonArray items) + => A2UIToolkit.WrapAsOperationsEnvelope(A2UIToolkit.AssembleOps( + "create", + surfaceId, + CatalogId, + schema, + new JsonObject { [dataKey] = items.DeepClone() })); + + // Flight search layout — the agent supplies the `flights` array; rendering is fixed. + private static JsonArray FlightSchema() => new( + new JsonObject + { + ["id"] = "root", + ["component"] = "Row", + ["children"] = new JsonObject { ["componentId"] = "flight-card", ["path"] = "/flights" }, + ["gap"] = 16, + }, + new JsonObject + { + ["id"] = "flight-card", + ["component"] = "FlightCard", + ["airline"] = new JsonObject { ["path"] = "airline" }, + ["airlineLogo"] = new JsonObject { ["path"] = "airlineLogo" }, + ["flightNumber"] = new JsonObject { ["path"] = "flightNumber" }, + ["origin"] = new JsonObject { ["path"] = "origin" }, + ["destination"] = new JsonObject { ["path"] = "destination" }, + ["date"] = new JsonObject { ["path"] = "date" }, + ["departureTime"] = new JsonObject { ["path"] = "departureTime" }, + ["arrivalTime"] = new JsonObject { ["path"] = "arrivalTime" }, + ["duration"] = new JsonObject { ["path"] = "duration" }, + ["status"] = new JsonObject { ["path"] = "status" }, + ["price"] = new JsonObject { ["path"] = "price" }, + ["action"] = new JsonObject + { + ["event"] = new JsonObject + { + ["name"] = "book_flight", + ["context"] = new JsonObject + { + ["flightNumber"] = new JsonObject { ["path"] = "flightNumber" }, + ["origin"] = new JsonObject { ["path"] = "origin" }, + ["destination"] = new JsonObject { ["path"] = "destination" }, + ["price"] = new JsonObject { ["path"] = "price" }, + }, + }, + }, + }); + + // Hotel search layout — the agent supplies the `hotels` array; rendering is fixed. + private static JsonArray HotelSchema() => new( + new JsonObject + { + ["id"] = "root", + ["component"] = "Row", + ["children"] = new JsonObject { ["componentId"] = "hotel-card", ["path"] = "/hotels" }, + ["gap"] = 16, + }, + new JsonObject + { + ["id"] = "hotel-card", + ["component"] = "HotelCard", + ["name"] = new JsonObject { ["path"] = "name" }, + ["location"] = new JsonObject { ["path"] = "location" }, + ["rating"] = new JsonObject { ["path"] = "rating" }, + ["pricePerNight"] = new JsonObject { ["path"] = "price" }, + ["action"] = new JsonObject + { + ["event"] = new JsonObject + { + ["name"] = "book_hotel", + ["context"] = new JsonObject + { + ["hotelName"] = new JsonObject { ["path"] = "name" }, + ["price"] = new JsonObject { ["path"] = "price" }, + }, + }, + }, + }); +} diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj index 03e2493623..e4174e5481 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj @@ -17,6 +17,7 @@ + diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs index 1cdd00731b..0e1274a317 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs @@ -10,31 +10,52 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using AGUIDojoServer.A2UI; +using Microsoft.Agents.AI.AGUI.A2UI; +using OpenAI; using OpenAI.Chat; namespace AGUIDojoServer; internal static class ChatClientAgentFactory { - private static AzureOpenAIClient? s_azureOpenAIClient; + private static OpenAIClient? s_openAIClient; private static string? s_deploymentName; public static void Initialize(IConfiguration configuration) { - string endpoint = configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); - s_deploymentName = configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); - - // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. - // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid - // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. - s_azureOpenAIClient = new AzureOpenAIClient( - new Uri(endpoint), - new DefaultAzureCredential()); + string? azureEndpoint = configuration["AZURE_OPENAI_ENDPOINT"]; + if (!string.IsNullOrEmpty(azureEndpoint)) + { + s_deploymentName = configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + s_openAIClient = new AzureOpenAIClient( + new Uri(azureEndpoint), + new DefaultAzureCredential()); + return; + } + + // OpenAI-compatible mode: OPENAI_API_KEY with an optional OPENAI_BASE_URL override + // (e.g. a local mock server for deterministic end-to-end tests). + string apiKey = configuration["OPENAI_API_KEY"] ?? throw new InvalidOperationException("Either AZURE_OPENAI_ENDPOINT or OPENAI_API_KEY must be set."); + s_deploymentName = configuration["OPENAI_CHAT_MODEL_ID"] ?? "gpt-4o"; + + var options = new OpenAIClientOptions(); + string? baseUrl = configuration["OPENAI_BASE_URL"]; + if (!string.IsNullOrEmpty(baseUrl)) + { + options.Endpoint = new Uri(baseUrl); + } + + s_openAIClient = new OpenAIClient(new System.ClientModel.ApiKeyCredential(apiKey), options); } public static ChatClientAgent CreateAgenticChat() { - ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!); + ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!); return chatClient.AsAIAgent( name: "AgenticChat", @@ -43,7 +64,7 @@ public static ChatClientAgent CreateAgenticChat() public static ChatClientAgent CreateBackendToolRendering() { - ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!); + ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!); return chatClient.AsAIAgent( name: "BackendToolRenderer", @@ -57,7 +78,7 @@ public static ChatClientAgent CreateBackendToolRendering() public static ChatClientAgent CreateHumanInTheLoop() { - ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!); + ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!); return chatClient.AsAIAgent( name: "HumanInTheLoopAgent", @@ -66,7 +87,7 @@ public static ChatClientAgent CreateHumanInTheLoop() public static ChatClientAgent CreateToolBasedGenerativeUI() { - ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!); + ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!); return chatClient.AsAIAgent( name: "ToolBasedGenerativeUIAgent", @@ -75,7 +96,7 @@ public static ChatClientAgent CreateToolBasedGenerativeUI() public static AIAgent CreateAgenticUI(JsonSerializerOptions options) { - ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!); + ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!); var baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions { Name = "AgenticUIAgent", @@ -117,7 +138,7 @@ again until all the steps in current plan are completed. public static AIAgent CreateSharedState(JsonSerializerOptions options) { - ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!); + ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!); var baseAgent = chatClient.AsAIAgent( name: "SharedStateAgent", @@ -128,7 +149,7 @@ public static AIAgent CreateSharedState(JsonSerializerOptions options) public static AIAgent CreatePredictiveStateUpdates(JsonSerializerOptions options) { - ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!); + ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!); var baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions { @@ -180,4 +201,55 @@ private static string WriteDocument([Description("The document content to write. // Simply return success - the document is tracked via state updates return "Document written successfully"; } + + public static ChatClientAgent CreateA2UIFixedSchema() + { + ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!); + + return chatClient.AsAIAgent( + instructions: """ + You are a helpful travel assistant that can search for flights and hotels. + + When the user asks about flights, use the search_flights tool. + When the user asks about hotels, use the search_hotels tool. + IMPORTANT: After calling a tool, do NOT repeat or summarize the data in your text response. The tool renders a rich UI automatically. Just say something brief like "Here are your results" or ask if they'd like to book. + """, + name: "A2UIFixedSchema", + description: "Fixed-schema A2UI demo: author-owned card layouts, agent-supplied data", + tools: [A2UIFixedSchemaTools.CreateSearchFlightsTool(), A2UIFixedSchemaTools.CreateSearchHotelsTool()]); + } + + public static AIAgent CreateA2UIDynamicSchema() + { + ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!); + + AIAgent planner = chatClient.AsAIAgent( + instructions: A2UICompositionGuides.PlannerInstructions, + name: "A2UIDynamicSchema", + description: "Dynamic-schema A2UI demo: a subagent designs the UI via generate_a2ui"); + + return new A2UIAgent(planner, chatClient.AsIChatClient(), new A2UIToolParams + { + DefaultCatalogId = A2UICompositionGuides.DynamicCatalogId, + Guidelines = new A2UIGuidelines { CompositionGuide = A2UICompositionGuides.DynamicSchema }, + }); + } + + public static AIAgent CreateA2UIRecovery() + { + ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!); + + AIAgent planner = chatClient.AsAIAgent( + instructions: A2UICompositionGuides.PlannerInstructions, + name: "A2UIRecovery", + description: "A2UI error-recovery demo: invalid surfaces are validated and regenerated"); + + return new A2UIAgent(planner, chatClient.AsIChatClient(), new A2UIToolParams + { + DefaultCatalogId = A2UICompositionGuides.DynamicCatalogId, + Guidelines = new A2UIGuidelines { CompositionGuide = A2UICompositionGuides.Recovery }, + // The recovery loop runs by default; set the cap explicitly for the showcase. + Recovery = new A2UIRecoveryConfig { MaxAttempts = 3 }, + }); + } } diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs index 3f0032d4da..9fcdab0018 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs @@ -47,6 +47,12 @@ app.MapAGUI("/predictive_state_updates", ChatClientAgentFactory.CreatePredictiveStateUpdates(jsonOptions.Value.SerializerOptions)); +app.MapAGUI("/a2ui_fixed_schema", ChatClientAgentFactory.CreateA2UIFixedSchema()); + +app.MapAGUI("/a2ui_dynamic_schema", ChatClientAgentFactory.CreateA2UIDynamicSchema()); + +app.MapAGUI("/a2ui_recovery", ChatClientAgentFactory.CreateA2UIRecovery()); + await app.RunAsync(); public partial class Program; From 981ac5377007bdab028432f85a02ce6adf901a69 Mon Sep 17 00:00:00 2001 From: ran Date: Wed, 10 Jun 2026 18:28:32 +0200 Subject: [PATCH 06/24] .NET: Return the parsed envelope from generate_a2ui 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 --- .../Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs index e6e2497800..11df4d5666 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs @@ -114,7 +114,10 @@ private AIFunction CreateGenerateA2UIFunction(IReadOnlyList message { List history = messages.Select(ToHistoryMessage).ToList(); - async Task GenerateA2UIAsync( + // The tool returns the parsed envelope (not the JSON string): a string result + // would be JSON-serialized a second time on its way into the AG-UI tool-result + // event, and the A2UI middleware would have to undo the double encoding. + async Task GenerateA2UIAsync( [Description(A2UIToolDefinitions.IntentArgumentDescription)] string? intent = null, [Description(A2UIToolDefinitions.TargetSurfaceIdArgumentDescription)] string? target_surface_id = null, [Description(A2UIToolDefinitions.ChangesArgumentDescription)] string? changes = null, @@ -124,7 +127,7 @@ async Task GenerateA2UIAsync( intent, target_surface_id, changes, history, state, this._parameters.Guidelines); if (prep.Error is not null) { - return A2UIToolkit.WrapErrorEnvelope(prep.Error); + return ParseEnvelope(A2UIToolkit.WrapErrorEnvelope(prep.Error)); } A2UIRecoveryResult result = await A2UIGenerationRecovery.RunAsync( @@ -142,7 +145,7 @@ async Task GenerateA2UIAsync( this._parameters.OnAttempt, cancellationToken).ConfigureAwait(false); - return result.Envelope; + return ParseEnvelope(result.Envelope); } return AIFunctionFactory.Create( @@ -233,6 +236,12 @@ private static A2UIHistoryMessage ToHistoryMessage(ChatMessage message) return new A2UIHistoryMessage(message.Role.Value, content); } + private static JsonElement ParseEnvelope(string envelope) + { + using var document = JsonDocument.Parse(envelope); + return document.RootElement.Clone(); + } + private static JsonObject ToJsonObject(IDictionary arguments) { var result = new JsonObject(); From 85babf3235fa51c4355f26cb9d10b7047918546d Mon Sep 17 00:00:00 2001 From: ran Date: Thu, 11 Jun 2026 09:15:17 +0200 Subject: [PATCH 07/24] .NET: Stream OpenAI tool-call argument fragments as incremental AG-UI events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ChatResponseUpdateAGUIExtensions.cs | 88 ++++++++++ ...t.Agents.AI.Hosting.AGUI.AspNetCore.csproj | 5 + .../StreamingToolCallArgsTests.cs | 163 ++++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/StreamingToolCallArgsTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs index d5451a9ff5..e41e7596f6 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs @@ -453,6 +453,18 @@ public static async IAsyncEnumerable AsAGUIEventStreamAsync( string? currentReasoningBaseId = null; string? currentReasoningId = null; string? currentReasoningMessageId = null; +#if ASPNETCORE + // Progressive tool-call argument streaming. MEAI chat clients yield one update + // per provider chunk but attach the typed FunctionCallContent only once a call's + // arguments are complete, so the AGUI wire would otherwise carry a single atomic + // TOOL_CALL_ARGS event per call — starving streaming consumers (e.g. generative-UI + // middlewares that paint arguments incrementally). For OpenAI-family providers the + // argument fragments are still observable on the update's RawRepresentation: + // surface them as incremental TOOL_CALL_ARGS events and suppress the duplicate + // atomic emission when the coalesced FunctionCallContent arrives. + Dictionary rawToolCallIdsByIndex = []; + HashSet rawStreamedToolCallIds = new(StringComparer.Ordinal); +#endif await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) { // The text-event surface (TextMessageStart/Content/End) requires a non-empty @@ -528,6 +540,69 @@ chatResponse.Contents[0] is TextContent && }; } +#if ASPNETCORE + // Surface OpenAI streamed tool-call argument fragments incrementally. + object? rawUpdate = chatResponse.RawRepresentation; + if (rawUpdate is ChatResponseUpdate innerUpdate) + { + // Agent pipelines (e.g. ChatClientAgent) wrap the provider update once. + rawUpdate = innerUpdate.RawRepresentation; + } + + if (rawUpdate is OpenAI.Chat.StreamingChatCompletionUpdate streamingChatUpdate) + { + foreach (OpenAI.Chat.StreamingChatToolCallUpdate toolCallUpdate in streamingChatUpdate.ToolCallUpdates ?? []) + { + if (!rawToolCallIdsByIndex.TryGetValue(toolCallUpdate.Index, out string? rawToolCallId)) + { + // The first fragment of a call carries its id and function name; + // later fragments only carry the index plus an arguments delta. + if (string.IsNullOrEmpty(toolCallUpdate.ToolCallId) || string.IsNullOrEmpty(toolCallUpdate.FunctionName)) + { + continue; + } + + rawToolCallId = toolCallUpdate.ToolCallId; + rawToolCallIdsByIndex[toolCallUpdate.Index] = rawToolCallId; + rawStreamedToolCallIds.Add(rawToolCallId); + + // Close any open reasoning block before emitting tool events. + if (currentReasoningMessageId is not null) + { + yield return new ReasoningMessageEndEvent + { + MessageId = currentReasoningMessageId + }; + yield return new ReasoningEndEvent + { + MessageId = currentReasoningId! + }; + currentReasoningBaseId = null; + currentReasoningId = null; + currentReasoningMessageId = null; + } + + yield return new ToolCallStartEvent + { + ToolCallId = rawToolCallId, + ToolCallName = toolCallUpdate.FunctionName, + ParentMessageId = chatResponse.MessageId + }; + } + + string argumentsDelta = toolCallUpdate.FunctionArgumentsUpdate?.ToString() ?? string.Empty; + if (argumentsDelta.Length > 0) + { + yield return new ToolCallArgsEvent + { + ToolCallId = rawToolCallId, + Delta = argumentsDelta + }; + } + } + } +#endif + // Emit tool call events and tool result events if (chatResponse is { Contents.Count: > 0 }) { @@ -535,6 +610,19 @@ chatResponse.Contents[0] is TextContent && { if (content is FunctionCallContent functionCallContent) { +#if ASPNETCORE + // This call's arguments already streamed incrementally from the raw + // provider fragments above — only the closing event remains. + if (rawStreamedToolCallIds.Contains(functionCallContent.CallId)) + { + yield return new ToolCallEndEvent + { + ToolCallId = functionCallContent.CallId + }; + continue; + } +#endif + // Close any open reasoning block before emitting tool events. if (currentReasoningMessageId is not null) { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj index 1565977149..dd345d30ce 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj @@ -26,6 +26,11 @@ + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/StreamingToolCallArgsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/StreamingToolCallArgsTests.cs new file mode 100644 index 0000000000..0dbce01fb1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/StreamingToolCallArgsTests.cs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; +using Microsoft.Extensions.AI; +using OpenAI.Chat; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests; + +/// +/// Tests for progressive tool-call argument streaming: OpenAI streamed argument +/// fragments observable on are +/// surfaced as incremental s, and the coalesced +/// that follows emits only the closing event. +/// +public sealed class StreamingToolCallArgsTests +{ + private const string ThreadId = "thread1"; + private const string RunId = "run1"; + private const string CallId = "call_123"; + + private static ChatResponseUpdate FragmentUpdate(int index, string? callId, string? functionName, string argumentsDelta) + { + StreamingChatToolCallUpdate toolCallUpdate = OpenAIChatModelFactory.StreamingChatToolCallUpdate( + index: index, + toolCallId: callId, + functionName: functionName, + functionArgumentsUpdate: BinaryData.FromString(argumentsDelta)); + StreamingChatCompletionUpdate rawUpdate = OpenAIChatModelFactory.StreamingChatCompletionUpdate( + toolCallUpdates: [toolCallUpdate]); + return new ChatResponseUpdate(ChatRole.Assistant, Array.Empty()) + { + RawRepresentation = rawUpdate, + }; + } + + private static ChatResponseUpdate CoalescedFunctionCallUpdate(string callId, string functionName) + => new(ChatRole.Assistant, [new FunctionCallContent(callId, functionName, new Dictionary { ["city"] = "Paris" })]); + + private static async Task> CollectAsync(IEnumerable updates) + { + List events = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync( + ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) + { + events.Add(evt); + } + + return events; + } + + [Fact] + public async Task AsAGUIEventStreamAsync_RawToolCallFragments_EmitIncrementalArgsAsync() + { + // Arrange: three fragments (first carries id+name) then the coalesced content. + List updates = + [ + FragmentUpdate(0, CallId, "get_weather", "{\"ci"), + FragmentUpdate(0, callId: null, functionName: null, "ty\":\"Pa"), + FragmentUpdate(0, callId: null, functionName: null, "ris\"}"), + CoalescedFunctionCallUpdate(CallId, "get_weather"), + ]; + + // Act + List events = await CollectAsync(updates); + + // Assert + ToolCallStartEvent start = Assert.Single(events.OfType()); + Assert.Equal(CallId, start.ToolCallId); + Assert.Equal("get_weather", start.ToolCallName); + + List args = events.OfType().ToList(); + Assert.Equal(3, args.Count); + Assert.All(args, a => Assert.Equal(CallId, a.ToolCallId)); + Assert.Equal("{\"city\":\"Paris\"}", string.Concat(args.Select(a => a.Delta))); + + ToolCallEndEvent end = Assert.Single(events.OfType()); + Assert.Equal(CallId, end.ToolCallId); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_CoalescedContentAfterFragments_DoesNotDuplicateArgsAsync() + { + // Arrange + List updates = + [ + FragmentUpdate(0, CallId, "get_weather", "{\"city\":\"Paris\"}"), + CoalescedFunctionCallUpdate(CallId, "get_weather"), + ]; + + // Act + List events = await CollectAsync(updates); + + // Assert: one Start, one Args (the fragment), one End — the coalesced + // FunctionCallContent must not re-emit the full arguments. + Assert.Single(events.OfType()); + ToolCallArgsEvent argsEvent = Assert.Single(events.OfType()); + Assert.Equal("{\"city\":\"Paris\"}", argsEvent.Delta); + Assert.Single(events.OfType()); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_NoRawFragments_KeepsAtomicEmission() + { + // Arrange: providers without raw OpenAI fragments keep the existing behavior. + List updates = [CoalescedFunctionCallUpdate(CallId, "get_weather")]; + + // Act + List events = await CollectAsync(updates); + + // Assert + Assert.Single(events.OfType()); + ToolCallArgsEvent argsEvent = Assert.Single(events.OfType()); + Assert.Contains("Paris", argsEvent.Delta); + Assert.Single(events.OfType()); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_ParallelToolCalls_StreamIndependentlyAsync() + { + // Arrange: two interleaved calls on distinct indexes. + List updates = + [ + FragmentUpdate(0, "call_a", "get_weather", "{\"city\":"), + FragmentUpdate(1, "call_b", "get_time", "{\"zone\":"), + FragmentUpdate(0, callId: null, functionName: null, "\"Paris\"}"), + FragmentUpdate(1, callId: null, functionName: null, "\"CET\"}"), + ]; + + // Act + List events = await CollectAsync(updates); + + // Assert + Assert.Equal(2, events.OfType().Count()); + Assert.Equal( + "{\"city\":\"Paris\"}", + string.Concat(events.OfType().Where(a => a.ToolCallId == "call_a").Select(a => a.Delta))); + Assert.Equal( + "{\"zone\":\"CET\"}", + string.Concat(events.OfType().Where(a => a.ToolCallId == "call_b").Select(a => a.Delta))); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_WrappedRawRepresentation_IsUnwrappedAsync() + { + // Arrange: agent pipelines wrap the provider update one level deep. + ChatResponseUpdate fragment = FragmentUpdate(0, CallId, "get_weather", "{}"); + var wrapped = new ChatResponseUpdate(ChatRole.Assistant, Array.Empty()) + { + RawRepresentation = fragment, + }; + + // Act + List events = await CollectAsync([wrapped]); + + // Assert + Assert.Single(events.OfType()); + } +} From 39f8b8ab3282b4d9fa97676e6b8f50a499fd30ea Mon Sep 17 00:00:00 2001 From: ran Date: Thu, 11 Jun 2026 09:15:17 +0200 Subject: [PATCH 08/24] .NET: Add zero-configuration A2UI chat demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../AGUIDojoServer/ChatClientAgentFactory.cs | 21 +++++- .../AGUIDojoServer/Program.cs | 2 + .../AGUIContextAgent.cs | 71 +++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/AGUIContextAgent.cs diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs index 0e1274a317..a263931251 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using System.Text.Json; +using AGUIDojoServer.A2UI; using AGUIDojoServer.AgenticUI; using AGUIDojoServer.BackendToolRendering; using AGUIDojoServer.PredictiveStateUpdates; @@ -9,9 +10,8 @@ using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using AGUIDojoServer.A2UI; using Microsoft.Agents.AI.AGUI.A2UI; +using Microsoft.Extensions.AI; using OpenAI; using OpenAI.Chat; @@ -235,6 +235,23 @@ public static AIAgent CreateA2UIDynamicSchema() }); } + public static AIAgent CreateA2UIChat() + { + ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!); + + // Zero-configuration A2UI: the AG-UI A2UI middleware injects the render_a2ui + // tool (auto-bound from the incoming tool list) plus its usage guidelines and + // component schema as context entries. AGUIContextAgent surfaces those entries + // to the model, so a plain chat agent renders A2UI surfaces with streamed, + // progressively painted tool arguments — no A2UI-specific agent code. + AIAgent inner = chatClient.AsAIAgent( + instructions: "You are a helpful assistant. When the user asks for visual content (cards, lists, comparisons, dashboards), render it with the available UI rendering tool.", + name: "A2UIChat", + description: "Zero-configuration A2UI chat demo: client-injected render tool with streamed arguments"); + + return new AGUIContextAgent(inner); + } + public static AIAgent CreateA2UIRecovery() { ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!); diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs index 9fcdab0018..8cb1c01a77 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs @@ -53,6 +53,8 @@ app.MapAGUI("/a2ui_recovery", ChatClientAgentFactory.CreateA2UIRecovery()); +app.MapAGUI("/a2ui_chat", ChatClientAgentFactory.CreateA2UIChat()); + await app.RunAsync(); public partial class Program; diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/AGUIContextAgent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/AGUIContextAgent.cs new file mode 100644 index 0000000000..edb642aea7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/AGUIContextAgent.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.AGUI.A2UI; + +/// +/// Surfaces forwarded AG-UI context entries to the model by prepending them as a system +/// message. The AG-UI hosting layer stores incoming context entries in +/// (ag_ui_context), where the model +/// never sees them; this wrapper renders each entry as a markdown section so +/// client-driven guidance — e.g. the A2UI middleware's injected component schema and +/// render-tool usage guide — reaches the prompt without any agent-specific code. +/// +/// +/// This is the missing piece of the zero-configuration A2UI path: the AG-UI client +/// middleware injects the render_a2ui tool (bound automatically from the incoming +/// tool list) plus its usage guidelines and catalog schema as context entries; with this +/// wrapper the model receives both, and a plain chat agent can render A2UI surfaces with +/// no further setup. +/// +public sealed class AGUIContextAgent : DelegatingAIAgent +{ + /// + /// Initializes a new instance of the class. + /// + /// The agent to wrap. + public AGUIContextAgent(AIAgent innerAgent) + : base(innerAgent) + { + } + + /// + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => this.InnerAgent.RunAsync(WithContextPrompt(messages, options), session, options, cancellationToken); + + /// + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => this.InnerAgent.RunStreamingAsync(WithContextPrompt(messages, options), session, options, cancellationToken); + + private static IEnumerable WithContextPrompt(IEnumerable messages, AgentRunOptions? options) + { + if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } || + !properties.TryGetValue("ag_ui_context", out object? contextValue) || + contextValue is not IEnumerable> entries) + { + return messages; + } + + string prompt = A2UIToolkit.BuildContextPrompt(new A2UIAgentState + { + Context = entries.Select(e => new A2UIContextEntry(e.Key, e.Value)).ToList(), + }); + + return prompt.Length == 0 + ? messages + : messages.Prepend(new ChatMessage(ChatRole.System, prompt)); + } +} From 10ccd65070ec16363ecde165948efb0e45a8f8fc Mon Sep 17 00:00:00 2001 From: ran Date: Thu, 11 Jun 2026 12:36:07 +0200 Subject: [PATCH 09/24] .NET: Reset raw tool-call streaming state per call and close open calls at end of stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ChatResponseUpdateAGUIExtensions.cs | 41 +++++++++++++- .../StreamingToolCallArgsTests.cs | 56 +++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs index e41e7596f6..9bdf9cb753 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs @@ -586,6 +586,8 @@ chatResponse.Contents[0] is TextContent && { ToolCallId = rawToolCallId, ToolCallName = toolCallUpdate.FunctionName, + // Fragment updates typically carry no MessageId, so this is + // often null — schema-legal, and the inbound builder ignores it. ParentMessageId = chatResponse.MessageId }; } @@ -612,9 +614,27 @@ chatResponse.Contents[0] is TextContent && { #if ASPNETCORE // This call's arguments already streamed incrementally from the raw - // provider fragments above — only the closing event remains. - if (rawStreamedToolCallIds.Contains(functionCallContent.CallId)) + // provider fragments above — only the closing event remains. The + // per-call state is then released: OpenAI restarts tool-call indexes + // at 0 for each assistant turn, so a stale index entry would silently + // absorb a later round's fragments into this finished call. + if (rawStreamedToolCallIds.Remove(functionCallContent.CallId)) { + int? closedIndex = null; + foreach (KeyValuePair entry in rawToolCallIdsByIndex) + { + if (string.Equals(entry.Value, functionCallContent.CallId, StringComparison.Ordinal)) + { + closedIndex = entry.Key; + break; + } + } + + if (closedIndex is int index) + { + rawToolCallIdsByIndex.Remove(index); + } + yield return new ToolCallEndEvent { ToolCallId = functionCallContent.CallId @@ -815,6 +835,23 @@ chatResponse.Contents[0] is TextContent && }; } +#if ASPNETCORE + // Close any raw-streamed tool call whose coalesced FunctionCallContent never + // arrived (stream cut short, or a pipeline that does not re-emit the typed + // content). Without this sweep the wire carries Start/Args with no End and + // streaming consumers stay in a perpetual "in progress" state. + foreach (string openToolCallId in rawStreamedToolCallIds) + { + yield return new ToolCallEndEvent + { + ToolCallId = openToolCallId + }; + } + + rawStreamedToolCallIds.Clear(); + rawToolCallIdsByIndex.Clear(); +#endif + yield return new RunFinishedEvent { ThreadId = threadId, diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/StreamingToolCallArgsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/StreamingToolCallArgsTests.cs index 0dbce01fb1..c48a6d4edf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/StreamingToolCallArgsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/StreamingToolCallArgsTests.cs @@ -23,6 +23,8 @@ public sealed class StreamingToolCallArgsTests private const string RunId = "run1"; private const string CallId = "call_123"; + private static readonly string[] s_sequentialCallIds = ["call_a", "call_b"]; + private static ChatResponseUpdate FragmentUpdate(int index, string? callId, string? functionName, string argumentsDelta) { StreamingChatToolCallUpdate toolCallUpdate = OpenAIChatModelFactory.StreamingChatToolCallUpdate( @@ -142,6 +144,60 @@ public async Task AsAGUIEventStreamAsync_ParallelToolCalls_StreamIndependentlyAs Assert.Equal( "{\"zone\":\"CET\"}", string.Concat(events.OfType().Where(a => a.ToolCallId == "call_b").Select(a => a.Delta))); + // Both calls were started on the wire, so both must close — here via the + // end-of-stream sweep, since no coalesced content ever arrives. + Assert.Equal( + s_sequentialCallIds, + events.OfType().Select(e => e.ToolCallId).OrderBy(id => id, StringComparer.Ordinal).ToArray()); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_IndexReusedAcrossRounds_StartsANewCallAsync() + { + // Arrange: two sequential tool-call rounds in one stream; OpenAI restarts the + // fragment index at 0 for the second round. + List updates = + [ + FragmentUpdate(0, "call_a", "get_weather", "{\"city\":\"Paris\"}"), + CoalescedFunctionCallUpdate("call_a", "get_weather"), + FragmentUpdate(0, "call_b", "get_time", "{\"zone\":\"CET\"}"), + CoalescedFunctionCallUpdate("call_b", "get_time"), + ]; + + // Act + List events = await CollectAsync(updates); + + // Assert: each round gets its own Start, its args land on its own call id, and + // each call closes exactly once (no duplicate atomic re-emission). + Assert.Equal( + s_sequentialCallIds, + events.OfType().Select(e => e.ToolCallId).ToArray()); + Assert.Equal( + "{\"city\":\"Paris\"}", + string.Concat(events.OfType().Where(a => a.ToolCallId == "call_a").Select(a => a.Delta))); + Assert.Equal( + "{\"zone\":\"CET\"}", + string.Concat(events.OfType().Where(a => a.ToolCallId == "call_b").Select(a => a.Delta))); + Assert.Equal( + s_sequentialCallIds, + events.OfType().Select(e => e.ToolCallId).ToArray()); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_FragmentsWithoutCoalescedContent_CloseAtEndOfStreamAsync() + { + // Arrange: a raw-streamed call whose coalesced FunctionCallContent never arrives. + List updates = [FragmentUpdate(0, CallId, "get_weather", "{\"city\":\"Paris\"}")]; + + // Act + List events = await CollectAsync(updates); + + // Assert: the end-of-stream sweep closes the call before RunFinished. + ToolCallEndEvent end = Assert.Single(events.OfType()); + Assert.Equal(CallId, end.ToolCallId); + Assert.True( + events.FindIndex(e => e is ToolCallEndEvent) < events.FindIndex(e => e is RunFinishedEvent), + "ToolCallEnd must precede RunFinished"); } [Fact] From b5a93640c5d7fffe1f316bef0986fed6109c5d70 Mon Sep 17 00:00:00 2001 From: ran Date: Thu, 11 Jun 2026 12:36:07 +0200 Subject: [PATCH 10/24] .NET: Harden A2UI adapter context routing and history capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../A2UIAgent.cs | 27 ++-- .../A2UIToolkit.cs | 7 +- .../AGUIContextAgent.cs | 13 +- .../A2UIAgentTests.cs | 86 +++++++++++- .../A2UIEnvelopeTests.cs | 23 ++++ .../A2UIFindPriorSurfaceTests.cs | 26 ++++ .../AGUIContextAgentTests.cs | 129 ++++++++++++++++++ 7 files changed, 288 insertions(+), 23 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/AGUIContextAgentTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs index 11df4d5666..1687b4a90f 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs @@ -188,7 +188,7 @@ async Task GenerateA2UIAsync( /// hosting layer: forwarded context entries plus the A2UI component catalog entry /// injected by the A2UI middleware. /// - private static A2UIAgentState ReadAgentState(AdditionalPropertiesDictionary? properties) + internal static A2UIAgentState ReadAgentState(AdditionalPropertiesDictionary? properties) { if (properties is null || !properties.TryGetValue("ag_ui_context", out object? contextValue) || @@ -223,14 +223,25 @@ private static A2UIHistoryMessage ToHistoryMessage(ChatMessage message) string? content = message.Text; if (string.IsNullOrEmpty(content) && message.Role == ChatRole.Tool) { - FunctionResultContent? result = message.Contents.OfType().FirstOrDefault(); - content = result?.Result switch + // A message can carry multiple tool results (parallel calls); use the first + // one with usable textual content rather than only the first item. + foreach (FunctionResultContent result in message.Contents.OfType()) { - string text => text, - JsonElement { ValueKind: JsonValueKind.String } element => element.GetString(), - JsonElement element => element.GetRawText(), - _ => null, - }; + content = result.Result switch + { + string text => text, + JsonElement { ValueKind: JsonValueKind.String } element => element.GetString(), + JsonElement element => element.GetRawText(), + JsonValue value when value.TryGetValue(out string? text) => text, + JsonNode node => node.ToJsonString(), + _ => null, + }; + + if (!string.IsNullOrEmpty(content)) + { + break; + } + } } return new A2UIHistoryMessage(message.Role.Value, content); diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolkit.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolkit.cs index c906998975..bdf9d8da2f 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolkit.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolkit.cs @@ -154,7 +154,10 @@ public static string BuildContextPrompt(A2UIAgentState? state) messageDeleted = false; if (updateComponents["components"] is JsonArray opComponents) { - messageComponents = opComponents; + // Clone: the captured nodes outlive this method inside the public + // A2UIPriorSurface, and a parent-attached JsonNode throws when a + // caller re-attaches it elsewhere. + messageComponents = (JsonArray)opComponents.DeepClone(); } } @@ -163,7 +166,7 @@ public static string BuildContextPrompt(A2UIAgentState? state) { messageMentions = true; messageDeleted = false; - messageData = updateDataModel["value"]; + messageData = updateDataModel["value"]?.DeepClone(); messageDataSeen = true; } } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/AGUIContextAgent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/AGUIContextAgent.cs index edb642aea7..c8f58b6465 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/AGUIContextAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/AGUIContextAgent.cs @@ -52,17 +52,16 @@ protected override IAsyncEnumerable RunCoreStreamingAsync( private static IEnumerable WithContextPrompt(IEnumerable messages, AgentRunOptions? options) { - if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } || - !properties.TryGetValue("ag_ui_context", out object? contextValue) || - contextValue is not IEnumerable> entries) + if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties }) { return messages; } - string prompt = A2UIToolkit.BuildContextPrompt(new A2UIAgentState - { - Context = entries.Select(e => new A2UIContextEntry(e.Key, e.Value)).ToList(), - }); + // Shared routing with A2UIAgent: the catalog schema entry lands in the + // canonical "## Available Components" section, other entries become + // plain context sections — both agents render the same prompt for the + // same forwarded context. + string prompt = A2UIToolkit.BuildContextPrompt(A2UIAgent.ReadAgentState(properties)); return prompt.Length == 0 ? messages diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs index 9245725ea0..7a7e524b2c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs @@ -143,6 +143,70 @@ public async Task GenerateA2UITool_SubagentNeverCallsTool_ReturnsRecoveryExhaust Assert.Equal(A2UIConstants.MaxA2UIAttempts, calls); } + [Fact] + public async Task GenerateA2UITool_JsonElementArguments_ReturnsOperationsEnvelopeAsync() + { + // Arrange: real chat clients deliver FunctionCallContent.Arguments values as + // JsonElement — exercise that marshalling arm rather than pre-built JsonNodes. + using JsonDocument argsDocument = JsonDocument.Parse(s_validRenderArgs.ToJsonString()); + var elementArgs = new Dictionary + { + ["surfaceId"] = argsDocument.RootElement.GetProperty("surfaceId").Clone(), + ["components"] = argsDocument.RootElement.GetProperty("components").Clone(), + ["data"] = argsDocument.RootElement.GetProperty("data").Clone(), + }; + var inner = new RecordingAgent(); + var agent = new A2UIAgent(inner, new ScriptedChatClient(_ => null) { RawArguments = elementArgs }); + await agent.RunAsync([new ChatMessage(ChatRole.User, "show hotels")]); + + // Act + string envelope = await InvokeGenerateToolAsync(inner, new() { ["intent"] = "create" }); + + // Assert + JsonObject parsed = Assert.IsType(JsonNode.Parse(envelope)); + JsonArray ops = Assert.IsType(parsed[A2UIConstants.A2UIOperationsKey]); + Assert.Equal(3, ops.Count); + } + + [Fact] + public async Task ReadAgentState_RoutesSchemaEntryIntoAvailableComponentsAsync() + { + // Arrange: the hosting layer forwards context entries; the catalog schema entry + // must land in the prompt's canonical "## Available Components" section and the + // plain entry under its own heading. + string? subagentPrompt = null; + var inner = new RecordingAgent(); + var agent = new A2UIAgent(inner, new ScriptedChatClient(options => s_validRenderArgs) + { + OnMessages = messages => subagentPrompt = messages.FirstOrDefault(m => m.Role == ChatRole.System)?.Text, + }); + var runOptions = new ChatClientAgentRunOptions + { + ChatOptions = new ChatOptions + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["ag_ui_context"] = new[] + { + new KeyValuePair(A2UIConstants.A2UISchemaContextDescription, "{\"components\":{}}"), + new KeyValuePair("Style guide", "use cards"), + }, + }, + }, + }; + await agent.RunAsync([new ChatMessage(ChatRole.User, "show hotels")], options: runOptions); + + // Act + await InvokeGenerateToolAsync(inner, new() { ["intent"] = "create" }); + + // Assert + Assert.NotNull(subagentPrompt); + Assert.Contains("## Available Components", subagentPrompt); + Assert.Contains("{\"components\":{}}", subagentPrompt); + Assert.Contains("## Style guide", subagentPrompt); + Assert.DoesNotContain($"## {A2UIConstants.A2UISchemaContextDescription}", subagentPrompt); + } + /// /// Pulls the injected generate_a2ui function from the recorded run options and /// invokes it the way a function-invoking chat client would. @@ -202,17 +266,27 @@ private sealed class ScriptedChatClient : IChatClient public ScriptedChatClient(Func script) => this._script = script; + /// When set, the function call carries these raw argument values verbatim. + public IDictionary? RawArguments { get; init; } + + /// Observes the messages each subagent invocation receives. + public Action>? OnMessages { get; init; } + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { - JsonObject? args = this._script(options); - ChatMessage message = args is null + this.OnMessages?.Invoke(messages); + IDictionary? arguments = this.RawArguments; + if (arguments is null) + { + JsonObject? args = this._script(options); + arguments = args?.ToDictionary(p => p.Key, object? (p) => p.Value?.DeepClone()); + } + + ChatMessage message = arguments is null ? new ChatMessage(ChatRole.Assistant, "no tool call") : new ChatMessage(ChatRole.Assistant, [ - new FunctionCallContent( - "call-1", - A2UIConstants.RenderA2UIToolName, - args.ToDictionary(p => p.Key, object? (p) => p.Value?.DeepClone())), + new FunctionCallContent("call-1", A2UIConstants.RenderA2UIToolName, arguments), ]); return Task.FromResult(new ChatResponse(message)); } diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIEnvelopeTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIEnvelopeTests.cs index 60e7dac758..9f80efa4bb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIEnvelopeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIEnvelopeTests.cs @@ -326,6 +326,29 @@ public void BuildA2UIEnvelope_Update_SkipsCreateSurfaceAndKeepsTarget() Assert.Equal("s1", (string?)SingleOperation(ops, "updateComponents")["surfaceId"]); } + [Fact] + public void BuildA2UIEnvelope_UpdateWithData_EmitsDataModelOp() + { + // Arrange + var args = new JsonObject + { + ["components"] = RowComponents(), + ["data"] = new JsonObject { ["items"] = new JsonArray(1) }, + }; + var prior = new A2UIPriorSurface(new JsonArray(), null, "cat://prior"); + + // Act + JsonArray ops = ParseOperations(A2UIToolkit.BuildA2UIEnvelope( + args, isUpdate: true, targetSurfaceId: "s1", prior)); + + // Assert: update path = updateComponents + updateDataModel, both on the target. + Assert.Equal(2, ops.Count); + Assert.Equal("s1", (string?)SingleOperation(ops, "updateComponents")["surfaceId"]); + JsonObject updateDataModel = SingleOperation(ops, "updateDataModel"); + Assert.Equal("s1", (string?)updateDataModel["surfaceId"]); + Assert.Equal("""{"items":[1]}""", updateDataModel["value"]?.ToJsonString()); + } + [Fact] public void ResolveA2UIToolParams_FillsCanonicalDefaults() { diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIFindPriorSurfaceTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIFindPriorSurfaceTests.cs index 8486a19264..cba009711c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIFindPriorSurfaceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIFindPriorSurfaceTests.cs @@ -221,6 +221,32 @@ public void FindPriorSurface_IntraMessageDeleteThenCreate_ReturnsRecreatedState( Assert.Null(prior.Data); } + [Fact] + public void FindPriorSurface_ReturnedNodes_AreDetachedFromTheParsedMessage() + { + // Arrange + A2UIHistoryMessage[] messages = + [ + ToolMessage(Operations( + A2UIOperationBuilder.CreateSurface("s1", "cat://x"), + A2UIOperationBuilder.UpdateComponents("s1", RowComponents()), + A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["items"] = new JsonArray(1) }))), + ]; + A2UIPriorSurface? prior = A2UIToolkit.FindPriorSurface(messages, "s1"); + Assert.NotNull(prior); + + // Act: re-attaching the returned nodes must not throw "node already has a parent". + var host = new JsonObject + { + ["components"] = prior.Components, + ["data"] = prior.Data, + }; + + // Assert + Assert.NotNull(host["components"]); + Assert.NotNull(host["data"]); + } + [Fact] public void FindPriorSurface_IntraMessageCreateThenDelete_ReturnsNull() { diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/AGUIContextAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/AGUIContextAgentTests.cs new file mode 100644 index 0000000000..3c0673b345 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/AGUIContextAgentTests.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests; + +/// +/// Unit tests for : forwarded AG-UI context entries are +/// surfaced to the model as a leading system message, with the catalog schema entry +/// routed into the canonical ## Available Components section. +/// +public sealed class AGUIContextAgentTests +{ + [Fact] + public async Task RunAsync_WithForwardedContext_PrependsSystemMessageAsync() + { + // Arrange + var inner = new RecordingAgent(); + var agent = new AGUIContextAgent(inner); + var options = new ChatClientAgentRunOptions + { + ChatOptions = new ChatOptions + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["ag_ui_context"] = new[] + { + new KeyValuePair(A2UIConstants.A2UISchemaContextDescription, "{\"components\":{}}"), + new KeyValuePair("Style guide", "use cards"), + }, + }, + }, + }; + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "hi")], options: options); + + // Assert: one system message prepended, carrying both the canonical schema + // section and the plain context section. + Assert.NotNull(inner.LastMessages); + Assert.Equal(2, inner.LastMessages!.Count); + ChatMessage system = inner.LastMessages[0]; + Assert.Equal(ChatRole.System, system.Role); + Assert.Contains("## Available Components", system.Text); + Assert.Contains("{\"components\":{}}", system.Text); + Assert.Contains("## Style guide", system.Text); + Assert.Contains("use cards", system.Text); + Assert.Equal(ChatRole.User, inner.LastMessages[1].Role); + } + + [Fact] + public async Task RunAsync_WithoutForwardedContext_PassesMessagesThroughAsync() + { + // Arrange + var inner = new RecordingAgent(); + var agent = new AGUIContextAgent(inner); + var userMessage = new ChatMessage(ChatRole.User, "hi"); + + // Act + await agent.RunAsync([userMessage]); + + // Assert: untouched — same single message instance, no system prefix. + Assert.NotNull(inner.LastMessages); + ChatMessage only = Assert.Single(inner.LastMessages!); + Assert.Same(userMessage, only); + } + + [Fact] + public async Task RunAsync_WithEmptyContextEntries_DoesNotPrependAsync() + { + // Arrange: entries that render to an empty prompt must not produce an + // empty system message. + var inner = new RecordingAgent(); + var agent = new AGUIContextAgent(inner); + var options = new ChatClientAgentRunOptions + { + ChatOptions = new ChatOptions + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["ag_ui_context"] = Array.Empty>(), + }, + }, + }; + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "hi")], options: options); + + // Assert + Assert.NotNull(inner.LastMessages); + ChatMessage only = Assert.Single(inner.LastMessages!); + Assert.Equal(ChatRole.User, only.Role); + } + + /// An inner agent that records the messages it was run with. + private sealed class RecordingAgent : AIAgent + { + public List? LastMessages { get; private set; } + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + this.LastMessages = messages.ToList(); + return Task.FromResult(new AgentResponse()); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + this.LastMessages = messages.ToList(); + await Task.CompletedTask.ConfigureAwait(false); + yield break; + } + } +} From 1734079b6a71866ee73d3f9eebe221daf2627cc7 Mon Sep 17 00:00:00 2001 From: ran Date: Thu, 11 Jun 2026 12:36:07 +0200 Subject: [PATCH 11/24] .NET: Tighten AGUIDojoServer sample configuration and metadata 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 --- .../A2UI/A2UIFixedSchemaTools.cs | 6 ++-- .../AGUIDojoServer/AGUIDojoServer.csproj | 4 +-- .../AGUIDojoServerSerializerContext.cs | 2 ++ .../AGUIDojoServer/ChatClientAgentFactory.cs | 28 +++++++++++++------ 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UIFixedSchemaTools.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UIFixedSchemaTools.cs index 8409297b85..724ffe2ab2 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UIFixedSchemaTools.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UIFixedSchemaTools.cs @@ -27,7 +27,8 @@ public static AIFunction CreateSearchFlightsTool() => AIFunctionFactory.Create( "favicon API like 'https://www.google.com/s2/favicons?domain=united.com&sz=128'), " + "flightNumber, origin, destination, date (e.g. 'Tue, Mar 18'), departureTime, " + "arrivalTime, duration (e.g. '4h 25m'), status ('On Time' or 'Delayed'), " + - "and price (e.g. '$289')."); + "and price (e.g. '$289').", + AGUIDojoServerSerializerContext.Default.Options); /// Creates the search_hotels tool. public static AIFunction CreateSearchHotelsTool() => AIFunctionFactory.Create( @@ -36,7 +37,8 @@ public static AIFunction CreateSearchHotelsTool() => AIFunctionFactory.Create( "Search for hotels and display the results as rich cards with star ratings. " + "Each hotel must have: id, name (e.g. 'The Plaza'), location " + "(e.g. 'Midtown Manhattan, NYC'), rating (float 0-5, e.g. 4.5), and " + - "price (per night, e.g. '$350'). Generate 3-4 realistic results."); + "price (per night, e.g. '$350'). Generate 3-4 realistic results.", + AGUIDojoServerSerializerContext.Default.Options); private static string SearchFlights( [Description("Array of flight result objects.")] JsonArray flights) diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj index e4174e5481..0de7806a1b 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj @@ -1,11 +1,11 @@ - + Exe net10.0 enable enable - b9c3f1e1-2fb4-5g29-0e52-53e2b7g9gf21 + 1d558f5d-c5c3-4178-bc08-edc445249828 diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs index c60db0efd0..36e6c11ce3 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs @@ -8,6 +8,8 @@ namespace AGUIDojoServer; +[JsonSerializable(typeof(System.Text.Json.Nodes.JsonArray))] +[JsonSerializable(typeof(System.Text.Json.Nodes.JsonObject))] [JsonSerializable(typeof(WeatherInfo))] [JsonSerializable(typeof(Recipe))] [JsonSerializable(typeof(Ingredient))] diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs index a263931251..7be95e7660 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs @@ -32,8 +32,13 @@ public static void Initialize(IConfiguration configuration) // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + if (!Uri.TryCreate(azureEndpoint, UriKind.Absolute, out Uri? azureUri)) + { + throw new InvalidOperationException($"AZURE_OPENAI_ENDPOINT is not a valid absolute URI: '{azureEndpoint}'."); + } + s_openAIClient = new AzureOpenAIClient( - new Uri(azureEndpoint), + azureUri, new DefaultAzureCredential()); return; } @@ -47,7 +52,12 @@ public static void Initialize(IConfiguration configuration) string? baseUrl = configuration["OPENAI_BASE_URL"]; if (!string.IsNullOrEmpty(baseUrl)) { - options.Endpoint = new Uri(baseUrl); + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? baseUri)) + { + throw new InvalidOperationException($"OPENAI_BASE_URL is not a valid absolute URI: '{baseUrl}'. Include the scheme, e.g. 'http://localhost:8000/v1'."); + } + + options.Endpoint = baseUri; } s_openAIClient = new OpenAIClient(new System.ClientModel.ApiKeyCredential(apiKey), options); @@ -59,7 +69,7 @@ public static ChatClientAgent CreateAgenticChat() return chatClient.AsAIAgent( name: "AgenticChat", - description: "A simple chat agent using Azure OpenAI"); + description: "A simple chat agent"); } public static ChatClientAgent CreateBackendToolRendering() @@ -68,7 +78,7 @@ public static ChatClientAgent CreateBackendToolRendering() return chatClient.AsAIAgent( name: "BackendToolRenderer", - description: "An agent that can render backend tools using Azure OpenAI", + description: "An agent that can render backend tools", tools: [AIFunctionFactory.Create( GetWeather, name: "get_weather", @@ -82,7 +92,7 @@ public static ChatClientAgent CreateHumanInTheLoop() return chatClient.AsAIAgent( name: "HumanInTheLoopAgent", - description: "An agent that involves human feedback in its decision-making process using Azure OpenAI"); + description: "An agent that involves human feedback in its decision-making process"); } public static ChatClientAgent CreateToolBasedGenerativeUI() @@ -91,7 +101,7 @@ public static ChatClientAgent CreateToolBasedGenerativeUI() return chatClient.AsAIAgent( name: "ToolBasedGenerativeUIAgent", - description: "An agent that uses tools to generate user interfaces using Azure OpenAI"); + description: "An agent that uses tools to generate user interfaces"); } public static AIAgent CreateAgenticUI(JsonSerializerOptions options) @@ -100,7 +110,7 @@ public static AIAgent CreateAgenticUI(JsonSerializerOptions options) var baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions { Name = "AgenticUIAgent", - Description = "An agent that generates agentic user interfaces using Azure OpenAI", + Description = "An agent that generates agentic user interfaces", ChatOptions = new ChatOptions { Instructions = """ @@ -142,7 +152,7 @@ public static AIAgent CreateSharedState(JsonSerializerOptions options) var baseAgent = chatClient.AsAIAgent( name: "SharedStateAgent", - description: "An agent that demonstrates shared state patterns using Azure OpenAI"); + description: "An agent that demonstrates shared state patterns"); return new SharedStateAgent(baseAgent, options); } @@ -154,7 +164,7 @@ public static AIAgent CreatePredictiveStateUpdates(JsonSerializerOptions options var baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions { Name = "PredictiveStateUpdatesAgent", - Description = "An agent that demonstrates predictive state updates using Azure OpenAI", + Description = "An agent that demonstrates predictive state updates", ChatOptions = new ChatOptions { Instructions = """ From a2fa458746649276c29a253dcfce35e0c5f6c42c Mon Sep 17 00:00:00 2001 From: ran Date: Thu, 11 Jun 2026 14:22:37 +0200 Subject: [PATCH 12/24] .NET: Preserve caller run options in A2UIAgent and tighten the AG-UI streaming sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../A2UI/A2UIFixedSchemaTools.cs | 2 + .../AGUIDojoServer/ChatClientAgentFactory.cs | 5 +- .../A2UIAgent.cs | 49 ++++--- .../Microsoft.Agents.AI.AGUI.A2UI.csproj | 2 + .../ChatResponseUpdateAGUIExtensions.cs | 32 ++++- .../A2UIAgentTests.cs | 120 +++++++++++++++++- .../A2UIConstantsTests.cs | 26 ++++ 7 files changed, 211 insertions(+), 25 deletions(-) diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UIFixedSchemaTools.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UIFixedSchemaTools.cs index 724ffe2ab2..4bcfbfd183 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UIFixedSchemaTools.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UIFixedSchemaTools.cs @@ -116,6 +116,8 @@ private static string RenderOperations(string surfaceId, JsonArray schema, strin ["name"] = new JsonObject { ["path"] = "name" }, ["location"] = new JsonObject { ["path"] = "location" }, ["rating"] = new JsonObject { ["path"] = "rating" }, + // Deliberate cross-name: the HotelCard prop is "pricePerNight" but the + // agent-supplied data field (per the search_hotels description) is "price". ["pricePerNight"] = new JsonObject { ["path"] = "price" }, ["action"] = new JsonObject { diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs index 7be95e7660..8a26bd2cf3 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs @@ -275,7 +275,10 @@ public static AIAgent CreateA2UIRecovery() { DefaultCatalogId = A2UICompositionGuides.DynamicCatalogId, Guidelines = new A2UIGuidelines { CompositionGuide = A2UICompositionGuides.Recovery }, - // The recovery loop runs by default; set the cap explicitly for the showcase. + // No Catalog is supplied, so the loop exercises structural validation (missing + // root, dangling child references, duplicate ids) like the sibling demos. The + // recovery loop runs by default; the cap is set explicitly to show where the + // knob lives and equals the default (A2UIConstants.MaxA2UIAttempts). Recovery = new A2UIRecoveryConfig { MaxAttempts = 3 }, }); } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs index 1687b4a90f..64858f188c 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs @@ -88,8 +88,9 @@ protected override IAsyncEnumerable RunCoreStreamingAsync( { List messageList = messages.ToList(); - ChatClientAgentRunOptions? original = options as ChatClientAgentRunOptions; - ChatOptions chatOptions = original?.ChatOptions?.Clone() ?? new ChatOptions(); + ChatClientAgentRunOptions runOptions = CloneRunOptions(options); + ChatOptions chatOptions = runOptions.ChatOptions ?? new ChatOptions(); + runOptions.ChatOptions = chatOptions; AIFunction generateTool = this.CreateGenerateA2UIFunction(messageList, ReadAgentState(chatOptions.AdditionalProperties)); chatOptions.Tools = (chatOptions.Tools ?? Enumerable.Empty()) @@ -97,15 +98,29 @@ protected override IAsyncEnumerable RunCoreStreamingAsync( .Append(generateTool) .ToList(); - var runOptions = new ChatClientAgentRunOptions - { - ChatOptions = chatOptions, - ChatClientFactory = original?.ChatClientFactory, - }; - return (messageList, runOptions); } + /// + /// Clones the caller's run options into a so the + /// base members (continuation token, background-response + /// opt-in, additional properties, response format) survive the per-run + /// augmentation rather than being dropped. + /// + private static ChatClientAgentRunOptions CloneRunOptions(AgentRunOptions? options) => + options switch + { + ChatClientAgentRunOptions chatRunOptions => (ChatClientAgentRunOptions)chatRunOptions.Clone(), + not null => new ChatClientAgentRunOptions + { + ContinuationToken = options.ContinuationToken, + AllowBackgroundResponses = options.AllowBackgroundResponses, + AdditionalProperties = options.AdditionalProperties?.Clone(), + ResponseFormat = options.ResponseFormat, + }, + null => new ChatClientAgentRunOptions(), + }; + /// /// Builds the per-run generate_a2ui tool with the run's conversation history and /// AG-UI state captured in its closure. @@ -281,21 +296,21 @@ private static JsonObject ToJsonObject(IDictionary arguments) /// private sealed class RenderA2UIToolDeclaration : AIFunctionDeclaration { - private static readonly JsonElement s_schema = ParseSchema(); + // Name, description, and schema all derive from the canonical tool definition so + // the declaration cannot drift from what other A2UI hosts advertise. + private static readonly (string Description, JsonElement Schema) s_definition = ParseDefinition(); - private static JsonElement ParseSchema() + private static (string Description, JsonElement Schema) ParseDefinition() { - using var document = JsonDocument.Parse( - A2UIToolDefinitions.CreateRenderA2UIToolDefinition()["function"]!["parameters"]!.ToJsonString()); - return document.RootElement.Clone(); + JsonNode function = A2UIToolDefinitions.CreateRenderA2UIToolDefinition()["function"]!; + using var document = JsonDocument.Parse(function["parameters"]!.ToJsonString()); + return (function["description"]!.GetValue(), document.RootElement.Clone()); } public override string Name => A2UIConstants.RenderA2UIToolName; - public override string Description => - "Render a dynamic A2UI v0.9 surface. The root component must have " + - "id 'root'. Use components from the available catalog only."; + public override string Description => s_definition.Description; - public override JsonElement JsonSchema => s_schema; + public override JsonElement JsonSchema => s_definition.Schema; } } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj index af34bed491..4054b1714f 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj @@ -6,6 +6,8 @@ Framework-agnostic helpers for building A2UI (Agent-to-UI) generation tools on top of the AG-UI protocol. true true + + $(NoWarn);MEAI001 diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs index 9bdf9cb753..6884d8b2c1 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs @@ -635,6 +635,22 @@ chatResponse.Contents[0] is TextContent && rawToolCallIdsByIndex.Remove(index); } + // Close any open reasoning block before emitting tool events. + if (currentReasoningMessageId is not null) + { + yield return new ReasoningMessageEndEvent + { + MessageId = currentReasoningMessageId + }; + yield return new ReasoningEndEvent + { + MessageId = currentReasoningId! + }; + currentReasoningBaseId = null; + currentReasoningId = null; + currentReasoningMessageId = null; + } + yield return new ToolCallEndEvent { ToolCallId = functionCallContent.CallId @@ -839,13 +855,19 @@ chatResponse.Contents[0] is TextContent && // Close any raw-streamed tool call whose coalesced FunctionCallContent never // arrived (stream cut short, or a pipeline that does not re-emit the typed // content). Without this sweep the wire carries Start/Args with no End and - // streaming consumers stay in a perpetual "in progress" state. - foreach (string openToolCallId in rawStreamedToolCallIds) + // streaming consumers stay in a perpetual "in progress" state. Sweep in + // tool-call index order so the close events are deterministic. + List openToolCallIndexes = new(rawToolCallIdsByIndex.Keys); + openToolCallIndexes.Sort(); + foreach (int openToolCallIndex in openToolCallIndexes) { - yield return new ToolCallEndEvent + if (rawStreamedToolCallIds.Remove(rawToolCallIdsByIndex[openToolCallIndex])) { - ToolCallId = openToolCallId - }; + yield return new ToolCallEndEvent + { + ToolCallId = rawToolCallIdsByIndex[openToolCallIndex] + }; + } } rawStreamedToolCallIds.Clear(); diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs index 7a7e524b2c..3d7fbe7037 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs @@ -41,7 +41,7 @@ public sealed class A2UIAgentTests }; [Fact] - public async Task RunStreamingAsync_InjectsGenerateA2UIToolIntoRunOptionsAsync() + public async Task RunAsync_InjectsGenerateA2UIToolIntoRunOptionsAsync() { // Arrange var inner = new RecordingAgent(); @@ -58,7 +58,25 @@ public async Task RunStreamingAsync_InjectsGenerateA2UIToolIntoRunOptionsAsync() } [Fact] - public async Task RunStreamingAsync_CustomToolName_IsHonoredAsync() + public async Task RunStreamingAsync_InjectsGenerateA2UIToolIntoRunOptionsAsync() + { + // Arrange + var inner = new RecordingAgent(); + var agent = new A2UIAgent(inner, new ScriptedChatClient(_ => s_validRenderArgs)); + + // Act: the streaming entry point goes through the same per-run preparation. + await foreach (AgentResponseUpdate _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "hi")])) + { + } + + // Assert + ChatClientAgentRunOptions options = Assert.IsType(inner.LastOptions); + AITool tool = Assert.Single(options.ChatOptions?.Tools ?? []); + Assert.Equal(A2UIConstants.GenerateA2UIToolName, tool.Name); + } + + [Fact] + public async Task RunAsync_CustomToolName_IsHonoredAsync() { // Arrange var inner = new RecordingAgent(); @@ -75,6 +93,70 @@ public async Task RunStreamingAsync_CustomToolName_IsHonoredAsync() Assert.Contains(options.ChatOptions?.Tools ?? [], t => t.Name == "custom_a2ui"); } + [Fact] + public async Task RunAsync_PreservesCallerToolsAndRunOptionsAsync() + { + // Arrange: a caller-supplied options object carrying chat tools and base + // AgentRunOptions members, plus a stale generate_a2ui entry that must be + // replaced rather than duplicated. + var inner = new RecordingAgent(); + var agent = new A2UIAgent(inner, new ScriptedChatClient(_ => s_validRenderArgs)); + AIFunction callerTool = AIFunctionFactory.Create(() => "weather", "get_weather"); + AIFunction staleGenerateTool = AIFunctionFactory.Create(() => "stale", A2UIConstants.GenerateA2UIToolName); + Func factory = client => client; + var callerOptions = new ChatClientAgentRunOptions + { + ChatOptions = new ChatOptions { Tools = [callerTool, staleGenerateTool] }, + ChatClientFactory = factory, + AllowBackgroundResponses = true, + ResponseFormat = ChatResponseFormat.Json, + AdditionalProperties = new AdditionalPropertiesDictionary { ["run-key"] = "run-value" }, + }; + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "hi")], options: callerOptions); + + // Assert: caller tool retained, generate tool replaced (no duplicate), base + // run-option members and the chat client factory all survive. + ChatClientAgentRunOptions forwarded = Assert.IsType(inner.LastOptions); + IList tools = forwarded.ChatOptions?.Tools ?? []; + Assert.Contains(tools, t => t.Name == "get_weather"); + AITool generateTool = Assert.Single(tools, t => t.Name == A2UIConstants.GenerateA2UIToolName); + Assert.NotSame(staleGenerateTool, generateTool); + Assert.Same(factory, forwarded.ChatClientFactory); + Assert.True(forwarded.AllowBackgroundResponses); + Assert.Same(ChatResponseFormat.Json, forwarded.ResponseFormat); + Assert.Equal("run-value", forwarded.AdditionalProperties?["run-key"]); + + // The caller's options object is not mutated. + Assert.Equal(2, callerOptions.ChatOptions!.Tools!.Count); + Assert.Same(staleGenerateTool, callerOptions.ChatOptions.Tools[1]); + } + + [Fact] + public async Task RunAsync_PlainAgentRunOptions_BaseMembersSurviveAsync() + { + // Arrange: a caller passing the base options type still gets its members forwarded. + var inner = new RecordingAgent(); + var agent = new A2UIAgent(inner, new ScriptedChatClient(_ => s_validRenderArgs)); + var callerOptions = new AgentRunOptions + { + AllowBackgroundResponses = true, + ResponseFormat = ChatResponseFormat.Json, + AdditionalProperties = new AdditionalPropertiesDictionary { ["run-key"] = "run-value" }, + }; + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "hi")], options: callerOptions); + + // Assert + ChatClientAgentRunOptions forwarded = Assert.IsType(inner.LastOptions); + Assert.True(forwarded.AllowBackgroundResponses); + Assert.Same(ChatResponseFormat.Json, forwarded.ResponseFormat); + Assert.Equal("run-value", forwarded.AdditionalProperties?["run-key"]); + Assert.Contains(forwarded.ChatOptions?.Tools ?? [], t => t.Name == A2UIConstants.GenerateA2UIToolName); + } + [Fact] public async Task GenerateA2UITool_CreateIntent_ReturnsOperationsEnvelopeAsync() { @@ -121,6 +203,40 @@ public async Task GenerateA2UITool_UpdateWithoutPrior_ReturnsErrorEnvelopeAsync( Assert.Contains("no prior render", (string?)parsed["error"]); } + [Fact] + public async Task GenerateA2UITool_UpdateWithPriorRenderInHistory_ReturnsUpdateOpsAsync() + { + // Arrange: a prior render envelope rides in a tool-result message, the way a real + // conversation history carries it. The update intent must find it through + // ToHistoryMessage + FindPriorSurface and emit in-place update operations. + string priorEnvelope = A2UIToolkit.WrapAsOperationsEnvelope( + [ + A2UIOperationBuilder.CreateSurface("s1", "https://example.test/catalog.json"), + A2UIOperationBuilder.UpdateComponents("s1", s_validRenderArgs["components"]!.AsArray()), + ]); + using JsonDocument priorDocument = JsonDocument.Parse(priorEnvelope); + var inner = new RecordingAgent(); + var agent = new A2UIAgent(inner, new ScriptedChatClient(_ => s_validRenderArgs)); + await agent.RunAsync( + [ + new ChatMessage(ChatRole.User, "show hotels"), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call-0", priorDocument.RootElement.Clone())]), + new ChatMessage(ChatRole.User, "make the cards bigger"), + ]); + + // Act + string envelope = await InvokeGenerateToolAsync( + inner, new() { ["intent"] = "update", ["target_surface_id"] = "s1" }); + + // Assert: no createSurface for an in-place update; the ops target the prior surface. + JsonObject parsed = Assert.IsType(JsonNode.Parse(envelope)); + JsonArray ops = Assert.IsType(parsed[A2UIConstants.A2UIOperationsKey]); + Assert.Equal(2, ops.Count); + Assert.DoesNotContain(ops, op => op is JsonObject obj && obj.ContainsKey("createSurface")); + JsonObject updateComponents = Assert.IsType(ops[0]?["updateComponents"]); + Assert.Equal("s1", (string?)updateComponents["surfaceId"]); + } + [Fact] public async Task GenerateA2UITool_SubagentNeverCallsTool_ReturnsRecoveryExhaustedEnvelopeAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIConstantsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIConstantsTests.cs index 4e8358cbea..d01dfc93ca 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIConstantsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIConstantsTests.cs @@ -39,4 +39,30 @@ public void RecoveryDefaults_MatchCrossLanguageContract() Assert.Equal(3, A2UIConstants.MaxA2UIAttempts); Assert.Equal("a2ui_recovery", A2UIConstants.A2UIRecoveryActivityType); } + + [Fact] + public void ProtocolVersion_MatchesCrossLanguageContract() + { + // Assert + Assert.Equal("v0.9", A2UIConstants.ProtocolVersion); + } + + [Fact] + public void ToolNames_MatchCrossLanguageContract() + { + // Assert + Assert.Equal("generate_a2ui", A2UIConstants.GenerateA2UIToolName); + Assert.Equal("render_a2ui", A2UIConstants.RenderA2UIToolName); + } + + [Fact] + public void A2UISchemaContextDescription_MatchesMiddlewareContract() + { + // The AG-UI A2UI middleware emits the catalog schema context entry under exactly + // this description; adapters match it byte-for-byte to route the catalog. + Assert.Equal( + "A2UI Component Schema — available components for generating UI surfaces. " + + "Use these component names and properties when creating A2UI operations.", + A2UIConstants.A2UISchemaContextDescription); + } } From 11358f5b895f2448018cbd4b7f08179f4727d485 Mon Sep 17 00:00:00 2001 From: ran Date: Thu, 11 Jun 2026 14:48:49 +0200 Subject: [PATCH 13/24] .NET: Polish A2UI toolkit docs and test assertions 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 --- .../AGUIDojoServer/ChatClientAgentFactory.cs | 4 ++-- .../Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs | 4 +++- .../Microsoft.Agents.AI.AGUI.A2UI.UnitTests.csproj | 4 ---- .../StreamingToolCallArgsTests.cs | 12 ++++++++---- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs index 8a26bd2cf3..39ec13d2a1 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs @@ -278,8 +278,8 @@ public static AIAgent CreateA2UIRecovery() // No Catalog is supplied, so the loop exercises structural validation (missing // root, dangling child references, duplicate ids) like the sibling demos. The // recovery loop runs by default; the cap is set explicitly to show where the - // knob lives and equals the default (A2UIConstants.MaxA2UIAttempts). - Recovery = new A2UIRecoveryConfig { MaxAttempts = 3 }, + // knob lives, using the default value. + Recovery = new A2UIRecoveryConfig { MaxAttempts = A2UIConstants.MaxA2UIAttempts }, }); } } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs index 56c8c8bea6..7e03c936f0 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs @@ -50,7 +50,9 @@ public static class A2UIConstants public const int MaxA2UIAttempts = 3; /// - /// The activity type identifier for A2UI recovery lifecycle records. + /// The activity type identifier for A2UI recovery lifecycle records. The records are + /// emitted by the AG-UI A2UI middleware on the client side; the constant is part of the + /// cross-language wire contract and is pinned here so adapters and tests can reference it. /// public const string A2UIRecoveryActivityType = "a2ui_recovery"; diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests.csproj index d33a1153df..58222be057 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests.csproj @@ -1,9 +1,5 @@  - - - - diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/StreamingToolCallArgsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/StreamingToolCallArgsTests.cs index c48a6d4edf..8c9d9540f0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/StreamingToolCallArgsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/StreamingToolCallArgsTests.cs @@ -136,8 +136,11 @@ public async Task AsAGUIEventStreamAsync_ParallelToolCalls_StreamIndependentlyAs // Act List events = await CollectAsync(updates); - // Assert - Assert.Equal(2, events.OfType().Count()); + // Assert: each call gets its own Start (no shared parent message collapsing + // the two calls into one bubble). + List starts = events.OfType().ToList(); + Assert.Equal(2, starts.Count); + Assert.Equal(s_sequentialCallIds, starts.Select(e => e.ToolCallId).ToArray()); Assert.Equal( "{\"city\":\"Paris\"}", string.Concat(events.OfType().Where(a => a.ToolCallId == "call_a").Select(a => a.Delta))); @@ -145,10 +148,11 @@ public async Task AsAGUIEventStreamAsync_ParallelToolCalls_StreamIndependentlyAs "{\"zone\":\"CET\"}", string.Concat(events.OfType().Where(a => a.ToolCallId == "call_b").Select(a => a.Delta))); // Both calls were started on the wire, so both must close — here via the - // end-of-stream sweep, since no coalesced content ever arrives. + // end-of-stream sweep, since no coalesced content ever arrives. The sweep + // promises deterministic tool-call index order, so assert the wire order. Assert.Equal( s_sequentialCallIds, - events.OfType().Select(e => e.ToolCallId).OrderBy(id => id, StringComparer.Ordinal).ToArray()); + events.OfType().Select(e => e.ToolCallId).ToArray()); } [Fact] From afe65605fdc866d12fbf94a3155b0cf359cf0078 Mon Sep 17 00:00:00 2001 From: ran Date: Thu, 11 Jun 2026 14:59:01 +0200 Subject: [PATCH 14/24] .NET: Clarify the A2UI recovery activity-type constant doc 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 --- dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs index 7e03c936f0..cb147e94b6 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs @@ -50,9 +50,9 @@ public static class A2UIConstants public const int MaxA2UIAttempts = 3; /// - /// The activity type identifier for A2UI recovery lifecycle records. The records are - /// emitted by the AG-UI A2UI middleware on the client side; the constant is part of the - /// cross-language wire contract and is pinned here so adapters and tests can reference it. + /// The activity type identifier reserved for the A2UI recovery status channel. Part of + /// the cross-language wire contract (it mirrors the TypeScript toolkit's + /// A2UI_RECOVERY_ACTIVITY_TYPE); pinned here so adapters and tests can reference it. /// public const string A2UIRecoveryActivityType = "a2ui_recovery"; From 9425ec40b2ab86c0487bc6d2dadf11c120202c00 Mon Sep 17 00:00:00 2001 From: ran Date: Thu, 11 Jun 2026 16:35:27 +0200 Subject: [PATCH 15/24] .NET: Stream the generate_a2ui subagent path for progressive rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../A2UIAgent.cs | 263 +++++++++++++++++- .../A2UIGenerationRecovery.cs | 6 +- .../A2UIAgentTests.cs | 163 ++++++++++- 3 files changed, 412 insertions(+), 20 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs index 64858f188c..155a5ebc73 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; @@ -66,17 +67,198 @@ protected override Task RunCoreAsync( return this.InnerAgent.RunAsync(messageList, session, runOptions, cancellationToken); } + /// + /// The cap on planner rounds (model turn → generation → result fed back) per run, + /// guarding against a planner that keeps requesting surfaces without terminating. + /// + private const int MaxPlannerRounds = 8; + /// - protected override IAsyncEnumerable RunCoreStreamingAsync( + /// + /// The streaming path runs the generate_a2ui invocation loop at the agent level + /// instead of through automatic function invocation: the tool is advertised as a + /// schema-only declaration so the planner's call surfaces on the update stream, the + /// render subagent is then run with a streaming chat call, and its raw updates are + /// forwarded so hosting layers can emit the tool-call argument fragments incrementally + /// (progressive surface rendering). The envelope is fed back to the planner and the + /// conversation continues — the same wire shape the LangGraph adapters produce. + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, - CancellationToken cancellationToken = default) + [EnumeratorCancellation] CancellationToken cancellationToken = default) { - (List messageList, AgentRunOptions runOptions) = this.PrepareRun(messages, options); - return this.InnerAgent.RunStreamingAsync(messageList, session, runOptions, cancellationToken); + List history = messages.ToList(); + + ChatClientAgentRunOptions runOptions = CloneRunOptions(options); + ChatOptions chatOptions = runOptions.ChatOptions ?? new ChatOptions(); + runOptions.ChatOptions = chatOptions; + + A2UIAgentState state = ReadAgentState(chatOptions.AdditionalProperties); + + var generateTool = new GenerateA2UIToolDeclaration(this._parameters.ToolName, this._parameters.ToolDescription); + chatOptions.Tools = (chatOptions.Tools ?? Enumerable.Empty()) + .Where(t => !string.Equals(t.Name, generateTool.Name, StringComparison.Ordinal)) + .Append(generateTool) + .ToList(); + + // Round 1 carries the caller's session so inner-agent bookkeeping still happens; + // later rounds resend the manually grown history without the session to avoid + // double-recording the same messages in session-aware inner agents. + List pending = history; + AgentSession? pendingSession = session; + for (int round = 1; round <= MaxPlannerRounds; round++) + { + List generateCalls = []; + await foreach (AgentResponseUpdate update in this.InnerAgent + .RunStreamingAsync(pending, pendingSession, runOptions, cancellationToken) + .ConfigureAwait(false)) + { + foreach (AIContent content in update.Contents) + { + if (content is FunctionCallContent call && + string.Equals(call.Name, generateTool.Name, StringComparison.Ordinal)) + { + generateCalls.Add(call); + } + } + + yield return update; + } + + if (generateCalls.Count == 0) + { + yield break; + } + + List results = []; + foreach (FunctionCallContent call in generateCalls) + { + var envelopeBox = new StrongBox(); + await foreach (AgentResponseUpdate update in this + .RunGenerateStreamingAsync(call, history, state, envelopeBox, cancellationToken) + .ConfigureAwait(false)) + { + yield return update; + } + + results.Add(new FunctionResultContent(call.CallId, envelopeBox.Value)); + } + + // Surface the tool results on the wire and feed them back to the planner. + var toolMessage = new ChatMessage(ChatRole.Tool, results); + yield return new AgentResponseUpdate(ChatRole.Tool, results); + + history.Add(new ChatMessage(ChatRole.Assistant, [.. generateCalls])); + history.Add(toolMessage); + pending = history; + pendingSession = null; + } } + /// + /// Runs one generate_a2ui invocation with the validate-and-retry loop, streaming + /// the render subagent's updates (each retry is a fresh, visible subagent call) and + /// depositing the final envelope — operations, request error, or recovery-exhausted — + /// into . + /// + private async IAsyncEnumerable RunGenerateStreamingAsync( + FunctionCallContent call, + IReadOnlyList conversation, + A2UIAgentState state, + StrongBox envelopeBox, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + string? intent = GetStringArgument(call.Arguments, "intent"); + string? targetSurfaceId = GetStringArgument(call.Arguments, "target_surface_id"); + string? changes = GetStringArgument(call.Arguments, "changes"); + + List history = conversation.Select(ToHistoryMessage).ToList(); + A2UIPreparedRequest prep = A2UIToolkit.PrepareA2UIRequest( + intent, targetSurfaceId, changes, history, state, this._parameters.Guidelines); + if (prep.Error is not null) + { + envelopeBox.Value = ParseEnvelope(A2UIToolkit.WrapErrorEnvelope(prep.Error)); + yield break; + } + + // The streaming twin of A2UIGenerationRecovery.RunAsync: same attempt semantics, + // but each subagent call streams so its updates can be forwarded between attempts. + int maxAttempts = this._parameters.Recovery?.MaxAttempts ?? A2UIConstants.MaxA2UIAttempts; + List attempts = []; + IReadOnlyList lastErrors = []; + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + string prompt = A2UIGenerationRecovery.AugmentPromptWithValidationErrors(prep.Prompt, lastErrors); + JsonObject? renderArgs = null; + await foreach (ChatResponseUpdate update in this._subagentChatClient + .GetStreamingResponseAsync(BuildSubagentMessages(prompt, conversation), CreateSubagentOptions(), cancellationToken) + .ConfigureAwait(false)) + { + foreach (AIContent content in update.Contents) + { + if (content is FunctionCallContent render && + string.Equals(render.Name, A2UIConstants.RenderA2UIToolName, StringComparison.Ordinal) && + render.Arguments is { } renderArguments) + { + renderArgs = ToJsonObject(renderArguments); + } + } + + yield return new AgentResponseUpdate(update); + } + + A2UIAttemptRecord record; + if (renderArgs is null) + { + record = new A2UIAttemptRecord(attempt, Ok: false, [A2UIGenerationRecovery.NoToolCallError]); + attempts.Add(record); + this._parameters.OnAttempt?.Invoke(record); + lastErrors = record.Errors; + continue; + } + + // The model output is untrusted: narrow components/data to the expected shapes. + JsonArray? components = renderArgs["components"] as JsonArray; + JsonObject? data = renderArgs["data"] as JsonObject; + A2UIValidationResult validation = A2UIComponentValidator.Validate(components, data, this._parameters.Catalog); + record = new A2UIAttemptRecord(attempt, validation.Valid, validation.Errors); + attempts.Add(record); + this._parameters.OnAttempt?.Invoke(record); + + if (validation.Valid) + { + envelopeBox.Value = ParseEnvelope(A2UIToolkit.BuildA2UIEnvelope( + renderArgs, + prep.IsUpdate, + targetSurfaceId, + prep.Prior, + this._parameters.DefaultSurfaceId, + this._parameters.DefaultCatalogId)); + yield break; + } + + lastErrors = validation.Errors; + } + + envelopeBox.Value = ParseEnvelope(A2UIGenerationRecovery.WrapRecoveryExhaustedEnvelope(maxAttempts, attempts)); + } + + /// Reads a string argument from a function call's argument dictionary. + private static string? GetStringArgument(IDictionary? arguments, string name) => + arguments is not null && arguments.TryGetValue(name, out object? value) + ? value switch + { + string text => text, + JsonElement element when element.ValueKind == JsonValueKind.String => element.GetString(), + JsonValue jsonValue when jsonValue.TryGetValue(out string? text) => text, + _ => null, + } + : null; + /// /// Builds the per-run options: clones the incoming chat options and appends a /// freshly constructed generate_a2ui tool that captures this run's @@ -179,15 +361,8 @@ async Task GenerateA2UIAsync( IReadOnlyList messages, CancellationToken cancellationToken) { - List subagentMessages = [new ChatMessage(ChatRole.System, prompt), .. messages]; - var subagentOptions = new ChatOptions - { - Tools = [new RenderA2UIToolDeclaration()], - ToolMode = ChatToolMode.RequireSpecific(A2UIConstants.RenderA2UIToolName), - }; - ChatResponse response = await this._subagentChatClient - .GetResponseAsync(subagentMessages, subagentOptions, cancellationToken) + .GetResponseAsync(BuildSubagentMessages(prompt, messages), CreateSubagentOptions(), cancellationToken) .ConfigureAwait(false); FunctionCallContent? call = response.Messages @@ -198,6 +373,17 @@ async Task GenerateA2UIAsync( return call?.Arguments is { } arguments ? ToJsonObject(arguments) : null; } + /// Builds the render subagent's message list: the generation prompt plus the conversation. + private static List BuildSubagentMessages(string prompt, IReadOnlyList messages) => + [new ChatMessage(ChatRole.System, prompt), .. messages]; + + /// Builds the render subagent's chat options: a forced render_a2ui structured call. + private static ChatOptions CreateSubagentOptions() => new() + { + Tools = [new RenderA2UIToolDeclaration()], + ToolMode = ChatToolMode.RequireSpecific(A2UIConstants.RenderA2UIToolName), + }; + /// /// Reads the AG-UI state slice from the additional properties stamped by the AG-UI /// hosting layer: forwarded context entries plus the A2UI component catalog entry @@ -290,6 +476,59 @@ private static JsonObject ToJsonObject(IDictionary arguments) return result; } + /// + /// The schema-only declaration of the planner-facing generate_a2ui tool, used on + /// the streaming path so the planner's call surfaces on the update stream instead of + /// being invoked by the automatic function-invocation layer. + /// + private sealed class GenerateA2UIToolDeclaration : AIFunctionDeclaration + { + private static readonly JsonElement s_schema = ParseSchema(); + + private readonly string _name; + private readonly string _description; + + public GenerateA2UIToolDeclaration(string name, string description) + { + this._name = name; + this._description = description; + } + + private static JsonElement ParseSchema() + { + var schema = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["intent"] = new JsonObject + { + ["type"] = "string", + ["description"] = A2UIToolDefinitions.IntentArgumentDescription, + }, + ["target_surface_id"] = new JsonObject + { + ["type"] = "string", + ["description"] = A2UIToolDefinitions.TargetSurfaceIdArgumentDescription, + }, + ["changes"] = new JsonObject + { + ["type"] = "string", + ["description"] = A2UIToolDefinitions.ChangesArgumentDescription, + }, + }, + }; + using var document = JsonDocument.Parse(schema.ToJsonString()); + return document.RootElement.Clone(); + } + + public override string Name => this._name; + + public override string Description => this._description; + + public override JsonElement JsonSchema => s_schema; + } + /// /// The schema-only declaration of the inner render_a2ui structured-output tool. /// The subagent is forced to call it; the adapter reads the arguments instead of invoking it. diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs index 10175d67da..ddcad03e9a 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs @@ -54,7 +54,7 @@ public sealed record A2UIRecoveryResult(string Envelope, IReadOnlyList public static class A2UIGenerationRecovery { - private static readonly A2UIValidationError s_noToolCallError = new( + internal static readonly A2UIValidationError NoToolCallError = new( A2UIValidationErrorCodes.EmptyComponents, "components", "Sub-agent did not call render_a2ui"); @@ -128,7 +128,7 @@ public static async Task RunAsync( A2UIAttemptRecord record; if (args is null) { - record = new A2UIAttemptRecord(attempt, Ok: false, [s_noToolCallError]); + record = new A2UIAttemptRecord(attempt, Ok: false, [NoToolCallError]); attempts.Add(record); onAttempt?.Invoke(record); lastErrors = record.Errors; @@ -154,7 +154,7 @@ public static async Task RunAsync( return new A2UIRecoveryResult(WrapRecoveryExhaustedEnvelope(maxAttempts, attempts), attempts, Ok: false); } - private static string WrapRecoveryExhaustedEnvelope(int maxAttempts, IReadOnlyList attempts) + internal static string WrapRecoveryExhaustedEnvelope(int maxAttempts, IReadOnlyList attempts) { var attemptsArray = new JsonArray(); foreach (A2UIAttemptRecord attempt in attempts) diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs index 3d7fbe7037..37528757fe 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs @@ -58,21 +58,104 @@ public async Task RunAsync_InjectsGenerateA2UIToolIntoRunOptionsAsync() } [Fact] - public async Task RunStreamingAsync_InjectsGenerateA2UIToolIntoRunOptionsAsync() + public async Task RunStreamingAsync_InjectsGenerateA2UIDeclarationIntoRunOptionsAsync() { // Arrange var inner = new RecordingAgent(); var agent = new A2UIAgent(inner, new ScriptedChatClient(_ => s_validRenderArgs)); - // Act: the streaming entry point goes through the same per-run preparation. + // Act await foreach (AgentResponseUpdate _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "hi")])) { } - // Assert + // Assert: the streaming path advertises a schema-only declaration so the planner's + // call surfaces on the update stream instead of being auto-invoked. ChatClientAgentRunOptions options = Assert.IsType(inner.LastOptions); AITool tool = Assert.Single(options.ChatOptions?.Tools ?? []); Assert.Equal(A2UIConstants.GenerateA2UIToolName, tool.Name); + Assert.IsNotType(tool, exactMatch: false); + Assert.IsType(tool, exactMatch: false); + } + + [Fact] + public async Task RunStreamingAsync_GenerateCall_StreamsSubagentAndFeedsResultBackAsync() + { + // Arrange: round 1 the planner calls generate_a2ui; round 2 it narrates. The + // subagent streams its forced render_a2ui call across several updates. + var inner = new ScriptedPlannerAgent(generateArguments: new() { ["intent"] = "create" }); + var subagent = new ScriptedChatClient(_ => s_validRenderArgs) { StreamingChunks = 3 }; + var agent = new A2UIAgent(inner, subagent); + + // Act + List updates = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "show hotels")])) + { + updates.Add(update); + } + + // Assert: the subagent's streamed updates were forwarded on the agent stream. + Assert.Equal(3, updates.Count(u => u.Contents.Any(c => c is TextContent text && text.Text == "chunk"))); + FunctionCallContent renderCall = Assert.Single( + updates.SelectMany(u => u.Contents).OfType(), + c => c.Name == A2UIConstants.RenderA2UIToolName); + + // The generate call's result rides the stream as a valid operations envelope. + FunctionResultContent result = Assert.Single( + updates.SelectMany(u => u.Contents).OfType()); + Assert.Equal("call-g1", result.CallId); + JsonElement envelope = Assert.IsType(result.Result); + Assert.True(envelope.TryGetProperty(A2UIConstants.A2UIOperationsKey, out JsonElement ops)); + Assert.Equal(3, ops.GetArrayLength()); + + // The planner's second round received the tool-call/result pair and narrated. + Assert.Equal(2, inner.Runs.Count); + IReadOnlyList secondRoundMessages = inner.Runs[1]; + Assert.Contains(secondRoundMessages, m => + m.Role == ChatRole.Assistant && m.Contents.OfType().Any(c => c.CallId == "call-g1")); + Assert.Contains(secondRoundMessages, m => + m.Role == ChatRole.Tool && m.Contents.OfType().Any(c => c.CallId == "call-g1")); + Assert.Contains(updates, u => u.Text == "done"); + } + + [Fact] + public async Task RunStreamingAsync_InvalidFirstAttempt_RetriesWithVisibleSecondSubagentCallAsync() + { + // Arrange: attempt 1 returns a dangling child reference, attempt 2 is valid. + int calls = 0; + var invalidArgs = new JsonObject + { + ["surfaceId"] = "s1", + ["components"] = new JsonArray( + new JsonObject + { + ["id"] = "root", + ["component"] = "Row", + ["children"] = new JsonObject { ["componentId"] = "ghost", ["path"] = "/items" }, + }), + }; + var inner = new ScriptedPlannerAgent(generateArguments: new() { ["intent"] = "create" }); + var subagent = new ScriptedChatClient(_ => ++calls == 1 ? invalidArgs : s_validRenderArgs) { StreamingChunks = 1 }; + var agent = new A2UIAgent(inner, subagent); + + // Act + List updates = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "show hotels")])) + { + updates.Add(update); + } + + // Assert: both attempts streamed (two visible render calls), and the final envelope + // is the valid second attempt. + Assert.Equal(2, updates + .SelectMany(u => u.Contents) + .OfType() + .Count(c => c.Name == A2UIConstants.RenderA2UIToolName)); + FunctionResultContent result = Assert.Single( + updates.SelectMany(u => u.Contents).OfType()); + JsonElement envelope = Assert.IsType(result.Result); + Assert.True(envelope.TryGetProperty(A2UIConstants.A2UIOperationsKey, out _)); + Assert.False(envelope.TryGetProperty("code", out _)); } [Fact] @@ -407,8 +490,34 @@ public Task GetResponseAsync(IEnumerable messages, Ch return Task.FromResult(new ChatResponse(message)); } - public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); + /// Text chunks streamed before the function call on the streaming path. + public int StreamingChunks { get; init; } + + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + this.OnMessages?.Invoke(messages); + for (int i = 0; i < this.StreamingChunks; i++) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, "chunk"); + } + + IDictionary? arguments = this.RawArguments; + if (arguments is null) + { + JsonObject? args = this._script(options); + arguments = args?.ToDictionary(p => p.Key, object? (p) => p.Value?.DeepClone()); + } + + // Real streaming chat clients coalesce the call's fragments and attach the + // typed FunctionCallContent on a trailing update. + yield return arguments is null + ? new ChatResponseUpdate(ChatRole.Assistant, "no tool call") + : new ChatResponseUpdate(ChatRole.Assistant, + [ + new FunctionCallContent($"render-call-{Guid.NewGuid():N}", A2UIConstants.RenderA2UIToolName, arguments), + ]); + await Task.CompletedTask.ConfigureAwait(false); + } public object? GetService(Type serviceType, object? serviceKey = null) => null; @@ -416,4 +525,48 @@ public void Dispose() { } } + + /// + /// A planner agent scripted for the streaming invocation loop: the first run emits a + /// generate_a2ui tool call, subsequent runs emit a closing narration. + /// + private sealed class ScriptedPlannerAgent : AIAgent + { + private readonly Dictionary _generateArguments; + + public ScriptedPlannerAgent(Dictionary generateArguments) + => this._generateArguments = generateArguments; + + public List> Runs { get; } = []; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + this.Runs.Add(messages.ToList()); + if (this.Runs.Count == 1) + { + yield return new AgentResponseUpdate(ChatRole.Assistant, + [ + new FunctionCallContent("call-g1", A2UIConstants.GenerateA2UIToolName, this._generateArguments), + ]); + } + else + { + yield return new AgentResponseUpdate(ChatRole.Assistant, "done"); + } + + await Task.CompletedTask.ConfigureAwait(false); + } + } } From 3066b46db9a66f01d7ff4fa989c25b0472ef1287 Mon Sep 17 00:00:00 2001 From: ran Date: Thu, 11 Jun 2026 17:06:37 +0200 Subject: [PATCH 16/24] .NET: Balance the forwarded render_a2ui call with a tool result 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 --- .../A2UIAgent.cs | 27 +++++++++++++-- .../A2UIAgentTests.cs | 34 ++++++++++++++----- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs index 155a5ebc73..9ac4adbb00 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs @@ -36,6 +36,10 @@ namespace Microsoft.Agents.AI.AGUI.A2UI; /// public sealed class A2UIAgent : DelegatingAIAgent { + // Bare acknowledgement returned as the inner render_a2ui tool result; the painted + // surface rides the streamed arguments, so the result only has to balance the call. + private const string RenderAcknowledgement = "{\"status\":\"rendered\"}"; + private readonly IChatClient _subagentChatClient; private readonly A2UIResolvedToolParams _parameters; @@ -194,6 +198,7 @@ private async IAsyncEnumerable RunGenerateStreamingAsync( string prompt = A2UIGenerationRecovery.AugmentPromptWithValidationErrors(prep.Prompt, lastErrors); JsonObject? renderArgs = null; + string? renderCallId = null; await foreach (ChatResponseUpdate update in this._subagentChatClient .GetStreamingResponseAsync(BuildSubagentMessages(prompt, conversation), CreateSubagentOptions(), cancellationToken) .ConfigureAwait(false)) @@ -201,16 +206,32 @@ private async IAsyncEnumerable RunGenerateStreamingAsync( foreach (AIContent content in update.Contents) { if (content is FunctionCallContent render && - string.Equals(render.Name, A2UIConstants.RenderA2UIToolName, StringComparison.Ordinal) && - render.Arguments is { } renderArguments) + string.Equals(render.Name, A2UIConstants.RenderA2UIToolName, StringComparison.Ordinal)) { - renderArgs = ToJsonObject(renderArguments); + renderCallId ??= render.CallId; + if (render.Arguments is { } renderArguments) + { + renderArgs = ToJsonObject(renderArguments); + } } } yield return new AgentResponseUpdate(update); } + // The subagent's render_a2ui call is forwarded onto the wire so the hosting + // layer can paint its argument fragments progressively — but that means it + // becomes part of the persisted conversation. Emit a matching tool result so + // the assistant tool call is balanced; an unanswered tool call would make the + // next turn's history invalid (e.g. OpenAI rejects it). The painted surface + // comes from the streamed arguments, so this result is a bare acknowledgement. + if (renderCallId is not null) + { + yield return new AgentResponseUpdate( + ChatRole.Tool, + [new FunctionResultContent(renderCallId, ParseEnvelope(RenderAcknowledgement))]); + } + A2UIAttemptRecord record; if (renderArgs is null) { diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs index 37528757fe..554223db27 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs @@ -100,10 +100,17 @@ public async Task RunStreamingAsync_GenerateCall_StreamsSubagentAndFeedsResultBa updates.SelectMany(u => u.Contents).OfType(), c => c.Name == A2UIConstants.RenderA2UIToolName); + // The forwarded render_a2ui call is balanced with a tool result so the persisted + // conversation stays valid for the next turn. + FunctionResultContent renderResult = Assert.Single( + updates.SelectMany(u => u.Contents).OfType(), + r => r.CallId == renderCall.CallId); + Assert.Equal("rendered", Assert.IsType(renderResult.Result).GetProperty("status").GetString()); + // The generate call's result rides the stream as a valid operations envelope. FunctionResultContent result = Assert.Single( - updates.SelectMany(u => u.Contents).OfType()); - Assert.Equal("call-g1", result.CallId); + updates.SelectMany(u => u.Contents).OfType(), + r => r.CallId == "call-g1"); JsonElement envelope = Assert.IsType(result.Result); Assert.True(envelope.TryGetProperty(A2UIConstants.A2UIOperationsKey, out JsonElement ops)); Assert.Equal(3, ops.GetArrayLength()); @@ -145,14 +152,25 @@ public async Task RunStreamingAsync_InvalidFirstAttempt_RetriesWithVisibleSecond updates.Add(update); } - // Assert: both attempts streamed (two visible render calls), and the final envelope - // is the valid second attempt. - Assert.Equal(2, updates + // Assert: both attempts streamed (two visible render calls), and every forwarded + // render call is balanced with its own tool result so the next turn's history is valid. + List renderCalls = updates .SelectMany(u => u.Contents) .OfType() - .Count(c => c.Name == A2UIConstants.RenderA2UIToolName)); - FunctionResultContent result = Assert.Single( - updates.SelectMany(u => u.Contents).OfType()); + .Where(c => c.Name == A2UIConstants.RenderA2UIToolName) + .ToList(); + Assert.Equal(2, renderCalls.Count); + List resultContents = updates + .SelectMany(u => u.Contents) + .OfType() + .ToList(); + foreach (FunctionCallContent renderCall in renderCalls) + { + Assert.Contains(resultContents, r => r.CallId == renderCall.CallId); + } + + // The generate call's final envelope is the valid second attempt. + FunctionResultContent result = Assert.Single(resultContents, r => r.CallId == "call-g1"); JsonElement envelope = Assert.IsType(result.Result); Assert.True(envelope.TryGetProperty(A2UIConstants.A2UIOperationsKey, out _)); Assert.False(envelope.TryGetProperty("code", out _)); From bdc57bbff33660e8e2f26ac40401783b7015f984 Mon Sep 17 00:00:00 2001 From: ran Date: Thu, 11 Jun 2026 17:36:20 +0200 Subject: [PATCH 17/24] =?UTF-8?q?.NET:=20Harden=20the=20streaming=20subage?= =?UTF-8?q?nt=20loop=20=E2=80=94=20coalesce=20render=20args,=20close=20the?= =?UTF-8?q?=20planner-round=20cap,=20share=20attempt=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../A2UIAgent.cs | 74 ++++++------ .../A2UIGenerationRecovery.cs | 46 ++++--- .../A2UIAgentTests.cs | 114 +++++++++++++++++- 3 files changed, 178 insertions(+), 56 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs index 9ac4adbb00..591b64f874 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs @@ -75,7 +75,7 @@ protected override Task RunCoreAsync( /// The cap on planner rounds (model turn → generation → result fed back) per run, /// guarding against a planner that keeps requesting surfaces without terminating. /// - private const int MaxPlannerRounds = 8; + internal const int MaxPlannerRounds = 8; /// /// @@ -159,6 +159,20 @@ protected override async IAsyncEnumerable RunCoreStreamingA pending = history; pendingSession = null; } + + // The planner kept requesting generations through the round cap. Give it one final + // turn to consume the last tool result and narrate, with the generate tool withheld + // so it cannot request another surface — otherwise the run would end on an + // unanswered tool result with no closing assistant message. + chatOptions.Tools = (chatOptions.Tools ?? Enumerable.Empty()) + .Where(t => !string.Equals(t.Name, generateTool.Name, StringComparison.Ordinal)) + .ToList(); + await foreach (AgentResponseUpdate update in this.InnerAgent + .RunStreamingAsync(history, pendingSession, runOptions, cancellationToken) + .ConfigureAwait(false)) + { + yield return update; + } } /// @@ -197,63 +211,51 @@ private async IAsyncEnumerable RunGenerateStreamingAsync( cancellationToken.ThrowIfCancellationRequested(); string prompt = A2UIGenerationRecovery.AugmentPromptWithValidationErrors(prep.Prompt, lastErrors); - JsonObject? renderArgs = null; - string? renderCallId = null; + + // Forward every update so the hosting layer can paint the render_a2ui argument + // fragments progressively, while accumulating them to coalesce the complete + // tool call afterward. Reading arguments off a single update is unsafe: a chat + // client may stream a tool call's arguments as fragments across updates, and + // only the coalesced response carries the full arguments (this mirrors the + // non-streaming path, which reads the already-coalesced ChatResponse). + List attemptUpdates = []; await foreach (ChatResponseUpdate update in this._subagentChatClient .GetStreamingResponseAsync(BuildSubagentMessages(prompt, conversation), CreateSubagentOptions(), cancellationToken) .ConfigureAwait(false)) { - foreach (AIContent content in update.Contents) - { - if (content is FunctionCallContent render && - string.Equals(render.Name, A2UIConstants.RenderA2UIToolName, StringComparison.Ordinal)) - { - renderCallId ??= render.CallId; - if (render.Arguments is { } renderArguments) - { - renderArgs = ToJsonObject(renderArguments); - } - } - } - + attemptUpdates.Add(update); yield return new AgentResponseUpdate(update); } + FunctionCallContent? renderCall = attemptUpdates.ToChatResponse().Messages + .SelectMany(m => m.Contents) + .OfType() + .FirstOrDefault(c => string.Equals(c.Name, A2UIConstants.RenderA2UIToolName, StringComparison.Ordinal)); + JsonObject? renderArgs = renderCall?.Arguments is { } renderArguments ? ToJsonObject(renderArguments) : null; + // The subagent's render_a2ui call is forwarded onto the wire so the hosting // layer can paint its argument fragments progressively — but that means it // becomes part of the persisted conversation. Emit a matching tool result so // the assistant tool call is balanced; an unanswered tool call would make the // next turn's history invalid (e.g. OpenAI rejects it). The painted surface // comes from the streamed arguments, so this result is a bare acknowledgement. - if (renderCallId is not null) + if (renderCall is not null) { yield return new AgentResponseUpdate( ChatRole.Tool, - [new FunctionResultContent(renderCallId, ParseEnvelope(RenderAcknowledgement))]); - } - - A2UIAttemptRecord record; - if (renderArgs is null) - { - record = new A2UIAttemptRecord(attempt, Ok: false, [A2UIGenerationRecovery.NoToolCallError]); - attempts.Add(record); - this._parameters.OnAttempt?.Invoke(record); - lastErrors = record.Errors; - continue; + [new FunctionResultContent(renderCall.CallId, ParseEnvelope(RenderAcknowledgement))]); } - // The model output is untrusted: narrow components/data to the expected shapes. - JsonArray? components = renderArgs["components"] as JsonArray; - JsonObject? data = renderArgs["data"] as JsonObject; - A2UIValidationResult validation = A2UIComponentValidator.Validate(components, data, this._parameters.Catalog); - record = new A2UIAttemptRecord(attempt, validation.Valid, validation.Errors); + // Validation and attempt accounting are shared with the non-streaming recovery + // loop so the two paths cannot drift on attempt semantics. + A2UIAttemptRecord record = A2UIGenerationRecovery.ValidateAttempt(attempt, renderArgs, this._parameters.Catalog); attempts.Add(record); this._parameters.OnAttempt?.Invoke(record); - if (validation.Valid) + if (record.Ok) { envelopeBox.Value = ParseEnvelope(A2UIToolkit.BuildA2UIEnvelope( - renderArgs, + renderArgs!, prep.IsUpdate, targetSurfaceId, prep.Prior, @@ -262,7 +264,7 @@ private async IAsyncEnumerable RunGenerateStreamingAsync( yield break; } - lastErrors = validation.Errors; + lastErrors = record.Errors; } envelopeBox.Value = ParseEnvelope(A2UIGenerationRecovery.WrapRecoveryExhaustedEnvelope(maxAttempts, attempts)); diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs index ddcad03e9a..a66252fbd5 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs @@ -125,35 +125,45 @@ public static async Task RunAsync( string prompt = AugmentPromptWithValidationErrors(basePrompt, lastErrors); JsonObject? args = await invokeSubagentAsync(prompt, attempt, cancellationToken).ConfigureAwait(false); - A2UIAttemptRecord record; - if (args is null) - { - record = new A2UIAttemptRecord(attempt, Ok: false, [NoToolCallError]); - attempts.Add(record); - onAttempt?.Invoke(record); - lastErrors = record.Errors; - continue; - } - - // The model output is untrusted: narrow components/data to the expected shapes. - JsonArray? components = args["components"] as JsonArray; - JsonObject? data = args["data"] as JsonObject; - A2UIValidationResult result = A2UIComponentValidator.Validate(components, data, catalog); - record = new A2UIAttemptRecord(attempt, result.Valid, result.Errors); + A2UIAttemptRecord record = ValidateAttempt(attempt, args, catalog); attempts.Add(record); onAttempt?.Invoke(record); - if (result.Valid) + if (record.Ok) { - return new A2UIRecoveryResult(buildEnvelope(args), attempts, Ok: true); + return new A2UIRecoveryResult(buildEnvelope(args!), attempts, Ok: true); } - lastErrors = result.Errors; + lastErrors = record.Errors; } return new A2UIRecoveryResult(WrapRecoveryExhaustedEnvelope(maxAttempts, attempts), attempts, Ok: false); } + /// + /// Validates one attempt's structured render_a2ui arguments, narrowing the + /// untrusted model output to the expected component/data shapes. A + /// (the subagent did not call the tool) is a failed, retryable + /// attempt. Shared by the non-streaming loop and the streaming twin in A2UIAgent + /// so the two cannot drift on attempt semantics. + /// + /// The 1-based attempt number. + /// The structured tool arguments, or when absent. + /// The catalog used for semantic validation, when available. + /// The attempt record. + internal static A2UIAttemptRecord ValidateAttempt(int attempt, JsonObject? args, A2UIValidationCatalog? catalog) + { + if (args is null) + { + return new A2UIAttemptRecord(attempt, Ok: false, [NoToolCallError]); + } + + JsonArray? components = args["components"] as JsonArray; + JsonObject? data = args["data"] as JsonObject; + A2UIValidationResult result = A2UIComponentValidator.Validate(components, data, catalog); + return new A2UIAttemptRecord(attempt, result.Valid, result.Errors); + } + internal static string WrapRecoveryExhaustedEnvelope(int maxAttempts, IReadOnlyList attempts) { var attemptsArray = new JsonArray(); diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs index 554223db27..d746674067 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs @@ -176,6 +176,105 @@ public async Task RunStreamingAsync_InvalidFirstAttempt_RetriesWithVisibleSecond Assert.False(envelope.TryGetProperty("code", out _)); } + [Fact] + public async Task RunStreamingAsync_SubagentNeverCallsTool_ReturnsRecoveryExhaustedEnvelopeAsync() + { + // Arrange: the subagent never calls render_a2ui, so every attempt fails. + int attempts = 0; + var inner = new ScriptedPlannerAgent(generateArguments: new() { ["intent"] = "create" }); + var subagent = new ScriptedChatClient(_ => null); + var agent = new A2UIAgent( + inner, + subagent, + new A2UIToolParams { OnAttempt = _ => attempts++ }); + + // Act + List updates = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "show hotels")])) + { + updates.Add(update); + } + + // Assert: OnAttempt fired once per attempt, and the generate result is the + // structured hard-failure envelope — matching the non-streaming path. + Assert.Equal(A2UIConstants.MaxA2UIAttempts, attempts); + FunctionResultContent result = Assert.Single( + updates.SelectMany(u => u.Contents).OfType(), + r => r.CallId == "call-g1"); + JsonElement envelope = Assert.IsType(result.Result); + Assert.Equal("a2ui_recovery_exhausted", envelope.GetProperty("code").GetString()); + } + + [Fact] + public async Task RunStreamingAsync_UpdateWithPriorRenderInHistory_ReturnsInPlaceUpdateAsync() + { + // Arrange: a prior render envelope rides in a tool-result message, the way the + // persisted conversation carries it back on a later turn. The streaming update + // intent must find it through ToHistoryMessage + FindPriorSurface. + string priorEnvelope = A2UIToolkit.WrapAsOperationsEnvelope( + [ + A2UIOperationBuilder.CreateSurface("s1", "https://example.test/catalog.json"), + A2UIOperationBuilder.UpdateComponents("s1", s_validRenderArgs["components"]!.AsArray()), + ]); + using JsonDocument priorDocument = JsonDocument.Parse(priorEnvelope); + var inner = new ScriptedPlannerAgent(generateArguments: new() + { + ["intent"] = "update", + ["target_surface_id"] = "s1", + }); + var subagent = new ScriptedChatClient(_ => s_validRenderArgs) { StreamingChunks = 1 }; + var agent = new A2UIAgent(inner, subagent); + + // Act + List updates = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( + [ + new ChatMessage(ChatRole.User, "show hotels"), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call-0", priorDocument.RootElement.Clone())]), + new ChatMessage(ChatRole.User, "make the cards bigger"), + ])) + { + updates.Add(update); + } + + // Assert: an in-place update — no createSurface for the existing surface. + FunctionResultContent result = Assert.Single( + updates.SelectMany(u => u.Contents).OfType(), + r => r.CallId == "call-g1"); + JsonElement envelope = Assert.IsType(result.Result); + JsonElement ops = envelope.GetProperty(A2UIConstants.A2UIOperationsKey); + Assert.Equal(2, ops.GetArrayLength()); + Assert.DoesNotContain( + ops.EnumerateArray(), + op => op.TryGetProperty("createSurface", out _)); + } + + [Fact] + public async Task RunStreamingAsync_PlannerExhaustsRounds_ClosesWithGenerateToolWithheldAsync() + { + // Arrange: a planner that requests a generation every round, so the round cap is hit. + var inner = new ScriptedPlannerAgent(generateArguments: new() { ["intent"] = "create" }) + { + AlwaysGenerate = true, + }; + var subagent = new ScriptedChatClient(_ => s_validRenderArgs) { StreamingChunks = 1 }; + var agent = new A2UIAgent(inner, subagent); + + // Act + List updates = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "show hotels")])) + { + updates.Add(update); + } + + // Assert: one final planner turn beyond the cap, and that final turn had the + // generate tool withheld so the planner could only narrate (no dangling tool result). + Assert.Equal(A2UIAgent.MaxPlannerRounds + 1, inner.Runs.Count); + IReadOnlyList finalTurnTools = inner.ToolsPerRun[^1]; + Assert.DoesNotContain(A2UIConstants.GenerateA2UIToolName, finalTurnTools); + Assert.Contains(updates, u => u.Text == "done"); + } + [Fact] public async Task RunAsync_CustomToolName_IsHonoredAsync() { @@ -555,8 +654,14 @@ private sealed class ScriptedPlannerAgent : AIAgent public ScriptedPlannerAgent(Dictionary generateArguments) => this._generateArguments = generateArguments; + /// When set, emit a generate_a2ui call on every run instead of narrating after the first. + public bool AlwaysGenerate { get; init; } + public List> Runs { get; } = []; + /// The tool names advertised on each run, in order. + public List> ToolsPerRun { get; } = []; + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -572,11 +677,16 @@ protected override Task RunCoreAsync(IEnumerable mes protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { this.Runs.Add(messages.ToList()); - if (this.Runs.Count == 1) + this.ToolsPerRun.Add((options as ChatClientAgentRunOptions)?.ChatOptions?.Tools?.Select(t => t.Name).ToList() ?? []); + + // A generate_a2ui call is only possible when the tool is still advertised; once + // the agent withholds it (the round-cap final turn), fall back to narration. + bool generateAdvertised = this.ToolsPerRun[^1].Contains(A2UIConstants.GenerateA2UIToolName); + if (generateAdvertised && (this.AlwaysGenerate || this.Runs.Count == 1)) { yield return new AgentResponseUpdate(ChatRole.Assistant, [ - new FunctionCallContent("call-g1", A2UIConstants.GenerateA2UIToolName, this._generateArguments), + new FunctionCallContent($"call-g{this.Runs.Count}", A2UIConstants.GenerateA2UIToolName, this._generateArguments), ]); } else From f2735ef7c45eacc712380dd9f03417010d38044c Mon Sep 17 00:00:00 2001 From: ran Date: Thu, 11 Jun 2026 17:50:20 +0200 Subject: [PATCH 18/24] =?UTF-8?q?.NET:=20Polish=20the=20streaming=20subage?= =?UTF-8?q?nt=20loop=20=E2=80=94=20preserve=20planner=20narration,=20isola?= =?UTF-8?q?te=20the=20closing-turn=20options,=20guard=20a=20non-positive?= =?UTF-8?q?=20attempt=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../A2UIAgent.cs | 23 ++++++++--- .../A2UIGenerationRecovery.cs | 13 +++++- .../A2UIAgentTests.cs | 40 +++++++++++++++++-- 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs index 591b64f874..955f154f97 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs @@ -115,15 +115,23 @@ protected override async IAsyncEnumerable RunCoreStreamingA for (int round = 1; round <= MaxPlannerRounds; round++) { List generateCalls = []; + List assistantContents = []; await foreach (AgentResponseUpdate update in this.InnerAgent .RunStreamingAsync(pending, pendingSession, runOptions, cancellationToken) .ConfigureAwait(false)) { foreach (AIContent content in update.Contents) { - if (content is FunctionCallContent call && + // Preserve the planner's own narration alongside its generate_a2ui + // calls so the message fed back to it next round is not lossy. + if (content is TextContent text) + { + assistantContents.Add(text); + } + else if (content is FunctionCallContent call && string.Equals(call.Name, generateTool.Name, StringComparison.Ordinal)) { + assistantContents.Add(call); generateCalls.Add(call); } } @@ -154,7 +162,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA var toolMessage = new ChatMessage(ChatRole.Tool, results); yield return new AgentResponseUpdate(ChatRole.Tool, results); - history.Add(new ChatMessage(ChatRole.Assistant, [.. generateCalls])); + history.Add(new ChatMessage(ChatRole.Assistant, assistantContents)); history.Add(toolMessage); pending = history; pendingSession = null; @@ -163,12 +171,15 @@ protected override async IAsyncEnumerable RunCoreStreamingA // The planner kept requesting generations through the round cap. Give it one final // turn to consume the last tool result and narrate, with the generate tool withheld // so it cannot request another surface — otherwise the run would end on an - // unanswered tool result with no closing assistant message. - chatOptions.Tools = (chatOptions.Tools ?? Enumerable.Empty()) + // unanswered tool result with no closing assistant message. A fresh options instance + // keeps the loop's own options untouched. + var closingChatOptions = chatOptions.Clone(); + closingChatOptions.Tools = (chatOptions.Tools ?? Enumerable.Empty()) .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, runOptions, cancellationToken) + .RunStreamingAsync(history, pendingSession, closingOptions, cancellationToken) .ConfigureAwait(false)) { yield return update; @@ -203,7 +214,7 @@ private async IAsyncEnumerable RunGenerateStreamingAsync( // The streaming twin of A2UIGenerationRecovery.RunAsync: same attempt semantics, // but each subagent call streams so its updates can be forwarded between attempts. - int maxAttempts = this._parameters.Recovery?.MaxAttempts ?? A2UIConstants.MaxA2UIAttempts; + int maxAttempts = A2UIGenerationRecovery.ResolveMaxAttempts(this._parameters.Recovery); List attempts = []; IReadOnlyList lastErrors = []; for (int attempt = 1; attempt <= maxAttempts; attempt++) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs index a66252fbd5..a388938c56 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs @@ -114,7 +114,7 @@ public static async Task RunAsync( Throw.IfNull(invokeSubagentAsync); Throw.IfNull(buildEnvelope); - int maxAttempts = config?.MaxAttempts ?? A2UIConstants.MaxA2UIAttempts; + int maxAttempts = ResolveMaxAttempts(config); List attempts = []; IReadOnlyList lastErrors = []; @@ -140,6 +140,17 @@ public static async Task RunAsync( return new A2UIRecoveryResult(WrapRecoveryExhaustedEnvelope(maxAttempts, attempts), attempts, Ok: false); } + /// + /// Resolves the attempt cap, falling back to + /// when the configured value is unset or non-positive. A zero/negative cap would skip + /// the loop entirely and emit a confusing "0 attempt(s)" envelope, so it is treated as + /// unset rather than honored. Shared by both generation paths. + /// + /// The recovery configuration, if any. + /// The effective maximum number of attempts (at least 1). + internal static int ResolveMaxAttempts(A2UIRecoveryConfig? config) + => config?.MaxAttempts is int max && max > 0 ? max : A2UIConstants.MaxA2UIAttempts; + /// /// Validates one attempt's structured render_a2ui arguments, narrowing the /// untrusted model output to the expected component/data shapes. A diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs index d746674067..6a3f639ec4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs @@ -176,6 +176,31 @@ public async Task RunStreamingAsync_InvalidFirstAttempt_RetriesWithVisibleSecond Assert.False(envelope.TryGetProperty("code", out _)); } + [Fact] + public async Task RunStreamingAsync_PreservesPlannerNarrationInNextRoundHistoryAsync() + { + // Arrange: the planner narrates alongside its generate_a2ui call on round 1. + var inner = new ScriptedPlannerAgent(generateArguments: new() { ["intent"] = "create" }) + { + RoundOneText = "Let me put that together.", + }; + var subagent = new ScriptedChatClient(_ => s_validRenderArgs) { StreamingChunks = 1 }; + var agent = new A2UIAgent(inner, subagent); + + // Act + await foreach (AgentResponseUpdate _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "show hotels")])) + { + } + + // Assert: round 2's history carries the planner's round-1 narration, not just the + // generate_a2ui call — the reconstructed assistant message is not lossy. + Assert.Equal(2, inner.Runs.Count); + Assert.Contains(inner.Runs[1], m => + m.Role == ChatRole.Assistant && + m.Contents.OfType().Any(t => t.Text == "Let me put that together.") && + m.Contents.OfType().Any(c => c.CallId == "call-g1")); + } + [Fact] public async Task RunStreamingAsync_SubagentNeverCallsTool_ReturnsRecoveryExhaustedEnvelopeAsync() { @@ -657,6 +682,9 @@ public ScriptedPlannerAgent(Dictionary generateArguments) /// When set, emit a generate_a2ui call on every run instead of narrating after the first. public bool AlwaysGenerate { get; init; } + /// When set, the first run emits this narration text alongside its generate_a2ui call. + public string? RoundOneText { get; init; } + public List> Runs { get; } = []; /// The tool names advertised on each run, in order. @@ -684,10 +712,14 @@ protected override async IAsyncEnumerable RunCoreStreamingA bool generateAdvertised = this.ToolsPerRun[^1].Contains(A2UIConstants.GenerateA2UIToolName); if (generateAdvertised && (this.AlwaysGenerate || this.Runs.Count == 1)) { - yield return new AgentResponseUpdate(ChatRole.Assistant, - [ - new FunctionCallContent($"call-g{this.Runs.Count}", A2UIConstants.GenerateA2UIToolName, this._generateArguments), - ]); + List contents = []; + if (this.RoundOneText is not null && this.Runs.Count == 1) + { + contents.Add(new TextContent(this.RoundOneText)); + } + + contents.Add(new FunctionCallContent($"call-g{this.Runs.Count}", A2UIConstants.GenerateA2UIToolName, this._generateArguments)); + yield return new AgentResponseUpdate(ChatRole.Assistant, contents); } else { From a0642b5c255dec4d66a4847b491bd6ac92a47586 Mon Sep 17 00:00:00 2001 From: ran Date: Thu, 11 Jun 2026 18:33:56 +0200 Subject: [PATCH 19/24] .NET: Document the AG-UI Dojo Server sample 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 --- .../AGUIClientServer/AGUIDojoServer/README.md | 75 +++++++++++++++++++ .../05-end-to-end/AGUIClientServer/README.md | 2 + 2 files changed, 77 insertions(+) create mode 100644 dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/README.md diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/README.md b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/README.md new file mode 100644 index 0000000000..f1bbfcdcf1 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/README.md @@ -0,0 +1,75 @@ +# AG-UI Dojo Server + +An ASP.NET Core server that hosts a suite of AG-UI agents, one per protocol feature, behind +the [AG-UI Dojo](https://github.com/ag-ui-protocol/ag-ui) demo viewer. Each feature is exposed +as its own AG-UI endpoint via `MapAGUI`, so the Dojo front end can exercise every capability +against a real .NET agent. + +> **Warning** +> The AG-UI protocol is still under development and changing. We will try to keep these +> samples updated as the protocol evolves. + +## Endpoints + +Each endpoint is a self-contained agent mapped with `MapAGUI("/", agent)`: + +| Endpoint | Feature | +| --- | --- | +| `/agentic_chat` | Plain streaming chat | +| `/backend_tool_rendering` | Tool results rendered by the client | +| `/human_in_the_loop` | Client-approved tool calls | +| `/tool_based_generative_ui` | Tool-driven generative UI | +| `/agentic_generative_ui` | Plan/state-driven generative UI | +| `/shared_state` | Shared state snapshots and deltas | +| `/predictive_state_updates` | Streamed document edits | +| `/a2ui_fixed_schema` | **A2UI** — author-owned card layouts, agent supplies only the data | +| `/a2ui_dynamic_schema` | **A2UI** — a subagent designs the surface via the `generate_a2ui` tool | +| `/a2ui_recovery` | **A2UI** — the validate-and-retry recovery loop for invalid surfaces | +| `/a2ui_chat` | **A2UI** — zero-configuration: the client middleware injects the render tool | + +The four `a2ui_*` endpoints demonstrate the +[`Microsoft.Agents.AI.AGUI.A2UI`](../../../../src/Microsoft.Agents.AI.AGUI.A2UI) toolkit. See +[A2UI/A2UICompositionGuides.cs](./A2UI/A2UICompositionGuides.cs) and +[ChatClientAgentFactory.cs](./ChatClientAgentFactory.cs) for how each agent is built. + +## Configuring Environment Variables + +The server runs in one of two modes, chosen by which variables are set. + +**Azure OpenAI** (uses `DefaultAzureCredential` — authenticate via `az login`, Visual Studio, +or environment variables): + +```powershell +$env:AZURE_OPENAI_ENDPOINT="<>" +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o" +``` + +**OpenAI / OpenAI-compatible** (API key, with an optional base-URL override for a local or +proxy endpoint — useful for deterministic end-to-end tests against a mock server): + +```powershell +$env:OPENAI_API_KEY="<>" +$env:OPENAI_CHAT_MODEL_ID="gpt-4o" # optional, defaults to gpt-4o +$env:OPENAI_BASE_URL="http://localhost:8000/v1" # optional +``` + +If `AZURE_OPENAI_ENDPOINT` is set the server uses Azure mode; otherwise it falls back to +`OPENAI_API_KEY`. + +## Running the Sample + +```bash +cd AGUIDojoServer +dotnet run --urls "http://localhost:8016" +``` + +Point the AG-UI Dojo's `microsoft-agent-framework-dotnet` integration at the server's URL +(`http://localhost:8016` by default), then open any feature page to interact with the +matching endpoint. The endpoints are plain AG-UI servers, so they can also be driven directly +over HTTP POST with a `RunAgentInput` body (see the +[AG-UI Client and Server sample](../README.md) for the request shape). + +> **Note** +> When deploying multi-user, register a session-isolation key provider (see the commented +> `UseClaimsBasedSessionIsolation` call in [Program.cs](./Program.cs)); otherwise sessions are +> shared across callers. diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/README.md b/dotnet/samples/05-end-to-end/AGUIClientServer/README.md index 788ae93d7d..27ae5a58fb 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/README.md +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/README.md @@ -9,6 +9,8 @@ The demonstration has two components: 1. **AGUIServer** - An ASP.NET Core web server that hosts an AI agent and exposes it via the AG-UI protocol 2. **AGUIClient** - A console application that connects to the AG-UI server and displays streaming updates +A third project, **[AGUIDojoServer](./AGUIDojoServer/README.md)**, hosts one AG-UI endpoint per protocol feature (agentic chat, generative UI, shared state, A2UI, and more) for use with the AG-UI Dojo demo viewer. See its [README](./AGUIDojoServer/README.md) for the endpoint list and configuration. + > **Warning** > The AG-UI protocol is still under development and changing. > We will try to keep these samples updated as the protocol evolves. From deacd89b240a7df5ca4b2d247b1e6d40d145846f Mon Sep 17 00:00:00 2001 From: ran Date: Fri, 12 Jun 2026 10:27:17 +0200 Subject: [PATCH 20/24] .NET: Preserve caller run options in the A2UIAgent closing turn 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 --- .../A2UIAgent.cs | 15 +++++++++------ .../A2UIAgentTests.cs | 19 ++++++++++++++++++- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs index 955f154f97..a2a0957943 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs @@ -170,14 +170,17 @@ protected override async IAsyncEnumerable RunCoreStreamingA // The planner kept requesting generations through the round cap. Give it one final // turn to consume the last tool result and narrate, with the generate tool withheld - // so it cannot request another surface — otherwise the run would end on an - // unanswered tool result with no closing assistant message. A fresh options instance - // keeps the loop's own options untouched. - var closingChatOptions = chatOptions.Clone(); - closingChatOptions.Tools = (chatOptions.Tools ?? Enumerable.Empty()) + // so it cannot request another surface, otherwise the run would end on an unanswered + // tool result with no closing assistant message. Clone the run options so the base + // AgentRunOptions members (continuation token, background-response opt-in, additional + // properties, response format) and the chat client factory all survive, then strip + // only the generate tool from the cloned chat options. + ChatClientAgentRunOptions closingOptions = CloneRunOptions(runOptions); + ChatOptions closingChatOptions = closingOptions.ChatOptions ?? new ChatOptions(); + closingOptions.ChatOptions = closingChatOptions; + closingChatOptions.Tools = (closingChatOptions.Tools ?? Enumerable.Empty()) .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)) diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs index 6a3f639ec4..46d703080b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs @@ -284,10 +284,16 @@ public async Task RunStreamingAsync_PlannerExhaustsRounds_ClosesWithGenerateTool }; var subagent = new ScriptedChatClient(_ => s_validRenderArgs) { StreamingChunks = 1 }; var agent = new A2UIAgent(inner, subagent); + var callerOptions = new ChatClientAgentRunOptions + { + AllowBackgroundResponses = true, + ResponseFormat = ChatResponseFormat.Json, + AdditionalProperties = new AdditionalPropertiesDictionary { ["run-key"] = "run-value" }, + }; // Act List updates = []; - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "show hotels")])) + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "show hotels")], options: callerOptions)) { updates.Add(update); } @@ -298,6 +304,13 @@ public async Task RunStreamingAsync_PlannerExhaustsRounds_ClosesWithGenerateTool IReadOnlyList finalTurnTools = inner.ToolsPerRun[^1]; Assert.DoesNotContain(A2UIConstants.GenerateA2UIToolName, finalTurnTools); Assert.Contains(updates, u => u.Text == "done"); + + // The closing turn preserves the caller's base run-option members rather than + // rebuilding a partial options instance. + ChatClientAgentRunOptions finalTurnOptions = Assert.IsType(inner.OptionsPerRun[^1]); + Assert.True(finalTurnOptions.AllowBackgroundResponses); + Assert.Same(ChatResponseFormat.Json, finalTurnOptions.ResponseFormat); + Assert.Equal("run-value", finalTurnOptions.AdditionalProperties?["run-key"]); } [Fact] @@ -690,6 +703,9 @@ public ScriptedPlannerAgent(Dictionary generateArguments) /// The tool names advertised on each run, in order. public List> ToolsPerRun { get; } = []; + /// The run options received on each run, in order. + public List OptionsPerRun { get; } = []; + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -706,6 +722,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA { this.Runs.Add(messages.ToList()); this.ToolsPerRun.Add((options as ChatClientAgentRunOptions)?.ChatOptions?.Tools?.Select(t => t.Name).ToList() ?? []); + this.OptionsPerRun.Add(options); // A generate_a2ui call is only possible when the tool is still advertised; once // the agent withholds it (the round-cap final turn), fall back to narration. From 12230f05a35cf13c46b7b101b29b23d73833d385 Mon Sep 17 00:00:00 2001 From: ran Date: Fri, 12 Jun 2026 10:41:00 +0200 Subject: [PATCH 21/24] .NET: Report empty-string component ids as missing, not duplicate 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 --- .../A2UIValidation.cs | 6 ++++-- .../A2UIComponentValidatorTests.cs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs index a377d3405f..b5d0e9b820 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs @@ -125,12 +125,14 @@ public static A2UIValidationResult Validate( List errors = []; - // First pass: collect ids and flag duplicates. + // First pass: collect ids and flag duplicates. Empty ids are skipped here so they + // are reported once as a missing id in the next pass rather than as spurious + // duplicates of each other. var ids = new HashSet(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)) + idValue.TryGetValue(out string? id) && !string.IsNullOrEmpty(id) && !ids.Add(id)) { errors.Add(new A2UIValidationError( A2UIValidationErrorCodes.DuplicateId, diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs index d5eadbd61a..14a7947328 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs @@ -136,6 +136,24 @@ public void Validate_DuplicateId_ReportsDuplicateId() Assert.Contains(A2UIValidationErrorCodes.DuplicateId, Codes(result)); } + [Fact] + public void Validate_EmptyStringIds_ReportMissingIdNotDuplicate() + { + // Arrange: two components with empty-string ids must each be reported as a missing + // id, not as duplicates of one another. + var components = new JsonArray( + new JsonObject { ["id"] = "root", ["component"] = "Row", ["children"] = new JsonArray() }, + new JsonObject { ["id"] = "", ["component"] = "Row", ["children"] = new JsonArray() }, + new JsonObject { ["id"] = "", ["component"] = "Row", ["children"] = new JsonArray() }); + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate(components); + + // Assert + Assert.Contains(A2UIValidationErrorCodes.MissingId, Codes(result)); + Assert.DoesNotContain(A2UIValidationErrorCodes.DuplicateId, Codes(result)); + } + [Fact] public void Validate_EmptyOrNullComponents_FailsLoud() { From dd534210f516ac652d60463e8e235975ad94143b Mon Sep 17 00:00:00 2001 From: ran Date: Fri, 12 Jun 2026 11:03:32 +0200 Subject: [PATCH 22/24] .NET: Validate singular child references, not only plural children 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 --- .../A2UIValidation.cs | 25 +++++++++++----- .../A2UIComponentValidatorTests.cs | 30 +++++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs index b5d0e9b820..62058adc85 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs @@ -93,6 +93,9 @@ public A2UIValidationCatalog(JsonObject components) /// public static class A2UIComponentValidator { + /// The component fields that carry child references: singular child and plural children. + private static readonly string[] s_childReferenceFields = ["child", "children"]; + /// /// Validates a flat A2UI component array against structural rules and, optionally, /// a component catalog and a data model. @@ -190,14 +193,20 @@ public static A2UIValidationResult Validate( if (component is not null) { - foreach (string reference in CollectChildReferences(component["children"])) + // Validate both the singular `child` (one-child containers such as Card and + // Button, which the default prompt uses) and the plural `children` so a + // dangling reference in either is caught and fed back to the recovery loop. + foreach (string field in s_childReferenceFields) { - if (!ids.Contains(reference)) + foreach (string reference in CollectChildReferences(component[field])) { - errors.Add(new A2UIValidationError( - A2UIValidationErrorCodes.UnresolvedChild, - $"components[{i}].children", - $"Child reference '{reference}' does not match any component id")); + if (!ids.Contains(reference)) + { + errors.Add(new A2UIValidationError( + A2UIValidationErrorCodes.UnresolvedChild, + $"components[{i}].{field}", + $"Child reference '{reference}' does not match any component id")); + } } } @@ -304,8 +313,10 @@ void Push(JsonNode? node) Push(child); } } - else if (children is JsonObject) + else if (children is JsonObject or JsonValue) { + // A JsonObject template ({ componentId, ... }) or a bare string id (the singular + // `child` shape). Push(children); } diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs index 14a7947328..79594e0c56 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs @@ -154,6 +154,36 @@ public void Validate_EmptyStringIds_ReportMissingIdNotDuplicate() Assert.DoesNotContain(A2UIValidationErrorCodes.DuplicateId, Codes(result)); } + [Fact] + public void Validate_SingularChildUnresolved_ReportsUnresolvedChild() + { + // Arrange: a one-child container (Card) whose singular `child` points at a + // component id that does not exist. + var components = new JsonArray( + new JsonObject { ["id"] = "root", ["component"] = "Card", ["child"] = "ghost" }); + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate(components); + + // Assert + Assert.Contains(A2UIValidationErrorCodes.UnresolvedChild, Codes(result)); + } + + [Fact] + public void Validate_SingularChildResolved_IsValid() + { + // Arrange: the singular `child` points at a real component id. + var components = new JsonArray( + new JsonObject { ["id"] = "root", ["component"] = "Card", ["child"] = "label" }, + new JsonObject { ["id"] = "label", ["component"] = "Text" }); + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate(components); + + // Assert + Assert.DoesNotContain(A2UIValidationErrorCodes.UnresolvedChild, Codes(result)); + } + [Fact] public void Validate_EmptyOrNullComponents_FailsLoud() { From fd7bc503861421c2fe835006234e32a6282f7f51 Mon Sep 17 00:00:00 2001 From: ran Date: Fri, 12 Jun 2026 11:10:49 +0200 Subject: [PATCH 23/24] .NET: Close coalesced streamed tool calls via an O(1) id-to-index map 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 --- .../ChatResponseUpdateAGUIExtensions.cs | 35 ++++++------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs index 6884d8b2c1..b50e04fd90 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs @@ -462,8 +462,11 @@ public static async IAsyncEnumerable AsAGUIEventStreamAsync( // argument fragments are still observable on the update's RawRepresentation: // surface them as incremental TOOL_CALL_ARGS events and suppress the duplicate // atomic emission when the coalesced FunctionCallContent arrives. + // Two views of the same open raw-streamed calls: by fragment index (for matching + // later fragments and the deterministic end-of-stream sweep) and by call id (for an + // O(1) close when the coalesced FunctionCallContent arrives). Dictionary rawToolCallIdsByIndex = []; - HashSet rawStreamedToolCallIds = new(StringComparer.Ordinal); + Dictionary rawToolCallIndexById = new(StringComparer.Ordinal); #endif await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) { @@ -564,7 +567,7 @@ chatResponse.Contents[0] is TextContent && rawToolCallId = toolCallUpdate.ToolCallId; rawToolCallIdsByIndex[toolCallUpdate.Index] = rawToolCallId; - rawStreamedToolCallIds.Add(rawToolCallId); + rawToolCallIndexById[rawToolCallId] = toolCallUpdate.Index; // Close any open reasoning block before emitting tool events. if (currentReasoningMessageId is not null) @@ -618,22 +621,9 @@ chatResponse.Contents[0] is TextContent && // per-call state is then released: OpenAI restarts tool-call indexes // at 0 for each assistant turn, so a stale index entry would silently // absorb a later round's fragments into this finished call. - if (rawStreamedToolCallIds.Remove(functionCallContent.CallId)) + if (rawToolCallIndexById.Remove(functionCallContent.CallId, out int closedIndex)) { - int? closedIndex = null; - foreach (KeyValuePair entry in rawToolCallIdsByIndex) - { - if (string.Equals(entry.Value, functionCallContent.CallId, StringComparison.Ordinal)) - { - closedIndex = entry.Key; - break; - } - } - - if (closedIndex is int index) - { - rawToolCallIdsByIndex.Remove(index); - } + rawToolCallIdsByIndex.Remove(closedIndex); // Close any open reasoning block before emitting tool events. if (currentReasoningMessageId is not null) @@ -861,17 +851,14 @@ chatResponse.Contents[0] is TextContent && openToolCallIndexes.Sort(); foreach (int openToolCallIndex in openToolCallIndexes) { - if (rawStreamedToolCallIds.Remove(rawToolCallIdsByIndex[openToolCallIndex])) + yield return new ToolCallEndEvent { - yield return new ToolCallEndEvent - { - ToolCallId = rawToolCallIdsByIndex[openToolCallIndex] - }; - } + ToolCallId = rawToolCallIdsByIndex[openToolCallIndex] + }; } - rawStreamedToolCallIds.Clear(); rawToolCallIdsByIndex.Clear(); + rawToolCallIndexById.Clear(); #endif yield return new RunFinishedEvent From 089f9166cdfc9240eb124090acde741bfce46f4d Mon Sep 17 00:00:00 2001 From: ran Date: Fri, 12 Jun 2026 12:17:35 +0200 Subject: [PATCH 24/24] .NET: Detect child-reference cycles in the A2UI validator 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 --- .../A2UIValidation.cs | 128 ++++++++++++++++++ .../A2UIComponentValidatorTests.cs | 78 +++++++++++ 2 files changed, 206 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs index 62058adc85..a793c8f637 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs @@ -40,6 +40,9 @@ public static class A2UIValidationErrorCodes /// A child reference points at a component id that does not exist. public const string UnresolvedChild = "unresolved_child"; + /// A component participates in a child-reference cycle; the child/children tree must be a DAG. + public const string ChildCycle = "child_cycle"; + /// An absolute data binding path does not resolve in the data model. public const string UnresolvedBinding = "unresolved_binding"; } @@ -228,6 +231,16 @@ public static A2UIValidationResult Validate( } } + // The child/children tree must be a DAG — a component that (transitively) + // references itself never terminates at render time. Report each cycle once. + foreach (List cycle in FindChildCycles(components)) + { + errors.Add(new A2UIValidationError( + A2UIValidationErrorCodes.ChildCycle, + $"components[id={cycle[0]}]", + $"Child reference cycle detected: {string.Join(" -> ", cycle)} -> {cycle[0]}")); + } + bool hasRoot = false; foreach (JsonNode? node in components) { @@ -323,6 +336,121 @@ void Push(JsonNode? node) return references; } + /// id → ordered child-id references, gathered from singular child + plural children. + private static Dictionary> BuildChildAdjacency(JsonArray components) + { + var adjacency = new Dictionary>(StringComparer.Ordinal); + foreach (JsonNode? node in components) + { + if (node is JsonObject component && TryGetString(component["id"]) is string id) + { + List references = []; + foreach (string field in s_childReferenceFields) + { + references.AddRange(CollectChildReferences(component[field])); + } + + adjacency[id] = references; + } + } + + return adjacency; + } + + /// + /// Finds unique child-reference cycles (self-references and longer loops) over the child graph + /// via an iterative depth-first search. The traversal is explicit-stack rather than recursive + /// so a pathologically deep child chain in untrusted model output cannot overflow the call + /// stack (an uncatchable failure on .NET). Each cycle is canonicalised — rotated so the + /// lexicographically smallest id leads — so the same loop reached from different entry points + /// collapses to one finding, and the reported chain stays byte-identical across the sibling + /// toolkits. + /// + private static List> FindChildCycles(JsonArray components) + { + Dictionary> adjacency = BuildChildAdjacency(components); + var color = new Dictionary(StringComparer.Ordinal); // absent/0 = unvisited, 1 = on path, 2 = done + var seenKeys = new HashSet(StringComparer.Ordinal); + var cycles = new List>(); + + // path = the ids currently on the DFS path (the "on path"/grey set, in order). + // frames = a resumable DFS frame per path node: which neighbor index to visit next. + var path = new List(); + var frames = new Stack<(string Node, int NextNeighbor)>(); + + foreach (string id in adjacency.Keys) + { + if (color.TryGetValue(id, out int rootState) && rootState != 0) + { + continue; + } + + color[id] = 1; + path.Add(id); + frames.Push((id, 0)); + + while (frames.Count > 0) + { + (string u, int next) = frames.Pop(); + List neighbors = adjacency.TryGetValue(u, out List? n) ? n : []; + + bool descended = false; + for (int i = next; i < neighbors.Count; i++) + { + string v = neighbors[i]; + int state = color.TryGetValue(v, out int c) ? c : 0; + if (state == 1) + { + // Back-edge to a node still on the path: extract the loop. + int start = path.IndexOf(v); + List cycle = Canonicalize(path.GetRange(start, path.Count - start)); + if (seenKeys.Add(string.Join(" ", cycle))) + { + cycles.Add(cycle); + } + } + else if (state == 0) + { + // Descend into v, resuming u after this neighbor on the way back up. + frames.Push((u, i + 1)); + color[v] = 1; + path.Add(v); + frames.Push((v, 0)); + descended = true; + break; + } + } + + if (!descended) + { + // u is fully explored; it is the top of the path. + color[u] = 2; + path.RemoveAt(path.Count - 1); + } + } + } + + return cycles; + } + + /// Rotates a cycle's node list so the lexicographically smallest (ordinal) id leads. + private static List Canonicalize(List nodes) + { + int m = 0; + for (int i = 1; i < nodes.Count; i++) + { + if (string.CompareOrdinal(nodes[i], nodes[m]) < 0) + { + m = i; + } + } + + var rotated = new List(nodes.Count); + rotated.AddRange(nodes.GetRange(m, nodes.Count - m)); + rotated.AddRange(nodes.GetRange(0, m)); + return rotated; + } + private static void CollectAbsoluteBindingPaths(JsonNode? node, List accumulator) { switch (node) diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs index 79594e0c56..ea740c330b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs @@ -184,6 +184,84 @@ public void Validate_SingularChildResolved_IsValid() Assert.DoesNotContain(A2UIValidationErrorCodes.UnresolvedChild, Codes(result)); } + [Fact] + public void Validate_SelfReferentialChild_ReportsChildCycle() + { + // Arrange: a one-child container whose singular `child` points at itself. + // The default prompt warns the model the child tree must be a DAG; a + // self-reference never terminates at render time. + var components = new JsonArray( + new JsonObject { ["id"] = "avatar", ["component"] = "Card", ["child"] = "avatar" }); + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate(components); + + // Assert + Assert.False(result.Valid); + Assert.Contains(result.Errors, e => + e.Code == A2UIValidationErrorCodes.ChildCycle && e.Message.Contains("avatar -> avatar")); + } + + [Fact] + public void Validate_MultiComponentCycle_ReportedOnce() + { + // Arrange: root -> a -> b -> a forms a cycle reachable from multiple entry points. + var components = new JsonArray( + new JsonObject { ["id"] = "root", ["component"] = "Row", ["children"] = new JsonArray("a") }, + new JsonObject { ["id"] = "a", ["component"] = "Row", ["children"] = new JsonArray("b") }, + new JsonObject { ["id"] = "b", ["component"] = "Row", ["children"] = new JsonArray("a") }); + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate(components); + + // Assert + Assert.Equal(1, result.Errors.Count(e => e.Code == A2UIValidationErrorCodes.ChildCycle)); + Assert.Contains(result.Errors, e => + e.Code == A2UIValidationErrorCodes.ChildCycle && e.Message.Contains("a -> b -> a")); + } + + [Fact] + public void Validate_AcyclicChildGraph_NoChildCycle() + { + // Arrange + var components = new JsonArray( + new JsonObject { ["id"] = "root", ["component"] = "Row", ["children"] = new JsonArray("a", "b") }, + new JsonObject { ["id"] = "a", ["component"] = "Text" }, + new JsonObject { ["id"] = "b", ["component"] = "Text" }); + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate(components); + + // Assert + Assert.DoesNotContain(A2UIValidationErrorCodes.ChildCycle, Codes(result)); + } + + [Fact] + public void Validate_DeepLinearChildChain_DoesNotOverflow() + { + // Arrange: a very deep linear child chain (root -> c1 -> c2 -> ... -> cN). The cycle + // walk must handle untrusted depth without overflowing the stack and must not report + // a cycle for an acyclic chain. + const int depth = 20_000; + var components = new JsonArray + { + new JsonObject { ["id"] = "root", ["component"] = "Card", ["child"] = "c1" }, + }; + for (int i = 1; i < depth; i++) + { + components.Add(new JsonObject { ["id"] = $"c{i}", ["component"] = "Card", ["child"] = $"c{i + 1}" }); + } + + components.Add(new JsonObject { ["id"] = $"c{depth}", ["component"] = "Text" }); + + // Act + A2UIValidationResult result = A2UIComponentValidator.Validate(components); + + // Assert + Assert.DoesNotContain(A2UIValidationErrorCodes.ChildCycle, Codes(result)); + Assert.DoesNotContain(A2UIValidationErrorCodes.UnresolvedChild, Codes(result)); + } + [Fact] public void Validate_EmptyOrNullComponents_FailsLoud() {