Skip to content

[Blazor] Add Components.AI human approval flows - #68329

Open
javiercn wants to merge 4 commits into
javiercn-components-ai-04-server-toolsfrom
javiercn-components-ai-05-human-in-loop
Open

[Blazor] Add Components.AI human approval flows#68329
javiercn wants to merge 4 commits into
javiercn-components-ai-04-server-toolsfrom
javiercn-components-ai-05-human-in-loop

Conversation

@javiercn

@javiercn javiercn commented Aug 10, 2026

Copy link
Copy Markdown
Member

Overview

This is position 5 of native stack #68340 and depends on #68327. Relative to javiercn-components-ai-04-server-tools (8c3be521) only, it adds provider-neutral handling for ToolApprovalRequestContent, then lights the canonical Human in the Loop generate_task_steps experience across the existing DojoClient → AGUIDojoApi HTTP/SSE boundary. The cross-cutting constraint is that approval semantics stay in Microsoft.AspNetCore.Components.AI while AG-UI-specific endpoint/client/replay code stays in test assets; the browser continues to use the real AGUIChatClient.

Design

The conversation engine now waits on one small public contract rather than knowing every interactive block type. The existing UIActionBlock and new FunctionApprovalBlock are the two implementations in this layer: both asynchronously produce continuation content, but UI actions produce FunctionResultContent for ChatRole.Tool, while approvals produce ToolApprovalResponseContent for ChatRole.User.

// src/Components/AI/src/Blocks/IInteractiveBlock.cs
// Shared pause contract: renderers decide when the user has completed the interaction;
// AgentContext only waits for the resulting Microsoft.Extensions.AI content.
public interface IInteractiveBlock
{
    Task<AIContent> GetResultAsync(CancellationToken cancellationToken = default);
}

The approval contract preserves the original request and the already-mapped function block. InteractiveFunctionBlock exposes the wrapped call/result/tool/arguments uniformly, so a source-generated or consumer-defined function renderer can remain nested inside the approval UI instead of being flattened into an untyped fallback.

// src/Components/AI/src/Blocks/FunctionApprovalBlock.cs
// Public state is intentionally three-valued: Pending until exactly one response wins,
// then Approved or Rejected. The response is created by the original MEAI request,
// preserving its tool-call identity and optional rejection reason.
public class FunctionApprovalBlock : InteractiveFunctionBlock, IInteractiveBlock
{
    public ApprovalStatus Status { get; private set; }
    public ToolApprovalRequestContent ApprovalRequest { get; }

    public void Approve()
    {
        Respond(ApprovalStatus.Approved, reason: null);
    }

    public void Reject(string? reason = null)
    {
        Respond(ApprovalStatus.Rejected, reason);
    }

    public Task<AIContent> GetResultAsync(CancellationToken cancellationToken = default)
        => _resultSource.Task.WaitAsync(cancellationToken);
}

The rejected alternative was to special-case approvals in AgentContext or couple the product to AG-UI. Instead, the product consumes only Microsoft.Extensions.AI approval content, and the existing interactive abstraction handles both equivalence classes without transport knowledge.

Implementation

Approval mapping is deliberately ordered after consumer handlers and registered UI actions, but before generic server-tool rendering. That preserves the established UI-action → approval → server-tool precedence: a consumer can customize the nested call, an explicitly registered client action still wins, and an approval request cannot be consumed as an ordinary invocation.

// src/Components/AI/src/Pipeline/BlockMappingPipeline.cs
// Consumer registrations are added first (omitted here), then the built-in interaction classes.
if (options.UIActions.Count > 0)
{
    _handlers.Add(new HandlerEntry<UIActionHandler.State>(
        new UIActionHandler(options.UIActions)));
}

_handlers.Add(new HandlerEntry<FunctionApprovalHandler.State>(
    new FunctionApprovalHandler()));

_handlers.Add(new HandlerEntry<FunctionInvocationContentBlock>(
    new FunctionInvocationHandler()));

The approval handler first asks the same handler set to map the wrapped tool call. This is the key extensibility path: typed/custom blocks survive as InnerBlock; only an unrecognized call uses the generic fallback. Its state is one-shot so a completed active handler cannot claim and swallow a later approval update.

