.NET: Add declarative HttpRequestAction sample (#5572)

* 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.

* Add declarative InvokeHttpRequest sample

* Fix solution file and update sample yaml comments

* Add final newline to sample class to fix formatting failure
This commit is contained in:
Peter Ibekwe
2026-04-29 19:19:31 +00:00
committed by GitHub
parent 570a4d54c2
commit 6853f64de8
10 changed files with 449 additions and 8 deletions
@@ -16,6 +16,60 @@ internal static class ChatMessageExtensions
public static RecordValue ToRecord(this ChatMessage message) =>
FormulaValue.NewRecordFromFields(message.GetMessageFields());
/// <summary>
/// Merges the user-authored <paramref name="input"/> with the round-tripped
/// <paramref name="inputMessage"/> returned by <c>AgentProvider.CreateMessageAsync</c>
/// to produce the value stored in <c>System.LastMessage</c>.
/// </summary>
/// <remarks>
/// The agent service often strips or alters <see cref="TextContent"/> on round-trip,
/// while replacing inline media (<see cref="DataContent"/>, <see cref="UriContent"/>)
/// with server-side references (typically <see cref="HostedFileContent"/>).
/// We want both: the original text (so <c>=System.LastMessage.Text</c> works) and
/// the server's media references (so subsequent actions don't re-upload large blobs).
/// <para>
/// Strategy: keep <paramref name="inputMessage"/> as the base — it has the server-generated
/// <see cref="ChatMessage.MessageId"/> and any provider-augmented metadata, and is forward-
/// compatible with new properties added on <see cref="ChatMessage"/> in the abstractions
/// layer. Only the <see cref="ChatMessage.Contents"/> list is mutated to substitute
/// original <see cref="TextContent"/> items in place (and append any extras the round-trip
/// dropped). Non-text content items returned by the service are left untouched so
/// server-side references survive.
/// </para>
/// </remarks>
public static ChatMessage MergeForLastMessage(this ChatMessage input, ChatMessage? inputMessage)
{
if (inputMessage is null)
{
return input;
}
// Build a queue of the original text items, in order. Fall back to ChatMessage.Text
// if the input has no explicit TextContent entries.
Queue<TextContent> originalTexts = new(input.Contents.OfType<TextContent>());
if (originalTexts.Count == 0 && !string.IsNullOrEmpty(input.Text))
{
originalTexts.Enqueue(new TextContent(input.Text));
}
// Replace TextContent items in inputMessage.Contents with the originals, in order.
for (int i = 0; i < inputMessage.Contents.Count && originalTexts.Count > 0; i++)
{
if (inputMessage.Contents[i] is TextContent)
{
inputMessage.Contents[i] = originalTexts.Dequeue();
}
}
// Append any remaining original text items that the round-trip dropped entirely.
while (originalTexts.Count > 0)
{
inputMessage.Contents.Add(originalTexts.Dequeue());
}
return inputMessage;
}
public static TableValue ToTable(this IEnumerable<ChatMessage> messages) =>
FormulaValue.NewTable(TypeSchema.Message.RecordType, messages.Select(message => message.ToRecord()));
@@ -43,7 +43,11 @@ internal sealed class DeclarativeWorkflowExecutor<TInput>(
await declarativeContext.QueueConversationUpdateAsync(conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId, input, cancellationToken).ConfigureAwait(false);
await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false);
// Use the original input for System.LastMessage to ensure Text is preserved (the
// service may strip text on round-trip), but substitute server-side media references
// (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
@@ -58,7 +58,6 @@ public abstract class RootExecutor<TInput> : Executor<TInput>, IResettableExecut
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
DeclarativeWorkflowContext declarativeContext = new(context, this._state);
await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false);
ChatMessage input = (this._inputTransform ?? DefaultInputTransform).Invoke(message);
@@ -69,7 +68,13 @@ public abstract class RootExecutor<TInput> : Executor<TInput>, IResettableExecut
await declarativeContext.QueueConversationUpdateAsync(this._conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
ChatMessage inputMessage = await this._agentProvider.CreateMessageAsync(this._conversationId, input, cancellationToken).ConfigureAwait(false);
await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false);
// Use the original input for System.LastMessage to ensure Text is preserved (the
// service may strip text on round-trip), but substitute server-side media references
// (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false);
await declarativeContext.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
@@ -25,6 +25,9 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
// Assign to provide MCP tool capabilities
public IMcpToolHandler? McpToolHandler { get; init; }
// Assign to enable HttpRequestAction support
public IHttpRequestHandler? HttpRequestHandler { get; init; }
/// <summary>
/// Create the workflow from the declarative YAML. Includes definition of the
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="ResponseAgentProvider"/>.
@@ -46,6 +49,7 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
ConversationId = this.ConversationId,
LoggerFactory = this.LoggerFactory,
McpToolHandler = this.McpToolHandler,
HttpRequestHandler = this.HttpRequestHandler,
};
string workflowPath = Path.Combine(AppContext.BaseDirectory, workflowFile);
@@ -162,7 +162,10 @@ internal sealed class WorkflowRunner
case RequestInfoEvent requestInfo:
Debug.WriteLine($"REQUEST #{requestInfo.Request.RequestId}");
externalResponse = requestInfo.Request;
if (response is null || !string.Equals(requestInfo.Request.RequestId, response.RequestId, StringComparison.Ordinal))
{
externalResponse = requestInfo.Request;
}
break;
case ConversationUpdateEvent invokeEvent: