From 4b6f8ba9d2814cf84323640416f8ed78c1e1eab0 Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Thu, 30 Oct 2025 00:13:22 +0100 Subject: [PATCH 1/6] Adding Sample for writer-critic workflow implemented using Worfklow, custom executors, agents, switch, custom states, different entry points for the executors. --- dotnet/agent-framework-dotnet.slnx | 1 + .../GettingStarted/Workflows/README.md | 1 + .../08_WriterCriticWorkflow.csproj | 24 + .../08_WriterCriticWorkflow/Program.cs | 438 ++++++++++++++++++ 4 files changed, 464 insertions(+) create mode 100644 dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj create mode 100644 dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index c82d5f02b6..84a76f258d 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -132,6 +132,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/README.md b/dotnet/samples/GettingStarted/Workflows/README.md index 4ea750e19e..072acfa560 100644 --- a/dotnet/samples/GettingStarted/Workflows/README.md +++ b/dotnet/samples/GettingStarted/Workflows/README.md @@ -19,6 +19,7 @@ Please begin with the [Foundational](./_Foundational) samples in order. These th | [Multi-Service Workflows](./_Foundational/05_MultiModelService) | Shows using multiple AI services in the same workflow | | [Sub-Workflows](./_Foundational/06_SubWorkflows) | Demonstrates composing workflows hierarchically by embedding workflows as executors | | [Mixed Workflow with Agents and Executors](./_Foundational/07_MixedWorkflowAgentsAndExecutors) | Shows how to mix agents and executors with adapter pattern for type conversion and protocol handling | +| [Writer-Critic Workflow](./_Foundational/08_WriterCriticWorkflow) | Demonstrates iterative refinement with quality gates, max iteration safety, multiple message handlers, and conditional routing for feedback loops | Once completed, please proceed to other samples listed below. diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj new file mode 100644 index 0000000000..3e8f2547d1 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj @@ -0,0 +1,24 @@ + + + + Exe + net9.0 + WriterCriticWorkflow + enable + enable + false + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs new file mode 100644 index 0000000000..50f1dbf795 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs @@ -0,0 +1,438 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using System.Text; +using System.Text.Json; + +namespace WriterCriticWorkflow; + +/// +/// This sample demonstrates an iterative refinement workflow between Writer and Critic agents. +/// +/// The workflow implements a content creation and review loop that: +/// 1. Writer creates initial content based on the user's request +/// 2. Critic reviews the content and provides feedback +/// 3. If approved: Summary executor presents the final content +/// 4. If rejected: Writer revises based on feedback (loops back) +/// 5. Continues until approval or max iterations (3) is reached +/// +/// This pattern is useful when you need: +/// - Iterative content improvement through feedback loops +/// - Quality gates with reviewer approval +/// - Maximum iteration limits to prevent infinite loops +/// - Conditional workflow routing based on agent decisions +/// +/// Key Learning: Workflows can implement loops with conditional edges and shared state +/// to track iteration progress across multiple executor invocations. +/// +/// +/// Pre-requisites: +/// - Previous foundational samples should be completed first. +/// - An Azure OpenAI chat completion deployment must be configured. +/// +public static class Program +{ + public const int MaxIterations = 3; + + private static async Task Main() + { + Console.WriteLine("\n=== Writer-Critic Iteration Workflow ===\n"); + Console.WriteLine($"Writer and Critic will iterate up to {MaxIterations} times until approval.\n"); + + // Set up the Azure OpenAI client + var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); + var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); + + // Create executors for content creation and review + WriterExecutor writer = new(chatClient); + CriticExecutor critic = new(chatClient); + SummaryExecutor summary = new(chatClient); + + // Build the workflow with conditional routing based on critic's decision + // Key Point: The workflow loops back to Writer if content is rejected, + // or proceeds to Summary if approved. State tracking ensures we don't loop forever. + WorkflowBuilder workflowBuilder = new WorkflowBuilder(writer) + .AddEdge(writer, critic) + .AddSwitch(critic, sw => sw + .AddCase(cd => cd?.Approved == true, summary) + .AddCase(cd => cd?.Approved == false, writer)) + .WithOutputFrom(summary); + + // Execute the workflow with a sample task + Console.WriteLine(new string('=', 80)); + Console.WriteLine("TASK: Write a short blog post about AI ethics (200 words)"); + Console.WriteLine(new string('=', 80) + "\n"); + + const string InitialTask = "Write a 200-word blog post about AI ethics. Make it thoughtful and engaging."; + + // Build a fresh workflow for execution + Workflow workflow = workflowBuilder.Build(); + await ExecuteWorkflowAsync(workflow, InitialTask); + + Console.WriteLine("\n✅ Sample Complete: Writer-Critic iteration demonstrates conditional workflow loops\n"); + Console.WriteLine("Key Concepts Demonstrated:"); + Console.WriteLine(" ✓ Iterative refinement loop with conditional routing"); + Console.WriteLine(" ✓ Shared workflow state for iteration tracking"); + Console.WriteLine($" ✓ Max iteration cap ({MaxIterations}) for safety"); + Console.WriteLine(" ✓ Multiple message handlers in a single executor"); + Console.WriteLine(" ✓ Streaming support for real-time feedback\n"); + } + + private static async Task ExecuteWorkflowAsync(Workflow workflow, string input) + { + // Execute in streaming mode to see real-time progress + await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); + + // Watch the workflow events + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + switch (evt) + { + case AgentRunUpdateEvent agentUpdate: + // Stream agent output in real-time (optional, controlled by ShowAgentThinking) + if (!string.IsNullOrEmpty(agentUpdate.Update.Text)) + { + Console.Write(agentUpdate.Update.Text); + } + break; + + case WorkflowOutputEvent output: + Console.WriteLine("\n\n" + new string('=', 80)); + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("✅ FINAL APPROVED CONTENT"); + Console.ResetColor(); + Console.WriteLine(new string('=', 80)); + Console.WriteLine(); + Console.WriteLine(output.Data); + Console.WriteLine(); + Console.WriteLine(new string('=', 80)); + break; + } + } + } +} + +// ==================================== +// Shared State for Iteration Tracking +// ==================================== + +/// +/// Tracks the current iteration and conversation history across workflow executions. +/// +internal sealed class FlowState +{ + public int Iteration { get; set; } = 1; + public List History { get; } = []; +} + +/// +/// Constants for accessing the shared flow state in workflow context. +/// +internal static class FlowStateShared +{ + public const string Scope = "FlowStateScope"; + public const string Key = "singleton"; +} + +/// +/// Helper methods for reading and writing shared flow state. +/// +internal static class FlowStateHelpers +{ + public static async Task ReadFlowStateAsync(IWorkflowContext context) + { + FlowState? state = await context.ReadStateAsync(FlowStateShared.Key, scopeName: FlowStateShared.Scope); + return state ?? new FlowState(); + } + + public static ValueTask SaveFlowStateAsync(IWorkflowContext context, FlowState state) + => context.QueueStateUpdateAsync(FlowStateShared.Key, state, scopeName: FlowStateShared.Scope); +} + +// ==================================== +// Data Transfer Objects +// ==================================== + +/// +/// Represents the critic's decision and feedback on the content. +/// +internal sealed class CriticDecision +{ + public bool Approved { get; set; } + public string Feedback { get; set; } = ""; + public string Content { get; set; } = ""; + public int Iteration { get; set; } +} + +// ==================================== +// Custom Executors +// ==================================== + +/// +/// Executor that creates or revises content based on user requests or critic feedback. +/// This executor demonstrates multiple message handlers for different input types. +/// +internal sealed class WriterExecutor : Executor +{ + private readonly AIAgent _agent; + + public WriterExecutor(IChatClient chatClient) : base("Writer") + { + this._agent = new ChatClientAgent( + chatClient, + name: "Writer", + instructions: """ + You are a skilled writer. Create clear, engaging content. + If you receive feedback, carefully revise the content to address all concerns. + Maintain the same topic and length requirements. + """ + ); + } + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder + .AddHandler(this.HandleInitialRequestAsync) + .AddHandler(this.HandleRevisionRequestAsync); + + /// + /// Handles the initial writing request from the user. + /// + private async ValueTask HandleInitialRequestAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + return await this.HandleAsyncCoreAsync(new ChatMessage(ChatRole.User, message), context, cancellationToken); + } + + /// + /// Handles revision requests from the critic with feedback. + /// + private async ValueTask HandleRevisionRequestAsync( + CriticDecision decision, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + string prompt = "Revise the following content based on this feedback:\n\n" + + $"Feedback: {decision.Feedback}\n\n" + + $"Original Content:\n{decision.Content}"; + + return await this.HandleAsyncCoreAsync(new ChatMessage(ChatRole.User, prompt), context, cancellationToken); + } + + /// + /// Core implementation for generating content (initial or revised). + /// + private async Task HandleAsyncCoreAsync( + ChatMessage message, + IWorkflowContext context, + CancellationToken cancellationToken) + { + FlowState state = await FlowStateHelpers.ReadFlowStateAsync(context); + + Console.WriteLine($"\n=== Writer (Iteration {state.Iteration}) ===\n"); + + StringBuilder sb = new(); + await foreach (AgentRunResponseUpdate update in this._agent.RunStreamingAsync(message, cancellationToken: cancellationToken)) + { + if (!string.IsNullOrEmpty(update.Text)) + { + sb.Append(update.Text); + Console.Write(update.Text); + } + } + Console.WriteLine("\n"); + + string text = sb.ToString(); + state.History.Add(new ChatMessage(ChatRole.Assistant, text)); + await FlowStateHelpers.SaveFlowStateAsync(context, state); + + return new ChatMessage(ChatRole.User, text); + } +} + +/// +/// Executor that reviews content and decides whether to approve or request revisions. +/// Uses JSON output for structured decision-making. +/// +internal sealed class CriticExecutor : Executor +{ + private readonly AIAgent _agent; + + public CriticExecutor(IChatClient chatClient) : base("Critic") + { + this._agent = new ChatClientAgent( + chatClient, + name: "Critic", + instructions: """ + You are a constructive critic. Review the content and provide specific feedback. + Always try to provide actionable suggestions for improvement and strive to identify improvement points. + Only approve if the content is high quality, clear, and meets the original requirements and you see no improvement points. + + At the end, output EXACTLY one JSON line: + {"approved":true,"feedback":""} if the content is good + {"approved":false,"feedback":""} if revisions are needed + + Be concise but specific in your feedback. + """ + ); + } + + public override async ValueTask HandleAsync( + ChatMessage message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + FlowState state = await FlowStateHelpers.ReadFlowStateAsync(context); + + Console.WriteLine($"=== Critic (Iteration {state.Iteration}) ===\n"); + + StringBuilder sb = new(); + await foreach (AgentRunResponseUpdate update in this._agent.RunStreamingAsync(message, cancellationToken: cancellationToken)) + { + if (!string.IsNullOrEmpty(update.Text)) + { + sb.Append(update.Text); + Console.Write(update.Text); + } + } + Console.WriteLine("\n"); + + string fullResponse = sb.ToString(); + (bool approved, string feedback) = ParseDecision(fullResponse); + + // Safety: approve if max iterations reached + if (!approved && state.Iteration >= Program.MaxIterations) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"⚠️ Max iterations ({Program.MaxIterations}) reached - auto-approving"); + Console.ResetColor(); + approved = true; + feedback = ""; + } + + // Increment iteration ONLY if rejecting (will loop back to Writer) + if (!approved) + { + state.Iteration++; + } + + state.History.Add(new ChatMessage(ChatRole.Assistant, StripTrailingJson(fullResponse))); + await FlowStateHelpers.SaveFlowStateAsync(context, state); + + return new CriticDecision + { + Approved = approved, + Feedback = feedback, + Content = message.Text ?? "", + Iteration = state.Iteration + }; + } + + /// + /// Parses the critic's response to extract the approval decision and feedback. + /// Looks for a JSON line in the format: {"approved":true/false,"feedback":"..."} + /// + private static (bool approved, string feedback) ParseDecision(string fullResponse) + { + string? lastJson = null; + foreach (string line in fullResponse.Replace("\r\n", "\n").Split('\n').Reverse()) + { + string trimmedLine = line.Trim(); + if (trimmedLine.StartsWith('{') && trimmedLine.EndsWith('}')) + { + lastJson = trimmedLine; + break; + } + } + + if (lastJson is null) + { + // Fallback: check for explicit approval text + if (fullResponse.Contains("APPROVE", StringComparison.OrdinalIgnoreCase)) + { + return (true, ""); + } + return (false, "Missing approval decision."); + } + + try + { + using JsonDocument doc = JsonDocument.Parse(lastJson); + bool ok = doc.RootElement.GetProperty("approved").GetBoolean(); + string fb = doc.RootElement.TryGetProperty("feedback", out JsonElement el) ? el.GetString() ?? "" : ""; + return (ok, fb); + } + catch + { + return (false, "Malformed approval JSON."); + } + } + + /// + /// Removes the trailing JSON decision line from the response for cleaner history. + /// + private static string StripTrailingJson(string text) + { + string[] lines = text.Replace("\r\n", "\n").Split('\n'); + if (lines.Length == 0) + { + return text; + } + + string lastLine = lines[^1].Trim(); + if (lastLine.StartsWith('{') && lastLine.EndsWith('}')) + { + return string.Join("\n", lines[..^1]); + } + return text; + } +} + +/// +/// Executor that presents the final approved content to the user. +/// +internal sealed class SummaryExecutor : Executor +{ + private readonly AIAgent _agent; + + public SummaryExecutor(IChatClient chatClient) : base("Summary") + { + this._agent = new ChatClientAgent( + chatClient, + name: "Summary", + instructions: """ + You present the final approved content to the user. + Simply output the polished content - no additional commentary needed. + """ + ); + } + + public override async ValueTask HandleAsync( + CriticDecision message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine("=== Summary ===\n"); + + string prompt = $"Present this approved content:\n\n{message.Content}"; + + StringBuilder sb = new(); + await foreach (AgentRunResponseUpdate update in this._agent.RunStreamingAsync(new ChatMessage(ChatRole.User, prompt), cancellationToken: cancellationToken)) + { + if (!string.IsNullOrEmpty(update.Text)) + { + sb.Append(update.Text); + } + } + + ChatMessage result = new(ChatRole.Assistant, sb.ToString()); + await context.YieldOutputAsync(result, cancellationToken); + return result; + } +} From ea02b1d065ab78617d9f00ce6a4208e9de8663dc Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Thu, 30 Oct 2025 00:54:59 +0100 Subject: [PATCH 2/6] Update dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../08_WriterCriticWorkflow/Program.cs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs index 50f1dbf795..d4c71b5c71 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs @@ -340,16 +340,12 @@ public override async ValueTask HandleAsync( /// private static (bool approved, string feedback) ParseDecision(string fullResponse) { - string? lastJson = null; - foreach (string line in fullResponse.Replace("\r\n", "\n").Split('\n').Reverse()) - { - string trimmedLine = line.Trim(); - if (trimmedLine.StartsWith('{') && trimmedLine.EndsWith('}')) - { - lastJson = trimmedLine; - break; - } - } + string? lastJson = fullResponse + .Replace("\r\n", "\n") + .Split('\n') + .Reverse() + .Select(line => line.Trim()) + .FirstOrDefault(trimmedLine => trimmedLine.StartsWith('{') && trimmedLine.EndsWith('}')); if (lastJson is null) { From d5e41f0c36c5f6d2276fd8fa3fef66689cac2088 Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Thu, 30 Oct 2025 00:55:09 +0100 Subject: [PATCH 3/6] Update dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs index d4c71b5c71..b1f7425fea 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs @@ -364,7 +364,7 @@ private static (bool approved, string feedback) ParseDecision(string fullRespons string fb = doc.RootElement.TryGetProperty("feedback", out JsonElement el) ? el.GetString() ?? "" : ""; return (ok, fb); } - catch + catch (JsonException) { return (false, "Malformed approval JSON."); } From a665ba2e3caaacaad801899e108d1f1ede3b4e1c Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Sun, 2 Nov 2025 20:40:18 +0100 Subject: [PATCH 4/6] using now structured output, with streaming for UX responsiveness. --- .../08_WriterCriticWorkflow/Program.cs | 160 ++++++++---------- 1 file changed, 67 insertions(+), 93 deletions(-) diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs index b1f7425fea..6bb21474db 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs @@ -1,12 +1,14 @@ // Copyright (c) Microsoft. All rights reserved. +using System.ComponentModel; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; -using System.Text; -using System.Text.Json; namespace WriterCriticWorkflow; @@ -15,7 +17,7 @@ namespace WriterCriticWorkflow; /// /// The workflow implements a content creation and review loop that: /// 1. Writer creates initial content based on the user's request -/// 2. Critic reviews the content and provides feedback +/// 2. Critic reviews the content and provides feedback using structured output /// 3. If approved: Summary executor presents the final content /// 4. If rejected: Writer revises based on feedback (loops back) /// 5. Continues until approval or max iterations (3) is reached @@ -25,9 +27,10 @@ namespace WriterCriticWorkflow; /// - Quality gates with reviewer approval /// - Maximum iteration limits to prevent infinite loops /// - Conditional workflow routing based on agent decisions +/// - Structured output for reliable decision-making /// -/// Key Learning: Workflows can implement loops with conditional edges and shared state -/// to track iteration progress across multiple executor invocations. +/// Key Learning: Workflows can implement loops with conditional edges, shared state, +/// and structured output for robust agent decision-making. /// /// /// Pre-requisites: @@ -44,9 +47,9 @@ private static async Task Main() Console.WriteLine($"Writer and Critic will iterate up to {MaxIterations} times until approval.\n"); // Set up the Azure OpenAI client - var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); - var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); + string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); + string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create executors for content creation and review WriterExecutor writer = new(chatClient); @@ -80,7 +83,7 @@ private static async Task Main() Console.WriteLine(" ✓ Shared workflow state for iteration tracking"); Console.WriteLine($" ✓ Max iteration cap ({MaxIterations}) for safety"); Console.WriteLine(" ✓ Multiple message handlers in a single executor"); - Console.WriteLine(" ✓ Streaming support for real-time feedback\n"); + Console.WriteLine(" ✓ Streaming support with structured output\n"); } private static async Task ExecuteWorkflowAsync(Workflow workflow, string input) @@ -94,7 +97,7 @@ private static async Task ExecuteWorkflowAsync(Workflow workflow, string input) switch (evt) { case AgentRunUpdateEvent agentUpdate: - // Stream agent output in real-time (optional, controlled by ShowAgentThinking) + // Stream agent output in real-time if (!string.IsNullOrEmpty(agentUpdate.Update.Text)) { Console.Write(agentUpdate.Update.Text); @@ -159,13 +162,25 @@ public static ValueTask SaveFlowStateAsync(IWorkflowContext context, FlowState s // ==================================== /// -/// Represents the critic's decision and feedback on the content. +/// Structured output schema for the Critic's decision. +/// Uses JsonPropertyName and Description attributes for OpenAI's JSON schema. /// +[Description("Critic's review decision including approval status and feedback")] internal sealed class CriticDecision { + [JsonPropertyName("approved")] + [Description("Whether the content is approved (true) or needs revision (false)")] public bool Approved { get; set; } + + [JsonPropertyName("feedback")] + [Description("Specific feedback for improvements if not approved, empty if approved")] public string Feedback { get; set; } = ""; + + // Non-JSON properties for workflow use + [JsonIgnore] public string Content { get; set; } = ""; + + [JsonIgnore] public int Iteration { get; set; } } @@ -258,7 +273,7 @@ private async Task HandleAsyncCoreAsync( /// /// Executor that reviews content and decides whether to approve or request revisions. -/// Uses JSON output for structured decision-making. +/// Uses structured output with streaming for reliable decision-making. /// internal sealed class CriticExecutor : Executor { @@ -266,21 +281,25 @@ internal sealed class CriticExecutor : Executor public CriticExecutor(IChatClient chatClient) : base("Critic") { - this._agent = new ChatClientAgent( - chatClient, - name: "Critic", - instructions: """ + this._agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions + { + Name = "Critic", + Instructions = """ You are a constructive critic. Review the content and provide specific feedback. Always try to provide actionable suggestions for improvement and strive to identify improvement points. Only approve if the content is high quality, clear, and meets the original requirements and you see no improvement points. - - At the end, output EXACTLY one JSON line: - {"approved":true,"feedback":""} if the content is good - {"approved":false,"feedback":""} if revisions are needed + + Provide your decision as structured output with: + - approved: true if content is good, false if revisions needed + - feedback: specific improvements needed (empty if approved) Be concise but specific in your feedback. - """ - ); + """, + ChatOptions = new() + { + ResponseFormat = ChatResponseFormat.ForJsonSchema() + } + }); } public override async ValueTask HandleAsync( @@ -292,101 +311,56 @@ public override async ValueTask HandleAsync( Console.WriteLine($"=== Critic (Iteration {state.Iteration}) ===\n"); - StringBuilder sb = new(); - await foreach (AgentRunResponseUpdate update in this._agent.RunStreamingAsync(message, cancellationToken: cancellationToken)) + // Use RunStreamingAsync to get streaming updates, then deserialize at the end + IAsyncEnumerable updates = this._agent.RunStreamingAsync(message, cancellationToken: cancellationToken); + + // Stream the output in real-time (for any rationale/explanation) + await foreach (AgentRunResponseUpdate update in updates) { if (!string.IsNullOrEmpty(update.Text)) { - sb.Append(update.Text); Console.Write(update.Text); } } Console.WriteLine("\n"); - string fullResponse = sb.ToString(); - (bool approved, string feedback) = ParseDecision(fullResponse); + // Convert the stream to a response and deserialize the structured output + AgentRunResponse response = await updates.ToAgentRunResponseAsync(cancellationToken); + CriticDecision decision = response.Deserialize(JsonSerializerOptions.Web); + + Console.WriteLine($"Decision: {(decision.Approved ? "✅ APPROVED" : "❌ NEEDS REVISION")}"); + if (!string.IsNullOrEmpty(decision.Feedback)) + { + Console.WriteLine($"Feedback: {decision.Feedback}"); + } + Console.WriteLine(); // Safety: approve if max iterations reached - if (!approved && state.Iteration >= Program.MaxIterations) + if (!decision.Approved && state.Iteration >= Program.MaxIterations) { Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine($"⚠️ Max iterations ({Program.MaxIterations}) reached - auto-approving"); Console.ResetColor(); - approved = true; - feedback = ""; + decision.Approved = true; + decision.Feedback = ""; } // Increment iteration ONLY if rejecting (will loop back to Writer) - if (!approved) + if (!decision.Approved) { state.Iteration++; } - state.History.Add(new ChatMessage(ChatRole.Assistant, StripTrailingJson(fullResponse))); + // Store the decision in history + state.History.Add(new ChatMessage(ChatRole.Assistant, + $"[Decision: {(decision.Approved ? "Approved" : "Needs Revision")}] {decision.Feedback}")); await FlowStateHelpers.SaveFlowStateAsync(context, state); - return new CriticDecision - { - Approved = approved, - Feedback = feedback, - Content = message.Text ?? "", - Iteration = state.Iteration - }; - } - - /// - /// Parses the critic's response to extract the approval decision and feedback. - /// Looks for a JSON line in the format: {"approved":true/false,"feedback":"..."} - /// - private static (bool approved, string feedback) ParseDecision(string fullResponse) - { - string? lastJson = fullResponse - .Replace("\r\n", "\n") - .Split('\n') - .Reverse() - .Select(line => line.Trim()) - .FirstOrDefault(trimmedLine => trimmedLine.StartsWith('{') && trimmedLine.EndsWith('}')); - - if (lastJson is null) - { - // Fallback: check for explicit approval text - if (fullResponse.Contains("APPROVE", StringComparison.OrdinalIgnoreCase)) - { - return (true, ""); - } - return (false, "Missing approval decision."); - } - - try - { - using JsonDocument doc = JsonDocument.Parse(lastJson); - bool ok = doc.RootElement.GetProperty("approved").GetBoolean(); - string fb = doc.RootElement.TryGetProperty("feedback", out JsonElement el) ? el.GetString() ?? "" : ""; - return (ok, fb); - } - catch (JsonException) - { - return (false, "Malformed approval JSON."); - } - } - - /// - /// Removes the trailing JSON decision line from the response for cleaner history. - /// - private static string StripTrailingJson(string text) - { - string[] lines = text.Replace("\r\n", "\n").Split('\n'); - if (lines.Length == 0) - { - return text; - } + // Populate workflow-specific fields + decision.Content = message.Text ?? ""; + decision.Iteration = state.Iteration; - string lastLine = lines[^1].Trim(); - if (lastLine.StartsWith('{') && lastLine.EndsWith('}')) - { - return string.Join("\n", lines[..^1]); - } - return text; + return decision; } } From d712e7baeea51c29ee90d46439b538f670e69580 Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Mon, 3 Nov 2025 19:48:40 +0100 Subject: [PATCH 5/6] improved comments and order, so comments directly precede what they're describing --- .../_Foundational/08_WriterCriticWorkflow/Program.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs index 6bb21474db..1f27c3a8bb 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs @@ -57,8 +57,6 @@ private static async Task Main() SummaryExecutor summary = new(chatClient); // Build the workflow with conditional routing based on critic's decision - // Key Point: The workflow loops back to Writer if content is rejected, - // or proceeds to Summary if approved. State tracking ensures we don't loop forever. WorkflowBuilder workflowBuilder = new WorkflowBuilder(writer) .AddEdge(writer, critic) .AddSwitch(critic, sw => sw @@ -67,13 +65,14 @@ private static async Task Main() .WithOutputFrom(summary); // Execute the workflow with a sample task + // The workflow loops back to Writer if content is rejected, + // or proceeds to Summary if approved. State tracking ensures we don't loop forever. Console.WriteLine(new string('=', 80)); Console.WriteLine("TASK: Write a short blog post about AI ethics (200 words)"); Console.WriteLine(new string('=', 80) + "\n"); const string InitialTask = "Write a 200-word blog post about AI ethics. Make it thoughtful and engaging."; - // Build a fresh workflow for execution Workflow workflow = workflowBuilder.Build(); await ExecuteWorkflowAsync(workflow, InitialTask); From 7d1c9403e3efdf454ec311b66730937fef57b989 Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Wed, 5 Nov 2025 00:33:21 +0100 Subject: [PATCH 6/6] fixing issue with internal class that the analyzer doesn't recognize that CriticDecision is instantiated, just indirectly via JSON deserialization --- .../Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs index 1f27c3a8bb..fc39044b42 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -165,6 +166,7 @@ public static ValueTask SaveFlowStateAsync(IWorkflowContext context, FlowState s /// Uses JsonPropertyName and Description attributes for OpenAI's JSON schema. /// [Description("Critic's review decision including approval status and feedback")] +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via JSON deserialization")] internal sealed class CriticDecision { [JsonPropertyName("approved")]