// src/Components/AI/src/Pipeline/FunctionApprovalHandler.cs
foreach (var content in context.UnhandledContents)
{
    if (content is not ToolApprovalRequestContent approvalRequest)
    {
        continue;
    }

    context.MarkHandled(approvalRequest);
    var innerBlock = context.CreateInnerBlock(approvalRequest.ToolCall)
        as FunctionInvocationContentBlock
        ?? CreateFallbackInnerBlock(approvalRequest.ToolCall);

    state.Emitted = true;
    return BlockMappingResult<State>.Emit(
        new FunctionApprovalBlock(innerBlock, approvalRequest)
        {
            // Example: call ID "approval-call-1" remains the block ID and response pairing key.
            Id = innerBlock.Call?.CallId ?? approvalRequest.RequestId
        },
        state);
}

Approve and reject are one equivalence class: both take the same lock, accept only the Pending state, create the response from the original request, and notify once. Their only deltas are final status, Approved, and optional Reason.

// src/Components/AI/src/Blocks/FunctionApprovalBlock.cs
private void Respond(ApprovalStatus status, string? reason)
{
    lock (_responseLock)
    {
        if (Status != ApprovalStatus.Pending)
        {
            return; // approve → reject → approve still emits exactly the first response
        }

        Status = status;
        var response = ApprovalRequest.CreateResponse(
            approved: status == ApprovalStatus.Approved,
            reason);
        _resultSource.SetResult(response);
    }

    NotifyChanged();
}

AgentContext collects every interactive block emitted by one model pass, publishes AwaitingInput, waits for them together, and resumes streaming. The role rule is the protocol boundary: all ordinary function results continue as tool content; any approval response makes the continuation a user message, as required by the MEAI approval contract.

// src/Components/AI/src/Engine/AgentContext.cs
if (block is IInteractiveBlock interactiveBlock)
{
    interactiveBlocks.Add(interactiveBlock);
}

// (after the model stream finishes)
Status = ConversationStatus.AwaitingInput;
NotifyStatusChanged();

var results = await Task.WhenAll(
    interactiveBlocks.Select(block => block.GetResultAsync(cancellationToken)));

var role = results.Any(result => result is not FunctionResultContent)
    ? ChatRole.User
    : ChatRole.Tool;
currentMessage = new ChatMessage(role, [.. results]);
Status = ConversationStatus.Streaming;
NotifyStatusChanged();

The canonical dojo scenario is the second HITL equivalence class. It registers generate_task_steps as a browser UI action and renders only that tool with a selectable task card. Confirm serializes enabled/disabled statuses and invokes once; Reject uses the same path after disabling every step, so the model receives either the selected descriptions or the single rejection result.

@* src/Components/AI/testassets/DojoClient/Components/Scenarios/HumanInTheLoop/HumanInTheLoopScenario.razor *@
<BlockRenderer TBlock="UIActionBlock"
               Context="action"
               When="@(block => block.ToolName == "generate_task_steps")">
    <TaskStepList Block="action" />
</BlockRenderer>

@code {
    protected override void OnInitialized()
    {
        _agent = new UIAgent(ChatClient, options =>
        {
            options.RegisterUIAction(AIFunctionFactory.Create(
                GenerateTaskSteps,
                name: "generate_task_steps",
                description: "Generate a list of task steps for the user to review and approve."));
        }, LoggerFactory);
    }

    private static string GenerateTaskSteps(List<TaskStep> steps)
    {
        var selected = steps
            .Where(step => step.Status != "disabled")
            .Select(step => step.Description)
            .ToList();

        return selected.Count == 0
            ? "The user rejected all proposed steps."
            : $"The user selected the following steps: {string.Join(", ", selected)}";
    }
}

The permanent browser fixture launches AGUIDojoApi and DojoClient separately. Only the API-side model is replayed; DojoClient receives the API URL and retains AGUI.Client.AGUIChatClient, so request serialization, HTTP POST, SSE parsing, task-card interaction, and continuation all cross the real wire. One shared harness covers two deltas: deselect 2 of 5 then confirm, or reject all. The generated recording asserts the exact human-in-the-loop-steps-1 call/result pairing and both continuation payloads.

// src/Components/AI/testassets/DojoClient.E2E.Tests/Tests/HumanInTheLoopScenarioTests.cs
_api = await StartServerAsync<AGUIDojoApiAssembly>(TestRoot.Servers, options =>
{
    // Replace only the model inside the API; never replace the UI's AGUIChatClient.
    options.ConfigureServices<DojoModelOverrides>(
        nameof(DojoModelOverrides.HumanInTheLoop));
});
_ui = await StartServerAsync<global::DojoClient.Components.App>(TestRoot.Servers, options =>
{
    options.EnvironmentVariables["AGUI_DOJO_API_URL"] = _api.AppUrl;
});

await _page.GotoAsync($"{_ui.TestUrl}/human_in_the_loop");
await _page.WaitForInteractiveAsync("textarea.sc-ai-input__textarea");

