diff --git a/dotnet/samples/AzureFunctions/01_SingleAgent/README.md b/dotnet/samples/AzureFunctions/01_SingleAgent/README.md index b0fea90795..5727f037c2 100644 --- a/dotnet/samples/AzureFunctions/01_SingleAgent/README.md +++ b/dotnet/samples/AzureFunctions/01_SingleAgent/README.md @@ -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 + } + } +} +``` diff --git a/dotnet/samples/AzureFunctions/06_LongRunningTools/README.md b/dotnet/samples/AzureFunctions/06_LongRunningTools/README.md index 80ea3f1be6..9c538298e8 100644 --- a/dotnet/samples/AzureFunctions/06_LongRunningTools/README.md +++ b/dotnet/samples/AzureFunctions/06_LongRunningTools/README.md @@ -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 content generation workflow for the topic "The Future of Artificial Intelligence" has been successfully started, and the instance ID is **6a04276e8d824d8d941e1dc4142cc254**. If you need any further assistance or updates on the workflow, feel free to ask! ``` -The `X-Agent-Thread` response header contains the thread ID, which can be used to continue the conversation by passing it as a query parameter to the `run` endpoint. The commands above show how to save the thread ID to a `$threadId` variable for use in subsequent requests. +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: @@ -70,12 +70,12 @@ Bash (Linux/macOS/WSL): ```bash # Approve the content -curl -X POST http://localhost:7071/api/agents/publisher/run?threadId=$threadId \ +curl -X POST "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" \ -H "Content-Type: text/plain" \ -d 'Approve the content' # Reject the content with feedback -curl -X POST http://localhost:7071/api/agents/publisher/run?threadId=$threadId \ +curl -X POST "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" \ -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: ```powershell # Approve the content Invoke-RestMethod -Method Post ` - -Uri http://localhost:7071/api/agents/publisher/run?threadId=$threadId ` + -Uri "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" ` -ContentType text/plain ` -Body 'Approve the content' # Reject the content with feedback Invoke-RestMethod -Method Post ` - -Uri http://localhost:7071/api/agents/publisher/run?threadId=$threadId ` + -Uri "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" ` -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 (Linux/macOS/WSL): ```bash -curl -X POST http://localhost:7071/api/agents/publisher/run?threadId=$threadId \ +curl -X POST "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" \ -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?threadId=$threadId ` + -Uri "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" ` -ContentType text/plain ` -Body 'Get the status of the workflow you previously started' ``` diff --git a/dotnet/samples/AzureFunctions/06_LongRunningTools/demo.http b/dotnet/samples/AzureFunctions/06_LongRunningTools/demo.http index 5bbf8bf7ee..c0f13f1992 100644 --- a/dotnet/samples/AzureFunctions/06_LongRunningTools/demo.http +++ b/dotnet/samples/AzureFunctions/06_LongRunningTools/demo.http @@ -9,19 +9,19 @@ Start a content generation workflow for the topic 'The Future of Artificial Inte @threadId = ### 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 diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs index 5a13c0cf87..f8c4618c9e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -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? contentTypeValues) && + contentTypeValues.Any(ct => ct.Contains("application/json", StringComparison.OrdinalIgnoreCase))) + { + // Parse JSON body using POCO record + AgentRunRequest? requestBody = await req.ReadFromJsonAsync(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? 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? 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 RunMcpToolAsync( @@ -128,6 +175,106 @@ internal static class BuiltInFunctions return agentResponse.Text; } + /// + /// Creates an error response with the specified status code and error message. + /// + /// The HTTP request data. + /// The function context. + /// The HTTP status code. + /// The error message. + /// The HTTP response data containing the error. + private static async Task CreateErrorResponseAsync( + HttpRequestData req, + FunctionContext context, + HttpStatusCode statusCode, + string errorMessage) + { + HttpResponseData response = req.CreateResponse(statusCode); + bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable? 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; + } + + /// + /// Creates a successful agent run response with the agent's response. + /// + /// The HTTP request data. + /// The function context. + /// The HTTP status code (typically 200 OK). + /// The thread ID for the conversation. + /// The agent's response. + /// The HTTP response data containing the success response. + private static async Task 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? 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; + } + + /// + /// Creates an accepted (fire-and-forget) agent run response. + /// + /// The HTTP request data. + /// The function context. + /// The thread ID for the conversation. + /// The HTTP response data containing the accepted response. + private static async Task 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? 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..]; } + /// + /// Represents a request to run an agent. + /// + /// The message to send to the agent. + /// The optional thread ID to continue a conversation. + private sealed record AgentRunRequest( + [property: JsonPropertyName("message")] string? Message, + [property: JsonPropertyName("thread_id")] string? ThreadId); + + /// + /// Represents an error response. + /// + /// The HTTP status code. + /// The error message. + private sealed record ErrorResponse( + [property: JsonPropertyName("status")] int Status, + [property: JsonPropertyName("error")] string Error); + + /// + /// Represents a successful agent run response. + /// + /// The HTTP status code. + /// The thread ID for the conversation. + /// The agent response. + private sealed record AgentRunSuccessResponse( + [property: JsonPropertyName("status")] int Status, + [property: JsonPropertyName("thread_id")] string ThreadId, + [property: JsonPropertyName("response")] AgentRunResponse Response); + + /// + /// Represents an accepted (fire-and-forget) agent run response. + /// + /// The HTTP status code. + /// The thread ID for the conversation. + private sealed record AgentRunAcceptedResponse( + [property: JsonPropertyName("status")] int Status, + [property: JsonPropertyName("thread_id")] string ThreadId); + /// /// A service provider that combines the original service provider with an additional DurableTaskClient instance. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs index e5c4dc8be0..f2bfcf234c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs @@ -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? agentIdValues); + startResponse.Headers.TryGetValues("x-ms-thread-id", out IEnumerable? 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 () => {