Skip to content

[Blazor] Add Components.AI server tool rendering - #68327

Open
javiercn wants to merge 5 commits into
javiercn-components-ai-03-client-toolsfrom
javiercn-components-ai-04-server-tools
Open

[Blazor] Add Components.AI server tool rendering#68327
javiercn wants to merge 5 commits into
javiercn-components-ai-03-client-toolsfrom
javiercn-components-ai-04-server-tools

Conversation

@javiercn

@javiercn javiercn commented Aug 10, 2026

Copy link
Copy Markdown
Member

Overview

This is position 4 in the native stack tracked by #68340 and depends on #68325. Relative to javiercn-components-ai-03-client-tools (0c83d0c84beaa93b7d5c2a44687675288b4f9093), this layer adds provider-neutral server-function blocks, a typed tool-block source generator, and the canonical get_weather dojo scenario through the existing two-process AG-UI HTTP/SSE boundary. The governing constraint is that shipping Components.AI code depends only on Microsoft.Extensions.AI; AG-UI endpoint, transport, replay, and weather presentation code remain in test assets.

Design

The generic contract preserves the original Microsoft.Extensions.AI call and result, while deriving stable block identity from CallId. A renderer can show an active invocation immediately and update the same instance when its result arrives.

// src/Components/AI/src/Blocks/FunctionInvocationContentBlock.cs
// The call ID is also the block ID, so concurrent calls remain independently addressable.
public class FunctionInvocationContentBlock : ContentBlock
{
    public FunctionCallContent? Call
    {
        get => _call;
        set
        {
            _call = value;
            if (value is not null)
            {
                Id = value.CallId;
            }
        }
    }

    public FunctionResultContent? Result { get; set; }
    public string? ToolName => Call?.Name;
    public IDictionary<string, object?>? Arguments => Call?.Arguments;
    public bool HasResult => Result is not null;
}

Typed blocks are an opt-in compile-time layer over that generic contract. [ToolBlock] selects the function name; [ToolParameter] maps call arguments; [ToolResult] maps the returned payload. ToolParameterAttribute.Name and ToolResultAttribute.Name are equivalent optional key overrides, so the representative declaration below covers both property-mapping classes without repeating them.

// src/Components/AI/src/Attributes/ToolBlockAttribute.cs
// Invalid empty names fail at the declaration boundary instead of producing an unusable handler.
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class ToolBlockAttribute : Attribute
{
    public ToolBlockAttribute(string toolName)
    {
        ArgumentException.ThrowIfNullOrEmpty(toolName);
        ToolName = toolName;
    }

    public string ToolName { get; }
}

// ToolParameterAttribute and ToolResultAttribute each expose:
// public string? Name { get; set; } // omit it to use the block property name

The generator is packaged as an analyzer, so NuGet consumers receive typed binding without a runtime reflection registry. It emits consumer-local handlers and one local AddGeneratedToolBlocks extension; this is necessary because analyzer project references do not flow transitively between projects.

<!-- src/Components/AI/src/Microsoft.AspNetCore.Components.AI.csproj -->
<!-- Build with the analyzer in-tree and place the same assembly in the package analyzer folder. -->
<ProjectReference Include="..\gen\Microsoft.AspNetCore.Components.AI.SourceGenerators.csproj"
                  OutputItemType="Analyzer"
                  ReferenceOutputAssembly="false" />
<None Include="$(ArtifactsBinDir)\Microsoft.AspNetCore.Components.AI.SourceGenerators\$(Configuration)\netstandard2.0\Microsoft.AspNetCore.Components.AI.SourceGenerators.dll"
      Pack="true"
      PackagePath="analyzers/dotnet/cs"
      Visible="false" />

Handler precedence is deliberate: consumer-generated typed handlers run first, browser-owned UI actions retain priority over the generic server fallback, and only then does the built-in function handler claim an otherwise-unhandled call. Unregistered server calls are kept out of the unknown-block fallback, so applications opt into their presentation rather than exposing raw payloads. Generator diagnostics compress into declaration-shape classes: non-partial/wrong-base/abstract/generic/nested types, empty or duplicate tool names, duplicate argument keys, and read-only mapped properties. Deserialization errors are not swallowed, making malformed server payloads observable.

Implementation

The runtime first emits a block for any unhandled server call. Later updates are offered to all active blocks, and only the block with the exact matching CallId consumes the result; this is what makes simultaneous calls and reverse result ordering safe.