Outcome

Equivalence class What is proven Result
Dependency graph Product, API host, Blazor client, replay fixture, and browser project compile together 0 warnings, 0 errors
Product approval lifecycle One-shot approve/reject, reason and call-ID preservation, typed nested blocks, multiple streamed approvals, AwaitingInput → user-role continuation 6/6 passed
Canonical dojo HITL Real dual-host approve/select and reject continuations through AGUI.Client/Server 0.0.5 HTTP/SSE 2/2 HumanInTheLoopScenarioTests passed

Review guidance / acceptance criteria: focus on the handler order, the one-shot lock, and the continuation role choice rather than the task-card CSS or generated JSON. Accept when an approval pauses the conversation; the first approve/reject response preserves the exact tool call and optional reason; duplicate responses are ignored; custom typed function blocks remain nested; selected task steps alone reach the continuation; rejection reaches the no-steps continuation; and the browser input is disabled while awaiting the interaction and re-enabled afterward.

@javiercn
javiercn requested a review from a team as a code owner August 10, 2026 19:19
@javiercn
javiercn force-pushed the javiercn-components-ai-05-human-in-loop branch from ad5d7bf to 7b86b12 Compare August 11, 2026 07:05
@javiercn

Copy link
Copy Markdown
Member Author

/azp run aspnetcore-ci

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Rationale: let provider-neutral Components.AI conversations surface model approval requests and pause without coupling the runtime to AG-UI or executing gated work prematurely.

Implementation: map approval requests ahead of generic server tools, preserve nested function blocks and call IDs, expose single-use approve and reject responses, and resume AgentContext with the correct user-role continuation while retaining existing UI-action ordering.

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

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
Rationale: demonstrate the canonical Human in the Loop scenario across the real DojoClient to AGUIDojoApi HTTP/SSE boundary while keeping model execution and replay on the API side.

Implementation: add the /human_in_the_loop endpoint and keyed AGUIChatClient, declare generate_task_steps as a browser UI action, render selectable task steps, and return approved selections or rejection before the model continuation.

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

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
Rationale: lock down approval lifecycle semantics and the canonical Human in the Loop browser flow without replacing DojoClient's real AG-UI transport.

Implementation: cover one-shot approval responses, exact call-ID pairing, user-role continuation, multiple streamed approvals, nested custom blocks, and approve/select plus reject behavior through the dual-host E2E harness.

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

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
Rationale: keep deterministic Human in the Loop browser coverage independent of live model credentials while exercising the real AG-UI HTTP/SSE boundary.

Implementation: record the task-step proposal plus approved-selection and rejected continuations, including exact generate_task_steps call IDs and AG-UI 0.0.5 result payloads.

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

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
Copilot AI lite review requested due to automatic review settings August 12, 2026 16:47
@kotlarmilos
kotlarmilos force-pushed the javiercn-components-ai-05-human-in-loop branch from b56220d to abd4b1f Compare August 12, 2026 16:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds provider-neutral “human approval” (HITL) support to Components.AI by introducing an interactive-block contract, mapping ToolApprovalRequestContent into a new approval block type, and wiring conversation pause/resume semantics end-to-end (including a DojoClient scenario and E2E replay coverage).

Changes:

  • Introduce IInteractiveBlock and new interactive block types (FunctionApprovalBlock, InteractiveFunctionBlock) to unify “pause until user responds” semantics.
  • Add FunctionApprovalHandler + nested mapping support (BlockMappingContext.CreateInnerBlock) and update AgentContext to wait for interactive results and choose continuation role.
  • Add a canonical DojoClient “Human in the Loop” scenario plus E2E tests/recordings and supporting UI/CSS.
