mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Move relevant stuff to DurableTask project from Hosting.AzureFunctions
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace WorkflowExecutorsAndEdgesSample;
|
||||
|
||||
/// <summary>
|
||||
/// This sample introduces the concepts of executors and edges in a workflow.
|
||||
///
|
||||
/// Workflows are built from executors (processing units) connected by edges (data flow paths).
|
||||
/// In this example, we create a simple text processing pipeline that:
|
||||
/// 1. Takes input text and converts it to uppercase using an UppercaseExecutor
|
||||
/// 2. Takes the uppercase text and reverses it using a ReverseTextExecutor
|
||||
///
|
||||
/// The executors are connected sequentially, so data flows from one to the next in order.
|
||||
/// For input "Hello, World!", the workflow produces "!DLROW ,OLLEH".
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
private static async Task Main()
|
||||
{
|
||||
// Create the executors
|
||||
Func<string, string> uppercaseFunc = s => s.ToUpperInvariant();
|
||||
var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor");
|
||||
|
||||
ReverseTextExecutor reverse = new();
|
||||
|
||||
// Build the workflow by connecting executors sequentially
|
||||
WorkflowBuilder builder = new(uppercase);
|
||||
builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse);
|
||||
var workflow = builder.Build();
|
||||
|
||||
// Execute the workflow with input data
|
||||
await using Run run = await InProcessExecution.RunAsync(workflow, "Hello, World!");
|
||||
foreach (WorkflowEvent evt in run.NewEvents)
|
||||
{
|
||||
if (evt is ExecutorCompletedEvent executorComplete)
|
||||
{
|
||||
Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Second executor: reverses the input text and completes the workflow.
|
||||
/// </summary>
|
||||
internal sealed class ReverseTextExecutor() : Executor<string, string>("ReverseTextExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes the input message by reversing the text.
|
||||
/// </summary>
|
||||
/// <param name="message">The input text to reverse</param>
|
||||
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The input text reversed</returns>
|
||||
public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Because we do not suppress it, the returned result will be yielded as an output from this executor.
|
||||
return ValueTask.FromResult(string.Concat(message.Reverse()));
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -7,10 +7,10 @@ using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Client.Entities;
|
||||
using Microsoft.DurableTask.Entities;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// An implementation of <see cref="IWorkflowContext"/> for workflow executors running as Azure Functions activities.
|
||||
/// An implementation of <see cref="IWorkflowContext"/> for workflow executors running as durable activities.
|
||||
/// Provides durable state management using Durable Entities. State is scoped to the orchestration instance
|
||||
/// and shared between executors running on potentially different compute instances.
|
||||
/// </summary>
|
||||
@@ -21,7 +21,7 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
/// </remarks>
|
||||
[RequiresUnreferencedCode("State serialization uses reflection-based JSON serialization.")]
|
||||
[RequiresDynamicCode("State serialization uses reflection-based JSON serialization.")]
|
||||
internal sealed class DurableExecutorContext : IWorkflowContext
|
||||
public sealed class DurableExecutorContext : IWorkflowContext
|
||||
{
|
||||
private readonly string _instanceId;
|
||||
private readonly DurableTaskClient _client;
|
||||
+2
-2
@@ -2,12 +2,12 @@
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request to run a durable workflow.
|
||||
/// </summary>
|
||||
internal sealed class DuableWorkflowRunRequest
|
||||
public sealed class DurableWorkflowRunRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the workflow to execute.
|
||||
@@ -0,0 +1,320 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Core workflow runner that executes workflow orchestrations using Durable Tasks.
|
||||
/// This class contains the core workflow execution logic independent of the hosting environment.
|
||||
/// </summary>
|
||||
public class DurableWorkflowRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableWorkflowRunner"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="durableOptions">The durable options containing workflow configurations.</param>
|
||||
public DurableWorkflowRunner(ILogger<DurableWorkflowRunner> logger, DurableOptions durableOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentNullException.ThrowIfNull(durableOptions);
|
||||
|
||||
this.Logger = logger;
|
||||
this.Options = durableOptions.Workflows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the workflow options.
|
||||
/// </summary>
|
||||
protected DurableWorkflowOptions Options { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the logger instance.
|
||||
/// </summary>
|
||||
protected ILogger Logger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Runs a workflow orchestration.
|
||||
/// </summary>
|
||||
/// <param name="context">The task orchestration context.</param>
|
||||
/// <param name="request">The workflow run request containing workflow name and input.</param>
|
||||
/// <param name="logger">The replay-safe logger for orchestration logging.</param>
|
||||
/// <returns>A list containing the workflow execution result.</returns>
|
||||
public async Task<List<string>> RunWorkflowOrchestrationAsync(
|
||||
TaskOrchestrationContext context,
|
||||
DurableWorkflowRunRequest request,
|
||||
ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (!this.Options.Workflows.TryGetValue(request.WorkflowName, out Workflow? workflow))
|
||||
{
|
||||
throw new InvalidOperationException($"Workflow '{request.WorkflowName}' not found.");
|
||||
}
|
||||
|
||||
logger.LogRunningWorkflow(workflow.Name);
|
||||
|
||||
string result = await this.ExecuteWorkflowLevelsAsync(context, workflow, request.Input, logger).ConfigureAwait(true);
|
||||
|
||||
await CleanupWorkflowStateAsync(context).ConfigureAwait(true);
|
||||
|
||||
return [result];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up the workflow state entity by signaling it to delete itself.
|
||||
/// </summary>
|
||||
private static async Task CleanupWorkflowStateAsync(TaskOrchestrationContext context)
|
||||
{
|
||||
EntityInstanceId stateEntityId = new(WorkflowSharedStateEntity.EntityName, context.InstanceId);
|
||||
|
||||
// Call the entity's Delete method to clean up state
|
||||
// Using CallEntityAsync ensures the deletion completes before the orchestration finishes
|
||||
await context.Entities.CallEntityAsync(stateEntityId, nameof(WorkflowSharedStateEntity.Delete)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the executor name from an activity function name.
|
||||
/// </summary>
|
||||
/// <param name="activityFunctionName">The activity function name.</param>
|
||||
/// <returns>The extracted executor name.</returns>
|
||||
protected static string ParseExecutorName(string activityFunctionName)
|
||||
{
|
||||
const string Prefix = "dafx-";
|
||||
|
||||
if (!activityFunctionName.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Activity function name '{activityFunctionName}' does not start with '{Prefix}' prefix.");
|
||||
}
|
||||
|
||||
string executorName = activityFunctionName[Prefix.Length..];
|
||||
|
||||
if (string.IsNullOrEmpty(executorName))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Activity function name '{activityFunctionName}' is not in the expected format '{Prefix}{{executorName}}'.");
|
||||
}
|
||||
|
||||
return executorName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base name from an executor ID by removing any GUID suffix.
|
||||
/// </summary>
|
||||
/// <param name="executorId">The executor ID.</param>
|
||||
/// <returns>The base name without the GUID suffix.</returns>
|
||||
protected static string GetBaseName(string executorId)
|
||||
{
|
||||
int underscoreIndex = executorId.IndexOf('_');
|
||||
return underscoreIndex > 0 ? executorId[..underscoreIndex] : executorId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a list of strings to JSON.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing known types.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing known types.")]
|
||||
protected static string SerializeToJson(List<string> values)
|
||||
{
|
||||
return JsonSerializer.Serialize(values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a result object to JSON or string.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types registered at startup.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types registered at startup.")]
|
||||
protected static string SerializeResult(object? result)
|
||||
{
|
||||
if (result is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (result is string str)
|
||||
{
|
||||
return str;
|
||||
}
|
||||
|
||||
Type resultType = result.GetType();
|
||||
if (resultType.IsPrimitive || resultType == typeof(decimal))
|
||||
{
|
||||
return result.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(result, resultType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes input from JSON to the target type.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
|
||||
protected static object DeserializeInput(string input, Type targetType)
|
||||
{
|
||||
if (targetType == typeof(string))
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
string json = input;
|
||||
if (input.StartsWith('"') && input.EndsWith('"'))
|
||||
{
|
||||
try
|
||||
{
|
||||
string? innerJson = JsonSerializer.Deserialize<string>(input);
|
||||
if (innerJson is not null)
|
||||
{
|
||||
json = innerJson;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not double-serialized, use original
|
||||
}
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(json, targetType)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'.");
|
||||
}
|
||||
|
||||
private async Task<string> ExecuteWorkflowLevelsAsync(
|
||||
TaskOrchestrationContext context,
|
||||
Workflow workflow,
|
||||
string initialInput,
|
||||
ILogger logger)
|
||||
{
|
||||
WorkflowExecutionPlan plan = WorkflowHelper.GetExecutionPlan(workflow);
|
||||
Dictionary<string, string> results = [];
|
||||
|
||||
foreach (WorkflowExecutionLevel level in plan.Levels)
|
||||
{
|
||||
if (level.Executors.Count == 1)
|
||||
{
|
||||
WorkflowExecutorInfo executorInfo = level.Executors[0];
|
||||
string input = GetExecutorInput(executorInfo.ExecutorId, initialInput, results, plan);
|
||||
results[executorInfo.ExecutorId] = await this.ExecuteExecutorAsync(context, executorInfo, input, logger).ConfigureAwait(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
List<Task<(string Id, string Result)>> tasks = [];
|
||||
foreach (WorkflowExecutorInfo executorInfo in level.Executors)
|
||||
{
|
||||
string input = GetExecutorInput(executorInfo.ExecutorId, initialInput, results, plan);
|
||||
tasks.Add(this.ExecuteExecutorWithIdAsync(context, executorInfo, input, logger));
|
||||
}
|
||||
|
||||
foreach ((string id, string result) in await Task.WhenAll(tasks).ConfigureAwait(true))
|
||||
{
|
||||
results[id] = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return GetFinalResult(plan, results);
|
||||
}
|
||||
|
||||
private async Task<(string Id, string Result)> ExecuteExecutorWithIdAsync(
|
||||
TaskOrchestrationContext context,
|
||||
WorkflowExecutorInfo executorInfo,
|
||||
string input,
|
||||
ILogger logger)
|
||||
{
|
||||
string result = await this.ExecuteExecutorAsync(context, executorInfo, input, logger).ConfigureAwait(true);
|
||||
return (executorInfo.ExecutorId, result);
|
||||
}
|
||||
|
||||
private async Task<string> ExecuteExecutorAsync(
|
||||
TaskOrchestrationContext context,
|
||||
WorkflowExecutorInfo executorInfo,
|
||||
string input,
|
||||
ILogger logger)
|
||||
{
|
||||
if (!executorInfo.IsAgenticExecutor)
|
||||
{
|
||||
string triggerName = $"dafx-{GetBaseName(executorInfo.ExecutorId)}";
|
||||
return await context.CallActivityAsync<string>(triggerName, input).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
return await ExecuteAgentAsync(context, executorInfo, input, logger).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private static async Task<string> ExecuteAgentAsync(
|
||||
TaskOrchestrationContext context,
|
||||
WorkflowExecutorInfo executorInfo,
|
||||
string input,
|
||||
ILogger logger)
|
||||
{
|
||||
string agentName = GetBaseName(executorInfo.ExecutorId);
|
||||
DurableAIAgent agent = context.GetAgent(agentName);
|
||||
|
||||
if (agent is null)
|
||||
{
|
||||
logger.LogWarning("Agent '{AgentName}' not found", agentName);
|
||||
return $"Agent '{agentName}' not found";
|
||||
}
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
AgentRunResponse response = await agent.RunAsync(input, thread).ConfigureAwait(true);
|
||||
return response.Text;
|
||||
}
|
||||
|
||||
private static string GetExecutorInput(
|
||||
string executorId,
|
||||
string initialInput,
|
||||
Dictionary<string, string> results,
|
||||
WorkflowExecutionPlan plan)
|
||||
{
|
||||
List<string> predecessors = plan.Predecessors[executorId];
|
||||
|
||||
if (predecessors.Count == 0)
|
||||
{
|
||||
return initialInput;
|
||||
}
|
||||
|
||||
if (predecessors.Count == 1)
|
||||
{
|
||||
return results.TryGetValue(predecessors[0], out string? result) ? result : initialInput;
|
||||
}
|
||||
|
||||
List<string> aggregated = [];
|
||||
foreach (string predecessorId in predecessors)
|
||||
{
|
||||
if (results.TryGetValue(predecessorId, out string? result))
|
||||
{
|
||||
aggregated.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
return SerializeToJson(aggregated);
|
||||
}
|
||||
|
||||
private static string GetFinalResult(WorkflowExecutionPlan plan, Dictionary<string, string> results)
|
||||
{
|
||||
WorkflowExecutionLevel lastLevel = plan.Levels[^1];
|
||||
|
||||
if (lastLevel.Executors.Count == 1)
|
||||
{
|
||||
return results[lastLevel.Executors[0].ExecutorId];
|
||||
}
|
||||
|
||||
List<string> finalResults = [];
|
||||
foreach (WorkflowExecutorInfo executor in lastLevel.Executors)
|
||||
{
|
||||
if (results.TryGetValue(executor.ExecutorId, out string? result))
|
||||
{
|
||||
finalResults.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join("\n---\n", finalResults);
|
||||
}
|
||||
}
|
||||
@@ -100,4 +100,34 @@ internal static partial class Logs
|
||||
public static partial void LogTTLExpirationTimeCleared(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 12,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Attempting to run workflow: {WorkflowName}")]
|
||||
public static partial void LogAttemptingToRunWorkflow(this ILogger logger, string workflowName);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 13,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Running workflow: {WorkflowName}")]
|
||||
public static partial void LogRunningWorkflow(this ILogger logger, string? workflowName);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 14,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Attempting to execute activity in workflow '{WorkflowName}' for executor '{ExecutorName}'")]
|
||||
public static partial void LogAttemptingToExecuteActivity(this ILogger logger, string workflowName, string executorName);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 15,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Executing activity for executor '{ExecutorId}' of type '{ExecutorType}'")]
|
||||
public static partial void LogExecutingActivity(this ILogger logger, string executorId, string executorType);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 16,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Activity executed for executor '{ExecutorId}' with result: {Result}")]
|
||||
public static partial void LogActivityExecuted(this ILogger logger, string executorId, string result);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.DurableTask.Client" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+9
-6
@@ -3,14 +3,14 @@
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an executor in the workflow with its metadata.
|
||||
/// </summary>
|
||||
/// <param name="ExecutorId">The unique identifier of the executor.</param>
|
||||
/// <param name="IsAgenticExecutor">Indicates whether this executor is an agentic executor.</param>
|
||||
internal sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExecutor);
|
||||
public sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExecutor);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a level of executors that can be executed in parallel (Fan-Out).
|
||||
@@ -19,12 +19,12 @@ internal sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExe
|
||||
/// <param name="Level">The level number (0-based, starting from the root executor).</param>
|
||||
/// <param name="Executors">The executors that can run in parallel at this level.</param>
|
||||
/// <param name="IsFanIn">Indicates if this level is a Fan-In point (has executors with multiple predecessors).</param>
|
||||
internal sealed record WorkflowExecutionLevel(int Level, List<WorkflowExecutorInfo> Executors, bool IsFanIn);
|
||||
public sealed record WorkflowExecutionLevel(int Level, List<WorkflowExecutorInfo> Executors, bool IsFanIn);
|
||||
|
||||
/// <summary>
|
||||
/// Represents the complete execution plan for a workflow, including parallel execution levels.
|
||||
/// </summary>
|
||||
internal sealed class WorkflowExecutionPlan
|
||||
public sealed class WorkflowExecutionPlan
|
||||
{
|
||||
/// <summary>
|
||||
/// The execution levels in order. Each level contains executors that can run in parallel.
|
||||
@@ -52,7 +52,10 @@ internal sealed class WorkflowExecutionPlan
|
||||
public bool HasFanIn => this.Levels.Any(l => l.IsFanIn);
|
||||
}
|
||||
|
||||
internal static class WorkflowHelper
|
||||
/// <summary>
|
||||
/// Provides helper methods for analyzing and executing workflows.
|
||||
/// </summary>
|
||||
public static class WorkflowHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Accepts a workflow instance and returns a list of executors with metadata in the order they should be executed.
|
||||
@@ -199,7 +202,7 @@ internal static class WorkflowHelper
|
||||
/// </summary>
|
||||
/// <param name="executorType">The executor type to check.</param>
|
||||
/// <returns><c>true</c> if the executor is an agentic executor; otherwise, <c>false</c>.</returns>
|
||||
private static bool IsAgentExecutorType(TypeId executorType)
|
||||
internal static bool IsAgentExecutorType(TypeId executorType)
|
||||
{
|
||||
// hack for now. In the future, the MAF type could expose something which can help with this.
|
||||
// Check if the type name or assembly indicates it's an agent executor
|
||||
+5
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
using Microsoft.DurableTask.Entities;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Durable entity that manages workflow state across activities within an orchestration run.
|
||||
@@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
/// ensuring state isolation between workflow runs. The entity is automatically cleaned up
|
||||
/// when the orchestration completes.
|
||||
/// </summary>
|
||||
internal sealed class WorkflowSharedStateEntity : TaskEntity<WorkflowStateData>
|
||||
public sealed class WorkflowSharedStateEntity : TaskEntity<WorkflowStateData>
|
||||
{
|
||||
/// <summary>
|
||||
/// The entity name used for registration and lookup.
|
||||
@@ -125,7 +125,7 @@ internal sealed class WorkflowSharedStateEntity : TaskEntity<WorkflowStateData>
|
||||
/// <summary>
|
||||
/// Represents the internal state data for a workflow state entity.
|
||||
/// </summary>
|
||||
internal sealed class WorkflowStateData
|
||||
public sealed class WorkflowStateData
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the state dictionary mapping scope-prefixed keys to serialized values.
|
||||
@@ -136,7 +136,7 @@ internal sealed class WorkflowStateData
|
||||
/// <summary>
|
||||
/// Request model for reading workflow state.
|
||||
/// </summary>
|
||||
internal sealed class WorkflowStateReadRequest
|
||||
public sealed class WorkflowStateReadRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the state key.
|
||||
@@ -152,7 +152,7 @@ internal sealed class WorkflowStateReadRequest
|
||||
/// <summary>
|
||||
/// Request model for writing workflow state.
|
||||
/// </summary>
|
||||
internal sealed class WorkflowStateWriteRequest
|
||||
public sealed class WorkflowStateWriteRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the state key.
|
||||
@@ -42,7 +42,7 @@ internal static class BuiltInFunctions
|
||||
|
||||
string activityFunctionName = functionContext.FunctionDefinition.Name;
|
||||
|
||||
DurableWorkflowRunner runner = functionContext.InstanceServices.GetRequiredService<DurableWorkflowRunner>();
|
||||
FunctionsWorkflowRunner runner = functionContext.InstanceServices.GetRequiredService<FunctionsWorkflowRunner>();
|
||||
return runner.ExecuteActivityAsync(activityFunctionName, input, durableTaskClient, functionContext);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ internal static class BuiltInFunctions
|
||||
var workflowName = context.FunctionDefinition.Name.Replace("http-", "");
|
||||
var orchestrationFunctionName = $"dafx-{workflowName}";
|
||||
var inputMessage = await req.ReadAsStringAsync();
|
||||
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, new DuableWorkflowRunRequest { WorkflowName = workflowName, Input = inputMessage! }); //OrchFunction"); // dafx-MyTestWorkflow");
|
||||
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, new DurableWorkflowRunRequest { WorkflowName = workflowName, Input = inputMessage! }); //OrchFunction"); // dafx-MyTestWorkflow");
|
||||
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
|
||||
await response.WriteStringAsync($"InvokeWorkflowOrechstrtationAsync is invoked for {workflowName}. Orchestration instanceId: {instanceId}");
|
||||
@@ -221,7 +221,7 @@ internal static class BuiltInFunctions
|
||||
}
|
||||
|
||||
#pragma warning disable DURTASK001 // Durable analyzer complained
|
||||
public static Task<List<string>> WorkflowRunnerOrchestrationAsync(TaskOrchestrationContext context, DuableWorkflowRunRequest input)
|
||||
public static Task<List<string>> WorkflowRunnerOrchestrationAsync(TaskOrchestrationContext context, DurableWorkflowRunRequest input)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ internal static class CoreAgentConfigurationExtensions
|
||||
/// <returns>The functions application builder for method chaining.</returns>
|
||||
internal static FunctionsApplicationBuilder RegisterWorkflowServices(this FunctionsApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.TryAddSingleton<DurableWorkflowRunner>();
|
||||
builder.Services.TryAddSingleton<FunctionsWorkflowRunner>();
|
||||
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IFunctionMetadataTransformer, DurableWorkflowFunctionMetadataTransformer>());
|
||||
|
||||
return builder;
|
||||
|
||||
@@ -91,7 +91,7 @@ public static class DurableOptionsExtensions
|
||||
|
||||
foreach (string workflowName in workflows.Workflows.Select(kp => kp.Key))
|
||||
{
|
||||
tasks.AddOrchestratorFunc<DuableWorkflowRunRequest, List<string>>(
|
||||
tasks.AddOrchestratorFunc<DurableWorkflowRunRequest, List<string>>(
|
||||
$"dafx-{workflowName}",
|
||||
async (orchestrationContext, request) =>
|
||||
{
|
||||
|
||||
+1
-13
@@ -64,7 +64,7 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta
|
||||
string functionName = $"dafx-{executorId.Split("_")[0]}";
|
||||
|
||||
// Check if the executor type is an agent-related type
|
||||
if (IsAgentExecutorType(executorInfo.ExecutorType))
|
||||
if (WorkflowHelper.IsAgentExecutorType(executorInfo.ExecutorType))
|
||||
{
|
||||
this._logger.LogAddingAgentEntityFunction(executorId, executorInfo.ExecutorType.TypeName, workflow.Key);
|
||||
//original.Add(CreateAgentTrigger(functionName));
|
||||
@@ -79,18 +79,6 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta
|
||||
}
|
||||
|
||||
this._logger.LogTransformFinished(original.Count);
|
||||
|
||||
static bool IsAgentExecutorType(TypeId executorType)
|
||||
{
|
||||
// hack for now. In the future, the MAF type could expose something which can help with this.
|
||||
// Check if the type name or assembly indicates it's an agent executor
|
||||
// This includes AgentRunStreamingExecutor, AgentExecutor, ChatClientAgent wrappers, etc.
|
||||
string typeName = executorType.TypeName;
|
||||
string assemblyName = executorType.AssemblyName;
|
||||
|
||||
return typeName.Contains("AIAgentHostExecutor", StringComparison.OrdinalIgnoreCase) &&
|
||||
assemblyName.Contains("Microsoft.Agents.AI", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
private static DefaultFunctionMetadata CreateHttpTrigger(string name, string route)
|
||||
|
||||
@@ -1,79 +1,29 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Executes workflow orchestrations and activity functions for durable workflows.
|
||||
/// Azure Functions-specific workflow runner that extends the base <see cref="DurableWorkflowRunner"/>
|
||||
/// with Azure Functions activity execution support.
|
||||
/// </summary>
|
||||
internal sealed class DurableWorkflowRunner
|
||||
internal sealed class FunctionsWorkflowRunner : DurableWorkflowRunner
|
||||
{
|
||||
private readonly DurableWorkflowOptions _options;
|
||||
private readonly ILogger<DurableWorkflowRunner> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableWorkflowRunner"/> class.
|
||||
/// Initializes a new instance of the <see cref="FunctionsWorkflowRunner"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="durableOptions">The durable options containing workflow configurations.</param>
|
||||
public DurableWorkflowRunner(ILogger<DurableWorkflowRunner> logger, DurableOptions durableOptions)
|
||||
public FunctionsWorkflowRunner(ILogger<FunctionsWorkflowRunner> logger, DurableOptions durableOptions)
|
||||
: base(logger, durableOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentNullException.ThrowIfNull(durableOptions);
|
||||
|
||||
this._logger = logger;
|
||||
this._options = durableOptions.Workflows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a workflow orchestration.
|
||||
/// </summary>
|
||||
/// <param name="context">The task orchestration context.</param>
|
||||
/// <param name="request">The workflow run request containing workflow name and input.</param>
|
||||
/// <param name="logger">The replay-safe logger for orchestration logging.</param>
|
||||
/// <returns>A list containing the workflow execution result.</returns>
|
||||
internal async Task<List<string>> RunWorkflowOrchestrationAsync(
|
||||
TaskOrchestrationContext context,
|
||||
DuableWorkflowRunRequest request,
|
||||
ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (!this._options.Workflows.TryGetValue(request.WorkflowName, out Workflow? workflow))
|
||||
{
|
||||
throw new InvalidOperationException($"Workflow '{request.WorkflowName}' not found.");
|
||||
}
|
||||
|
||||
logger.LogRunningWorkflow(workflow.Name);
|
||||
|
||||
string result = await this.ExecuteWorkflowLevelsAsync(context, workflow, request.Input, logger).ConfigureAwait(true);
|
||||
|
||||
await CleanupWorkflowStateAsync(context).ConfigureAwait(true);
|
||||
|
||||
return [result];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up the workflow state entity by signaling it to delete itself.
|
||||
/// </summary>
|
||||
private static async Task CleanupWorkflowStateAsync(TaskOrchestrationContext context)
|
||||
{
|
||||
EntityInstanceId stateEntityId = new(WorkflowSharedStateEntity.EntityName, context.InstanceId);
|
||||
|
||||
// Call the entity's Delete method to clean up state
|
||||
// Using CallEntityAsync ensures the deletion completes before the orchestration finishes
|
||||
await context.Entities.CallEntityAsync(stateEntityId, nameof(WorkflowSharedStateEntity.Delete)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -97,12 +47,12 @@ internal sealed class DurableWorkflowRunner
|
||||
|
||||
string executorName = ParseExecutorName(activityFunctionName);
|
||||
|
||||
if (!this._options.Executors.TryGetExecutor(executorName, out ExecutorRegistration? registration) || registration is null)
|
||||
if (!this.Options.Executors.TryGetExecutor(executorName, out ExecutorRegistration? registration) || registration is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Executor '{executorName}' not found in the executor registry.");
|
||||
}
|
||||
|
||||
this._logger.LogExecutingActivity(registration.ExecutorId, executorName);
|
||||
this.Logger.LogExecutingActivity(registration.ExecutorId, executorName);
|
||||
|
||||
Executor executor = await registration.CreateExecutorInstanceAsync("activity-run", CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
@@ -147,223 +97,4 @@ internal sealed class DurableWorkflowRunner
|
||||
{
|
||||
return new DurableExecutorContext(instanceId, client);
|
||||
}
|
||||
|
||||
private async Task<string> ExecuteWorkflowLevelsAsync(
|
||||
TaskOrchestrationContext context,
|
||||
Workflow workflow,
|
||||
string initialInput,
|
||||
ILogger logger)
|
||||
{
|
||||
WorkflowExecutionPlan plan = WorkflowHelper.GetExecutionPlan(workflow);
|
||||
Dictionary<string, string> results = [];
|
||||
|
||||
foreach (WorkflowExecutionLevel level in plan.Levels)
|
||||
{
|
||||
if (level.Executors.Count == 1)
|
||||
{
|
||||
WorkflowExecutorInfo executorInfo = level.Executors[0];
|
||||
string input = GetExecutorInput(executorInfo.ExecutorId, initialInput, results, plan);
|
||||
results[executorInfo.ExecutorId] = await this.ExecuteExecutorAsync(context, executorInfo, input, logger).ConfigureAwait(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
List<Task<(string Id, string Result)>> tasks = [];
|
||||
foreach (WorkflowExecutorInfo executorInfo in level.Executors)
|
||||
{
|
||||
string input = GetExecutorInput(executorInfo.ExecutorId, initialInput, results, plan);
|
||||
tasks.Add(this.ExecuteExecutorWithIdAsync(context, executorInfo, input, logger));
|
||||
}
|
||||
|
||||
foreach ((string id, string result) in await Task.WhenAll(tasks).ConfigureAwait(true))
|
||||
{
|
||||
results[id] = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return GetFinalResult(plan, results);
|
||||
}
|
||||
|
||||
private async Task<(string Id, string Result)> ExecuteExecutorWithIdAsync(
|
||||
TaskOrchestrationContext context,
|
||||
WorkflowExecutorInfo executorInfo,
|
||||
string input,
|
||||
ILogger logger)
|
||||
{
|
||||
string result = await this.ExecuteExecutorAsync(context, executorInfo, input, logger).ConfigureAwait(true);
|
||||
return (executorInfo.ExecutorId, result);
|
||||
}
|
||||
|
||||
private async Task<string> ExecuteExecutorAsync(
|
||||
TaskOrchestrationContext context,
|
||||
WorkflowExecutorInfo executorInfo,
|
||||
string input,
|
||||
ILogger logger)
|
||||
{
|
||||
if (!executorInfo.IsAgenticExecutor)
|
||||
{
|
||||
string triggerName = $"dafx-{GetBaseName(executorInfo.ExecutorId)}";
|
||||
return await context.CallActivityAsync<string>(triggerName, input).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
return await ExecuteAgentAsync(context, executorInfo, input, logger).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private static async Task<string> ExecuteAgentAsync(
|
||||
TaskOrchestrationContext context,
|
||||
WorkflowExecutorInfo executorInfo,
|
||||
string input,
|
||||
ILogger logger)
|
||||
{
|
||||
string agentName = GetBaseName(executorInfo.ExecutorId);
|
||||
DurableAIAgent agent = context.GetAgent(agentName);
|
||||
|
||||
if (agent is null)
|
||||
{
|
||||
logger.LogWarning("Agent '{AgentName}' not found", agentName);
|
||||
return $"Agent '{agentName}' not found";
|
||||
}
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
AgentRunResponse response = await agent.RunAsync(input, thread).ConfigureAwait(true);
|
||||
return response.Text;
|
||||
}
|
||||
|
||||
private static string GetExecutorInput(
|
||||
string executorId,
|
||||
string initialInput,
|
||||
Dictionary<string, string> results,
|
||||
WorkflowExecutionPlan plan)
|
||||
{
|
||||
List<string> predecessors = plan.Predecessors[executorId];
|
||||
|
||||
if (predecessors.Count == 0)
|
||||
{
|
||||
return initialInput;
|
||||
}
|
||||
|
||||
if (predecessors.Count == 1)
|
||||
{
|
||||
return results.TryGetValue(predecessors[0], out string? result) ? result : initialInput;
|
||||
}
|
||||
|
||||
List<string> aggregated = [];
|
||||
foreach (string predecessorId in predecessors)
|
||||
{
|
||||
if (results.TryGetValue(predecessorId, out string? result))
|
||||
{
|
||||
aggregated.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
return SerializeToJson(aggregated);
|
||||
}
|
||||
|
||||
private static string GetFinalResult(WorkflowExecutionPlan plan, Dictionary<string, string> results)
|
||||
{
|
||||
WorkflowExecutionLevel lastLevel = plan.Levels[^1];
|
||||
|
||||
if (lastLevel.Executors.Count == 1)
|
||||
{
|
||||
return results[lastLevel.Executors[0].ExecutorId];
|
||||
}
|
||||
|
||||
List<string> finalResults = [];
|
||||
foreach (WorkflowExecutorInfo executor in lastLevel.Executors)
|
||||
{
|
||||
if (results.TryGetValue(executor.ExecutorId, out string? result))
|
||||
{
|
||||
finalResults.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join("\n---\n", finalResults);
|
||||
}
|
||||
|
||||
private static string ParseExecutorName(string activityFunctionName)
|
||||
{
|
||||
const string Prefix = "dafx-";
|
||||
|
||||
if (!activityFunctionName.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Activity function name '{activityFunctionName}' does not start with '{Prefix}' prefix.");
|
||||
}
|
||||
|
||||
string executorName = activityFunctionName[Prefix.Length..];
|
||||
|
||||
if (string.IsNullOrEmpty(executorName))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Activity function name '{activityFunctionName}' is not in the expected format '{Prefix}{{executorName}}'.");
|
||||
}
|
||||
|
||||
return executorName;
|
||||
}
|
||||
|
||||
private static string GetBaseName(string executorId)
|
||||
{
|
||||
int underscoreIndex = executorId.IndexOf('_');
|
||||
return underscoreIndex > 0 ? executorId[..underscoreIndex] : executorId;
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (result is string str)
|
||||
{
|
||||
return str;
|
||||
}
|
||||
|
||||
Type resultType = result.GetType();
|
||||
if (resultType.IsPrimitive || resultType == typeof(decimal))
|
||||
{
|
||||
return result.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(result, resultType);
|
||||
}
|
||||
|
||||
[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))
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
string json = input;
|
||||
if (input.StartsWith('"') && input.EndsWith('"'))
|
||||
{
|
||||
try
|
||||
{
|
||||
string? innerJson = JsonSerializer.Deserialize<string>(input);
|
||||
if (innerJson is not null)
|
||||
{
|
||||
json = innerJson;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not double-serialized, use original
|
||||
}
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(json, targetType)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Logging messages for <see cref="DurableWorkflowRunner"/>.
|
||||
/// </summary>
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal static partial class DurableWorkflowRunnerLogs
|
||||
{
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = "Attempting to run workflow: {WorkflowName}")]
|
||||
public static partial void LogAttemptingToRunWorkflow(this ILogger logger, string workflowName);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = "Running workflow: {WorkflowName}")]
|
||||
public static partial void LogRunningWorkflow(this ILogger logger, string? workflowName);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = "Attempting to execute activity in workflow '{WorkflowName}' for executor '{ExecutorName}'")]
|
||||
public static partial void LogAttemptingToExecuteActivity(this ILogger logger, string workflowName, string executorName);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = "Executing activity for executor '{ExecutorId}' of type '{ExecutorType}'")]
|
||||
public static partial void LogExecutingActivity(this ILogger logger, string executorId, string executorType);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = "Activity executed for executor '{ExecutorId}' with result: {Result}")]
|
||||
public static partial void LogActivityExecuted(this ILogger logger, string executorId, string result);
|
||||
}
|
||||
Reference in New Issue
Block a user