// src/Components/AI/src/Pipeline/BlockMappingPipeline.cs
// UI actions must claim browser-owned calls before the generic server-tool fallback.
if (options.UIActions.Count > 0)
{
    _handlers.Add(new HandlerEntry<UIActionHandler.State>(
        new UIActionHandler(options.UIActions)));
}

_handlers.Add(new HandlerEntry<FunctionInvocationContentBlock>(
    new FunctionInvocationHandler()));
// src/Components/AI/src/Pipeline/FunctionInvocationHandler.cs
// Example: active calls A and B receive result B first; A passes, B matches and completes.
if (state.Call is null)
{
    foreach (var content in context.UnhandledContents)
    {
        if (content is FunctionCallContent call)
        {
            context.MarkHandled(call);
            state.Call = call;
            return BlockMappingResult<FunctionInvocationContentBlock>.Emit(state, state);
        }
    }
}

foreach (var content in context.UnhandledContents)
{
    if (content is FunctionResultContent result &&
        result.CallId == state.Call?.CallId)
    {
        context.MarkHandled(result);
        state.Result = result;
        return BlockMappingResult<FunctionInvocationContentBlock>.Complete();
    }
}

The source generator specializes that state machine by tool name and property types. The excerpt below is representative generated output, de-templatized to the actual WeatherToolBlock: one [ToolResult] complex object accepts both an in-process JsonElement and the JSON string carried by AG-UI. Multiple result properties use the same lifecycle but read named properties from a JSON object; primitive arguments/results use the corresponding JsonElement getter or conversion.

// src/Components/AI/gen/ToolBlockEmitter.cs
// Representative de-templatized output produced by the emitter for WeatherToolBlock.
if (state.Call is null)
{
    foreach (var content in context.UnhandledContents)
    {
        if (content is FunctionCallContent call && call.Name == "get_weather")
        {
            context.MarkHandled(call);
            state.Call = call;
            state.Location = call.Arguments?["location"] switch
            {
                JsonElement value => value.GetString(),
                string value => value,
                _ => null,
            };
            return BlockMappingResult<WeatherToolBlock>.Emit(state, state);
        }
    }
}

if (resultContent.CallId == state.Call?.CallId)
{
    state.Result = resultContent;
    state.Weather = resultContent.Result switch
    {
        JsonElement value => JsonSerializer.Deserialize<WeatherInfo>(value),
        string json => JsonSerializer.Deserialize<WeatherInfo>(json),
        WeatherInfo value => value,
        _ => null,
    };
    return BlockMappingResult<WeatherToolBlock>.Complete();
}

The consumer surface remains a small partial block declaration; generated registration installs its handler before the generic fallback.

// src/Components/AI/testassets/DojoClient/Components/Scenarios/BackendToolRendering/WeatherToolBlock.cs
[ToolBlock("get_weather")]
public partial class WeatherToolBlock : FunctionInvocationContentBlock
{
    [ToolParameter(Name = "location")]
    public string? Location { get; set; }

    [ToolResult]
    public WeatherInfo? Weather { get; set; }
}

The canonical scenario keeps execution on the API. AGUIDojoApi injects the executable function into ChatOptions and wraps either the configured model or credential-free scripted model with function invocation. DojoClient has only a keyed AGUIChatClient for this endpoint; it registers the generated renderer but never declares or executes get_weather locally.

// src/Components/AI/testassets/AGUIDojoApi/Program.cs
// Separate API endpoint: the executable tool lives on the server side of the HTTP/SSE boundary.
app.MapDojoEndpoint(
    "/backend_tool_rendering",
    serverTools: ChatClientAgentFactory.CreateBackendToolRenderingTools(
        jsonOptions.Value.SerializerOptions));
// src/Components/AI/testassets/AGUIDojoApi/ChatClientAgentFactory.cs
// Both live and credential-free models execute API-owned functions through the same wrapper.
return modelClient
    .AsBuilder()
    .UseFunctionInvocation()
    .Build();
@* src/Components/AI/testassets/DojoClient/Components/Scenarios/BackendToolRendering/BackendToolRenderingScenario.razor *@
@* Generated registration claims get_weather; the renderer observes loading/result state. *@
<BlockRenderer TBlock="WeatherToolBlock"
               Context="weather"
               When="@(block => block.ToolName == "get_weather")">
    <WeatherCard Block="weather" />
</BlockRenderer>

@code {
    protected override void OnInitialized()
    {
        _agent = new UIAgent(
            ChatClient,
            options => options.AddGeneratedToolBlocks(),
            LoggerFactory);
    }
}

