diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 246b3e7e7b..7b6327c13e 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -55,6 +55,7 @@ + diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/08_WorkflowLoop.csproj b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/08_WorkflowLoop.csproj new file mode 100644 index 0000000000..8a0c04e2dc --- /dev/null +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/08_WorkflowLoop.csproj @@ -0,0 +1,30 @@ + + + net10.0 + Exe + enable + enable + WorkflowLoop + WorkflowLoop + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/FeedbackExecutor.cs b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/FeedbackExecutor.cs new file mode 100644 index 0000000000..b212af1015 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/FeedbackExecutor.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace WorkflowLoop; + +/// +/// Evaluates slogans and either accepts them or sends feedback back for refinement. +/// Uses SendMessageAsync to loop back to SloganWriter and YieldOutputAsync to end the workflow. +/// +internal sealed class FeedbackExecutor : Executor +{ + private readonly AIAgent _agent; + private AgentSession? _session; + + /// + /// Gets or sets the minimum rating required to accept a slogan. + /// + public int MinimumRating { get; init; } = 9; + + /// + /// Gets or sets the maximum number of refinement attempts before accepting the slogan. + /// + public int MaxAttempts { get; init; } = 3; + + private int _attempts; + + /// + /// Initializes a new instance of the class. + /// + /// A unique identifier for the executor. + /// The chat client to use for the AI agent. + public FeedbackExecutor(string id, IChatClient chatClient) : base(id) + { + ChatClientAgentOptions agentOptions = new() + { + ChatOptions = new() + { + Instructions = "You are a professional editor. You will be given a slogan and the task it is meant to accomplish.", + ResponseFormat = ChatResponseFormat.ForJsonSchema() + } + }; + + this._agent = new ChatClientAgent(chatClient, agentOptions); + } + + /// + public override async ValueTask HandleAsync(SloganResult message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + Console.WriteLine($" [FeedbackProvider] Evaluating slogan: \"{message.Slogan}\""); + this._session ??= await this._agent.GetNewSessionAsync(cancellationToken); + + string sloganMessage = $""" + Here is a slogan for the task '{message.Task}': + Slogan: {message.Slogan} + Please provide feedback on this slogan, including comments, a rating from 1 to 10, and suggested actions for improvement. + """; + + AgentResponse response = await this._agent.RunAsync(sloganMessage, this._session, cancellationToken: cancellationToken); + FeedbackResult feedback = JsonSerializer.Deserialize(response.Text) ?? throw new InvalidOperationException("Failed to deserialize feedback."); + + Console.WriteLine($" [FeedbackProvider] Rating: {feedback.Rating}/{this.MinimumRating} - {feedback.Comments}"); + + // If the rating meets the threshold, accept the slogan and end the workflow + if (feedback.Rating >= this.MinimumRating) + { + Console.WriteLine(" [FeedbackProvider] Accepted!"); + await context.YieldOutputAsync($"The following slogan was accepted:\n\n{message.Slogan}", cancellationToken); + return; + } + + // If we've exceeded max attempts, accept the slogan anyway + if (this._attempts >= this.MaxAttempts) + { + Console.WriteLine(" [FeedbackProvider] Max attempts reached, accepting final slogan."); + await context.YieldOutputAsync($"The slogan was accepted after {this.MaxAttempts} attempts. Final slogan:\n\n{message.Slogan}", cancellationToken); + return; + } + + // Otherwise, send feedback back to the slogan writer for refinement (circular edge) + Console.WriteLine($" [FeedbackProvider] Sending back for refinement (attempt {this._attempts + 1}/{this.MaxAttempts})..."); + await context.SendMessageAsync(feedback, cancellationToken: cancellationToken); + this._attempts++; + } +} diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/Program.cs b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/Program.cs new file mode 100644 index 0000000000..fc83baf150 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/Program.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates a CYCLIC WORKFLOW (back-edges in the graph). +// SloganWriter and FeedbackProvider loop until the slogan meets quality criteria. + +using Azure; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using WorkflowLoop; + +// Get DTS connection string from environment variable +string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set."); +string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); + +// Create the chat client using key-based or Azure CLI credential authentication +AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey) + ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) + : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); +IChatClient chatClient = openAiClient.GetChatClient(deploymentName).AsIChatClient(); + +// Define executors for the workflow +SloganWriterExecutor sloganWriter = new("SloganWriter", chatClient); +FeedbackExecutor feedbackProvider = new("FeedbackProvider", chatClient); + +// Build the workflow with a circular edge: SloganWriter → FeedbackProvider → SloganWriter +Workflow workflow = new WorkflowBuilder(sloganWriter) + .WithName("SloganCreationWorkflow") + .AddEdge(sloganWriter, feedbackProvider) + .AddEdge(feedbackProvider, sloganWriter) + .WithOutputFrom(feedbackProvider) + .Build(); + +IHost host = Host.CreateDefaultBuilder(args) + .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) + .ConfigureServices(services => + { + services.ConfigureDurableWorkflows( + workflowOptions => workflowOptions.AddWorkflow(workflow), + workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), + clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); + }) + .Build(); + +await host.StartAsync(); + +IWorkflowClient workflowClient = host.Services.GetRequiredService(); + +Console.WriteLine("Workflow Loop Demo - Enter a topic for slogan generation (or 'exit'):"); + +while (true) +{ + Console.Write("> "); + string? input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + try + { + // RunAsync starts the workflow; cast to IAwaitableWorkflowRun to wait for the result + IAwaitableWorkflowRun run = (IAwaitableWorkflowRun)await workflowClient.RunAsync(workflow, input); + Console.WriteLine($"Started run: {run.RunId}"); + + string? result = await run.WaitForCompletionAsync(); + Console.WriteLine($"Result: {result}"); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + + Console.WriteLine(); +} + +await host.StopAsync(); diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/README.md b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/README.md new file mode 100644 index 0000000000..2d57c89e91 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/README.md @@ -0,0 +1,69 @@ +# Workflow Loop Sample + +This sample demonstrates how to run a **cyclic workflow** (containing loops / back-edges) as a durable orchestration. The workflow iteratively improves a slogan based on AI feedback until it meets quality criteria. + +## Key Concepts Demonstrated + +- **Cyclic workflow support** — back-edges in the graph (A → B → A) +- **Multi-type executor handlers** — SloganWriter handles both `string` and `FeedbackResult` inputs +- **Message routing via `SendMessageAsync`** — FeedbackProvider sends messages back to SloganWriter +- **Workflow termination via `YieldOutputAsync`** — FeedbackProvider yields output when the slogan is accepted + +## Overview + +``` + ┌──────────────────────┐ + │ │ + input ──→ SloganWriter ──→ FeedbackProvider + ▲ │ + │ (FeedbackResult) │ + └──────────────────────┘ + back-edge +``` + +| Executor | Description | +|----------|-------------| +| SloganWriter | Generates slogans from user input; refines them based on feedback | +| FeedbackProvider | Evaluates slogans — accepts (YieldOutput) or loops (SendMessage) | + +### Loop Behavior + +1. **SloganWriter** generates a slogan based on user input +2. **FeedbackProvider** evaluates the slogan and provides a rating +3. If the rating is below the threshold (default: 9), feedback is sent back to SloganWriter +4. SloganWriter improves the slogan based on feedback +5. The loop continues until the slogan is accepted or max attempts (default: 3) are reached + +## Environment Setup + +See the [README.md](../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler. + +### Required Environment Variables + +| Variable | Description | +|----------|-------------| +| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint URL | +| `AZURE_OPENAI_DEPLOYMENT` | Azure OpenAI deployment name | +| `AZURE_OPENAI_KEY` | (Optional) Azure OpenAI API key. If not set, uses Azure CLI credential | +| `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` | (Optional) DTS connection string. Defaults to local emulator | + +## Running the Sample + +```bash +cd dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop +dotnet run --framework net10.0 +``` + +### Sample Output + +```text +Workflow Loop Demo - Enter a topic for slogan generation (or 'exit'): +> sustainable energy +Started run: abc123... + [FeedbackProvider] Rating 6/9 - sending back for refinement (attempt 1/3) + [FeedbackProvider] Rating 8/9 - sending back for refinement (attempt 2/3) + Event: WorkflowOutputEvent + Completed: The following slogan was accepted: + +Power the future, preserve the planet. +``` diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/SloganResult.cs b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/SloganResult.cs new file mode 100644 index 0000000000..4b1f0befa5 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/SloganResult.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace WorkflowLoop; + +/// +/// Represents the result produced by the slogan writer executor. +/// +public sealed class SloganResult +{ + [JsonPropertyName("task")] + public required string Task { get; set; } + + [JsonPropertyName("slogan")] + public required string Slogan { get; set; } +} + +/// +/// Represents feedback from the feedback executor, including comments, rating, and improvement actions. +/// +public sealed class FeedbackResult +{ + [JsonPropertyName("comments")] + public string Comments { get; set; } = string.Empty; + + [JsonPropertyName("rating")] + public int Rating { get; set; } + + [JsonPropertyName("actions")] + public string Actions { get; set; } = string.Empty; +} diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/SloganWriterExecutor.cs b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/SloganWriterExecutor.cs new file mode 100644 index 0000000000..f609d346ee --- /dev/null +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowLoop/SloganWriterExecutor.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace WorkflowLoop; + +/// +/// Generates slogans based on user input or refines them based on feedback. +/// This executor handles two input types: string (initial request) and FeedbackResult (refinement loop). +/// +internal sealed class SloganWriterExecutor : Executor +{ + private readonly AIAgent _agent; + private AgentSession? _session; + + /// + /// Initializes a new instance of the class. + /// + /// A unique identifier for the executor. + /// The chat client to use for the AI agent. + public SloganWriterExecutor(string id, IChatClient chatClient) : base(id) + { + ChatClientAgentOptions agentOptions = new() + { + ChatOptions = new() + { + Instructions = "You are a professional slogan writer. You will be given a task to create a slogan.", + ResponseFormat = ChatResponseFormat.ForJsonSchema() + } + }; + + this._agent = new ChatClientAgent(chatClient, agentOptions); + } + + /// + /// Configures two routes: one for initial string input and one for feedback-based refinement. + /// + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler(this.HandleAsync) + .AddHandler(this.HandleFeedbackAsync); + + /// + /// Handles the initial slogan generation request. + /// + public async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + Console.WriteLine($" [SloganWriter] Generating slogan for: {message}"); + this._session ??= await this._agent.GetNewSessionAsync(cancellationToken); + + AgentResponse result = await this._agent.RunAsync(message, this._session, cancellationToken: cancellationToken); + SloganResult slogan = JsonSerializer.Deserialize(result.Text) ?? throw new InvalidOperationException("Failed to deserialize slogan result."); + Console.WriteLine($" [SloganWriter] Generated: \"{slogan.Slogan}\""); + return slogan; + } + + /// + /// Handles feedback from the feedback executor to refine the slogan. + /// + public async ValueTask HandleFeedbackAsync(FeedbackResult message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + Console.WriteLine($" [SloganWriter] Refining slogan based on feedback (rating was {message.Rating})..."); + + string feedbackMessage = $""" + Here is the feedback on your previous slogan: + Comments: {message.Comments} + Rating: {message.Rating} + Suggested Actions: {message.Actions} + + Please use this feedback to improve your slogan. + """; + + AgentResponse result = await this._agent.RunAsync(feedbackMessage, this._session, cancellationToken: cancellationToken); + SloganResult slogan = JsonSerializer.Deserialize(result.Text) ?? throw new InvalidOperationException("Failed to deserialize slogan result."); + Console.WriteLine($" [SloganWriter] Refined: \"{slogan.Slogan}\""); + return slogan; + } +}