This commit is contained in:
Shyju Krishnankutty
2026-01-15 11:10:46 -08:00
Unverified
parent 6e029eb039
commit ff230c86ce
13 changed files with 111 additions and 133 deletions
+2
View File
@@ -35,6 +35,8 @@
<Project Path="samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj" />
<Project Path="samples/AzureFunctions/08_ReliableStreaming/08_ReliableStreaming.csproj" />
<Project Path="samples/AzureFunctions/09_Workflow/09_Workflow.csproj" />
<Project Path="samples/AzureFunctions/10_WorkflowConcurrent/10_WorkflowConcurrent.csproj" />
<Project Path="samples/AzureFunctions/11_WorkflowWithDifferentTypes/11_WorkflowWithDifferentTypes.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/">
<File Path="samples/GettingStarted/README.md" />
@@ -26,7 +26,7 @@ AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey)
// Set up an AI agent following the standard Microsoft Agent Framework pattern.
const string JokerName = "Joker";
const string JokerInstructions = "You are good at telling jokes.";
const string AnalysisInstructions = """
const string FeedbackAnalysisInstructions = """
You are a Customer Feedback Analyzer. Your task is to analyze customer survey responses and categorize them accurately.
INPUT: You will receive customer feedback text that may include a rating and comments.
@@ -44,31 +44,44 @@ CATEGORIZATION RULES:
- "Support Incident Status": Follow-ups on existing tickets, status inquiries about previous issues
RESPONSE FORMAT: Return only the category name exactly as shown above, with no additional text or explanation.
Examples:
- "The app crashes when I try to export" Bug Report
- "Love the new design! Great work" General Feedback
- "Why was I charged twice this month?" Billing Question
- "What's the status of ticket #12345?" Support Incident Status
""";
AIAgent agent = client.GetChatClient(deploymentName).CreateAIAgent(JokerInstructions, JokerName);
AIAgent surveyFeedbackAgent = client.GetChatClient(deploymentName).CreateAIAgent(AnalysisInstructions, "FeedbackAnalysisBot");
AIAgent agent = client.GetChatClient(deploymentName).CreateAIAgent(JokerInstructions, JokerName);
// 3 Executors usd by the workflow.
// 1. Class based executor to parse survey response.
SurveyResponseParserExecutor surveyResponseParserExecutor = new();
ResponseRouterExecutor responseRouterExecutor = new();
// 2. AI Agent to analyze feedback and categorize it.
AIAgent surveyFeedbackAgent = client.GetChatClient(deploymentName).CreateAIAgent(FeedbackAnalysisInstructions, "FeedbackAnalyzerAgent");
// 3. Function delegate executor to route response based on category.
Func<string, string> responseHandlingExecutorFunc = input => input switch
{
var s when s.Contains("billing", StringComparison.OrdinalIgnoreCase)
=> "Will notify Billing Team",
var s when s.Contains("Bug Report", StringComparison.OrdinalIgnoreCase)
=> "Will notify Technical Support Team",
_ => "Will notify General Support Team"
};
var responseRouterExecutor = responseHandlingExecutorFunc.BindAsExecutor("ResponseRouterExecutor");
WorkflowBuilder builder = new(surveyResponseParserExecutor);
builder.AddEdge(surveyResponseParserExecutor, surveyFeedbackAgent);
builder.AddEdge(surveyFeedbackAgent, responseRouterExecutor).WithOutputFrom(responseRouterExecutor);
var workflow = builder.WithName("HandleSurveyResponse").Build();
// Configure the function app to host AI agents and workflows in a unified way.
// This will automatically generate HTTP API endpoints for agents and workflows.
var functionBuilder = FunctionsApplication.CreateBuilder(args);
functionBuilder.ConfigureFunctionsWebApplication().ConfigureDurableOptions(options =>
{
// Configure workflows
options.Workflows.AddWorkflow(workflow);
});
functionBuilder.Build().Run();
FunctionsApplication.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
//.ConfigureDurableAgents(options => options.AddAIAgent(agent))
.ConfigureDurableOptions(options =>
{
// Configure workflows
options.Workflows.AddWorkflow(workflow);
// Optional - Configure AI agents
// options.Agents.AddAIAgent(agent);
})
.Build().Run();
@@ -1,27 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace SingleAgent;
/// <summary>
/// Routes survey responses to appropriate teams based on rating and category.
/// </summary>
public sealed class ResponseRouterExecutor() : Executor<string, string>("ResponseRouterExecutor")
{
public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (message.Contains("billing", StringComparison.OrdinalIgnoreCase))
{
return ValueTask.FromResult("Routed to Billing Team");
}
else if (message.Contains("technical", StringComparison.OrdinalIgnoreCase))
{
return ValueTask.FromResult("Routed to Technical Support Team");
}
else
{
return ValueTask.FromResult("Routed to General Support Team");
}
}
}
@@ -23,12 +23,12 @@ internal sealed partial class SurveyResponseParserExecutor() : Executor<string,
public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
SurveyResponse response = this.ParseSurveyResponse(message);
SurveyResponse response = ParseSurveyResponse(message);
string jsonResult = JsonSerializer.Serialize(response, s_jsonOptions);
return ValueTask.FromResult(jsonResult);
}
private SurveyResponse ParseSurveyResponse(string message)
private static SurveyResponse ParseSurveyResponse(string message)
{
// Parse the message to extract rating and comment
int? rating = null;
@@ -6,3 +6,9 @@ POST {{authority}}/api/workflows/HandleSurveyResponse/run
Content-Type: text/plain
Rating: 10. Why was I charged $99 when my plan should be $49? I need a refund for the overcharge
POST {{authority}}/api/workflows/HandleSurveyResponse/run
Content-Type: text/plain
Rating: 10. Why was I charged $99 when my plan should be $49? I need a refund for the overcharge
@@ -171,9 +171,19 @@ public sealed class DurableAgentsOptions
/// Determines whether an agent is configured as workflow-only (no HTTP triggers).
/// </summary>
/// <param name="agentName">The name of the agent.</param>
/// <returns>True if the agent is workflow-only; otherwise, false.</returns>
/// <returns><see langword="true"/> if the agent is workflow-only; otherwise, <see langword="false"/>.</returns>
internal bool IsWorkflowOnly(string agentName)
{
return this._workflowOnlyAgents.Contains(agentName);
}
/// <summary>
/// Determines whether an agent with the specified name is already registered.
/// </summary>
/// <param name="agentName">The name of the agent.</param>
/// <returns><see langword="true"/> if an agent with the name is registered; otherwise, <see langword="false"/>.</returns>
internal bool ContainsAgent(string agentName)
{
return this._agentFactories.ContainsKey(agentName);
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
namespace Microsoft.Agents.AI.DurableTask;
@@ -25,13 +26,10 @@ public sealed class DurableWorkflowOptions
/// <summary>
/// Gets the collection of workflows available in the current context, keyed by their unique names.
/// </summary>
/// <remarks>The returned dictionary is read-only and reflects the current set of registered workflows.
/// Changes to the underlying workflow collection are immediately visible through this property. Accessing a
/// workflow by name that does not exist will result in a KeyNotFoundException.</remarks>
public IReadOnlyDictionary<string, Workflow> Workflows => this._workflows;
/// <summary>
/// Gets the executor registry.
/// Gets the executor registry for direct executor lookup.
/// </summary>
internal ExecutorRegistry Executors { get; }
@@ -41,8 +39,10 @@ public sealed class DurableWorkflowOptions
/// <param name="workflow">The workflow instance to add. Cannot be null.</param>
/// <remarks>
/// When a workflow is added, any AI agent executors in the workflow will be automatically
/// registered with the DurableAgentsOptions if it was provided during construction.
/// registered with the <see cref="DurableAgentsOptions"/> if it was provided during construction.
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="workflow"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when the workflow does not have a valid name.</exception>
public void AddWorkflow(Workflow workflow)
{
ArgumentNullException.ThrowIfNull(workflow);
@@ -54,48 +54,33 @@ public sealed class DurableWorkflowOptions
this._workflows[workflow.Name] = workflow;
// Register executors in the registry for direct lookup
RegisterExecutors(workflow, this.Executors);
// Register any agentic executors with DurableAgentsOptions if available through parent
DurableAgentsOptions? agentOptions = this.TryGetAgentOptions();
DurableAgentsOptions? agentOptions = this._parentOptions?.Agents;
if (agentOptions is not null)
{
RegisterAgenticExecutors(workflow, agentOptions);
}
}
private DurableAgentsOptions? TryGetAgentOptions()
private static void RegisterExecutors(Workflow workflow, ExecutorRegistry registry)
{
return this._parentOptions?.Agents;
foreach (KeyValuePair<string, ExecutorInfo> executor in workflow.ReflectExecutors())
{
int underscoreIndex = executor.Key.IndexOf('_');
string executorName = underscoreIndex > 0 ? executor.Key[..underscoreIndex] : executor.Key;
registry.Register(executorName, executor.Key, workflow);
}
}
private static void RegisterAgenticExecutors(Workflow workflow, DurableAgentsOptions agentOptions)
{
// Use the public EnumerateAgentExecutors method to get all AIAgent instances
foreach (AIAgent agent in workflow.EnumerateAgentExecutors())
{
try
if (agent.Name is not null && !agentOptions.ContainsAgent(agent.Name))
{
// Register the agent as workflow-only (no HTTP trigger)
agentOptions.AddAIAgent(agent, workflowOnly: true);
}
catch (ArgumentException)
{
// Agent with this name is already registered, skip it
// This is expected behavior when multiple workflows use the same agent
}
}
}
private static void RegisterExecutors(Workflow workflow, ExecutorRegistry registry)
{
// Register all executors from the workflow in the registry
foreach (KeyValuePair<string, Workflows.Checkpointing.ExecutorInfo> executor in workflow.ReflectExecutors())
{
// Extract the executor name (without GUID suffix)
string executorName = executor.Key.Split('_')[0];
registry.Register(executorName, executor.Key, workflow);
}
}
}
@@ -7,52 +7,40 @@ namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Provides a registry for storing and retrieving executor bindings independently from workflows.
/// </summary>
/// <remarks>
/// This registry allows executors to be looked up by name without needing to search through all workflows,
/// which is useful for activity function execution where only the executor name is known.
/// </remarks>
internal sealed class ExecutorRegistry
{
private readonly Dictionary<string, ExecutorRegistration> _executors = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets the number of registered executors.
/// </summary>
public int Count => this._executors.Count;
/// <summary>
/// Attempts to get an executor registration by name.
/// </summary>
/// <param name="executorName">The executor name to look up.</param>
/// <param name="registration">When this method returns, contains the registration if found; otherwise, null.</param>
/// <returns><see langword="true"/> if the executor was found; otherwise, <see langword="false"/>.</returns>
public bool TryGetExecutor(string executorName, out ExecutorRegistration? registration)
{
return this._executors.TryGetValue(executorName, out registration);
}
/// <summary>
/// Registers an executor binding from a workflow.
/// </summary>
/// <param name="executorName">The executor name (without GUID suffix).</param>
/// <param name="executorId">The full executor ID (may include GUID suffix).</param>
/// <param name="workflow">The workflow containing the executor.</param>
/// <remarks>
/// If an executor with the same name is already registered, it will be skipped.
/// This is expected behavior when the same executor is used across multiple workflows.
/// </remarks>
internal void Register(string executorName, string executorId, Workflow workflow)
{
ArgumentException.ThrowIfNullOrEmpty(executorName);
ArgumentException.ThrowIfNullOrEmpty(executorId);
ArgumentNullException.ThrowIfNull(workflow);
// Only register if not already present (first registration wins)
if (!this._executors.ContainsKey(executorName))
{
this._executors[executorName] = new ExecutorRegistration(executorId, workflow);
}
this._executors.TryAdd(executorName, new ExecutorRegistration(executorId, workflow));
}
/// <summary>
/// Attempts to get an executor registration by name.
/// </summary>
/// <param name="executorName">The executor name to look up.</param>
/// <param name="registration">When this method returns, contains the registration if found; otherwise, null.</param>
/// <returns><c>true</c> if the executor was found; otherwise, <c>false</c>.</returns>
public bool TryGetExecutor(string executorName, out ExecutorRegistration? registration)
{
return this._executors.TryGetValue(executorName, out registration);
}
/// <summary>
/// Gets the number of registered executors.
/// </summary>
public int Count => this._executors.Count;
}
/// <summary>
@@ -44,7 +44,6 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
string? encodedEntityRequest = null;
DurableTaskClient? durableTaskClient = null;
ToolInvocationContext? mcpToolInvocationContext = null;
//string? encodedTaskOrchestrationContext = null;
foreach (var binding in values)
{
@@ -62,9 +61,6 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
case ToolInvocationContext toolContext:
mcpToolInvocationContext = toolContext;
break;
//case string orchestrationContext:
// encodedTaskOrchestrationContext = orchestrationContext;
// break;
}
}
@@ -84,7 +80,7 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
context.GetInvocationResult().Value = await BuiltInFunctions.RunAgentHttpAsync(
httpRequestData,
durableTaskClient!,
durableTaskClient,
context);
return;
}
@@ -97,7 +93,7 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
}
context.GetInvocationResult().Value = await BuiltInFunctions.InvokeAgentAsync(
durableTaskClient!,
durableTaskClient,
encodedEntityRequest,
context);
return;
@@ -111,7 +107,7 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
}
context.GetInvocationResult().Value =
await BuiltInFunctions.RunMcpToolAsync(mcpToolInvocationContext, durableTaskClient!, context);
await BuiltInFunctions.RunMcpToolAsync(mcpToolInvocationContext, durableTaskClient, context);
return;
}
@@ -124,7 +120,7 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowOrechstrtationHttpTriggerAsync(
httpRequestData,
durableTaskClient!,
durableTaskClient,
context);
return;
}
@@ -35,6 +35,9 @@ internal static class BuiltInFunctions
[ActivityTrigger] string input,
FunctionContext functionContext)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(functionContext);
string activityFunctionName = functionContext.FunctionDefinition.Name;
DurableWorkflowRunner runner = functionContext.InstanceServices.GetRequiredService<DurableWorkflowRunner>();
@@ -5,18 +5,18 @@ using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Represents a request to run a workflow in the Duable system.
/// Represents a request to run a durable workflow.
/// </summary>
public sealed class DuableWorkflowRunRequest
internal sealed class DuableWorkflowRunRequest
{
/// <summary>
/// Gets or sets the name of the workflow.
/// Gets or sets the name of the workflow to execute.
/// </summary>
[JsonPropertyName("workflowName")]
public string WorkflowName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the input string to be processed or analyzed.
/// Gets or sets the input for the workflow.
/// </summary>
[JsonPropertyName("input")]
public string Input { get; set; } = string.Empty;
@@ -98,6 +98,9 @@ public static class DurableOptionsExtensions
private static void ConfigureWorkflowOrchestration(FunctionsApplicationBuilder builder)
{
// Registering a single orchestration function to handle all workflow runs.
// This is due to a gap in durable extension today and can be replace with dynamic orchestration registration in future, per workflow.
builder.ConfigureDurableWorker().AddTasks(tasks =>
tasks.AddOrchestratorFunc<DuableWorkflowRunRequest, List<string>>(
"WorkflowRunnerOrchestration",
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.Workflows;
@@ -113,7 +114,7 @@ internal sealed class DurableWorkflowRunner
{
WorkflowExecutorInfo executorInfo = level.Executors[0];
string input = GetExecutorInput(executorInfo.ExecutorId, initialInput, results, plan);
results[executorInfo.ExecutorId] = await this.ExecuteExecutorAsync(context, workflow, executorInfo, input, logger).ConfigureAwait(true);
results[executorInfo.ExecutorId] = await this.ExecuteExecutorAsync(context, executorInfo, input, logger).ConfigureAwait(true);
}
else
{
@@ -121,7 +122,7 @@ internal sealed class DurableWorkflowRunner
foreach (WorkflowExecutorInfo executorInfo in level.Executors)
{
string input = GetExecutorInput(executorInfo.ExecutorId, initialInput, results, plan);
tasks.Add(this.ExecuteExecutorWithIdAsync(context, workflow, executorInfo, input, logger));
tasks.Add(this.ExecuteExecutorWithIdAsync(context, executorInfo, input, logger));
}
foreach ((string id, string result) in await Task.WhenAll(tasks).ConfigureAwait(true))
@@ -136,18 +137,16 @@ internal sealed class DurableWorkflowRunner
private async Task<(string Id, string Result)> ExecuteExecutorWithIdAsync(
TaskOrchestrationContext context,
Workflow workflow,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
{
string result = await this.ExecuteExecutorAsync(context, workflow, executorInfo, input, logger).ConfigureAwait(true);
string result = await this.ExecuteExecutorAsync(context, executorInfo, input, logger).ConfigureAwait(true);
return (executorInfo.ExecutorId, result);
}
private async Task<string> ExecuteExecutorAsync(
TaskOrchestrationContext context,
Workflow workflow,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
@@ -158,10 +157,10 @@ internal sealed class DurableWorkflowRunner
return await context.CallActivityAsync<string>(triggerName, input).ConfigureAwait(true);
}
return await this.ExecuteAgentAsync(context, executorInfo, input, logger).ConfigureAwait(true);
return await ExecuteAgentAsync(context, executorInfo, input, logger).ConfigureAwait(true);
}
private async Task<string> ExecuteAgentAsync(
private static async Task<string> ExecuteAgentAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
@@ -259,15 +258,15 @@ internal sealed class DurableWorkflowRunner
return underscoreIndex > 0 ? executorId[..underscoreIndex] : executorId;
}
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing known types.")]
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing known types.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing known types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing known types.")]
private static string SerializeToJson(List<string> values)
{
return JsonSerializer.Serialize(values);
}
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types registered at startup.")]
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types registered at startup.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types registered at startup.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types registered at startup.")]
private static string SerializeResult(object? result)
{
if (result is null)
@@ -289,8 +288,8 @@ internal sealed class DurableWorkflowRunner
return JsonSerializer.Serialize(result, resultType);
}
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
private static object DeserializeInput(string input, Type targetType)
{
if (targetType == typeof(string))