From 40e90c96c396011cf160f943c4daa2e83afc6989 Mon Sep 17 00:00:00 2001
From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com>
Date: Tue, 28 Apr 2026 14:53:19 -0700
Subject: [PATCH] .NET: Add HttpRequestAction support to declarative workflows
(#5474)
* Add declarative HttpRequestAction support to workflows
* Clean up response body for diagnostics and fix tests.
* Fix merge with main.
* Remove redundant fallback for request content headers.
---
.../DeclarativeWorkflowOptions.cs | 6 +
.../DefaultHttpRequestHandler.cs | 289 +++++++
.../IHttpRequestHandler.cs | 103 +++
.../Interpreter/WorkflowActionVisitor.cs | 14 +-
.../ObjectModel/HttpRequestExecutor.cs | 346 ++++++++
.../Framework/IntegrationTest.cs | 15 +-
.../InvokeToolWorkflowTest.cs | 43 +
.../Workflows/HttpRequest.yaml | 32 +
.../DeclarativeWorkflowTest.cs | 24 +-
.../DefaultHttpRequestHandlerTests.cs | 510 ++++++++++++
.../ObjectModel/HttpRequestExecutorTest.cs | 759 ++++++++++++++++++
.../Workflows/HttpRequest.yaml | 15 +
12 files changed, 2150 insertions(+), 6 deletions(-)
create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IHttpRequestHandler.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/HttpRequest.yaml
create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/HttpRequestExecutorTest.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/HttpRequest.yaml
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs
index 9e421832d4..90439402db 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs
@@ -26,6 +26,12 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid
///
public IMcpToolHandler? McpToolHandler { get; init; }
+ ///
+ /// Gets or sets the HTTP request handler for executing HttpRequestAction actions within workflows.
+ /// If not set, HTTP request actions will fail with an appropriate error message.
+ ///
+ public IHttpRequestHandler? HttpRequestHandler { get; init; }
+
///
/// Defines the configuration settings for the workflow.
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs
new file mode 100644
index 0000000000..606a716c20
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs
@@ -0,0 +1,289 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.Workflows.Declarative;
+
+///
+/// Default implementation of built on .
+///
+///
+///
+/// This handler supports per-request authentication via an optional httpClientProvider callback that
+/// returns a pre-configured for a given request (e.g. authenticated, custom handler).
+/// When the provider returns , or no provider is supplied, a shared internal
+/// is used.
+///
+///
+/// The handler applies the per-request using a linked
+/// so it does not mutate on shared instances.
+///
+///
+public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDisposable
+{
+ private readonly Func>? _httpClientProvider;
+ private readonly Lazy _ownedHttpClient;
+
+ ///
+ /// Initializes a new instance of the class that uses an
+ /// internally owned for all requests. The internal client is disposed
+ /// when is called.
+ ///
+ public DefaultHttpRequestHandler()
+ : this(httpClientProvider: null)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class that uses the
+ /// supplied for all requests.
+ ///
+ ///
+ /// The to use for all requests. The caller retains ownership of this
+ /// instance; it is not disposed by .
+ ///
+ /// is .
+ public DefaultHttpRequestHandler(HttpClient httpClient)
+ : this(CreateSingleClientProvider(httpClient))
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class that selects
+ /// an per request via a caller-supplied callback — for example, to route
+ /// different URLs through differently authenticated clients.
+ ///
+ ///
+ /// An optional callback invoked for each request. The callback receives the
+ /// and should return a pre-configured (e.g. with authentication or a custom
+ /// transport). Return to fall back to the handler's shared internal
+ /// .
+ ///
+ ///
+ ///
+ /// Ownership: the caller is solely responsible for the lifetime of clients returned by this
+ /// callback. will not dispose provider-returned
+ /// clients; only the handler's internally owned fallback client is disposed by .
+ ///
+ ///
+ /// Reuse: callers are expected to cache and reuse clients (for example, keyed by base URL or
+ /// auth scope) across requests. Returning a newly allocated on every
+ /// invocation will leak sockets and handler resources.
+ ///
+ ///
+ public DefaultHttpRequestHandler(Func>? httpClientProvider)
+ {
+ this._httpClientProvider = httpClientProvider;
+ this._ownedHttpClient = new Lazy(() => new HttpClient(), LazyThreadSafetyMode.ExecutionAndPublication);
+ }
+
+ private static Func> CreateSingleClientProvider(HttpClient httpClient)
+ {
+ if (httpClient is null)
+ {
+ throw new ArgumentNullException(nameof(httpClient));
+ }
+
+ return (_, _) => Task.FromResult(httpClient);
+ }
+
+ ///
+ public async Task SendAsync(HttpRequestInfo request, CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Url))
+ {
+ throw new ArgumentException("Request URL must be provided.", nameof(request));
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Method))
+ {
+ throw new ArgumentException("Request method must be provided.", nameof(request));
+ }
+
+ HttpClient? providedClient = null;
+ if (this._httpClientProvider is not null)
+ {
+ providedClient = await this._httpClientProvider(request, cancellationToken).ConfigureAwait(false);
+ }
+
+ HttpClient client = providedClient ?? this._ownedHttpClient.Value;
+
+ using HttpRequestMessage httpRequest = BuildHttpRequestMessage(request);
+
+ using CancellationTokenSource? timeoutCts = request.Timeout is { } timeout && timeout > TimeSpan.Zero
+ ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
+ : null;
+
+ timeoutCts?.CancelAfter(request.Timeout!.Value);
+
+ CancellationToken effectiveToken = timeoutCts?.Token ?? cancellationToken;
+
+ using HttpResponseMessage httpResponse = await client
+ .SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, effectiveToken)
+ .ConfigureAwait(false);
+
+ string? body = httpResponse.Content is null
+ ? null
+#if NET
+ : await httpResponse.Content.ReadAsStringAsync(effectiveToken).ConfigureAwait(false);
+#else
+ : await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
+#endif
+
+ Dictionary> headers = new(StringComparer.OrdinalIgnoreCase);
+ AppendHeaders(headers, httpResponse.Headers);
+ if (httpResponse.Content is not null)
+ {
+ AppendHeaders(headers, httpResponse.Content.Headers);
+ }
+
+ return new HttpRequestResult
+ {
+ StatusCode = (int)httpResponse.StatusCode,
+ IsSuccessStatusCode = httpResponse.IsSuccessStatusCode,
+ Body = body,
+ Headers = headers,
+ };
+ }
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ if (this._ownedHttpClient.IsValueCreated)
+ {
+ this._ownedHttpClient.Value.Dispose();
+ }
+
+ return default;
+ }
+
+ private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo request)
+ {
+ HttpMethod method = ResolveMethod(request.Method);
+ string requestUri = ResolveRequestUri(request);
+ HttpRequestMessage httpRequest = new(method, requestUri);
+
+ if (request.Body is not null)
+ {
+ string contentType = string.IsNullOrWhiteSpace(request.BodyContentType)
+ ? "text/plain"
+ : request.BodyContentType!;
+
+ httpRequest.Content = new StringContent(request.Body, Encoding.UTF8);
+ // Replace the default content-type header (including charset) with the declared type.
+ httpRequest.Content.Headers.Remove("Content-Type");
+ httpRequest.Content.Headers.TryAddWithoutValidation("Content-Type", contentType);
+ }
+
+ if (request.Headers is not null)
+ {
+ foreach (KeyValuePair header in request.Headers)
+ {
+ if (string.IsNullOrEmpty(header.Key))
+ {
+ continue;
+ }
+
+ // Content-* headers belong on HttpContent; all others belong on the request.
+ if (header.Key.StartsWith("Content-", StringComparison.OrdinalIgnoreCase) && httpRequest.Content is not null)
+ {
+ httpRequest.Content.Headers.Remove(header.Key);
+ httpRequest.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ continue;
+ }
+
+ if (!httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value))
+ {
+ httpRequest.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
+ }
+ }
+
+ return httpRequest;
+ }
+
+ private static HttpMethod ResolveMethod(string method)
+ {
+ string normalized = method.Trim().ToUpperInvariant();
+ return normalized switch
+ {
+ "GET" => HttpMethod.Get,
+ "POST" => HttpMethod.Post,
+ "PUT" => HttpMethod.Put,
+ "DELETE" => HttpMethod.Delete,
+#if NET
+ "PATCH" => HttpMethod.Patch,
+#else
+ "PATCH" => new HttpMethod("PATCH"),
+#endif
+ _ => new HttpMethod(normalized),
+ };
+ }
+
+ private static string ResolveRequestUri(HttpRequestInfo request)
+ {
+ string baseUrl = request.Url;
+ if (request.QueryParameters is null || request.QueryParameters.Count == 0)
+ {
+ return baseUrl;
+ }
+
+ StringBuilder queryBuilder = new();
+ foreach (KeyValuePair parameter in request.QueryParameters)
+ {
+ if (string.IsNullOrEmpty(parameter.Key))
+ {
+ continue;
+ }
+
+ if (queryBuilder.Length > 0)
+ {
+ queryBuilder.Append('&');
+ }
+
+ queryBuilder.Append(Uri.EscapeDataString(parameter.Key))
+ .Append('=')
+ .Append(Uri.EscapeDataString(parameter.Value ?? string.Empty));
+ }
+
+ if (queryBuilder.Length == 0)
+ {
+ return baseUrl;
+ }
+
+ char separator = baseUrl.Contains('?') ? '&' : '?';
+ return string.Concat(baseUrl, separator.ToString(), queryBuilder.ToString());
+ }
+
+ private static void AppendHeaders(
+ Dictionary> target,
+ System.Net.Http.Headers.HttpHeaders source)
+ {
+ foreach (KeyValuePair> header in source)
+ {
+ string[] values = header.Value.ToArray();
+
+ if (target.TryGetValue(header.Key, out IReadOnlyList? existing))
+ {
+ List combined = new(existing);
+ combined.AddRange(values);
+ target[header.Key] = combined;
+ }
+ else
+ {
+ target[header.Key] = values;
+ }
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IHttpRequestHandler.cs
new file mode 100644
index 0000000000..df80433d41
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IHttpRequestHandler.cs
@@ -0,0 +1,103 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.Workflows.Declarative;
+
+///
+/// Defines the contract for executing HTTP requests emitted by HttpRequestAction within declarative workflows.
+///
+///
+/// This interface allows the HTTP request dispatch to be abstracted, enabling different implementations
+/// for local development, hosted workflows, authenticated scenarios, and testing.
+///
+public interface IHttpRequestHandler
+{
+ ///
+ /// Sends an HTTP request and returns the response.
+ ///
+ /// The HTTP request to send.
+ /// A token to observe cancellation.
+ /// The describing the HTTP response.
+ Task SendAsync(
+ HttpRequestInfo request,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Describes an HTTP request to be sent by an .
+///
+[SuppressMessage("Design", "CA1056:URI-like properties should not be strings", Justification = "URL is carried as a string to preserve the declarative expression result and to avoid forcing handler implementations to construct a Uri eagerly.")]
+public sealed class HttpRequestInfo
+{
+ ///
+ /// Gets the HTTP method to use (GET, POST, PUT, PATCH, DELETE).
+ ///
+ public string Method { get; init; } = "GET";
+
+ ///
+ /// Gets the absolute URL to send the request to.
+ ///
+ public string Url { get; init; } = string.Empty;
+
+ ///
+ /// Gets the headers to include on the request, excluding the Content-Type header (which is supplied via ).
+ ///
+ public IReadOnlyDictionary? Headers { get; init; }
+
+ ///
+ /// Gets the Content-Type of the request body, or if no body is sent.
+ ///
+ public string? BodyContentType { get; init; }
+
+ ///
+ /// Gets the serialized request body, or if no body is sent.
+ ///
+ public string? Body { get; init; }
+
+ ///
+ /// Gets the maximum amount of time to wait for the request to complete, or to use the handler default.
+ ///
+ public TimeSpan? Timeout { get; init; }
+
+ ///
+ /// Gets the query parameters to append to the request URL, with values already formatted as strings.
+ ///
+ public IReadOnlyDictionary? QueryParameters { get; init; }
+
+ ///
+ /// Gets the name of the declared remote connection, or if no connection is declared.
+ /// This maps to the Foundry project connection Id and is only used when running in foundry service.
+ ///
+ public string? ConnectionName { get; init; }
+}
+
+///
+/// Represents the result of an HTTP request executed by an .
+///
+public sealed class HttpRequestResult
+{
+ ///
+ /// Gets the HTTP status code returned by the server.
+ ///
+ public int StatusCode { get; init; }
+
+ ///
+ /// Gets a value indicating whether the status code is in the range 200-299.
+ ///
+ public bool IsSuccessStatusCode { get; init; }
+
+ ///
+ /// Gets the response body, or if no body was returned.
+ ///
+ public string? Body { get; init; }
+
+ ///
+ /// Gets the response headers keyed by header name. Each header may have multiple values.
+ ///
+ public IReadOnlyDictionary>? Headers { get; init; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs
index fd818672dd..1cd1b2bc94 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs
@@ -529,6 +529,18 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId);
}
+ protected override void Visit(HttpRequestAction item)
+ {
+ this.Trace(item);
+
+ if (this._workflowOptions.HttpRequestHandler is null)
+ {
+ throw new DeclarativeModelException("HTTP request handler not configured. Set HttpRequestHandler in DeclarativeWorkflowOptions to use HttpRequestAction actions.");
+ }
+
+ this.ContinueWith(new HttpRequestExecutor(item, this._workflowOptions.HttpRequestHandler, this._workflowOptions.AgentProvider, this._workflowState));
+ }
+
#region Not supported
protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item);
@@ -573,8 +585,6 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
protected override void Visit(GetConversationMembers item) => this.NotSupported(item);
- protected override void Visit(HttpRequestAction item) => this.NotSupported(item);
-
protected override void Visit(RecognizeIntent item) => this.NotSupported(item);
protected override void Visit(TransferConversation item) => this.NotSupported(item);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs
new file mode 100644
index 0000000000..6bdddbf4e5
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs
@@ -0,0 +1,346 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
+using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
+using Microsoft.Agents.AI.Workflows.Declarative.Kit;
+using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
+using Microsoft.Agents.ObjectModel;
+using Microsoft.Extensions.AI;
+using Microsoft.PowerFx.Types;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
+
+///
+/// Executor for the action.
+/// Dispatches the request through the configured and assigns
+/// the response body and headers to the declared property paths.
+///
+internal sealed class HttpRequestExecutor(
+ HttpRequestAction model,
+ IHttpRequestHandler httpRequestHandler,
+ ResponseAgentProvider agentProvider,
+ WorkflowFormulaState state) :
+ DeclarativeActionExecutor(model, state)
+{
+ ///
+ protected override async ValueTask