diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticWorkflowBuilder.cs index 30ef409149..f5b8091dc2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticWorkflowBuilder.cs @@ -99,9 +99,11 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) private WorkflowBuilder ReduceToWorkflowBuilder() { - ExecutorFactoryFunc factory = this.CreateOrchestratorAsync; - ExecutorBinding orchestrator = factory.BindExecutor(nameof(MagenticOrchestrator)); + // Create a copy of the team so that improper modifications by using the builder after .Build() do not affect the + // workflow in unexpected ways. + List team = [.. this._team]; + ExecutorBinding orchestrator = CreateOrchestratorBinding(managerAgent, team, this.Limits, this._requirePlanSignoff); WorkflowBuilder result = new(orchestrator); AIAgentHostOptions options = new() @@ -111,7 +113,7 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) }; List teamBindings = []; - foreach (AIAgent agent in this._team) + foreach (AIAgent agent in team) { ExecutorBinding binding = agent.BindAsExecutor(options); teamBindings.Add(binding); @@ -143,8 +145,14 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) MaxResetCount: this._maxResets, MaxStallCount: this._maxStalls); - private ValueTask CreateOrchestratorAsync(ExecutorConfig options, string sessionId) + private static ExecutorBinding CreateOrchestratorBinding(AIAgent managerAgent, List team, TaskLimits limits, bool requirePlanSignoff) { - return new(new MagenticOrchestrator(managerAgent, this._team, this.Limits, this._requirePlanSignoff)); + ExecutorFactoryFunc factory = CreateOrchestratorAsync; + return factory.BindExecutor(nameof(MagenticOrchestrator)); + + ValueTask CreateOrchestratorAsync(ExecutorConfig options, string sessionId) + { + return new(new MagenticOrchestrator(managerAgent, team, limits, requirePlanSignoff)); + } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/ChatMessageExtensions.cs index 310d6ef3ef..6230711077 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/ChatMessageExtensions.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Text; using System.Text.Json; @@ -112,9 +113,8 @@ internal static partial class ChatMessageExtensions new(FencedJsonRegexPattern, RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture); #endif - public static JsonElement ExtractJson(this ChatMessage message) + internal static JsonElement ExtractJson(string messageText) { - string messageText = message.Text; Match match = FencedJsonRegex().Match(messageText); if (match.Success) { @@ -130,20 +130,35 @@ internal static partial class ChatMessageExtensions } int depth = 0; + bool inQuotes = false, inEscape = false; for (; scanHead < messageText.Length && end is null; scanHead++) { + if (inEscape) + { + inEscape = false; + continue; + } + switch (messageText[scanHead]) { - case '{': + case '{' when !inQuotes: depth++; break; - case '}': + case '}' when !inQuotes: depth--; if (depth == 0) { end = scanHead; } + break; + case '\"': + // We already handled inEscape, so we can always flip inQuotes here + inQuotes = !inQuotes; + break; + case '\\': + Debug.Assert(!inEscape); + inEscape = true; break; } } @@ -155,4 +170,6 @@ internal static partial class ChatMessageExtensions return JsonElement.Parse(messageText.Substring(start, end.Value - start + 1)); } + + public static JsonElement ExtractJson(this ChatMessage message) => ExtractJson(message.Text); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs index c625cdfe32..8b3d3e4ce8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs @@ -101,6 +101,7 @@ internal static partial class WorkflowsJsonUtilities [JsonSerializable(typeof(MagenticPlanReviewRequest))] [JsonSerializable(typeof(MagenticPlanReviewResponse))] [JsonSerializable(typeof(MagenticTaskState))] + [JsonSerializable(typeof(ResetChatSignal))] // Event Types //[JsonSerializable(typeof(WorkflowEvent))] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticProgressLedgerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticProgressLedgerTests.cs index 9f936f04b2..fdc66f69d9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticProgressLedgerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticProgressLedgerTests.cs @@ -11,6 +11,7 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests; public class MagenticProgressLedgerTests { public record KVPair(string key); + public record AnswerReasonPair(bool answer, string reason); [Theory] [InlineData(false)] @@ -92,6 +93,26 @@ public class MagenticProgressLedgerTests action.Should().Throw(); } + [Fact] + public void Test_ExtractJson_SuceedsWithQuotesBrackets() + { + // Arrange + ChatMessage message = new(ChatRole.Assistant, +""" + {"reason":"the output contained }", "answer": false} +"""); + + // Act + JsonElement element = message.ExtractJson(); + + // Assert + AnswerReasonPair? result = element.Deserialize(); + + result.Should().NotBeNull(); + result.reason.Should().Be("the output contained }"); + result.answer.Should().BeFalse(); + } + public static readonly string TestTeamNames = string.Join(", ", ["CodingAgent", "CodeExecutor", "WebSurferAgent", "FileSurferAgent"]); [Fact]