Show a summary per file
File Description
src/Components/AI/testassets/DojoClient/Program.cs Registers a keyed chat client for the new HITL scenario endpoint.
src/Components/AI/testassets/DojoClient/DojoScenarios.cs Adds the /human_in_the_loop scenario route constant.
src/Components/AI/testassets/DojoClient/Components/Scenarios/HumanInTheLoop/TaskStepList.razor.css Adds scenario-specific task-card styling for selecting/rejecting steps.
src/Components/AI/testassets/DojoClient/Components/Scenarios/HumanInTheLoop/TaskStepList.razor Implements the task-step selection UI and serializes selection back through a UI action tool call.
src/Components/AI/testassets/DojoClient/Components/Scenarios/HumanInTheLoop/TaskStep.cs Defines the task-step payload model (description/status) for tool args/results.
src/Components/AI/testassets/DojoClient/Components/Scenarios/HumanInTheLoop/HumanInTheLoopScenario.razor Adds the Dojo scenario page and registers the generate_task_steps UI action.
src/Components/AI/testassets/DojoClient/Components/Pages/Home.razor Links the new scenario from the Dojo home page.
src/Components/AI/testassets/DojoClient/Components/_Imports.razor Imports the new scenario namespace for Razor compilation.
src/Components/AI/testassets/DojoClient.E2E.Tests/Tests/HumanInTheLoopScenarioTests.cs Adds Playwright E2E coverage for approve/select and reject-all flows.
src/Components/AI/testassets/DojoClient.E2E.Tests/ServiceOverrides/RecordedScript.cs Extends the recording schema to optionally assert exact tool results.
src/Components/AI/testassets/DojoClient.E2E.Tests/ServiceOverrides/RecordedChatClient.cs Adds assertions to compare actual tool results against the baseline recording.
src/Components/AI/testassets/DojoClient.E2E.Tests/ServiceOverrides/DojoModelOverrides.cs Adds a model override entry for the HITL recording.
src/Components/AI/testassets/DojoClient.E2E.Tests/Baselines/HumanInTheLoop.recording.json Adds a replay recording baseline validating tool-call/result pairing and continuations.
src/Components/AI/testassets/AGUIDojoApi/ScriptedChatClient.cs Extends scripted model behavior to emit generate_task_steps and summarize tool results.
src/Components/AI/testassets/AGUIDojoApi/Program.cs Maps a new Dojo API endpoint for the HITL scenario.
src/Components/AI/testassets/AGUIDojoApi/ChatClientAgentFactory.cs Adds a system prompt for HITL planning behavior/tool usage.
src/Components/AI/test/Pipeline/FunctionApprovalHandlerTests.cs Adds unit tests for approval mapping and nested custom function block preservation.
src/Components/AI/test/Engine/AgentContextApprovalTests.cs Adds engine tests ensuring approval continuations resume with ChatRole.User.
src/Components/AI/test/Blocks/FunctionApprovalBlockTests.cs Adds unit tests for one-shot approve/reject behavior and reason preservation.
src/Components/AI/src/wwwroot/ai-chat.css Adds shared UI styles for approval rendering and a primary button variant.
src/Components/AI/src/PublicAPI.Unshipped.txt Updates public API surface for approvals, interactive blocks, and nested mapping.
src/Components/AI/src/Pipeline/FunctionApprovalHandler.cs Adds a handler that maps ToolApprovalRequestContent into FunctionApprovalBlock instances.
src/Components/AI/src/Pipeline/BlockMappingPipeline.cs Inserts the approval handler into the mapping precedence and passes handlers into context.
src/Components/AI/src/Pipeline/BlockMappingContext.cs Adds CreateInnerBlock to map nested tool calls through existing handlers.
src/Components/AI/src/Engine/AgentContext.cs Generalizes “wait for UI actions” into “wait for interactive blocks” and chooses continuation role.
src/Components/AI/src/Components/MessageListContext.cs Adds built-in rendering for approval blocks (tool name, args, approve/reject actions).
src/Components/AI/src/Blocks/UIActionBlock.cs Implements IInteractiveBlock and makes GetResultAsync public.
src/Components/AI/src/Blocks/InteractiveFunctionBlock.cs Introduces a base wrapper for interactive blocks that represent function invocations.
src/Components/AI/src/Blocks/IInteractiveBlock.cs Adds the shared “pause until result” contract for interactive blocks.
src/Components/AI/src/Blocks/FunctionApprovalBlock.cs Adds the approval block implementation (one-shot approve/reject producing MEAI response content).
src/Components/AI/src/Blocks/ApprovalStatus.cs Adds a public approval status enum (Pending/Approved/Rejected).

Review details

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

  • Files reviewed: 31/31 changed files
  • Comments generated: 3
  • Review effort level: Lite

Comment on lines +27 to +30
var functionResult = messageList[^1].Contents
.OfType<FunctionResultContent>()
.Any(result => result.CallId == "backend-tool-weather-1");
.SingleOrDefault();
var response = functionResult switch
MessageId = Update.MessageId,
Contents = [content],
};
var context = new BlockMappingContext(update);
Comment on lines +42 to +49
<div class="task-steps-card__actions">
<button class="task-steps-btn task-steps-btn--reject" @onclick="RejectAsync">
Reject
</button>
<button class="task-steps-btn task-steps-btn--confirm" @onclick="ConfirmAsync">
Confirm
</button>
</div>
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.

2 participants