Skip to content

Commit 6853f64

Browse files
authored
.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
1 parent 570a4d5 commit 6853f64

10 files changed

Lines changed: 449 additions & 8 deletions

File tree

dotnet/agent-framework-dotnet.slnx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,10 @@
163163
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj" />
164164
</Folder>
165165
<Folder Name="/Samples/02-agents/Evaluation/">
166-
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
167166
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
168167
<Project Path="samples/02-agents/Evaluation/Evaluation_ExpectedOutputs/Evaluation_ExpectedOutputs.csproj" />
169168
<Project Path="samples/02-agents/Evaluation/Evaluation_Multimodal/Evaluation_Multimodal.csproj" />
169+
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
170170
</Folder>
171171
<Folder Name="/Samples/02-agents/AgentWithMemory/">
172172
<File Path="samples/02-agents/AgentWithMemory/README.md" />
@@ -226,6 +226,7 @@
226226
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
227227
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.csproj" />
228228
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
229+
<Project Path="samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj" />
229230
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
230231
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
231232
<Project Path="samples/03-workflows/Declarative/StudentTeacher/StudentTeacher.csproj" />
@@ -347,17 +348,17 @@
347348
<File Path="samples/02-agents/A2A/README.md" />
348349
<Project Path="samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj" />
349350
<Project Path="samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
350-
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
351351
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
352+
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
352353
</Folder>
353354
<Folder Name="/Samples/05-end-to-end/">
354355
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
355356
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
356357
</Folder>
357358
<Folder Name="/Samples/05-end-to-end/Evaluation/">
359+
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Evaluation_ConversationSplits.csproj" />
358360
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_FoundryQuality/Evaluation_FoundryQuality.csproj" />
359361
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_MixedProviders/Evaluation_MixedProviders.csproj" />
360-
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Evaluation_ConversationSplits.csproj" />
361362
</Folder>
362363
<Folder Name="/Samples/05-end-to-end/A2AClientServer/">
363364
<File Path="samples/05-end-to-end/A2AClientServer/README.md" />
@@ -543,8 +544,8 @@
543544
<Project Path="src/Microsoft.Agents.AI.Declarative/Microsoft.Agents.AI.Declarative.csproj" />
544545
<Project Path="src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj" />
545546
<Project Path="src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj" />
546-
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
547547
<Project Path="src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj" />
548+
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
548549
<Project Path="src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj" />
549550
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
550551
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFrameworks>net10.0</TargetFrameworks>
6+
<Nullable>enable</Nullable>
7+
<ImplicitUsings>enable</ImplicitUsings>
8+
</PropertyGroup>
9+
10+
<PropertyGroup>
11+
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
12+
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
13+
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
14+
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
15+
</PropertyGroup>
16+
17+
<ItemGroup>
18+
<PackageReference Include="Microsoft.Extensions.Configuration" />
19+
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
20+
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
21+
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
22+
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
23+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
24+
<PackageReference Include="Microsoft.Extensions.Logging" />
25+
</ItemGroup>
26+
27+
<ItemGroup>
28+
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
29+
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Foundry\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
30+
</ItemGroup>
31+
32+
<ItemGroup>
33+
<None Include="InvokeHttpRequest.yaml">
34+
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
35+
</None>
36+
</ItemGroup>
37+
38+
</Project>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#
2+
# This workflow demonstrates using HttpRequestAction to call a REST API directly
3+
# from the workflow without going through an AI agent first.
4+
#
5+
# HttpRequestAction allows workflows to:
6+
# - Fetch data from external HTTP endpoints
7+
# - Store the parsed response in workflow variables for later use
8+
# - Add the response body to the conversation so a downstream agent can
9+
# answer questions based on it
10+
#
11+
# This sample fetches public metadata for the dotnet/runtime repository from
12+
# the GitHub REST API (no authentication required) and uses an agent to
13+
# answer follow-up questions about it.
14+
#
15+
# Example input:
16+
# How many subscribers does the repository have?
17+
#
18+
kind: Workflow
19+
trigger:
20+
21+
kind: OnConversationStart
22+
id: workflow_invoke_http_request_demo
23+
actions:
24+
25+
# Capture the original user message for input to the follow-up agent.
26+
- kind: SetVariable
27+
id: set_user_message
28+
variable: Local.InputMessage
29+
value: =System.LastMessage
30+
31+
# Set the repository org/name used to form the request URL.
32+
- kind: SetVariable
33+
id: set_repo_name
34+
variable: Local.RepoName
35+
value: microsoft/agent-framework
36+
37+
# Invoke the GitHub repo API. The response body is parsed into Local.RepoInfo
38+
# and also added to the conversation (via conversationId) so the agent below
39+
# can answer questions based on it.
40+
- kind: HttpRequestAction
41+
id: fetch_repo_info
42+
conversationId: =System.ConversationId
43+
method: GET
44+
url: =Concatenate("https://api.github.com/repos/", Local.RepoName)
45+
headers:
46+
Accept: application/vnd.github+json
47+
User-Agent: agent-framework-sample
48+
response: Local.RepoInfo
49+
50+
# Display a confirmation message showing key fields from the parsed response.
51+
- kind: SendMessage
52+
id: show_repo_summary
53+
message: "Fetched repo: visibility={Local.RepoInfo.visibility}, description={Local.RepoInfo.description}"
54+
55+
# Use the agent to summarize the repo using the conversation context.
56+
- kind: InvokeAzureAgent
57+
id: summarize_repo
58+
conversationId: =System.ConversationId
59+
agent:
60+
name: GitHubRepoInfoAgent
61+
input:
62+
messages: =UserMessage("Please provide a brief summary of this GitHub repository based on the data already in the conversation.")
63+
output:
64+
autoSend: true
65+
messages: Local.AgentResponse
66+
67+
# Allow the user to ask follow-up questions about the repo in a loop.
68+
- kind: InvokeAzureAgent
69+
id: invoke_followup
70+
conversationId: =System.ConversationId
71+
agent:
72+
name: GitHubRepoInfoAgent
73+
input:
74+
messages: =Local.InputMessage
75+
externalLoop:
76+
when: =Upper(System.LastMessage.Text) <> "EXIT"
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using Azure.AI.Projects;
4+
using Azure.AI.Projects.Agents;
5+
using Azure.Identity;
6+
using Microsoft.Agents.AI.Workflows.Declarative;
7+
using Microsoft.Extensions.Configuration;
8+
using Shared.Foundry;
9+
using Shared.Workflows;
10+
11+
namespace Demo.Workflows.Declarative.InvokeHttpRequest;
12+
13+
/// <summary>
14+
/// Demonstrates a workflow that uses HttpRequestAction to call a REST API
15+
/// directly from the workflow.
16+
/// </summary>
17+
/// <remarks>
18+
/// <para>
19+
/// The HttpRequestAction allows workflows to issue HTTP requests and:
20+
/// </para>
21+
/// <list type="bullet">
22+
/// <item>Fetch data from external REST endpoints</item>
23+
/// <item>Store the parsed response in workflow variables</item>
24+
/// <item>Add the response body to the conversation so an agent can answer
25+
/// questions based on it</item>
26+
/// </list>
27+
/// <para>
28+
/// This sample fetches public metadata for the dotnet/runtime repository from
29+
/// the GitHub REST API (no authentication required) and uses a Foundry agent
30+
/// to answer follow-up questions about it. Type "EXIT" to end the conversation.
31+
/// </para>
32+
/// <para>
33+
/// See the README.md file in the parent folder (../README.md) for detailed
34+
/// information about the configuration required to run this sample.
35+
/// </para>
36+
/// </remarks>
37+
internal sealed class Program
38+
{
39+
public static async Task Main(string[] args)
40+
{
41+
// Initialize configuration
42+
IConfiguration configuration = Application.InitializeConfig();
43+
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
44+
45+
// Ensure sample agent exists in Foundry. The agent has no tools - it answers
46+
// questions about the GitHub repository using only the JSON data that the
47+
// HttpRequestAction adds to the conversation.
48+
await CreateAgentAsync(foundryEndpoint, configuration);
49+
50+
// Get input from command line or console
51+
string workflowInput = Application.GetInput(args);
52+
53+
// The default HttpRequestHandler is sufficient for this sample because the
54+
// GitHub REST endpoint used here does not require authentication. For
55+
// authenticated endpoints, supply a custom Func<HttpRequestInfo, ..., HttpClient?>
56+
// to DefaultHttpRequestHandler so each request can be routed through a
57+
// pre-configured (cached) HttpClient with the appropriate credentials.
58+
await using DefaultHttpRequestHandler httpRequestHandler = new();
59+
60+
// Create the workflow factory with the HTTP request handler
61+
WorkflowFactory workflowFactory = new("InvokeHttpRequest.yaml", foundryEndpoint)
62+
{
63+
HttpRequestHandler = httpRequestHandler
64+
};
65+
66+
// Execute the workflow
67+
WorkflowRunner runner = new() { UseJsonCheckpoints = true };
68+
await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
69+
}
70+
71+
private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration)
72+
{
73+
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
74+
AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential());
75+
76+
await aiProjectClient.CreateAgentAsync(
77+
agentName: "GitHubRepoInfoAgent",
78+
agentDefinition: DefineAgent(configuration),
79+
agentDescription: "Answers questions about a GitHub repository using HTTP response data in the conversation");
80+
}
81+
82+
private static DeclarativeAgentDefinition DefineAgent(IConfiguration configuration)
83+
{
84+
return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel))
85+
{
86+
Instructions =
87+
"""
88+
Answer the user's questions about the GitHub repository using only the
89+
JSON data already present in the conversation history.
90+
If the answer is not contained in the conversation, say so plainly
91+
rather than guessing. Be concise and helpful.
92+
"""
93+
};
94+
}
95+
}

dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,60 @@ internal static class ChatMessageExtensions
1616
public static RecordValue ToRecord(this ChatMessage message) =>
1717
FormulaValue.NewRecordFromFields(message.GetMessageFields());
1818

19+
/// <summary>
20+
/// Merges the user-authored <paramref name="input"/> with the round-tripped
21+
/// <paramref name="inputMessage"/> returned by <c>AgentProvider.CreateMessageAsync</c>
22+
/// to produce the value stored in <c>System.LastMessage</c>.
23+
/// </summary>
24+
/// <remarks>
25+
/// The agent service often strips or alters <see cref="TextContent"/> on round-trip,
26+
/// while replacing inline media (<see cref="DataContent"/>, <see cref="UriContent"/>)
27+
/// with server-side references (typically <see cref="HostedFileContent"/>).
28+
/// We want both: the original text (so <c>=System.LastMessage.Text</c> works) and
29+
/// the server's media references (so subsequent actions don't re-upload large blobs).
30+
/// <para>
31+
/// Strategy: keep <paramref name="inputMessage"/> as the base — it has the server-generated
32+
/// <see cref="ChatMessage.MessageId"/> and any provider-augmented metadata, and is forward-
33+
/// compatible with new properties added on <see cref="ChatMessage"/> in the abstractions
34+
/// layer. Only the <see cref="ChatMessage.Contents"/> list is mutated to substitute
35+
/// original <see cref="TextContent"/> items in place (and append any extras the round-trip
36+
/// dropped). Non-text content items returned by the service are left untouched so
37+
/// server-side references survive.
38+
/// </para>
39+
/// </remarks>
40+
public static ChatMessage MergeForLastMessage(this ChatMessage input, ChatMessage? inputMessage)
41+
{
42+
if (inputMessage is null)
43+
{
44+
return input;
45+
}
46+
47+
// Build a queue of the original text items, in order. Fall back to ChatMessage.Text
48+
// if the input has no explicit TextContent entries.
49+
Queue<TextContent> originalTexts = new(input.Contents.OfType<TextContent>());
50+
if (originalTexts.Count == 0 && !string.IsNullOrEmpty(input.Text))
51+
{
52+
originalTexts.Enqueue(new TextContent(input.Text));
53+
}
54+
55+
// Replace TextContent items in inputMessage.Contents with the originals, in order.
56+
for (int i = 0; i < inputMessage.Contents.Count && originalTexts.Count > 0; i++)
57+
{
58+
if (inputMessage.Contents[i] is TextContent)
59+
{
60+
inputMessage.Contents[i] = originalTexts.Dequeue();
61+
}
62+
}
63+
64+
// Append any remaining original text items that the round-trip dropped entirely.
65+
while (originalTexts.Count > 0)
66+
{
67+
inputMessage.Contents.Add(originalTexts.Dequeue());
68+
}
69+
70+
return inputMessage;
71+
}
72+
1973
public static TableValue ToTable(this IEnumerable<ChatMessage> messages) =>
2074
FormulaValue.NewTable(TypeSchema.Message.RecordType, messages.Select(message => message.ToRecord()));
2175

dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@ public override async ValueTask HandleAsync(TInput message, IWorkflowContext con
4343
await declarativeContext.QueueConversationUpdateAsync(conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
4444

4545
ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId, input, cancellationToken).ConfigureAwait(false);
46-
await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false);
46+
47+
// Use the original input for System.LastMessage to ensure Text is preserved (the
48+
// service may strip text on round-trip), but substitute server-side media references
49+
// (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
50+
await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
4751

4852
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
4953
}

dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ public ValueTask ResetAsync()
5858
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
5959
{
6060
DeclarativeWorkflowContext declarativeContext = new(context, this._state);
61-
await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false);
6261

6362
ChatMessage input = (this._inputTransform ?? DefaultInputTransform).Invoke(message);
6463

@@ -69,7 +68,13 @@ public override async ValueTask HandleAsync(TInput message, IWorkflowContext con
6968
await declarativeContext.QueueConversationUpdateAsync(this._conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
7069

7170
ChatMessage inputMessage = await this._agentProvider.CreateMessageAsync(this._conversationId, input, cancellationToken).ConfigureAwait(false);
72-
await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false);
71+
72+
// Use the original input for System.LastMessage to ensure Text is preserved (the
73+
// service may strip text on round-trip), but substitute server-side media references
74+
// (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
75+
await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
76+
77+
await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false);
7378

7479
await declarativeContext.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
7580
}

dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
2525
// Assign to provide MCP tool capabilities
2626
public IMcpToolHandler? McpToolHandler { get; init; }
2727

28+
// Assign to enable HttpRequestAction support
29+
public IHttpRequestHandler? HttpRequestHandler { get; init; }
30+
2831
/// <summary>
2932
/// Create the workflow from the declarative YAML. Includes definition of the
3033
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="ResponseAgentProvider"/>.
@@ -46,6 +49,7 @@ public Workflow CreateWorkflow()
4649
ConversationId = this.ConversationId,
4750
LoggerFactory = this.LoggerFactory,
4851
McpToolHandler = this.McpToolHandler,
52+
HttpRequestHandler = this.HttpRequestHandler,
4953
};
5054

5155
string workflowPath = Path.Combine(AppContext.BaseDirectory, workflowFile);

dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,10 @@ public async Task ExecuteAsync(Func<Workflow> workflowProvider, string input)
162162

163163
case RequestInfoEvent requestInfo:
164164
Debug.WriteLine($"REQUEST #{requestInfo.Request.RequestId}");
165-
externalResponse = requestInfo.Request;
165+
if (response is null || !string.Equals(requestInfo.Request.RequestId, response.RequestId, StringComparison.Ordinal))
166+
{
167+
externalResponse = requestInfo.Request;
168+
}
166169
break;
167170

168171
case ConversationUpdateEvent invokeEvent:

0 commit comments

Comments
 (0)