.NET: [Feature Branch] Update HTTP API to be consistent across languages (#2118)

This commit is contained in:
Chris Gillum
2025-11-12 08:55:53 -08:00
committed by GitHub
Unverified
parent 586238c1c3
commit 2329bd4841
5 changed files with 283 additions and 51 deletions
@@ -35,8 +35,55 @@ Invoke-RestMethod -Method Post `
-Body "Tell me a joke about a pirate."
```
The response from the agent will be displayed in the terminal where you ran `func start`. The expected output will look something like:
You can also send JSON requests:
```bash
curl -X POST http://localhost:7071/api/agents/Joker/run \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"message": "Tell me a joke about a pirate."}'
```
To continue a conversation, include the `thread_id` in the query string or JSON body:
```bash
curl -X POST "http://localhost:7071/api/agents/Joker/run?thread_id=@dafx-joker@your-thread-id" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"message": "Tell me another one."}'
```
The response from the agent will be displayed in the terminal where you ran `func start`. The expected `text/plain` output will look something like:
```text
Why don't pirates ever learn the alphabet? Because they always get stuck at "C"!
```
The expected `application/json` output will look something like:
```json
{
"status": 200,
"thread_id": "@dafx-joker@your-thread-id",
"response": {
"Messages": [
{
"AuthorName": "Joker",
"CreatedAt": "2025-11-11T12:00:00.0000000Z",
"Role": "assistant",
"Contents": [
{
"Type": "text",
"Text": "Why don't pirates ever learn the alphabet? Because they always get stuck at 'C'!"
}
]
}
],
"Usage": {
"InputTokenCount": 78,
"OutputTokenCount": 36,
"TotalTokenCount": 114
}
}
}
```
@@ -29,7 +29,7 @@ curl -i -X POST http://localhost:7071/api/agents/publisher/run \
-d 'Start a content generation workflow for the topic \"The Future of Artificial Intelligence\"'
# Save the thread ID to a variable and print it to the terminal
threadId=$(cat headers.txt | grep "X-Agent-Thread" | cut -d' ' -f2)
threadId=$(cat headers.txt | grep "x-ms-thread-id" | cut -d' ' -f2)
echo "Thread ID: $threadId"
```
@@ -43,7 +43,7 @@ Invoke-RestMethod -Method Post `
-Body 'Start a content generation workflow for the topic \"The Future of Artificial Intelligence\"' `
# Save the thread ID to a variable and print it to the console
$threadId = $ResponseHeaders['X-Agent-Thread']
$threadId = $ResponseHeaders['x-ms-thread-id']
Write-Host "Thread ID: $threadId"
```
@@ -52,12 +52,12 @@ The response will be a text string that looks something like the following, indi
```http
HTTP/1.1 200 OK
Content-Type: text/plain
X-Agent-Thread: @publisher@351ec855-7f4d-4527-a60d-498301ced36d
x-ms-thread-id: @publisher@351ec855-7f4d-4527-a60d-498301ced36d
```
The `x-ms-thread-id` response header contains the thread ID, which can be used to continue the conversation by passing it as a query parameter (`thread_id`) to the `run` endpoint. The commands above show how to save the thread ID to a `$threadId` variable for use in subsequent requests.
Behind the scenes, the publisher agent will:
1. Start the content generation workflow via a tool call
@@ -70,12 +70,12 @@ Bash (Linux/macOS/WSL):
```bash
# Approve the content
curl -X POST "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" \
-H "Content-Type: text/plain" \
-H "Content-Type: text/plain" \
-d 'Approve the content'
# Reject the content with feedback
curl -X POST "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" \
-H "Content-Type: text/plain" \
-H "Content-Type: text/plain" \
-d 'Reject the content with feedback: The article needs more technical depth and better examples.'
```
@@ -85,13 +85,13 @@ PowerShell:
# Approve the content
Invoke-RestMethod -Method Post `
-Uri "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" `
-ContentType text/plain `
-ContentType text/plain `
-Body 'Approve the content'
# Reject the content with feedback
Invoke-RestMethod -Method Post `
-Uri "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" `
-ContentType text/plain `
-ContentType text/plain `
-Body 'Reject the content with feedback: The article needs more technical depth and better examples.'
```
@@ -101,7 +101,7 @@ Once the workflow has completed, you can get the status by prompting the publish
```bash
curl -X POST "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" \
-H "Content-Type: text/plain" \
-H "Content-Type: text/plain" \
-d 'Get the status of the workflow you previously started'
```
@@ -110,7 +110,7 @@ PowerShell:
```powershell
Invoke-RestMethod -Method Post `
-Uri "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" `
-ContentType text/plain `
-ContentType text/plain `
-Body 'Get the status of the workflow you previously started'
```
@@ -9,19 +9,19 @@ Start a content generation workflow for the topic 'The Future of Artificial Inte
@threadId = <YOUR_THREAD_ID>
### Check the status of the workflow
POST http://localhost:7071/api/agents/publisher/run?threadId={{threadId}}
POST http://localhost:7071/api/agents/publisher/run?thread_id={{threadId}}
Content-Type: text/plain
Check the status of the workflow you previously started
### Reject content with feedback
POST http://localhost:7071/api/agents/publisher/run?threadId={{threadId}}
POST http://localhost:7071/api/agents/publisher/run?thread_id={{threadId}}
Content-Type: text/plain
Reject the content with feedback: The article needs more technical depth and better examples.
### Approve content
POST http://localhost:7071/api/agents/publisher/run?threadId={{threadId}}
POST http://localhost:7071/api/agents/publisher/run?thread_id={{threadId}}
Content-Type: text/plain
Approve the content
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Extensions.Mcp;
@@ -45,55 +46,101 @@ internal static class BuiltInFunctions
[DurableClient] DurableTaskClient client,
FunctionContext context)
{
// The session ID is an optional query string parameter.
string? threadIdFromQuery = req.Query["threadId"];
// Parse request body - support both JSON and plain text
string? message = null;
string? threadIdFromBody = null;
if (req.Headers.TryGetValues("Content-Type", out IEnumerable<string>? contentTypeValues) &&
contentTypeValues.Any(ct => ct.Contains("application/json", StringComparison.OrdinalIgnoreCase)))
{
// Parse JSON body using POCO record
AgentRunRequest? requestBody = await req.ReadFromJsonAsync<AgentRunRequest>(context.CancellationToken);
if (requestBody != null)
{
message = requestBody.Message;
threadIdFromBody = requestBody.ThreadId;
}
}
else
{
// Plain text body
message = await req.ReadAsStringAsync();
}
// The thread ID can come from query string or JSON body
string? threadIdFromQuery = req.Query["thread_id"];
// Validate that if thread_id is specified in both places, they must match
if (!string.IsNullOrEmpty(threadIdFromQuery) && !string.IsNullOrEmpty(threadIdFromBody) &&
!string.Equals(threadIdFromQuery, threadIdFromBody, StringComparison.Ordinal))
{
return await CreateErrorResponseAsync(
req,
context,
HttpStatusCode.BadRequest,
"thread_id specified in both query string and request body must match.");
}
string? threadIdValue = threadIdFromBody ?? threadIdFromQuery;
// If no session ID is provided, use a new one based on the function name and invocation ID.
// This may be better than a random one because it can be correlated with the function invocation.
// Specifying a session ID is how the caller correlates multiple calls to the same agent session.
AgentSessionId sessionId = string.IsNullOrEmpty(threadIdFromQuery)
AgentSessionId sessionId = string.IsNullOrEmpty(threadIdValue)
? new AgentSessionId(GetAgentName(context), context.InvocationId)
: AgentSessionId.Parse(threadIdFromQuery);
: AgentSessionId.Parse(threadIdValue);
string? message = await req.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(message))
{
HttpResponseData response = req.CreateResponse(HttpStatusCode.BadRequest);
await response.WriteAsJsonAsync(
new { error = "Run request cannot be empty." },
context.CancellationToken);
return response;
return await CreateErrorResponseAsync(
req,
context,
HttpStatusCode.BadRequest,
"Run request cannot be empty.");
}
// Check if we should wait for response (default is true)
bool waitForResponse = true;
if (req.Headers.TryGetValues("x-ms-wait-for-response", out IEnumerable<string>? waitForResponseValues))
{
string? waitForResponseValue = waitForResponseValues.FirstOrDefault();
if (!string.IsNullOrEmpty(waitForResponseValue) && bool.TryParse(waitForResponseValue, out bool parsedValue))
{
waitForResponse = parsedValue;
}
}
AIAgent agentProxy = client.AsDurableAgentProxy(context, GetAgentName(context));
AgentRunResponse agentResponse = await agentProxy.RunAsync(
DurableAgentRunOptions options = new() { IsFireAndForget = !waitForResponse };
if (waitForResponse)
{
AgentRunResponse agentResponse = await agentProxy.RunAsync(
message: new ChatMessage(ChatRole.User, message),
thread: new DurableAgentThread(sessionId),
options: options,
cancellationToken: context.CancellationToken);
return await CreateSuccessResponseAsync(
req,
context,
HttpStatusCode.OK,
sessionId.ToString(),
agentResponse);
}
// Fire and forget - return 202 Accepted
await agentProxy.RunAsync(
message: new ChatMessage(ChatRole.User, message),
thread: new DurableAgentThread(sessionId),
options: null,
options: options,
cancellationToken: context.CancellationToken);
HttpResponseData httpResponse = req.CreateResponse(HttpStatusCode.OK);
httpResponse.Headers.Add("X-Agent-Thread", sessionId.ToString());
// If the caller accepts JSON, return the entire response object as JSON.
// TODO: Need to define a standard response schema for agent responses.
// https://github.com/Azure/durable-agent-framework/issues/113
if (req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase))
{
await httpResponse.WriteAsJsonAsync(
new { response = agentResponse, threadId = sessionId },
context.CancellationToken);
}
else
{
// The default is to return the response as text/plain.
httpResponse.Headers.Add("Content-Type", "text/plain");
await httpResponse.WriteStringAsync(agentResponse.Text, context.CancellationToken);
}
return httpResponse;
return await CreateAcceptedResponseAsync(
req,
context,
sessionId.ToString());
}
public static async Task<string?> RunMcpToolAsync(
@@ -128,6 +175,106 @@ internal static class BuiltInFunctions
return agentResponse.Text;
}
/// <summary>
/// Creates an error response with the specified status code and error message.
/// </summary>
/// <param name="req">The HTTP request data.</param>
/// <param name="context">The function context.</param>
/// <param name="statusCode">The HTTP status code.</param>
/// <param name="errorMessage">The error message.</param>
/// <returns>The HTTP response data containing the error.</returns>
private static async Task<HttpResponseData> CreateErrorResponseAsync(
HttpRequestData req,
FunctionContext context,
HttpStatusCode statusCode,
string errorMessage)
{
HttpResponseData response = req.CreateResponse(statusCode);
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
if (acceptsJson)
{
ErrorResponse errorResponse = new((int)statusCode, errorMessage);
await response.WriteAsJsonAsync(errorResponse, context.CancellationToken);
}
else
{
response.Headers.Add("Content-Type", "text/plain");
await response.WriteStringAsync(errorMessage, context.CancellationToken);
}
return response;
}
/// <summary>
/// Creates a successful agent run response with the agent's response.
/// </summary>
/// <param name="req">The HTTP request data.</param>
/// <param name="context">The function context.</param>
/// <param name="statusCode">The HTTP status code (typically 200 OK).</param>
/// <param name="threadId">The thread ID for the conversation.</param>
/// <param name="agentResponse">The agent's response.</param>
/// <returns>The HTTP response data containing the success response.</returns>
private static async Task<HttpResponseData> CreateSuccessResponseAsync(
HttpRequestData req,
FunctionContext context,
HttpStatusCode statusCode,
string threadId,
AgentRunResponse agentResponse)
{
HttpResponseData response = req.CreateResponse(statusCode);
response.Headers.Add("x-ms-thread-id", threadId);
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
if (acceptsJson)
{
AgentRunSuccessResponse successResponse = new((int)statusCode, threadId, agentResponse);
await response.WriteAsJsonAsync(successResponse, context.CancellationToken);
}
else
{
response.Headers.Add("Content-Type", "text/plain");
await response.WriteStringAsync(agentResponse.Text, context.CancellationToken);
}
return response;
}
/// <summary>
/// Creates an accepted (fire-and-forget) agent run response.
/// </summary>
/// <param name="req">The HTTP request data.</param>
/// <param name="context">The function context.</param>
/// <param name="threadId">The thread ID for the conversation.</param>
/// <returns>The HTTP response data containing the accepted response.</returns>
private static async Task<HttpResponseData> CreateAcceptedResponseAsync(
HttpRequestData req,
FunctionContext context,
string threadId)
{
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
response.Headers.Add("x-ms-thread-id", threadId);
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
if (acceptsJson)
{
AgentRunAcceptedResponse acceptedResponse = new((int)HttpStatusCode.Accepted, threadId);
await response.WriteAsJsonAsync(acceptedResponse, context.CancellationToken);
}
else
{
response.Headers.Add("Content-Type", "text/plain");
await response.WriteStringAsync("Request accepted.", context.CancellationToken);
}
return response;
}
private static string GetAgentName(FunctionContext context)
{
// Check if the function name starts with the HttpPrefix
@@ -144,6 +291,44 @@ internal static class BuiltInFunctions
return functionName[HttpPrefix.Length..];
}
/// <summary>
/// Represents a request to run an agent.
/// </summary>
/// <param name="Message">The message to send to the agent.</param>
/// <param name="ThreadId">The optional thread ID to continue a conversation.</param>
private sealed record AgentRunRequest(
[property: JsonPropertyName("message")] string? Message,
[property: JsonPropertyName("thread_id")] string? ThreadId);
/// <summary>
/// Represents an error response.
/// </summary>
/// <param name="Status">The HTTP status code.</param>
/// <param name="Error">The error message.</param>
private sealed record ErrorResponse(
[property: JsonPropertyName("status")] int Status,
[property: JsonPropertyName("error")] string Error);
/// <summary>
/// Represents a successful agent run response.
/// </summary>
/// <param name="Status">The HTTP status code.</param>
/// <param name="ThreadId">The thread ID for the conversation.</param>
/// <param name="Response">The agent response.</param>
private sealed record AgentRunSuccessResponse(
[property: JsonPropertyName("status")] int Status,
[property: JsonPropertyName("thread_id")] string ThreadId,
[property: JsonPropertyName("response")] AgentRunResponse Response);
/// <summary>
/// Represents an accepted (fire-and-forget) agent run response.
/// </summary>
/// <param name="Status">The HTTP status code.</param>
/// <param name="ThreadId">The thread ID for the conversation.</param>
private sealed record AgentRunAcceptedResponse(
[property: JsonPropertyName("status")] int Status,
[property: JsonPropertyName("thread_id")] string ThreadId);
/// <summary>
/// A service provider that combines the original service provider with an additional DurableTaskClient instance.
/// </summary>
@@ -73,7 +73,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
this._outputHelper.WriteLine($"Agent run response: {responseText}");
// The response headers should include the agent thread ID, which can be used to continue the conversation.
string? threadId = response.Headers.GetValues("X-Agent-Thread")?.FirstOrDefault();
string? threadId = response.Headers.GetValues("x-ms-thread-id")?.FirstOrDefault();
Assert.NotNull(threadId);
this._outputHelper.WriteLine($"Agent thread ID: {threadId}");
@@ -286,7 +286,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
this._outputHelper.WriteLine($"Agent response: {startResponseText}");
// The response should be deserializable as an AgentRunResponse object and have a valid thread ID
startResponse.Headers.TryGetValues("X-Agent-Thread", out IEnumerable<string>? agentIdValues);
startResponse.Headers.TryGetValues("x-ms-thread-id", out IEnumerable<string>? agentIdValues);
string? threadId = agentIdValues?.FirstOrDefault();
Assert.NotNull(threadId);
Assert.StartsWith("@dafx-publisher@", threadId);
@@ -307,7 +307,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
timeout: TimeSpan.FromSeconds(60));
// Approve the content
Uri approvalUri = new($"{runAgentUri}?threadId={threadId}");
Uri approvalUri = new($"{runAgentUri}?thread_id={threadId}");
using HttpContent approvalContent = new StringContent("Approve the content", Encoding.UTF8, "text/plain");
using HttpResponseMessage approvalResponse = await s_sharedHttpClient.PostAsync(approvalUri, approvalContent);
Assert.True(approvalResponse.IsSuccessStatusCode, $"Approve content request failed with status: {approvalResponse.StatusCode}");
@@ -327,7 +327,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
timeout: TimeSpan.FromSeconds(60));
// Verify the final orchestration status by asking the agent for the status
Uri statusUri = new($"{runAgentUri}?threadId={threadId}");
Uri statusUri = new($"{runAgentUri}?thread_id={threadId}");
await this.WaitForConditionAsync(
condition: async () =>
{