Skip to content
Draft
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
.NET: Add final answer synthesis to MagenticGroupChatManager
Completes the Magentic One orchestration flow by synthesizing agent outputs
into a conclusive final answer via the FinalAnswer prompt, then using a sentinel
agent to cause GroupChatHost to yield the full enriched history as workflow output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
  • Loading branch information
Abram Bradley and claude committed Apr 17, 2026
commit bc89c4e2e36ee89c2d41d4d38cf82b23549fdddd
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;

Expand Down Expand Up @@ -66,6 +68,11 @@ public partial class MagenticGroupChatManager : GroupChatManager
private static readonly Regex s_jsonCodeFenceRegex = new(@"```(?:json)?\s*(\{[\s\S]*?\})\s*```", RegexOptions.IgnoreCase | RegexOptions.Compiled);
#endif

// Sentinel agent returned from SelectNextAgentAsync when the task is complete. GroupChatHost will
// fail the _agentMap.TryGetValue lookup (reference equality) and fall through to YieldOutputAsync,
// yielding the full history (including the synthesized final answer) as the workflow result.
private static readonly AIAgent s_sentinelAgent = new SentinelAIAgent();

/// <summary>
/// Initializes a new instance of the <see cref="MagenticGroupChatManager"/> class.
/// </summary>
Expand Down Expand Up @@ -179,6 +186,9 @@ protected internal override async ValueTask<IEnumerable<ChatMessage>> UpdateHist
// Check whether the task has been fully satisfied.
if (progressLedger.IsRequestSatisfied.GetBoolAnswer())
{
// Synthesize the final answer and add it to the history before yielding.
ChatMessage finalAnswer = await CreateFinalAnswerAsync(cancellationToken).ConfigureAwait(false);
_fullHistory.Add(finalAnswer);
_taskSatisfied = true;
return _fullHistory;
}
Expand Down Expand Up @@ -222,7 +232,14 @@ protected internal override ValueTask<AIAgent> SelectNextAgentAsync(
IReadOnlyList<ChatMessage> history,
CancellationToken cancellationToken = default)
{
// The agent is selected during UpdateHistoryAsync where we have full context.
// When the task is satisfied, return the sentinel so that GroupChatHost falls through
// to YieldOutputAsync with the full history (including the synthesized final answer).
if (_taskSatisfied)
{
return new(s_sentinelAgent);
}

// Otherwise return the agent selected during UpdateHistoryAsync.
return new(_nextAgent ?? _agents[0]);
}

Expand Down Expand Up @@ -251,6 +268,18 @@ private async Task ReplanAndResetAsync(CancellationToken cancellationToken)
_fullHistory.Add(new ChatMessage(ChatRole.Assistant, _taskLedger) { AuthorName = MagenticPrompts.ManagerName });
}

private async Task<ChatMessage> CreateFinalAnswerAsync(CancellationToken cancellationToken)
{
string finalPrompt = FormatTemplate(FinalAnswerPrompt, ("task", _currentTask!));
var messages = new List<ChatMessage>(_fullHistory)
{
new ChatMessage(ChatRole.User, finalPrompt)
};
var response = await _chatClient.GetResponseAsync(messages, null, cancellationToken).ConfigureAwait(false);
string text = response.Messages.LastOrDefault()?.Text ?? string.Empty;
return new ChatMessage(ChatRole.Assistant, text) { AuthorName = MagenticPrompts.ManagerName };
}

private async Task<string> CreateTaskLedgerAsync(CancellationToken cancellationToken)
{
string teamBlock = BuildTeamDescription();
Expand Down Expand Up @@ -401,6 +430,31 @@ private static string ExtractJson(string text)

throw new InvalidOperationException("Unbalanced JSON braces in model response.");
}

// Returned from SelectNextAgentAsync when the task is complete.
// GroupChatHost performs a reference-equality lookup in _agentMap; this sentinel will never be found,
// so execution falls through to YieldOutputAsync with the full history (including the synthesized answer).
private sealed class SentinelAIAgent : AIAgent
{
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException();

protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();

protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();

protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();

protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}

/// <summary>
Expand Down