// Copyright (c) Microsoft. All rights reserved.
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Extensions.Mcp;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Worker.Grpc;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
internal static class BuiltInFunctions
{
internal const string HttpPrefix = "http-";
internal const string McpToolPrefix = "mcptool-";
internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}";
internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}";
internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}";
internal static readonly string RunWorkflowOrchestrationHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestrationHttpTriggerAsync)}";
internal static readonly string RunWorkflowOrchestrationFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestration)}";
internal static readonly string InvokeWorkflowActivityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeWorkflowActivityAsync)}";
internal static readonly string GetWorkflowStatusHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(GetWorkflowStatusAsync)}";
internal static readonly string RespondToWorkflowHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RespondToWorkflowAsync)}";
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing
internal static readonly string ScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location);
#pragma warning restore IL3000
///
/// Starts a workflow orchestration in response to an HTTP request.
/// The workflow name is derived from the function name by stripping the .
/// Callers can optionally provide a custom run ID via the runId query string parameter
/// (e.g., /api/workflows/MyWorkflow/run?runId=my-id). If not provided, one is auto-generated.
///
public static async Task RunWorkflowOrchestrationHttpTriggerAsync(
[HttpTrigger] HttpRequestData req,
[DurableClient] DurableTaskClient client,
FunctionContext context)
{
string workflowName = context.FunctionDefinition.Name.Replace(HttpPrefix, string.Empty);
string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
string? inputMessage = await req.ReadAsStringAsync();
if (string.IsNullOrEmpty(inputMessage))
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Workflow input cannot be empty.");
}
DurableWorkflowInput orchestrationInput = new() { Input = inputMessage };
// Allow users to provide a custom run ID via query string; otherwise, auto-generate one.
string? instanceId = req.Query["runId"];
StartOrchestrationOptions? options = instanceId is not null ? new StartOrchestrationOptions(instanceId) : null;
string resolvedInstanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput, options);
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
await response.WriteStringAsync($"Workflow orchestration started for {workflowName}. Orchestration runId: {resolvedInstanceId}");
return response;
}
///
/// Returns the workflow status including any pending HITL requests.
/// The run ID is extracted from the route parameter {runId}.
///
public static async Task GetWorkflowStatusAsync(
[HttpTrigger] HttpRequestData req,
[DurableClient] DurableTaskClient client,
FunctionContext context)
{
string? runId = context.BindingContext.BindingData.TryGetValue("runId", out object? value) ? value?.ToString() : null;
if (string.IsNullOrEmpty(runId))
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required.");
}
OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true);
if (metadata is null)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found.");
}
// Parse HITL inputs the workflow is waiting for from the durable workflow status
List? waitingForInput = null;
if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus)
&& liveStatus.PendingEvents.Count > 0)
{
waitingForInput = liveStatus.PendingEvents;
}
HttpResponseData response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
runId,
status = metadata.RuntimeStatus.ToString(),
waitingForInput = waitingForInput?.Select(p => new { eventName = p.EventName, input = JsonDocument.Parse(p.Input).RootElement })
});
return response;
}
///
/// Sends a response to a pending RequestPort, resuming the workflow.
/// Expects a JSON body: { "eventName": "...", "response": { ... } }.
///
public static async Task RespondToWorkflowAsync(
[HttpTrigger] HttpRequestData req,
[DurableClient] DurableTaskClient client,
FunctionContext context)
{
string? runId = context.BindingContext.BindingData.TryGetValue("runId", out object? value) ? value?.ToString() : null;
if (string.IsNullOrEmpty(runId))
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required.");
}
WorkflowRespondRequest? request;
try
{
request = await req.ReadFromJsonAsync(context.CancellationToken);
}
catch (JsonException)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Request body is not valid JSON.");
}
if (request is null || string.IsNullOrEmpty(request.EventName)
|| request.Response.ValueKind == JsonValueKind.Undefined)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Body must contain a non-empty 'eventName' and a 'response' property.");
}
// Verify the orchestration exists and is in a valid state
OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true);
if (metadata is null)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found.");
}
if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Completed
or OrchestrationRuntimeStatus.Failed
or OrchestrationRuntimeStatus.Terminated)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest,
$"Workflow run '{runId}' is in terminal state '{metadata.RuntimeStatus}'.");
}
// Verify the workflow is waiting for the specified event.
// If status can't be parsed (e.g., not yet set during early execution), allow the event through —
// Durable Task safely queues it until the orchestration reaches WaitForExternalEvent.
bool eventValidated = false;
if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus))
{
if (!liveStatus.PendingEvents.Exists(p => string.Equals(p.EventName, request.EventName, StringComparison.Ordinal)))
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest,
$"Workflow is not waiting for event '{request.EventName}'.");
}
eventValidated = true;
}
// Raise the external event to unblock the orchestration's WaitForExternalEvent call
await client.RaiseEventAsync(runId, request.EventName, request.Response.GetRawText());
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
await response.WriteAsJsonAsync(new
{
message = eventValidated
? "Response sent to workflow."
: "Response sent to workflow. Event could not be validated against pending requests.",
runId,
eventName = request.EventName,
validated = eventValidated,
});
return response;
}
///
/// Executes a workflow activity by looking up the registered executor and delegating to it.
/// The executor name is derived from the activity function name via .
///
public static Task InvokeWorkflowActivityAsync(
[ActivityTrigger] string input,
[DurableClient] DurableTaskClient durableTaskClient,
FunctionContext functionContext)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(durableTaskClient);
ArgumentNullException.ThrowIfNull(functionContext);
string activityFunctionName = functionContext.FunctionDefinition.Name;
string executorName = WorkflowNamingHelper.ToWorkflowName(activityFunctionName);
DurableOptions durableOptions = functionContext.InstanceServices.GetRequiredService();
if (!durableOptions.Workflows.Executors.TryGetExecutor(executorName, out ExecutorRegistration? registration))
{
throw new InvalidOperationException($"Executor '{executorName}' not found in workflow options.");
}
return DurableActivityExecutor.ExecuteAsync(registration.Binding, input, functionContext.CancellationToken);
}
///
/// Runs a workflow orchestration by delegating to
/// via .
///
public static string RunWorkflowOrchestration(
string encodedOrchestratorRequest,
FunctionContext functionContext)
{
ArgumentNullException.ThrowIfNull(encodedOrchestratorRequest);
ArgumentNullException.ThrowIfNull(functionContext);
WorkflowOrchestrator orchestrator = new(functionContext.InstanceServices);
return GrpcOrchestrationRunner.LoadAndRun(encodedOrchestratorRequest, orchestrator, functionContext.InstanceServices);
}
// Exposed as an entity trigger via AgentFunctionsProvider
public static Task InvokeAgentAsync(
[DurableClient] DurableTaskClient client,
string encodedEntityRequest,
FunctionContext functionContext)
{
// This should never be null except if the function trigger is misconfigured.
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(encodedEntityRequest);
ArgumentNullException.ThrowIfNull(functionContext);
// Create a combined service provider that includes both the existing services
// and the DurableTaskClient instance
IServiceProvider combinedServiceProvider = new CombinedServiceProvider(functionContext.InstanceServices, client);
// This method is the entry point for the agent entity.
// It will be invoked by the Azure Functions runtime when the entity is called.
AgentEntity entity = new(combinedServiceProvider, functionContext.CancellationToken);
return GrpcEntityRunner.LoadAndRunAsync(encodedEntityRequest, entity, combinedServiceProvider);
}
public static async Task RunAgentHttpAsync(
[HttpTrigger] HttpRequestData req,
[DurableClient] DurableTaskClient client,
FunctionContext context)
{
// 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 session 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;
// The thread_id is treated as a session key (not a full session ID).
// If no session key is provided, use the function invocation ID as the session key
// to help correlate the session with the function invocation.
string agentName = GetAgentName(context);
AgentSessionId sessionId = string.IsNullOrEmpty(threadIdValue)
? new AgentSessionId(agentName, context.InvocationId)
: new AgentSessionId(agentName, threadIdValue);
if (string.IsNullOrWhiteSpace(message))
{
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, agentName);
DurableAgentRunOptions options = new() { IsFireAndForget = !waitForResponse };
if (waitForResponse)
{
AgentResponse agentResponse = await agentProxy.RunAsync(
message: new ChatMessage(ChatRole.User, message),
session: new DurableAgentSession(sessionId),
options: options,
cancellationToken: context.CancellationToken);
return await CreateSuccessResponseAsync(
req,
context,
HttpStatusCode.OK,
sessionId.Key,
agentResponse);
}
// Fire and forget - return 202 Accepted
await agentProxy.RunAsync(
message: new ChatMessage(ChatRole.User, message),
session: new DurableAgentSession(sessionId),
options: options,
cancellationToken: context.CancellationToken);
return await CreateAcceptedResponseAsync(
req,
context,
sessionId.Key);
}
public static async Task RunMcpToolAsync(
[McpToolTrigger("BuiltInMcpTool")] ToolInvocationContext context,
[DurableClient] DurableTaskClient client,
FunctionContext functionContext)
{
if (context.Arguments is null)
{
throw new ArgumentException("MCP Tool invocation is missing required arguments.");
}
if (!context.Arguments.TryGetValue("query", out object? queryObj) || queryObj is not string query)
{
throw new ArgumentException("MCP Tool invocation is missing required 'query' argument of type string.");
}
string agentName = context.Name;
// Derive session id: try to parse provided threadId, otherwise create a new one.
AgentSessionId sessionId = context.Arguments.TryGetValue("threadId", out object? threadObj) && threadObj is string threadId && !string.IsNullOrWhiteSpace(threadId)
? AgentSessionId.Parse(threadId)
: new AgentSessionId(agentName, functionContext.InvocationId);
AIAgent agentProxy = client.AsDurableAgentProxy(functionContext, agentName);
AgentResponse agentResponse = await agentProxy.RunAsync(
message: new ChatMessage(ChatRole.User, query),
session: new DurableAgentSession(sessionId),
options: null);
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 session 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 sessionId,
AgentResponse agentResponse)
{
HttpResponseData response = req.CreateResponse(statusCode);
response.Headers.Add("x-ms-thread-id", sessionId);
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable? acceptValues) &&
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
if (acceptsJson)
{
AgentRunSuccessResponse successResponse = new((int)statusCode, sessionId, 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 session ID for the conversation.
/// The HTTP response data containing the accepted response.
private static async Task CreateAcceptedResponseAsync(
HttpRequestData req,
FunctionContext context,
string sessionId)
{
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
response.Headers.Add("x-ms-thread-id", sessionId);
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable? acceptValues) &&
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
if (acceptsJson)
{
AgentRunAcceptedResponse acceptedResponse = new((int)HttpStatusCode.Accepted, sessionId);
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
string functionName = context.FunctionDefinition.Name;
if (!functionName.StartsWith(HttpPrefix, StringComparison.Ordinal))
{
// This should never happen because the function metadata provider ensures
// that the function name starts with the HttpPrefix (http-).
throw new InvalidOperationException(
$"Built-in HTTP trigger function name '{functionName}' does not start with '{HttpPrefix}'.");
}
// Remove the HttpPrefix from the function name to get the agent name.
return functionName[HttpPrefix.Length..];
}
///
/// Represents a request to run an agent.
///
/// The message to send to the agent.
/// The optional session 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 session 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")] AgentResponse Response);
///
/// Represents an accepted (fire-and-forget) agent run response.
///
/// The HTTP status code.
/// The session ID for the conversation.
private sealed record AgentRunAcceptedResponse(
[property: JsonPropertyName("status")] int Status,
[property: JsonPropertyName("thread_id")] string ThreadId);
///
/// Represents a request to respond to a pending RequestPort in a workflow.
///
/// The name of the event to raise (the RequestPort ID).
/// The response payload to send to the workflow.
private sealed record WorkflowRespondRequest(
[property: JsonPropertyName("eventName")] string? EventName,
[property: JsonPropertyName("response")] JsonElement Response);
///
/// A service provider that combines the original service provider with an additional DurableTaskClient instance.
///
private sealed class CombinedServiceProvider(IServiceProvider originalProvider, DurableTaskClient client)
: IServiceProvider, IKeyedServiceProvider
{
private readonly IServiceProvider _originalProvider = originalProvider;
private readonly DurableTaskClient _client = client;
public object? GetKeyedService(Type serviceType, object? serviceKey)
{
if (this._originalProvider is IKeyedServiceProvider keyedProvider)
{
return keyedProvider.GetKeyedService(serviceType, serviceKey);
}
return null;
}
public object GetRequiredKeyedService(Type serviceType, object? serviceKey)
{
if (this._originalProvider is IKeyedServiceProvider keyedProvider)
{
return keyedProvider.GetRequiredKeyedService(serviceType, serviceKey);
}
throw new InvalidOperationException("The original service provider does not support keyed services.");
}
public object? GetService(Type serviceType)
{
// If the requested service is DurableTaskClient, return our instance
if (serviceType == typeof(DurableTaskClient))
{
return this._client;
}
// Otherwise try to get the service from the original provider
return this._originalProvider.GetService(serviceType);
}
}
}