The browser test preserves the production boundary: it replaces only the model inside AGUIDojoApi, starts API and UI hosts separately, and drives the real AGUIChatClient transport. The generated recording is isolated in the final commit and contains only the deterministic call/continuation transcript.

// src/Components/AI/testassets/DojoClient.E2E.Tests/Tests/BackendToolRenderingScenarioTests.cs
// Two real hosts; only the API model service is replayed.
_api = await StartServerAsync<AGUIDojoApiAssembly>(TestRoot.Servers, options =>
{
    options.ConfigureServices<DojoModelOverrides>(
        nameof(DojoModelOverrides.BackendToolRendering));
});
_ui = await StartServerAsync<global::DojoClient.Components.App>(TestRoot.Servers, options =>
{
    options.EnvironmentVariables["AGUI_DOJO_API_URL"] = _api.AppUrl;
});

// Acceptance evidence: exactly one completed typed card plus the final model summary.
await Expect(_page.Locator(".weather-card")).ToHaveCountAsync(1);
await Expect(_page.Locator(".weather-card--loading")).ToHaveCountAsync(0);
await Expect(_page.Locator(".weather-card__location")).ToHaveTextAsync("San Francisco");
await Expect(_page.Locator(".weather-card__temp-value")).ToHaveTextAsync("20");
await Expect(_page.Locator(".weather-card__condition")).ToHaveTextAsync("sunny");

Outcome

Equivalence class Evidence
Generic server-tool lifecycle Exact-ID pairing, multiple active calls, reverse/mismatched results, and inactive completion are covered by the FunctionInvocation runtime tests.
Typed generated binding Declaration diagnostics, primitive/complex mapping, unique generated names, consumer-output compilation, registration, and AG-UI JSON-string result decoding are covered by the generator tests.
Real transport and presentation The API executes get_weather; AG-UI streams call/result/final text over HTTP/SSE; the client renders one completed typed weather card.

Validation on head 8c3be521014a60563c56701b37e3bf2f9700d1d8:

  • dependency-aware build: 0 warnings, 0 errors
  • source-generator tests: 14/14 passed
  • FunctionInvocation runtime tests: 10/10 passed
  • BackendToolRenderingScenarioTests: 1/1 passed

Review guidance: read the public block/attribute contracts, handler ordering, exact-ID completion, generated consumer compilation, and server-only execution closely. Spot-check the repetitive diagnostic cases, primitive conversion branches, weather-card CSS, and generated recording.

Acceptance criteria: get_weather is executable only in AGUIDojoApi; a call produces a typed loading block, its matching result completes that same block even with other calls active, and DojoClient renders the San Francisco 20°C / sunny card before the final assistant summary through the real AG-UI HTTP/SSE path.

@javiercn
javiercn requested a review from a team as a code owner August 10, 2026 18:11
Map server-owned function calls into provider-neutral content blocks and pair each result by call ID, even when multiple invocations remain active. Custom Blazor renderers can observe the loading-to-result transition while unmatched server tools remain hidden by default.

Keep UI actions ahead of generic function mapping so browser-owned tools preserve their execution path. The runtime continues to depend only on Microsoft.Extensions.AI and exposes no AG-UI protocol types.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
Add ToolBlock, ToolParameter, and ToolResult contracts plus an incremental generator that emits strongly typed function handlers and consumer-local registration. Generated handlers bind call arguments and results, pair results by call ID, and fall back to the generic server-tool block when no typed tool matches.

Diagnose invalid declarations at compile time, preserve incremental-generator cacheability, support escaped consumer namespaces, and package the analyzer with Components.AI. Deserialization failures surface instead of being silently swallowed, keeping malformed tool payloads observable.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
Rationale: demonstrate typed server-tool rendering across the real AG-UI HTTP/SSE boundary without moving execution into the client.

Implementation: add the backend_tool_rendering endpoint and keyed client, execute get_weather on the API, bind its streamed JSON result to a generated WeatherToolBlock, and render the canonical weather card.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
Rationale: lock down call-id pairing, generated consumer compatibility, and the real dual-host weather path before adding its recorded model payload.

Coverage: add concurrent server-tool mapping tests, generator diagnostics and compilation tests, and a browser scenario that replaces only the API model while preserving AG-UI HTTP/SSE transport.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
Rationale: keep the deterministic model transcript separate from handwritten server-tool and browser-test code.

Recording: capture the get_weather call and its continuation so the dual-host scenario runs without external credentials while retaining real API execution and AG-UI transport.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
@javiercn
javiercn force-pushed the javiercn-components-ai-04-server-tools branch from df3bcda to 8c3be52 Compare August 11, 2026 06:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant