🙆

Microsoft Agent Framework の Magentic を頑張って動かしてみた ( C# ver 1.5.0 での確認 )

に公開
3

はじめに

前回の記事では、 Microsoft Agent Framework で Magentic を LM Studio上の gpt-oss:20b で試すと計画生成と人手レビューまでは確認できましたがそこでエラーになり最終結果まで動きませんでした。

今回は、失敗要因と考えられる「管理役エージェントの制御用 JSON が安定しないこと」と「System.InvalidOperationException で停止すること」の 2 点に絞って検証を行い、独自実装で回避策を入れたうえで、LM Studio と gpt-oss の組み合わせでどこまで動作するかを見ていきます。

注意

表記対応表

コード識別子、例外文、メソッド名は原文のままにし、それ以外の本文では次の表記でそろえます。

表記対応表
原文や識別子 この記事での表記 補足
manager 管理役エージェント 例外文やコード識別子では原文のまま
participant 参加エージェント 同上
workflow ワークフロー
progress ledger 進捗台帳
Tool ツール メソッド名や属性名は原文のまま
Structured Output 構造化出力
Human Review 人手レビュー
Plan review 計画レビュー
Final Result 最終結果 画面表示名や原文引用は除く
fallback 代替処理
protocol プロトコル
chat client チャットクライアント
next_speaker 次の担当エージェント名 進捗台帳に入る担当者名

簡易フロー

Magenticの簡易フロー

問題1: 管理役エージェントの制御用 JSON が安定しない

現象

前回は計画生成の後で止まる実行が多くありました。
特に管理役エージェントが返す進捗台帳用の JSON が崩れると、next_speakerを安全に決められません。
(next_speakerは、管理役エージェントが進捗台帳に書き込む「次に作業を依頼する参加エージェント名」です。)

仮説

LM Studio では自然文の回答はできても、管理役エージェント制御用の厳密な JSON は安定しにくい。

検証

管理役エージェント向けのチャットクライアントだけを分けて、進捗台帳プロンプトと判定した場面だけ構造化出力を強制しました。
JSON スキーマでnext_speaker等の形を固定し、LM Studio の応答とワークフローの進み方を見直しました。

結果

制御用 JSON は安定するようになって「管理役エージェントが次の担当を決められずに止まる」という詰まり方は減りました。
ただし、これだけでは最終結果の問題は解決しませんでした。

問題2: System.InvalidOperationException で停止する

現象

ワークフローが参加エージェントへ指示を送る直前で、次の例外が発生しました。

System.InvalidOperationException: Executor 'MagenticOrchestrator' cannot send messages of type 'Microsoft.Extensions.AI.ChatMessage'.

この停止は進捗台帳の品質問題とは別の問題でSDK側のプロトコル宣言と送信型解決の層で止まっていました。こちらは独自実装で回避を試みました。

5/12 GitHub の PR #5778 を見ると、MagenticOrchestrator.csにはfix: declare Magentic protocol messagesという修正が入っていました。
このため、使っていた時点ではChatMessageの送信型宣言不足が停止要因だった可能性が高いと考えています。

独自実装で行ったこと

今回、独自実装したのは次の5点です。

  • ForceManagerStructuredOutputMiddlewareShouldForceManagerLedgerSchema: 進捗台帳プロンプトと判定した場面だけ JSON スキーマで構造化出力を強制し、next_speaker は実在する参加エージェント名だけに絞ります。
  • CompatMagenticOrchestrator.ConfigureProtocol: SendsMessage<ChatMessage>()SendsMessage<ResetChatSignal>() を宣言し、参加エージェントへ指示を送る直前の System.InvalidOperationException を避けます。
  • WithVerifiedFactsProvider(BuildVerifiedFactsContext): get_date_timeget_weather_forecastget_city_highlights の結果を verified facts として shared history に戻し、不足事実と完了条件も明示します。
  • CompatMagenticWorkflowBuilder 側の participant response relay: 参加エージェントの自由文返答を、verified facts を優先するという注意書き付きで shared history に戻し、管理役が「最終回答草案がもうある」ことを見落としにくくします。
  • LocalGuideAgent の指示: city_highlights がすでに確認済みなら再取得せず、その確認済み内容を使って最終提案を組み立てるように指示します。

実行結果の考察

3回連続実行して動作を確認しました。

  • モデルは openai/gpt-oss-20b を使用
  • 3回とも計画生成と人手レビューは通過しました
  • 3回とも System.InvalidOperationException: Executor 'MagenticOrchestrator' cannot send messages of type 'Microsoft.Extensions.AI.ChatMessage'. は再現しませんでした
  • 3回とも get_date_timeget_weather_forecastget_city_highlights は1回ずつ実行されました
  • 2回は自然文の最終回答まで到達し、1回は再計画の途中で maximum round count limit に達しました
  • 成功した 2回は confirmed tool values をかなり素直に反映していました
  • ただし、get_city_highlights 自体が「東京駅周辺」という依頼に対して上野・浅草・隅田川を返すため、最終提案もその出力に引っ張られました

状況はかなり改善しましたが、LM Studio と gpt-oss の組み合わせではまだ 3 回連続で必ず成功するところまでは達しませんでした。

まとめ

今回の検証では、Microsoft Agent Framework で Magentic を動かすために「進捗台帳の JSON を安定させること」と「System.InvalidOperationException を避けること」を行いました。

その結果、3回連続実行ではすべての参加エージェントが実行され、2回は自然文の最終回答まで到達しました。
Magentic は Semantic Kernel の時から試していましたがようやく最後まで動作確認することができたので個人的に満足のいく結果を得られました。
次のリリースで Magentic が安定したらハーネスとの違いも確認してみたいです。

ソース

Program.cs
#pragma warning disable MAAIW001

using System.ClientModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
using Microsoft.Extensions.AI;
using OpenAI;

Console.OutputEncoding = Encoding.UTF8;

Dictionary<string, string> observedToolOutputs = new(StringComparer.Ordinal);

[DisplayName("get_date_time")]
[Description("現在の日時を取得します。")]
string get_date_time()
{
    string result = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
    RecordToolOutput("get_date_time", result);
    Console.WriteLine($"[Tool:get_date_time] result={result}");
    return result;
}

[DisplayName("get_weather_forecast")]
[Description("指定した都市の当日天気と気温の目安を返します。")]
string get_weather_forecast(string city)
{
    string normalizedCity = NormalizeCity(city);
    string result = normalizedCity switch
    {
        "東京" => "東京: 晴れ時々くもり、最高気温24℃、最低気温18℃。夕方は少し肌寒く、折りたたみ傘があると安心です。",
        "大阪" => "大阪: くもり、最高気温26℃、最低気温20℃。日中は暖かめで、水分補給が必要です。",
        "札幌" => "札幌: 小雨、最高気温14℃、最低気温8℃。上着と防水の靴があると安心です。",
        _ => $"{city}: 天気データがありません。東京・大阪・札幌のいずれかで質問してください。"
    };

    RecordToolOutput("get_weather_forecast", result);
    Console.WriteLine($"[Tool:get_weather_forecast] city={normalizedCity} result={result}");
    return result;
}

[DisplayName("get_city_highlights")]
[Description("指定した都市で軽く立ち寄れる見どころや過ごし方の候補を返します。")]
string get_city_highlights(string city)
{
    string normalizedCity = NormalizeCity(city);
    string result = normalizedCity switch
    {
        "東京" => "東京の候補: 上野公園の散策、東京駅周辺のカフェ、浅草の食べ歩き、隅田川周辺の夕景。",
        "大阪" => "大阪の候補: 中之島散策、梅田スカイビル周辺、道頓堀の食べ歩き、大阪城公園。",
        "札幌" => "札幌の候補: 大通公園、北海道庁旧本庁舎周辺、札幌時計台、すすきので食事。",
        _ => $"{city}: 観光候補データがありません。東京・大阪・札幌のいずれかで質問してください。"
    };

    RecordToolOutput("get_city_highlights", result);
    Console.WriteLine($"[Tool:get_city_highlights] city={normalizedCity} result={result}");
    return result;
}

AppSettings settings = AppSettings.Load();
JsonElement managerLedgerSchema = JsonDocument.Parse(
    """
    {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "is_request_satisfied",
        "is_in_loop",
        "is_progress_being_made",
        "next_speaker",
        "instruction_or_question"
      ],
      "properties": {
        "is_request_satisfied": {
          "type": "object",
          "additionalProperties": false,
          "required": ["answer", "reason"],
          "properties": {
            "answer": { "type": "boolean" },
            "reason": { "type": "string" }
          }
        },
        "is_in_loop": {
          "type": "object",
          "additionalProperties": false,
          "required": ["answer", "reason"],
          "properties": {
            "answer": { "type": "boolean" },
            "reason": { "type": "string" }
          }
        },
        "is_progress_being_made": {
          "type": "object",
          "additionalProperties": false,
          "required": ["answer", "reason"],
          "properties": {
            "answer": { "type": "boolean" },
            "reason": { "type": "string" }
          }
        },
        "next_speaker": {
          "type": "object",
          "additionalProperties": false,
          "required": ["answer", "reason"],
          "properties": {
                        "answer": {
                            "type": "string",
                            "enum": ["TimeAgent", "WeatherAgent", "LocalGuideAgent"]
                        },
            "reason": { "type": "string" }
          }
        },
        "instruction_or_question": {
          "type": "object",
          "additionalProperties": false,
          "required": ["answer", "reason"],
          "properties": {
            "answer": { "type": "string" },
            "reason": { "type": "string" }
          }
        }
      }
    }
    """).RootElement.Clone();

OpenAIClient openAIClient = new(
    new ApiKeyCredential(settings.ApiKey),
    new OpenAIClientOptions
    {
        Endpoint = new Uri(settings.BaseUrl)
    });

IChatClient baseChatClient = openAIClient
    .GetChatClient(settings.Model)
    .AsIChatClient();

IChatClient managerChatClient = baseChatClient
    .AsBuilder()
    .Use(getResponseFunc: ForceManagerStructuredOutputMiddleware, getStreamingResponseFunc: ForceManagerStructuredOutputStreamingMiddleware)
    .Build();

AIAgent managerAgent = managerChatClient.AsAIAgent(new ChatClientAgentOptions
{
    Name = "Manager",
    Description = "参加エージェントへ適切に作業を割り振るマネージャーです。",
    ChatOptions = new ChatOptions
    {
        Instructions = settings.ManagerInstructions,
        MaxOutputTokens = 14000
    }
});

AIAgent timeAgent = CreateParticipantAgent(
    "TimeAgent",
    "日時確認を担当するエージェントです。",
    "あなたは日時確認の担当です。現在時刻や日付が必要な場合は必ず get_date_time を使い、日本語で簡潔に回答してください。",
    [AIFunctionFactory.Create(get_date_time)]);

AIAgent weatherAgent = CreateParticipantAgent(
    "WeatherAgent",
    "天気確認を担当するエージェントです。",
    "あなたは天気確認の担当です。都市名に応じて必ず get_weather_forecast を使い、日本語で簡潔に回答してください。",
    [AIFunctionFactory.Create(get_weather_forecast)]);

AIAgent localGuideAgent = CreateParticipantAgent(
    "LocalGuideAgent",
    "現地での過ごし方を提案するエージェントです。",
    "あなたは街歩きと現地の過ごし方を提案する担当です。必要に応じて get_city_highlights を使い、天候や時間帯に合わせた候補を日本語で提案してください。city_highlights がすでに会話中で確認済みなら、get_city_highlights を再度呼ばず、その確認済み内容を使って最終提案を組み立ててください。未確認の営業時間や施設詳細は作らず、分からない場合は一般的な注意点にとどめてください。",
    [AIFunctionFactory.Create(get_city_highlights)]);

Workflow workflow = new CompatMagenticWorkflowBuilder(managerAgent)
    .WithName("TokyoTripAdvisor")
    .WithDescription("Magentic Orchestration で日時・天気・街歩き情報をまとめて提案するサンプルです。")
    .WithVerifiedFactsProvider(BuildVerifiedFactsContext)
    .WithMaxRounds(settings.MaxRounds)
    .WithMaxStalls(settings.MaxStalls)
    .WithMaxResets(settings.MaxResets)
    .RequirePlanSignoff(settings.RequirePlanReview)
    .AddParticipants(timeAgent, weatherAgent, localGuideAgent)
    .Build();

Console.WriteLine("=== Responses Parity Sample ===");
Console.WriteLine($"endpoint={settings.BaseUrl}");
Console.WriteLine($"model={settings.Model}");
Console.WriteLine($"require_plan_review={settings.RequirePlanReview}");
Console.WriteLine($"auto_approve_plan={settings.AutoApprovePlan}");
Console.WriteLine($"task={settings.Task}");

await RunWorkflowAsync(workflow, settings.Task, settings.AutoApprovePlan);

async Task RunWorkflowAsync(Workflow targetWorkflow, string task, bool autoApprovePlan)
{
    List<ChatMessage> input = [new(ChatRole.User, task)];

    await using StreamingRun run = await InProcessExecution.RunStreamingAsync(targetWorkflow, input);
    bool started = await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
    if (!started)
    {
        throw new InvalidOperationException("Failed to start the Magentic workflow.");
    }

    List<ChatMessage> result = [];

    await foreach (WorkflowEvent evt in run.WatchStreamAsync())
    {
        if (evt is RequestInfoEvent requestInfoEvent
            && requestInfoEvent.Request.TryGetDataAs<MagenticPlanReviewRequest>(out MagenticPlanReviewRequest? reviewRequest)
            && reviewRequest is not null)
        {
            Console.WriteLine();
            Console.WriteLine("=== Human Review: Proposed Plan ===");
            Console.WriteLine(reviewRequest.Plan.Text);

            MagenticPlanReviewResponse reviewResponse = GetPlanReviewResponse(reviewRequest, autoApprovePlan);
            await run.SendResponseAsync(requestInfoEvent.Request.CreateResponse(reviewResponse));
            continue;
        }

        if (evt is MagenticPlanCreatedEvent planCreatedEvent)
        {
            Console.WriteLine();
            Console.WriteLine("=== Initial Plan Created ===");
            Console.WriteLine(planCreatedEvent.FullTaskLedger.Text);
            continue;
        }

        if (evt is MagenticReplannedEvent replannedEvent)
        {
            Console.WriteLine();
            Console.WriteLine("=== Replanned ===");
            Console.WriteLine(replannedEvent.FullTaskLedger.Text);
            continue;
        }

        if (evt is WorkflowWarningEvent workflowWarningEvent)
        {
            Console.WriteLine();
            Console.WriteLine("=== Workflow Warning ===");
            Console.WriteLine(workflowWarningEvent.Data);
            continue;
        }

        if (evt is ExecutorFailedEvent executorFailedEvent)
        {
            Console.WriteLine();
            Console.WriteLine("=== Executor Failed ===");
            Console.WriteLine($"ExecutorId: {executorFailedEvent.ExecutorId}");
            Console.WriteLine(executorFailedEvent.Data);
            continue;
        }

        if (evt is WorkflowErrorEvent workflowErrorEvent)
        {
            Console.WriteLine();
            Console.WriteLine("=== Workflow Error ===");
            Console.WriteLine(workflowErrorEvent.Exception);
            continue;
        }

        if (evt is WorkflowOutputEvent outputEvent && outputEvent.Is<List<ChatMessage>>())
        {
            result = outputEvent.As<List<ChatMessage>>() ?? [];
            break;
        }
    }

    Console.WriteLine();
    Console.WriteLine("=== Final Result ===");
    if (result.Count == 0)
    {
        Console.WriteLine("最終メッセージは取得できませんでした。");
        return;
    }

    PrintMessages(result);
}

AIAgent CreateParticipantAgent(string name, string description, string instructions, IEnumerable<AIFunction> tools)
{
    return baseChatClient.AsAIAgent(new ChatClientAgentOptions
    {
        Name = name,
        Description = description,
        ChatOptions = new ChatOptions
        {
            Instructions = instructions,
            Tools = [.. tools],
            MaxOutputTokens = 2000
        }
    });
}

void RecordToolOutput(string toolName, string result)
{
    observedToolOutputs[toolName] = result;
}

string? BuildVerifiedFactsContext()
{
    List<string> lines = [];
    List<string> missingFacts = [];

    if (observedToolOutputs.TryGetValue("get_date_time", out string? currentDateTime))
    {
        lines.Add($"- current_date_time: {currentDateTime}");
    }
    else
    {
        missingFacts.Add("current_date_time");
    }

    if (observedToolOutputs.TryGetValue("get_weather_forecast", out string? weather))
    {
        lines.Add($"- weather: {weather}");
    }
    else
    {
        missingFacts.Add("weather");
    }

    if (observedToolOutputs.TryGetValue("get_city_highlights", out string? highlights))
    {
        lines.Add($"- city_highlights: {highlights}");
    }
    else
    {
        missingFacts.Add("city_highlights");
    }

    if (lines.Count == 0 && missingFacts.Count == 0)
    {
        return null;
    }

    List<string> contextLines =
    [
        "Verified tool results already obtained in this workflow.",
        "Treat the following lines as confirmed facts from prior tool executions.",
        "Prefer them over guesses or repeated tool requests when updating the plan, the progress ledger, and the final answer.",
        "When referring to current_date_time, weather, or city_highlights, copy the confirmed values exactly.",
        "If a detail was not confirmed by these tool results, omit it or label it unconfirmed instead of inventing it.",
        string.Empty
    ];

    if (missingFacts.Count > 0)
    {
        contextLines.Add($"Missing required confirmed facts: {string.Join(", ", missingFacts)}.");
        contextLines.Add("Do not mark the request as fully satisfied until those missing facts have been obtained.");
        contextLines.Add(string.Empty);
    }
    else
    {
        contextLines.Add("All required confirmed facts are now available.");
        contextLines.Add("The next step should be to synthesize the final Japanese answer instead of repeating lookup requests.");
        contextLines.Add(string.Empty);
    }

    contextLines.AddRange(lines);

    return string.Join(
        Environment.NewLine,
        contextLines);
}

MagenticPlanReviewResponse GetPlanReviewResponse(MagenticPlanReviewRequest reviewRequest, bool autoApprovePlan)
{
    if (autoApprovePlan)
    {
        Console.WriteLine("=== Human Review: Approved ===");
        return reviewRequest.Approve();
    }

    Console.Write("フィードバックを入力してください。Enter のみで承認: ");
    string feedback = Console.ReadLine()?.Trim() ?? string.Empty;
    if (string.IsNullOrWhiteSpace(feedback))
    {
        Console.WriteLine("=== Human Review: Approved ===");
        return reviewRequest.Approve();
    }

    Console.WriteLine("=== Human Review: Revised ===");
    return reviewRequest.Revise(feedback);
}

async Task<ChatResponse> ForceManagerStructuredOutputMiddleware(
    IEnumerable<ChatMessage> messages,
    ChatOptions? options,
    IChatClient innerChatClient,
    CancellationToken cancellationToken)
{
    List<ChatMessage> materializedMessages = [.. messages];
    bool isManagerLedgerTurn = ShouldForceManagerLedgerSchema(materializedMessages);

    if (settings.ForceManagerStructuredOutput && isManagerLedgerTurn)
    {
        options ??= new ChatOptions();
        options.ResponseFormat = ChatResponseFormat.ForJsonSchema(
            managerLedgerSchema,
            schemaName: "magentic_progress_ledger",
            schemaDescription: "Progress ledger returned by the Magentic manager.");
    }

    if (settings.TraceManagerLedgerDiagnostics && isManagerLedgerTurn)
    {
        LogManagerLedgerRequest(materializedMessages);
    }

    ChatResponse response = await innerChatClient.GetResponseAsync(materializedMessages, options, cancellationToken);

    if (settings.TraceManagerLedgerDiagnostics && isManagerLedgerTurn)
    {
        LogManagerLedgerResponse(response.Text);
    }

    return response;
}

async IAsyncEnumerable<ChatResponseUpdate> ForceManagerStructuredOutputStreamingMiddleware(
    IEnumerable<ChatMessage> messages,
    ChatOptions? options,
    IChatClient innerChatClient,
    [EnumeratorCancellation] CancellationToken cancellationToken)
{
    List<ChatMessage> materializedMessages = [.. messages];
    bool isManagerLedgerTurn = ShouldForceManagerLedgerSchema(materializedMessages);

    if (settings.ForceManagerStructuredOutput && isManagerLedgerTurn)
    {
        options ??= new ChatOptions();
        options.ResponseFormat = ChatResponseFormat.ForJsonSchema(
            managerLedgerSchema,
            schemaName: "magentic_progress_ledger",
            schemaDescription: "Progress ledger returned by the Magentic manager.");
    }

    if (settings.TraceManagerLedgerDiagnostics && isManagerLedgerTurn)
    {
        LogManagerLedgerRequest(materializedMessages);
    }

    StringBuilder? streamedResponseBuilder = settings.TraceManagerLedgerDiagnostics && isManagerLedgerTurn
        ? new StringBuilder()
        : null;

    await foreach (ChatResponseUpdate update in innerChatClient.GetStreamingResponseAsync(materializedMessages, options, cancellationToken))
    {
        if (streamedResponseBuilder is not null && !string.IsNullOrWhiteSpace(update.Text))
        {
            streamedResponseBuilder.Append(update.Text);
        }

        yield return update;
    }

    if (streamedResponseBuilder is { Length: > 0 })
    {
        LogManagerLedgerResponse(streamedResponseBuilder.ToString());
    }
}

bool ShouldForceManagerLedgerSchema(IEnumerable<ChatMessage> messages)
{
    ChatMessage? lastUserMessage = messages.LastOrDefault(static message =>
        message.Role == ChatRole.User && !string.IsNullOrWhiteSpace(message.Text));

    if (lastUserMessage?.Text is not string text)
    {
        return false;
    }

    return text.Contains("To make progress on the request", StringComparison.OrdinalIgnoreCase)
        && text.Contains("DO NOT OUTPUT ANYTHING OTHER THAN JSON", StringComparison.OrdinalIgnoreCase)
        && text.Contains("\"is_request_satisfied\"", StringComparison.OrdinalIgnoreCase)
        && text.Contains("Who should speak next?", StringComparison.OrdinalIgnoreCase);
}

void LogManagerLedgerRequest(IEnumerable<ChatMessage> messages)
{
    Console.WriteLine();
    Console.WriteLine("=== Manager Ledger Request ===");
    Console.WriteLine(FormatMessagesForDiagnostics(messages));
}

void LogManagerLedgerResponse(string responseText)
{
    if (string.IsNullOrWhiteSpace(responseText))
    {
        return;
    }

    Console.WriteLine();
    Console.WriteLine("=== Manager Ledger Response ===");
    Console.WriteLine(responseText);

    try
    {
        JsonElement json = ExtractFirstJsonObject(responseText);

        Console.WriteLine();
        Console.WriteLine("=== Manager Ledger Fields ===");
        Console.WriteLine($"next_speaker.answer={GetNestedJsonValue(json, "next_speaker", "answer") ?? "<null>"}");
        Console.WriteLine($"instruction_or_question.answer={GetNestedJsonValue(json, "instruction_or_question", "answer") ?? "<null>"}");
        Console.WriteLine($"is_request_satisfied.answer={GetNestedJsonValue(json, "is_request_satisfied", "answer") ?? "<null>"}");
        Console.WriteLine($"is_progress_being_made.answer={GetNestedJsonValue(json, "is_progress_being_made", "answer") ?? "<null>"}");
    }
    catch (Exception ex)
    {
        Console.WriteLine();
        Console.WriteLine("=== Manager Ledger Parse Error ===");
        Console.WriteLine(ex.Message);
    }
}

static string FormatMessagesForDiagnostics(IEnumerable<ChatMessage> messages)
{
    StringBuilder builder = new();
    int index = 0;

    foreach (ChatMessage message in messages)
    {
        index++;
        builder.Append('[')
               .Append(index)
               .Append("] ")
               .Append(FormatMessageHeader(message))
               .AppendLine();
        builder.AppendLine(message.Text ?? "<null>");
        builder.AppendLine("---");
    }

    return builder.ToString();
}

static string FormatMessageHeader(ChatMessage message)
{
    return string.IsNullOrWhiteSpace(message.AuthorName)
        ? message.Role.Value
        : $"{message.Role.Value}/{message.AuthorName}";
}

static JsonElement ExtractFirstJsonObject(string messageText)
{
    int start = messageText.IndexOf('{');
    if (start < 0)
    {
        throw new InvalidOperationException("No JSON object found.");
    }

    int depth = 0;
    bool inQuotes = false;
    bool inEscape = false;

    for (int index = start; index < messageText.Length; index++)
    {
        char current = messageText[index];

        if (inEscape)
        {
            inEscape = false;
            continue;
        }

        if (current == '\\')
        {
            inEscape = inQuotes;
            continue;
        }

        if (current == '"')
        {
            inQuotes = !inQuotes;
            continue;
        }

        if (inQuotes)
        {
            continue;
        }

        if (current == '{')
        {
            depth++;
        }
        else if (current == '}')
        {
            depth--;
            if (depth == 0)
            {
                string jsonText = messageText[start..(index + 1)];
                return JsonDocument.Parse(jsonText).RootElement.Clone();
            }
        }
    }

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

static string? GetNestedJsonValue(JsonElement root, string parentPropertyName, string childPropertyName)
{
    if (!root.TryGetProperty(parentPropertyName, out JsonElement parent)
        || !parent.TryGetProperty(childPropertyName, out JsonElement child))
    {
        return null;
    }

    return child.ValueKind switch
    {
        JsonValueKind.String => child.GetString(),
        JsonValueKind.True => bool.TrueString,
        JsonValueKind.False => bool.FalseString,
        JsonValueKind.Number => child.GetRawText(),
        _ => child.GetRawText()
    };
}

static string NormalizeCity(string rawCity)
{
    string trimmed = rawCity.Trim();
    if (string.IsNullOrWhiteSpace(trimmed))
    {
        return string.Empty;
    }

    string collapsed = trimmed.Replace(" ", string.Empty, StringComparison.Ordinal)
        .Replace("-", string.Empty, StringComparison.Ordinal)
        .Replace("_", string.Empty, StringComparison.Ordinal)
        .ToLowerInvariant();

    if (trimmed.Contains("東京", StringComparison.Ordinal) || collapsed.Contains("tokyo", StringComparison.Ordinal))
    {
        return "東京";
    }

    if (trimmed.Contains("大阪", StringComparison.Ordinal) || collapsed.Contains("osaka", StringComparison.Ordinal))
    {
        return "大阪";
    }

    if (trimmed.Contains("札幌", StringComparison.Ordinal) || collapsed.Contains("sapporo", StringComparison.Ordinal))
    {
        return "札幌";
    }

    return trimmed;
}

static void PrintMessages(IEnumerable<ChatMessage> messages)
{
    foreach (ChatMessage message in messages)
    {
        string author = string.IsNullOrWhiteSpace(message.AuthorName)
            ? message.Role.Value
            : $"{message.Role.Value}/{message.AuthorName}";

        Console.WriteLine($"[{author}] {message.Text}");
    }
}

sealed record AppSettings(
    string BaseUrl,
    string ApiKey,
    string Model,
    string Task,
    bool RequirePlanReview,
    bool AutoApprovePlan,
    bool ForceManagerStructuredOutput,
    bool TraceManagerLedgerDiagnostics,
    int MaxRounds,
    int MaxStalls,
    int MaxResets,
    string ManagerInstructions)
{
    public static AppSettings Load()
    {
        return new AppSettings(
            BaseUrl: Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? "http://localhost:1234/v1",
            ApiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? "sk-dummy",
            Model: Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "openai/gpt-oss-20b",
            Task: Environment.GetEnvironmentVariable("MAGENTIC_TASK")
                ?? "今日の午後に東京駅周辺へ行きます。現在日時、東京の天気、立ち寄り先候補を確認したうえで、持ち物・過ごし方・注意点をまとめて提案してください。",
            RequirePlanReview: ReadFlag("MAGENTIC_REQUIRE_PLAN_REVIEW", true),
            AutoApprovePlan: ReadFlag("MAGENTIC_AUTO_APPROVE_PLAN", true),
            ForceManagerStructuredOutput: ReadFlag("MAGENTIC_FORCE_MANAGER_LEDGER_SCHEMA", true),
            TraceManagerLedgerDiagnostics: ReadFlag("MAGENTIC_TRACE_MANAGER_LEDGER", false),
            MaxRounds: ReadInt("MAGENTIC_MAX_ROUNDS", 8),
            MaxStalls: ReadInt("MAGENTIC_MAX_STALLS", 2),
            MaxResets: ReadInt("MAGENTIC_MAX_RESETS", 2),
            ManagerInstructions: "あなたはMagenticのマネージャーです。最初に事実整理と計画を行い、その後は状況に応じて最適な担当エージェントを選んでください。最終回答は必ず日本語で、持ち物・過ごし方・注意点が分かる形にまとめてください。"
        );
    }

    private static bool ReadFlag(string name, bool defaultValue)
    {
        string? raw = Environment.GetEnvironmentVariable(name);
        if (raw is null)
        {
            return defaultValue;
        }

        return raw.Trim().ToLowerInvariant() is "1" or "true" or "yes" or "on";
    }

    private static int ReadInt(string name, int defaultValue)
    {
        string? raw = Environment.GetEnvironmentVariable(name);
        return int.TryParse(raw, out int value) ? value : defaultValue;
    }
}

2026/6/9 追記

CompatMagenticWorkflowBuilder.cs
#pragma warning disable MAAIW001

using System.Reflection;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
using Microsoft.Extensions.AI;

internal sealed class CompatMagenticWorkflowBuilder(AIAgent managerAgent)
{
    private readonly List<AIAgent> _team = [];
    private string? _name;
    private string? _description;
    private Func<string?>? _verifiedFactsProvider;
    private int _maxStalls = 3;
    private int? _maxRounds;
    private int? _maxResets;
    private bool _requirePlanSignoff = true;

    public CompatMagenticWorkflowBuilder AddParticipants(IEnumerable<AIAgent> agents)
    {
        _team.AddRange(agents);
        return this;
    }

    public CompatMagenticWorkflowBuilder AddParticipants(params AIAgent[] agents)
        => AddParticipants((IEnumerable<AIAgent>)agents);

    public CompatMagenticWorkflowBuilder WithName(string name)
    {
        _name = name;
        return this;
    }

    public CompatMagenticWorkflowBuilder WithDescription(string description)
    {
        _description = description;
        return this;
    }

    public CompatMagenticWorkflowBuilder WithVerifiedFactsProvider(Func<string?> verifiedFactsProvider)
    {
        _verifiedFactsProvider = verifiedFactsProvider;
        return this;
    }

    public CompatMagenticWorkflowBuilder WithMaxRounds(int? maxRounds = null)
    {
        _maxRounds = maxRounds;
        return this;
    }

    public CompatMagenticWorkflowBuilder WithMaxResets(int? maxResets = null)
    {
        _maxResets = maxResets;
        return this;
    }

    public CompatMagenticWorkflowBuilder WithMaxStalls(int maxStalls = 3)
    {
        _maxStalls = maxStalls;
        return this;
    }

    public CompatMagenticWorkflowBuilder RequirePlanSignoff(bool requirePlanSignoff = true)
    {
        _requirePlanSignoff = requirePlanSignoff;
        return this;
    }

    public Workflow Build()
    {
        List<AIAgent> team = [.. _team];
        AIAgentHostOptions options = new()
        {
            ReassignOtherAgentsAsUsers = true,
            ForwardIncomingMessages = false,
        };

        List<(AIAgent Agent, ExecutorBinding Binding)> participants = [];
        foreach (AIAgent agent in team)
        {
            participants.Add((agent, agent.BindAsExecutor(options)));
        }

        ExecutorBinding orchestrator = CreateOrchestratorBinding(managerAgent, team, participants, _verifiedFactsProvider, _maxStalls, _maxRounds, _maxResets, _requirePlanSignoff);
        WorkflowBuilder builder = new(orchestrator);

        foreach ((_, ExecutorBinding binding) in participants)
        {
            builder.AddEdge(binding, orchestrator);
        }

         builder.AddFanOutEdge(orchestrator, participants.Select(static participant => participant.Binding))
             .WithOutputFrom(orchestrator)
             .AddExternalCall<MagenticPlanReviewRequest, MagenticPlanReviewResponse>(orchestrator, "RequestPlanReview");

        if (!string.IsNullOrWhiteSpace(_name))
        {
            builder.WithName(_name);
        }

        if (!string.IsNullOrWhiteSpace(_description))
        {
            builder.WithDescription(_description);
        }

        return builder.Build();
    }

    private static ExecutorBinding CreateOrchestratorBinding(
        AIAgent managerAgent,
        List<AIAgent> team,
        List<(AIAgent Agent, ExecutorBinding Binding)> participants,
        Func<string?>? verifiedFactsProvider,
        int maxStalls,
        int? maxRounds,
        int? maxResets,
        bool requirePlanSignoff)
    {
        Func<string, string, ValueTask<CompatMagenticOrchestrator>> factory = (_, _) =>
        {
            Dictionary<string, string> executorIdsByAgentName = participants.ToDictionary(
                static participant => participant.Agent.Name ?? throw new InvalidOperationException("All Magentic participants must have a name."),
                static participant => participant.Binding.Id,
                StringComparer.Ordinal);

            return ValueTask.FromResult(
                new CompatMagenticOrchestrator(
                    CompatMagenticOrchestrator.ExecutorId,
                    managerAgent,
                    team,
                    executorIdsByAgentName,
                    verifiedFactsProvider,
                    maxStalls,
                    maxRounds,
                    maxResets,
                    requirePlanSignoff));
        };

        return factory.BindExecutor<CompatMagenticOrchestrator>(CompatMagenticOrchestrator.ExecutorId);
    }
}

internal sealed class CompatMagenticOrchestrator : ChatProtocolExecutor
{
    internal const string ExecutorId = "MagenticOrchestrator";

    private static readonly ChatProtocolExecutorOptions s_options = new()
    {
        StringMessageChatRole = ChatRole.User,
        AutoSendTurnToken = false,
    };

    private readonly List<AIAgent> _team;
    private readonly Dictionary<string, string> _executorIdsByAgentName;
    private readonly bool _requirePlanSignoff;
    private readonly Func<string?>? _verifiedFactsProvider;
    private readonly MagenticReflectionBridge _bridge = MagenticReflectionBridge.Instance;
    private readonly object _manager;

    private object? _taskContext;
    private ChatMessage? _fullTaskLedgerMessage;
    private string? _lastInjectedVerifiedFacts;
    private readonly Dictionary<string, string> _lastRelayedParticipantResponses = new(StringComparer.Ordinal);

    public CompatMagenticOrchestrator(
        string id,
        AIAgent managerAgent,
        List<AIAgent> team,
        Dictionary<string, string> executorIdsByAgentName,
        Func<string?>? verifiedFactsProvider,
        int maxStalls,
        int? maxRounds,
        int? maxResets,
        bool requirePlanSignoff)
        : base(id, s_options, declareCrossRunShareable: false)
    {
        _team = [.. team];
        _executorIdsByAgentName = new(executorIdsByAgentName, StringComparer.Ordinal);
        _requirePlanSignoff = requirePlanSignoff;
        _verifiedFactsProvider = verifiedFactsProvider;
        _manager = _bridge.CreateManager(managerAgent);
        Limits = _bridge.CreateLimits(maxStalls, maxRounds, maxResets);
    }

    private object Limits { get; }

    protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
        => base.ConfigureProtocol(protocolBuilder)
               .SendsMessage<ChatMessage>()
               .SendsMessage<MagenticPlanReviewRequest>()
               .SendsMessage<ResetChatSignal>()
               .YieldsOutput<List<ChatMessage>>()
               .ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<MagenticPlanReviewResponse>(ProcessPlanReviewAsync));

    protected override async ValueTask TakeTurnAsync(
        List<ChatMessage> messages,
        IWorkflowContext context,
        bool? emitEvents,
        CancellationToken cancellationToken = default)
    {
        if (_taskContext is null)
        {
            if (messages.Count == 0)
            {
                throw new InvalidOperationException("Magentic orchestration requires at least one input message.");
            }

            _taskContext = _bridge.CreateTaskContext([.. messages], _team, Limits, emitEvents);
            await UpdatePlanAndContinueAsync(context, cancellationToken).ConfigureAwait(false);
            return;
        }

        if (_bridge.GetIsTerminated(_taskContext))
        {
            throw new InvalidOperationException("Magentic Orchestration has already been terminated and cannot process new messages. Please start a new session.");
        }

        if (messages.Count == 0)
        {
            return;
        }

        RelayParticipantResponses(_taskContext, messages);

        await RunCoordinationRoundAsync(context, cancellationToken).ConfigureAwait(false);
    }

    private async ValueTask UpdatePlanAndContinueAsync(IWorkflowContext context, CancellationToken cancellationToken)
    {
        object taskContext = _taskContext ?? throw new InvalidOperationException("Magentic task context was not initialized.");
        bool isReplan = _bridge.HasTaskLedger(taskContext);

        InjectVerifiedFactsIfAvailable(taskContext);

        object taskLedger = await _bridge.UpdatePlanAsync(_manager, taskContext, context, cancellationToken).ConfigureAwait(false);
        _bridge.SetTaskLedger(taskContext, taskLedger);

        _fullTaskLedgerMessage = _bridge.CreateFullTaskLedgerMessage(taskContext);
        _bridge.GetChatHistory(taskContext).Add(_fullTaskLedgerMessage);

        await context.AddEventAsync(
            isReplan ? new MagenticReplannedEvent(_fullTaskLedgerMessage) : new MagenticPlanCreatedEvent(_fullTaskLedgerMessage),
            cancellationToken).ConfigureAwait(false);

        if (_requirePlanSignoff)
        {
            await SubmitPlanReviewRequestAsync(context, cancellationToken).ConfigureAwait(false);
        }
        else
        {
            await RunCoordinationRoundAsync(context, cancellationToken).ConfigureAwait(false);
        }
    }

    private ValueTask SubmitPlanReviewRequestAsync(IWorkflowContext context, CancellationToken cancellationToken)
    {
        object taskContext = _taskContext ?? throw new InvalidOperationException("Magentic task context was not initialized.");

        MagenticProgressLedger? progressLedger = _bridge.GetProgressLedger(taskContext);
        if (progressLedger?.IsStarted is not true)
        {
            progressLedger = null;
        }

        MagenticPlanReviewRequest request = new(_bridge.GetCurrentPlan(taskContext), progressLedger, _bridge.GetIsStalled(taskContext));
        return context.SendMessageAsync(request, cancellationToken: cancellationToken);
    }

    private async ValueTask ProcessPlanReviewAsync(MagenticPlanReviewResponse response, IWorkflowContext context, CancellationToken cancellationToken)
    {
        if (_taskContext is null || !_bridge.HasTaskLedger(_taskContext))
        {
            throw new InvalidOperationException("Magentic Orchestration was not initialized correctly.");
        }

        if (_bridge.GetIsTerminated(_taskContext))
        {
            throw new InvalidOperationException("Magentic Orchestration has already been terminated and cannot process new messages. Please start a new session.");
        }

        if (response.Review.Count == 0)
        {
            await RunCoordinationRoundAsync(context, cancellationToken).ConfigureAwait(false);
            return;
        }

        _bridge.GetChatHistory(_taskContext).AddRange(response.Review);
        await UpdatePlanAndContinueAsync(context, cancellationToken).ConfigureAwait(false);
    }

    private async ValueTask RunCoordinationRoundAsync(IWorkflowContext context, CancellationToken cancellationToken)
    {
        object taskContext = _taskContext ?? throw new InvalidOperationException("Magentic task context was not initialized.");

        (bool hitRoundLimit, bool hitResetLimit) = _bridge.CheckLimits(taskContext);
        if (hitRoundLimit || hitResetLimit)
        {
            string limitType = hitRoundLimit ? "round" : "reset";

            List<ChatMessage> stoppedMessages = [new ChatMessage(ChatRole.Assistant, $"Task execution stopped due to hitting the maximum {limitType} count limit.")];
            await context.YieldOutputAsync(stoppedMessages, cancellationToken).ConfigureAwait(false);
            _bridge.SetIsTerminated(taskContext, true);
            return;
        }

        _bridge.IncrementRoundCount(taskContext);

        InjectVerifiedFactsIfAvailable(taskContext);

        try
        {
            await _bridge.UpdateProgressLedgerAsync(_manager, taskContext, context, cancellationToken).ConfigureAwait(false);
            await context.AddEventAsync(new MagenticProgressLedgerUpdatedEvent(_bridge.GetProgressLedger(taskContext)), cancellationToken)
                         .ConfigureAwait(false);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            await context.AddEventAsync(
                new WorkflowWarningEvent($"Magentic Orchestrator: Progress ledger creation failed, triggering reset: {ex}"),
                cancellationToken).ConfigureAwait(false);
            await ResetAndReplanAsync(context, cancellationToken).ConfigureAwait(false);
            return;
        }

        MagenticProgressLedger progressLedger = _bridge.GetProgressLedger(taskContext);
        if (progressLedger.IsRequestSatisfied)
        {
            await PrepareFinalAnswerAsync(context, cancellationToken).ConfigureAwait(false);
            return;
        }

        _bridge.UpdateStallCount(taskContext, progressLedger);
        if (_bridge.GetIsStalled(taskContext))
        {
            await ResetAndReplanAsync(context, cancellationToken).ConfigureAwait(false);
            return;
        }

        string? nextSpeakerCandidate = progressLedger.NextSpeaker;
        if (string.IsNullOrWhiteSpace(nextSpeakerCandidate))
        {
            await context.AddEventAsync(
                new WorkflowWarningEvent("Magentic Orchestrator: Next speaker was empty; selecting the first participant as fallback."),
                cancellationToken).ConfigureAwait(false);
            nextSpeakerCandidate = _team[0].Name ?? _executorIdsByAgentName.Keys.First();
        }

        string nextSpeaker = nextSpeakerCandidate ?? _executorIdsByAgentName.Keys.First();

        if (!_executorIdsByAgentName.TryGetValue(nextSpeaker, out string? nextExecutorId))
        {
            await context.AddEventAsync(
                new WorkflowWarningEvent($"Magentic Orchestrator: Unknown next speaker '{nextSpeaker}', preparing a final answer from the shared context."),
                cancellationToken).ConfigureAwait(false);
            await PrepareFinalAnswerAsync(context, cancellationToken).ConfigureAwait(false);
            return;
        }

        if (!string.IsNullOrWhiteSpace(progressLedger.InstructionOrQuestion))
        {
            ChatMessage instruction = new(ChatRole.Assistant, progressLedger.InstructionOrQuestion);
            _bridge.GetChatHistory(taskContext).Add(instruction);
            await context.SendMessageAsync(instruction, cancellationToken: cancellationToken).ConfigureAwait(false);
        }

        await context.SendMessageAsync(new TurnToken(_bridge.GetEmitUpdateEvents(taskContext)), nextExecutorId, cancellationToken)
                     .ConfigureAwait(false);
    }

    private async ValueTask ResetAndReplanAsync(IWorkflowContext context, CancellationToken cancellationToken)
    {
        object taskContext = _taskContext ?? throw new InvalidOperationException("Magentic task context was not initialized.");
        _bridge.Reset(taskContext);
        _lastInjectedVerifiedFacts = null;
        _lastRelayedParticipantResponses.Clear();

        await context.SendMessageAsync(new ResetChatSignal(), cancellationToken: cancellationToken).ConfigureAwait(false);
        await UpdatePlanAndContinueAsync(context, cancellationToken).ConfigureAwait(false);
    }

    private async ValueTask PrepareFinalAnswerAsync(IWorkflowContext context, CancellationToken cancellationToken)
    {
        object taskContext = _taskContext ?? throw new InvalidOperationException("Magentic task context was not initialized.");

        InjectVerifiedFactsIfAvailable(taskContext);

        ChatMessage finalAnswer = await _bridge.PrepareFinalAnswerAsync(_manager, taskContext, context, cancellationToken).ConfigureAwait(false);

        List<ChatMessage> finalMessages = [finalAnswer];
        await context.YieldOutputAsync(finalMessages, cancellationToken).ConfigureAwait(false);
        _bridge.SetIsTerminated(taskContext, true);
    }

    private void InjectVerifiedFactsIfAvailable(object taskContext)
    {
        if (_verifiedFactsProvider is null)
        {
            return;
        }

        string? verifiedFacts = _verifiedFactsProvider();
        if (string.IsNullOrWhiteSpace(verifiedFacts)
            || string.Equals(_lastInjectedVerifiedFacts, verifiedFacts, StringComparison.Ordinal))
        {
            return;
        }

        _bridge.GetChatHistory(taskContext).Add(new ChatMessage(ChatRole.User, verifiedFacts));
        _lastInjectedVerifiedFacts = verifiedFacts;
    }

    private void RelayParticipantResponses(object taskContext, IEnumerable<ChatMessage> messages)
    {
        foreach (ChatMessage message in messages)
        {
            string? authorName = string.IsNullOrWhiteSpace(message.AuthorName) ? null : message.AuthorName;
            if (authorName is null || !_executorIdsByAgentName.ContainsKey(authorName))
            {
                continue;
            }

            string text = message.Text?.Trim() ?? string.Empty;
            if (string.IsNullOrWhiteSpace(text))
            {
                continue;
            }

            if (_lastRelayedParticipantResponses.TryGetValue(authorName, out string? previousText)
                && string.Equals(previousText, text, StringComparison.Ordinal))
            {
                continue;
            }

            string relayText = string.Join(
                Environment.NewLine,
                [
                    "Verified participant response already obtained in this workflow.",
                    $"Source: {authorName}",
                    "Treat the following text as the participant's latest completed result when updating the progress ledger and deciding whether the user's request is already satisfied.",
                    "If this text conflicts with verified tool results, the verified tool results take precedence.",
                    "Do not promote unconfirmed specifics from this participant draft into the final answer as if they were verified facts.",
                    string.Empty,
                    text
                ]);

            _bridge.GetChatHistory(taskContext).Add(new ChatMessage(ChatRole.User, relayText));
            _lastRelayedParticipantResponses[authorName] = text;
        }
    }
}

internal sealed class MagenticReflectionBridge
{
    public static MagenticReflectionBridge Instance { get; } = new();

    private readonly ConstructorInfo _taskLimitsConstructor;
    private readonly ConstructorInfo _taskContextConstructor;
    private readonly ConstructorInfo _managerConstructor;
    private readonly MethodInfo _checkLimitsMethod;
    private readonly MethodInfo _resetMethod;
    private readonly MethodInfo _toTaskLedgerFullPromptMethod;
    private readonly MethodInfo _updatePlanAsyncMethod;
    private readonly MethodInfo _updateProgressLedgerAsyncMethod;
    private readonly MethodInfo _prepareFinalAnswerAsyncMethod;
    private readonly PropertyInfo _chatHistoryProperty;
    private readonly PropertyInfo _taskLedgerProperty;
    private readonly PropertyInfo _progressLedgerProperty;
    private readonly PropertyInfo _isTerminatedProperty;
    private readonly PropertyInfo _isStalledProperty;
    private readonly PropertyInfo _taskCountersProperty;
    private readonly PropertyInfo _emitUpdateEventsProperty;
    private readonly PropertyInfo _currentPlanProperty;
    private readonly PropertyInfo _roundCountProperty;
    private readonly PropertyInfo _stallCountProperty;
    private readonly int _defaultProgressLedgerRetryCount;
    private readonly Array _emptyAdditionalQuestions;

    private MagenticReflectionBridge()
    {
        Assembly workflowsAssembly = typeof(MagenticWorkflowBuilder).Assembly;
        Type progressLedgerSlotType = GetRequiredType(workflowsAssembly, "Microsoft.Agents.AI.Workflows.ProgressLedgerSlot");
        Type taskLimitsType = GetRequiredType(workflowsAssembly, "Microsoft.Agents.AI.Workflows.Specialized.Magentic.TaskLimits");
        Type taskContextType = GetRequiredType(workflowsAssembly, "Microsoft.Agents.AI.Workflows.Specialized.Magentic.MagenticTaskContext");
        Type taskLedgerType = GetRequiredType(workflowsAssembly, "Microsoft.Agents.AI.Workflows.Specialized.Magentic.TaskLedger");
        Type taskCountersType = GetRequiredType(workflowsAssembly, "Microsoft.Agents.AI.Workflows.Specialized.Magentic.TaskCounters");
        Type managerType = GetRequiredType(workflowsAssembly, "Microsoft.Agents.AI.Workflows.Specialized.Magentic.MagenticManager");
        Type promptTemplateExtensionsType = GetRequiredType(workflowsAssembly, "Microsoft.Agents.AI.Workflows.Specialized.Magentic.PromptTemplateExtensions");

        _defaultProgressLedgerRetryCount = (int)(taskLimitsType.GetField("DefaultMaxProgressLedgerRetryCount", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)?.GetValue(null)
            ?? throw new InvalidOperationException("Could not resolve TaskLimits.DefaultMaxProgressLedgerRetryCount."));

        _taskLimitsConstructor = taskLimitsType.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
            .Single(static constructor => constructor.GetParameters().Length == 4);

        _taskContextConstructor = taskContextType.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
            .Single(static constructor => constructor.GetParameters().Length == 5);

        _managerConstructor = managerType.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
            .Single(static constructor => constructor.GetParameters().Length == 1);

        _checkLimitsMethod = GetRequiredMethod(taskContextType, "CheckLimits");
        _resetMethod = GetRequiredMethod(taskContextType, "Reset");
        _toTaskLedgerFullPromptMethod = promptTemplateExtensionsType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
            .Single(method => method.Name == "ToTaskLedgerFullPrompt" && method.GetParameters().Length == 1);
        _updatePlanAsyncMethod = GetRequiredMethod(managerType, "UpdatePlanAsync");
        _updateProgressLedgerAsyncMethod = GetRequiredMethod(managerType, "UpdateProgressLedgerAsync");
        _prepareFinalAnswerAsyncMethod = GetRequiredMethod(managerType, "PrepareFinalAnswerAsync");

        _chatHistoryProperty = GetRequiredProperty(taskContextType, "ChatHistory");
        _taskLedgerProperty = GetRequiredProperty(taskContextType, "TaskLedger");
        _progressLedgerProperty = GetRequiredProperty(taskContextType, "ProgressLedger");
        _isTerminatedProperty = GetRequiredProperty(taskContextType, "IsTerminated");
        _isStalledProperty = GetRequiredProperty(taskContextType, "IsStalled");
        _taskCountersProperty = GetRequiredProperty(taskContextType, "TaskCounters");
        _emitUpdateEventsProperty = GetRequiredProperty(taskContextType, "EmitUpdateEvents");

        _currentPlanProperty = GetRequiredProperty(taskLedgerType, "CurrentPlan");
        _roundCountProperty = GetRequiredProperty(taskCountersType, "RoundCount");
        _stallCountProperty = GetRequiredProperty(taskCountersType, "StallCount");

        _emptyAdditionalQuestions = Array.CreateInstance(progressLedgerSlotType, 0);
    }

    public object CreateLimits(int maxStalls, int? maxRounds, int? maxResets)
        => _taskLimitsConstructor.Invoke([maxStalls, maxRounds, maxResets, _defaultProgressLedgerRetryCount]);

    public object CreateManager(AIAgent managerAgent)
        => _managerConstructor.Invoke([managerAgent]);

    public object CreateTaskContext(List<ChatMessage> taskDefinition, List<AIAgent> team, object limits, bool? emitUpdateEvents)
        => _taskContextConstructor.Invoke([taskDefinition, team, limits, emitUpdateEvents, _emptyAdditionalQuestions]);

    public bool HasTaskLedger(object taskContext) => _taskLedgerProperty.GetValue(taskContext) is not null;

    public void SetTaskLedger(object taskContext, object taskLedger) => _taskLedgerProperty.SetValue(taskContext, taskLedger);

    public List<ChatMessage> GetChatHistory(object taskContext)
        => (List<ChatMessage>)(_chatHistoryProperty.GetValue(taskContext)
            ?? throw new InvalidOperationException("Task context chat history is not available."));

    public MagenticProgressLedger GetProgressLedger(object taskContext)
        => (MagenticProgressLedger)(_progressLedgerProperty.GetValue(taskContext)
            ?? throw new InvalidOperationException("Task context progress ledger is not available."));

    public ChatMessage GetCurrentPlan(object taskContext)
    {
        object taskLedger = _taskLedgerProperty.GetValue(taskContext)
            ?? throw new InvalidOperationException("Task context does not have a plan yet.");
        return (ChatMessage)(_currentPlanProperty.GetValue(taskLedger)
            ?? throw new InvalidOperationException("Task ledger current plan is not available."));
    }

    public bool GetIsTerminated(object taskContext) => (bool)(_isTerminatedProperty.GetValue(taskContext) ?? false);

    public void SetIsTerminated(object taskContext, bool value) => _isTerminatedProperty.SetValue(taskContext, value);

    public bool GetIsStalled(object taskContext) => (bool)(_isStalledProperty.GetValue(taskContext) ?? false);

    public bool? GetEmitUpdateEvents(object taskContext) => (bool?)_emitUpdateEventsProperty.GetValue(taskContext);

    public (bool HitRoundLimit, bool HitResetLimit) CheckLimits(object taskContext)
    {
        object result = _checkLimitsMethod.Invoke(taskContext, null)
            ?? throw new InvalidOperationException("Task context limit check returned null.");
        Type tupleType = result.GetType();

        return (
            (bool)(tupleType.GetField("Item1")?.GetValue(result) ?? false),
            (bool)(tupleType.GetField("Item2")?.GetValue(result) ?? false));
    }

    public void IncrementRoundCount(object taskContext)
    {
        object taskCounters = _taskCountersProperty.GetValue(taskContext)
            ?? throw new InvalidOperationException("Task counters are not available.");
        int roundCount = (int)(_roundCountProperty.GetValue(taskCounters) ?? 0);
        _roundCountProperty.SetValue(taskCounters, roundCount + 1);
    }

    public void UpdateStallCount(object taskContext, MagenticProgressLedger progressLedger)
    {
        object taskCounters = _taskCountersProperty.GetValue(taskContext)
            ?? throw new InvalidOperationException("Task counters are not available.");
        int stallCount = (int)(_stallCountProperty.GetValue(taskCounters) ?? 0);

        if (progressLedger.IsInLoop || !progressLedger.IsProgressBeingMade)
        {
            _stallCountProperty.SetValue(taskCounters, stallCount + 1);
        }
        else
        {
            _stallCountProperty.SetValue(taskCounters, Math.Max(0, stallCount - 1));
        }
    }

    public void Reset(object taskContext) => _resetMethod.Invoke(taskContext, null);

    public ChatMessage CreateFullTaskLedgerMessage(object taskContext)
    {
        string prompt = (string)(_toTaskLedgerFullPromptMethod.Invoke(null, [taskContext])
            ?? throw new InvalidOperationException("Could not create the full task ledger prompt."));
        return new ChatMessage(ChatRole.User, prompt);
    }

    public async ValueTask<object> UpdatePlanAsync(object manager, object taskContext, IWorkflowContext context, CancellationToken cancellationToken)
    {
        object invocation = _updatePlanAsyncMethod.Invoke(manager, [taskContext, context, cancellationToken])
            ?? throw new InvalidOperationException("MagenticManager.UpdatePlanAsync returned null.");
        return await AwaitAsyncResultAsync(invocation).ConfigureAwait(false)
            ?? throw new InvalidOperationException("MagenticManager.UpdatePlanAsync completed without a task ledger.");
    }

    public async ValueTask UpdateProgressLedgerAsync(object manager, object taskContext, IWorkflowContext context, CancellationToken cancellationToken)
    {
        object invocation = _updateProgressLedgerAsyncMethod.Invoke(manager, [taskContext, context, cancellationToken])
            ?? throw new InvalidOperationException("MagenticManager.UpdateProgressLedgerAsync returned null.");
        await AwaitAsyncResultAsync(invocation).ConfigureAwait(false);
    }

    public async ValueTask<ChatMessage> PrepareFinalAnswerAsync(object manager, object taskContext, IWorkflowContext context, CancellationToken cancellationToken)
    {
        object invocation = _prepareFinalAnswerAsyncMethod.Invoke(manager, [taskContext, context, cancellationToken])
            ?? throw new InvalidOperationException("MagenticManager.PrepareFinalAnswerAsync returned null.");
        return (ChatMessage)(await AwaitAsyncResultAsync(invocation).ConfigureAwait(false)
            ?? throw new InvalidOperationException("MagenticManager.PrepareFinalAnswerAsync completed without a final answer."));
    }

    private static Type GetRequiredType(Assembly assembly, string fullName)
        => assembly.GetType(fullName, throwOnError: true)
            ?? throw new InvalidOperationException($"Type '{fullName}' was not found.");

    private static MethodInfo GetRequiredMethod(Type type, string name)
        => type.GetMethod(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static)
            ?? throw new InvalidOperationException($"Method '{type.FullName}.{name}' was not found.");

    private static PropertyInfo GetRequiredProperty(Type type, string name)
        => type.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static)
            ?? throw new InvalidOperationException($"Property '{type.FullName}.{name}' was not found.");

    private static async Task<object?> AwaitAsyncResultAsync(object asyncValue)
    {
        MethodInfo asTaskMethod = asyncValue.GetType().GetMethod("AsTask", BindingFlags.Public | BindingFlags.Instance)
            ?? throw new InvalidOperationException($"Type '{asyncValue.GetType().FullName}' does not expose AsTask().");

        Task task = (Task)(asTaskMethod.Invoke(asyncValue, null)
            ?? throw new InvalidOperationException("AsTask() returned null."));

        await task.ConfigureAwait(false);

        PropertyInfo? resultProperty = task.GetType().GetProperty("Result", BindingFlags.Public | BindingFlags.Instance);
        return resultProperty?.GetValue(task);
    }
}

#pragma warning restore MAAIW001

実行結果の例

実行ログの例
=== Responses Parity Sample ===
endpoint=http://localhost:1234/v1
model=openai/gpt-oss-20b
require_plan_review=True
auto_approve_plan=True
task=今日の午後に東京駅周辺へ行きます。現在日時、東京の天気、立ち寄り先候補を確認したうえで、持ち物・過ごし方・注意点をまとめて提案してください。

=== Initial Plan Created ===
We are working to address the following user request:

今日の午後に東京駅周辺へ行きます。現在日時、東京の天気、立ち寄り先候補を確認したうえで、持ち物・過ごし方・注意点をまとめて提案してください。



To answer this request we have assembled the following team:

- TimeAgent: 日時確認を担当するエージェントです。
- WeatherAgent: 天気確認を担当するエージェントです。
- LocalGuideAgent: 現地での過ごし方を提案するエージェントです。


Here is an initial fact sheet to consider:

**1. GIVEN OR VERIFIED FACTS**  
- ユーザーは「今日の午後に東京駅周辺へ行く」予定です。  

**2. FACTS TO LOOK UP**  
- 現在日時(正確な時刻)  
- 今日の東京市内の天気予報(温度、降水確率・量、風速・方向など)  
- 東京駅周辺で立ち寄りやすい店舗・施設・観光スポット(飲食店、土産物屋、博物館、美術館、公園、カフェ等)  

**3. FACTS TO DERIVE**  
- 「午後」の具体的な時間帯を推定(例:12:00〜18:00)  
- 立ち寄り候補の距離・所要時間(徒歩・電車など)から訪問可能な数や順序  

**4. EDUCATED GUESSES**  
- なし(正確な情報は上記項目を確認後に決定する)。


Here is the plan to follow as best as possible:

- **Step 1**: Ask *TimeAgent* for the exact current date and time (to determine the precise “午後” window).  
- **Step 2**: Instruct *WeatherAgent* to retrieve today’s Tokyo weather forecast (temperature, precipitation probability/amount, wind direction/speed).  
- **Step 3**: Request *LocalGuideAgent* to compile a list of recommended spots within walking distance from Tokyo Station—restaurants, cafés, souvenir shops, museums, parks, and any special events.  
- **Step 4**: Combine the data from Steps 1‑3 to draft a concise guide covering:
  - 必要持ち物(天候・温度に合わせた服装、傘/レインコートなど)
  - 推奨過ごし方(順序立てた散策ルートや時間配分)
  - 注意点(雨時の足元安全、混雑情報、乗換えタイミングなど)

=== Human Review: Proposed Plan ===
- **Step 1**: Ask *TimeAgent* for the exact current date and time (to determine the precise “午後” window).  
- **Step 2**: Instruct *WeatherAgent* to retrieve today’s Tokyo weather forecast (temperature, precipitation probability/amount, wind direction/speed).  
- **Step 3**: Request *LocalGuideAgent* to compile a list of recommended spots within walking distance from Tokyo Station—restaurants, cafés, souvenir shops, museums, parks, and any special events.  
- **Step 4**: Combine the data from Steps 1‑3 to draft a concise guide covering:
  - 必要持ち物(天候・温度に合わせた服装、傘/レインコートなど)
  - 推奨過ごし方(順序立てた散策ルートや時間配分)
  - 注意点(雨時の足元安全、混雑情報、乗換えタイミングなど)
=== Human Review: Approved ===
[Tool:get_date_time] result=2026-05-14 21:50:10
[Tool:get_weather_forecast] city=東京 result=東京: 晴れ時々くもり、最高気温24℃、最低気温18℃。夕方は少し肌寒く、折りたたみ傘があると安心です。
[Tool:get_city_highlights] city=東京 result=東京の候補: 上野公園の散策、東京駅周辺のカフェ、浅草の食べ歩き、隅田川周辺の夕景。

=== Replanned ===
We are working to address the following user request:

今日の午後に東京駅周辺へ行きます。現在日時、東京の天気、立ち寄り先候補を確認したうえで、持ち物・過ごし方・注意点をまとめて提案してください。



To answer this request we have assembled the following team:

- TimeAgent: 日時確認を担当するエージェントです。
- WeatherAgent: 天気確認を担当するエージェントです。
- LocalGuideAgent: 現地での過ごし方を提案するエージェントです。


Here is an initial fact sheet to consider:

**【更新された事実表】**

---

### 1. **確認済み(既知)情報**  
- ユーザーは「今日の午後に東京駅周辺へ行く」予定です。  
- 現在日時:2026‑05‑14 21:50:10  
- 東京の天気:晴れ時々くもり、最高気温24℃、最低気温18℃。夕方は少し肌寒く、折りたたみ傘があると安心です。  
- 東京駅周辺で立ち寄りやすい候補(city_highlights):  
  - 上野公園の散策  
  - 東京駅周辺のカフェ  
  - 浅草の食べ歩き  
  - 隅田川周辺の夕景  

> **備考**:これらはツール実行結果に基づく確定情報です。推測や未確認事項は除外します。

---

### 2. **確認が必要な項目(現時点では不明)**  
- 「午後」の具体的な時間帯(例:12:00〜18:00)。  
- 上記候補の距離・所要時間(徒歩・電車など)、訪問可能数、順序。  

> *これらはまだ取得していないため、後で確認する必要があります。*

---

### 3. **導出が期待される情報**  
- 「午後」の実際の時刻範囲を決定(上記未確定項目に基づく)。  
- 立ち寄り候補の順序・所要時間を算出し、効率的なルートを提案。  

---

### 4. **教育的推測(hunch)**  
> **推測:** 「午後」の活動開始時刻は14:00頃と仮定します。  
> **根拠:**  
> - ユーザーが「今日の午後」と言及しているため、昼食後の時間帯を想定。  
> - 東京駅周辺のカフェや飲食店は正午以降に多く利用者が増える傾向がある。  
> - 夕方までに隅田川の夕景を楽しむ場合、午後遅く(17:00〜18:30)まで滞在できると予測。  

---

**結論**:現在確定している情報を基に、ユーザーは21:50時点で東京駅周辺へ午後に出発予定であることが分かります。天気は晴れで少し肌寒く、折りたたみ傘と軽い上着があれば快適です。立ち寄り先としてはカフェや上野公園、浅草の食べ歩き、隅田川沿いの夕景を計画に入れると良いでしょう。午後開始時刻は14:00前後で検討し、所要時間・距離を確認したうえでルートを決定してください。


Here is the plan to follow as best as possible:

## 何がうまくいかなかったか(根本原因)

1. **確定情報を十分に活用できていない**  
   - ツール実行結果で得られた日時・天気・立ち寄り候補をそのまま取り入れるべきところ、誤って「未確認」として扱った。  
2. **再度検索リクエストを繰り返した**  
   - 既に取得済みの値(`current_date_time`, `weather`, `city_highlights`)をコピーすればよかったが、同じ情報を求める別ツール呼び出しを行ってしまった。  
3. **作業フローを踏襲できていない**  
   - 「事実整理 → 計画立案 → 担当エージェント選択」という順序が守れず、結果として最終回答の生成に至らなかった。

---

## 新しい計画(ミス回避策を盛り込む)

- **Step 1: 確認済み情報を整理**  
  - ユーザーは午後(12:00〜18:00)に東京駅周辺へ行く予定。  
  - 現在時刻:2026‑05‑14 21:50:10  
  - 天気:晴れ時々くもり、最高24℃/最低18℃。肌寒い夕方用に折りたたみ傘を持参。  

- **Step 2: 必要な未確認情報の取得**  
  - 「午後」の具体的開始時間(推測は14:00頃)。  
  - 上記候補(カフェ・上野公園・浅草・隅田川)の距離と徒歩/電車所要時間。  

- **Step 3: ロジックでルートを決定**  
  - 1. 東京駅 → カフェ(最寄り)  
  - 2. 上野公園(歩いて20分程度)  
  - 3. 浅草へ向かう(電車で10〜15分)  
  - 4. 隅田川沿いの夕景を楽しむ(約30分待ち時間確保)  

- **Step 4: 担当エージェント選択**  
  1. **TimeAgent** – 現在日時と午後開始時刻の確認。  
  2. **WeatherAgent** – 天気詳細を再度確認(既に確定値を使用)。  
  3. **LocalGuideAgent** – 上記順序で訪問可能か距離・所要時間を算出し、最適ルートと持ち物リストを作成。  

- **Step 5: 最終提案(日本語)**  
  - 持ち物:折りたたみ傘、軽いジャケット/カーディガン、財布・スマホ充電器、飲料(暑さ対策として水やアイスティー)。  
  - 過ごし方:14:00頃に東京駅へ出発。まずは駅周辺のカフェでゆっくり過ごし、次に上野公園を散策。その後浅草で食べ歩き(お好み焼き・人形町プリン等)。最後に隅田川沿いで夕景を鑑賞。  
  - 注意点:  
    * 天気が晴れ時々くもりのため、傘は必須。  
    * 夕方は肌寒いため、防寒対策を忘れず。  
    * 混雑時間帯(15:00〜17:00)に合わせてカフェや浅草へ行くと待ち時間が短縮されます。  

> **備考**:上記のルート・所要時間は、実際に距離を測定して確認することで最終的に調整してください。これで同じミスを繰り返さず、効率よく東京駅周辺を楽しめます。

=== Human Review: Proposed Plan ===
## 何がうまくいかなかったか(根本原因)

1. **確定情報を十分に活用できていない**  
   - ツール実行結果で得られた日時・天気・立ち寄り候補をそのまま取り入れるべきところ、誤って「未確認」として扱った。  
2. **再度検索リクエストを繰り返した**  
   - 既に取得済みの値(`current_date_time`, `weather`, `city_highlights`)をコピーすればよかったが、同じ情報を求める別ツール呼び出しを行ってしまった。  
3. **作業フローを踏襲できていない**  
   - 「事実整理 → 計画立案 → 担当エージェント選択」という順序が守れず、結果として最終回答の生成に至らなかった。

---

## 新しい計画(ミス回避策を盛り込む)

- **Step 1: 確認済み情報を整理**  
  - ユーザーは午後(12:00〜18:00)に東京駅周辺へ行く予定。  
  - 現在時刻:2026‑05‑14 21:50:10  
  - 天気:晴れ時々くもり、最高24℃/最低18℃。肌寒い夕方用に折りたたみ傘を持参。  

- **Step 2: 必要な未確認情報の取得**  
  - 「午後」の具体的開始時間(推測は14:00頃)。  
  - 上記候補(カフェ・上野公園・浅草・隅田川)の距離と徒歩/電車所要時間。  

- **Step 3: ロジックでルートを決定**  
  - 1. 東京駅 → カフェ(最寄り)  
  - 2. 上野公園(歩いて20分程度)  
  - 3. 浅草へ向かう(電車で10〜15分)  
  - 4. 隅田川沿いの夕景を楽しむ(約30分待ち時間確保)  

- **Step 4: 担当エージェント選択**  
  1. **TimeAgent** – 現在日時と午後開始時刻の確認。  
  2. **WeatherAgent** – 天気詳細を再度確認(既に確定値を使用)。  
  3. **LocalGuideAgent** – 上記順序で訪問可能か距離・所要時間を算出し、最適ルートと持ち物リストを作成。  

- **Step 5: 最終提案(日本語)**  
  - 持ち物:折りたたみ傘、軽いジャケット/カーディガン、財布・スマホ充電器、飲料(暑さ対策として水やアイスティー)。  
  - 過ごし方:14:00頃に東京駅へ出発。まずは駅周辺のカフェでゆっくり過ごし、次に上野公園を散策。その後浅草で食べ歩き(お好み焼き・人形町プリン等)。最後に隅田川沿いで夕景を鑑賞。  
  - 注意点:  
    * 天気が晴れ時々くもりのため、傘は必須。  
    * 夕方は肌寒いため、防寒対策を忘れず。  
    * 混雑時間帯(15:00〜17:00)に合わせてカフェや浅草へ行くと待ち時間が短縮されます。  

> **備考**:上記のルート・所要時間は、実際に距離を測定して確認することで最終的に調整してください。これで同じミスを繰り返さず、効率よく東京駅周辺を楽しめます。
=== Human Review: Approved ===

=== Final Result ===
[assistant] Task execution stopped due to hitting the maximum round count limit.
PS C:\Users\YUKI28295\source\repos\MAF_LLMStudio_Samples\ResponsesParitySample> dotnet run
=== Responses Parity Sample ===
endpoint=http://localhost:1234/v1
model=openai/gpt-oss-20b
require_plan_review=True
auto_approve_plan=True
task=今日の午後に東京駅周辺へ行きます。現在日時、東京の天気、立ち寄り先候補を確認したうえで、持ち物・過ごし方・注意点をまとめて提案してください。

=== Initial Plan Created ===
We are working to address the following user request:

今日の午後に東京駅周辺へ行きます。現在日時、東京の天気、立ち寄り先候補を確認したうえで、持ち物・過ごし方・注意点をまとめて提案してください。



To answer this request we have assembled the following team:

- TimeAgent: 日時確認を担当するエージェントです。
- WeatherAgent: 天気確認を担当するエージェントです。
- LocalGuideAgent: 現地での過ごし方を提案するエージェントです。


Here is an initial fact sheet to consider:

**1. GIVEN OR VERIFIED FACTS**  
- The user plans to visit the Tokyo Station area in the afternoon today.

**2. FACTS TO LOOK UP**  
- Current date and time (`current_date_time`).  
- Current weather conditions in Tokyo (`weather`).  
- Recommended places or attractions near Tokyo Station that could be visited (`city_highlights`).

**3. FACTS TO DERIVE**  
- From the user’s mention of “今日の午後”, we can infer that the visit will occur sometime between 12:00 PM and 6:00 PM local time.

**4. EDUCATED GUESSES**  
- Typical attractions near Tokyo Station might include Marunouchi, Ginza, Imperial Palace East Gardens, Hibiya Park, or the Shinjuku area (though outside Tokyo Station proper).  
- Common items people bring for an afternoon outing in Tokyo could be a light jacket (depending on weather), umbrella, reusable water bottle, and a small backpack. 


Here is the plan to follow as best as possible:

- **Step 1:** Invoke TimeAgent → obtain `current_date_time` (today’s date & current time).  
- **Step 2:** Invoke WeatherAgent → fetch Tokyo’s present weather conditions (`weather`).  
- **Step 3:** Invoke LocalGuideAgent → request:  
  - Recommended spots around Tokyo Station for an afternoon visit (`city_highlights`).  
  - Suggested items to bring (e.g., jacket, umbrella).  
  - Practical tips & cautions (e.g., crowds, traffic, opening hours).  

These three agents together supply all the verified facts and actionable advice needed for the user’s afternoon trip.

=== Human Review: Proposed Plan ===
- **Step 1:** Invoke TimeAgent → obtain `current_date_time` (today’s date & current time).  
- **Step 2:** Invoke WeatherAgent → fetch Tokyo’s present weather conditions (`weather`).  
- **Step 3:** Invoke LocalGuideAgent → request:  
  - Recommended spots around Tokyo Station for an afternoon visit (`city_highlights`).  
  - Suggested items to bring (e.g., jacket, umbrella).  
  - Practical tips & cautions (e.g., crowds, traffic, opening hours).  

These three agents together supply all the verified facts and actionable advice needed for the user’s afternoon trip.
=== Human Review: Approved ===
[Tool:get_date_time] result=2026-05-14 21:52:34
[Tool:get_weather_forecast] city=東京 result=東京: 晴れ時々くもり、最高気温24℃、最低気温18℃。夕方は少し肌寒く、折りたたみ傘があると安心です。
[Tool:get_city_highlights] city=東京 result=東京の候補: 上野公園の散策、東京駅周辺のカフェ、浅草の食べ歩き、隅田川周辺の夕景。

=== Final Result ===
[assistant/Manager] **今日の午後、東京駅周辺で楽しく過ごすための案内**

---

### 1️⃣ 現在日時  
- **2026 年 5 月 14 日 21:52:34(日本標準時)**

### 2️⃣ 天気情報  
- **天候**:晴れ時々くもり  
- **最高気温**:24 ℃  
- **最低気温**:18 ℃  
- **夕方は少し肌寒いので折りたたみ傘があると安心です。**

### 3️⃣ おすすめアフタヌーンスポット(東京駅近辺)  
| スポット | アクセス・特徴 |
|-----------|----------------|
| **上野公園** | 散策に最適。緑豊かな風景と、周辺の博物館や動物園も併せて楽しめます。 |
| **東京駅周辺カフェ(丸の内・グランスタ、KITTE等)** | 休憩&コーヒータイムにおすすめ。ビジネスマンとの交流も◎です。 |
| **浅草寺 & 仲見世通り** | 食べ歩きと伝統的な雰囲気を満喫。雷門や仲見世でのショッピングが楽しめます。 |
| **隅田川沿い** | 夕景鑑賞に最適。川辺を散策しながら、夜景も堪能できます。 |

### 4️⃣ 持ち物チェックリスト  
- 軽い傘またはレインコート(雨雲対策)  
- 水筒/ペットボトル(水分補給)  
- スマホ+充電器(地図・時間確認)  
- 日焼け止め+帽子(午後の直射日光対策)  
- 軽バックパック(荷物整理&歩きやすさ)

### 5️⃣ 過ごし方と注意点  
1. **午前中に東京駅周辺カフェでゆっくり休憩** → コーヒーやスイーツを楽しみ、午後の活動に備えます。  
2. **上野公園へ向かい散策** → 緑と文化施設を同時に満喫。  
3. **浅草寺・仲見世通りで食べ歩き** → 地元グルメやお土産を探します。  
4. **隅田川沿いで夕景鑑賞** → 夕方の風が少し肌寒くなるため、折りたたみ傘や軽い上着を持参すると快適です。  

- **混雑予想時間帯(12:00〜14:00)**は避け、早めに訪れるとスムーズに回れます。  
- 雨雲があるため、傘の携帯は必須。風が強い場合は帽子や軽い上着で調整してください。

---

以上を参考に、快適かつ安全なアフタヌーンをお楽しみください!

LM Studioログ(各ステップの抜粋)

以下は、LM Studio の生ログから request/response の流れを追ううえで重要だった箇所だけを抜き出したものです。

各ステップの抜粋

1. 最初の manager request

2026-05-14 21:52:25 [DEBUG]
 Received request: POST to /v1/chat/completions with body  {
  "messages": [
    {
      "role": "system",
      "content": "あなたはMagenticのマネージャーです。最初に事実整理と計画を行い、その後は状況に応じて最適な担当エージェントを選んでください。最終回答は必ず日本語で、持ち物・過ごし方・注意点が分かる形にまとめてください。"
    },
    {
      "role": "user",
      "content": "Verified tool results already obtained in this wor... <Truncated in logs> ...ed until those missing facts have been obtained.\r\n"
    },
    {
      "role": "user",
      "content": "Below I will present you a request.\n\nBefore we beg... <Truncated in logs> ...NOT list next steps or plans until asked to do so."
    }
  ],
  "model": "openai/gpt-oss-20b",
  "max_completion_tokens": 14000
}

ここで確認したいポイントは、manager への入力に元の依頼だけでなく verified facts relay も混ざっていることです。

2. 最初の manager response

2026-05-14 21:52:29  [INFO]
 [openai/gpt-oss-20b] Generated prediction:  {
  "id": "chatcmpl-wmbehkwuexgbj4em552i",
  "object": "chat.completion",
  "created": 1778763145,
  "model": "openai/gpt-oss-20b",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "**1. GIVEN OR VERIFIED FACTS**  \n- The user plans to visit the Tokyo Station area in the afternoon today.\n\n**2. FACTS TO LOOK UP**  \n- Current date and time (`current_date_time`).  \n- Current weather conditions in Tokyo (`weather`).  \n- Recommended places or attractions near Tokyo Station that could be visited (`city_highlights`).\n\n**3. FACTS TO DERIVE**  \n- From the user’s mention of \"今日の午後\", we can infer that the visit will occur sometime between 12:00 PM and 6:00 PM local time.\n\n**4. EDUCATED GUESSES**  \n- Typical attractions near Tokyo Station might include Marunouchi, Ginza, Imperial Palace East Gardens, Hibiya Park, or the Shinjuku area (though outside Tokyo Station proper).  \n- Common items people bring for an afternoon outing in Tokyo could be a light jacket (depending on weather), umbrella, reusable water bottle, and a small backpack. ",
        "tool_calls": []
      }
    }
  ]
}

最初の応答では、まず「何が既知で何が未取得か」を切り分けています。

3. 進捗台帳 JSON を強制して次の担当を決める request/response

2026-05-14 21:52:32 [DEBUG]
 Received request: POST to /v1/chat/completions with body  {
  "model": "openai/gpt-oss-20b",
  "max_completion_tokens": 14000,
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "description": "Progress ledger returned by the Magentic manager.",
      "name": "magentic_progress_ledger",
      "schema": {
        "required": [
          "is_request_satisfied",
          "is_in_loop",
          "is_progress_being_made",
          "next_speaker",
          "instruction_or_question"
        ],
        "properties": {
          "next_speaker": {
            "properties": {
              "answer": {
                "type": "string",
                "enum": [
                  "TimeAgent",
                  "WeatherAgent",
                  "LocalGuideAgent"
                ]
              }
            }
          }
        }
      }
    }
  }
}

2026-05-14 21:52:33  [INFO]
 [openai/gpt-oss-20b] Generated prediction:  {
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "{\"is_request_satisfied\":{\"answer\":false,\"reason\":\"We have not yet obtained current_date_time, weather, or city_highlights.\"},\"is_in_loop\":{\"answer\":false,\"reason\":\"No repeated actions seen; we are at the planning stage.\"},\"is_progress_being_made\":{\"answer\":true,\"reason\":\"Plan to invoke agents is established and ready to proceed.\"},\"next_speaker\":{\"answer\":\"TimeAgent\",\"reason\":\"Need current date/time first before weather or guide details.\"},\"instruction_or_question\":{\"answer\":\"Please provide the current date and time for Tokyo (Japan Standard Time).\",\"reason\":\"This information is required to satisfy the user’s request.\"}}",
        "tool_calls": []
      }
    }
  ]
}

response_format 設定によってレスポンスに JSON スキーマ強制が効いています。

4. facts が埋まるにつれて next_speaker が順に切り替わる

2026-05-14 21:52:36  [INFO]
 [openai/gpt-oss-20b] Generated prediction:  {
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "{\"is_request_satisfied\":{\"answer\":false,\"reason\":\"We have only confirmed the current date and time. Weather and city highlights are still missing.\"},\"is_in_loop\":{\"answer\":false,\"reason\":\"No repeated actions detected; we are progressing toward gathering missing facts.\"},\"is_progress_being_made\":{\"answer\":true,\"reason\":\"The request for weather data has not yet been issued, so next steps remain clear.\"},\"next_speaker\":{\"answer\":\"WeatherAgent\",\"reason\":\"We still need the current weather in Tokyo to satisfy the user’s request.\"},\"instruction_or_question\":{\"answer\":\"Please provide the current weather conditions in Tokyo (temperature, precipitation chance, wind speed, etc.).\",\"reason\":\"These details are required to complete the user’s request.\"}}"
      }
    }
  ]
}

2026-05-14 21:52:41  [INFO]
 [openai/gpt-oss-20b] Generated prediction:  {
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "{\"is_request_satisfied\":{\"answer\":false,\"reason\":\"We have confirmed current date/time and weather, but we still lack city_highlights (recommended places near Tokyo Station) and a comprehensive plan of items, activities, and cautions. The user’s request requires these missing details.\"},\"is_in_loop\":{\"answer\":false,\"reason\":\"No repeated requests or identical responses have been observed; each step is advancing the information gathering process.\"},\"is_progress_being_made\":{\"answer\":true,\"reason\":\"We have successfully obtained the time and weather data, which are two of the three required pieces. The next logical step is to gather city highlights from LocalGuideAgent.\"},\"next_speaker\":{\"answer\":\"LocalGuideAgent\",\"reason\":\"The only remaining missing information is needed from this agent: city highlights and related recommendations.\"},\"instruction_or_question\":{\"answer\":\"Please provide a list of recommended places near Tokyo Station for an afternoon visit, along with suggested items to bring, practical tips, and cautions. Include details such as opening hours, transport links, and any seasonal considerations. The information should be tailored to the weather conditions (晴れ時々くもり 24℃/18℃).\",\"reason\":\"These are the specific content needs that this agent will deliver.\"}}"
      }
    }
  ]
}

2026-05-14 21:53:00  [INFO]
 [openai/gpt-oss-20b] Generated prediction:  {
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "{\"is_request_satisfied\":{\"answer\":false,\"reason\":\"The user has requested a comprehensive plan including current date/time, weather, recommended spots, and packing/behavior tips. While we have the date/time, weather, and city highlights, we have not yet synthesized them into a final Japanese answer or provided any explicit guidance on items to bring, how to spend time, or cautions.\"},\"is_in_loop\":{\"answer\":false,\"reason\":\"No repeated requests or identical responses have been observed; each turn introduced new information.\"},\"is_progress_being_made\":{\"answer\":true,\"reason\":\"We have collected all required confirmed facts and are ready to produce the final response.\"},\"next_speaker\":{\"answer\":\"LocalGuideAgent\",\"reason\":\"The next step is to synthesize a complete answer for the user using the verified data.\"},\"instruction_or_question\":{\"answer\":\"Please compose a concise Japanese summary that includes: 1) current date/time, 2) weather details, 3) recommended afternoon spots near Tokyo Station (using city highlights), 4) suggested items to bring, 5) how to spend the time and any precautions. Ensure all facts match the confirmed values exactly.\",\"reason\":\"The user’s request requires a final synthesized plan; LocalGuideAgent can generate it based on the confirmed data.\"}}"
      }
    }
  ]
}

重要なのは、最後の段階で manager が「まだ facts が足りない」とは言わず、 confirmed values を使った最終要約へ進んでいることです。

5. 最終回答の response

2026-05-14 21:53:15  [INFO]
 [openai/gpt-oss-20b] Generated prediction:  {
  "id": "chatcmpl-lh6mpm3yak4dpd2p3x3wn",
  "object": "chat.completion",
  "created": 1778763188,
  "model": "openai/gpt-oss-20b",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "**今日の午後、東京駅周辺で楽しく過ごすための案内**\n\n---\n\n### 1️⃣ 現在日時  \n- **2026 年 5 月 14 日 21:52:34(日本標準時)**\n\n### 2️⃣ 天気情報  \n- **天候**:晴れ時々くもり  \n- **最高気温**:24 ℃  \n- **最低気温**:18 ℃  \n- **夕方は少し肌寒いので折りたたみ傘があると安心です。**\n\n### 3️⃣ おすすめアフタヌーンスポット(東京駅近辺)  \n| スポット | アクセス・特徴 |\n|-----------|----------------|\n| **上野公園** | 散策に最適。緑豊かな風景と、周辺の博物館や動物園も併せて楽しめます。 |\n| **東京駅周辺カフェ(丸の内・グランスタ、KITTE等)** | 休憩&コーヒータイムにおすすめ。ビジネスマンとの交流も◎です。 |\n| **浅草寺 & 仲見世通り** | 食べ歩きと伝統的な雰囲気を満喫。雷門や仲見世でのショッピングが楽しめます。 |\n| **隅田川沿い** | 夕景鑑賞に最適。川辺を散策しながら、夜景も堪能できます。 |\n\n### 4️⃣ 持ち物チェックリスト  \n- 軽い傘またはレインコート(雨雲対策)  \n- 水筒/ペットボトル(水分補給)  \n- スマホ+充電器(地図・時間確認)  \n- 日焼け止め+帽子(午後の直射日光対策)  \n- 軽バックパック(荷物整理&歩きやすさ)\n\n### 5️⃣ 過ごし方と注意点  \n1. **午前中に東京駅周辺カフェでゆっくり休憩** → コーヒーやスイーツを楽しみ、午後の活動に備えます。  \n2. **上野公園へ向かい散策** → 緑と文化施設を同時に満喫。  \n3. **浅草寺・仲見世通りで食べ歩き** → 地元グルメやお土産を探します。  \n4. **隅田川沿いで夕景鑑賞** → 夕方の風が少し肌寒くなるため、折りたたみ傘や軽い上着を持参すると快適です。  \n\n- **混雑予想時間帯(12:00〜14:00)**は避け、早めに訪れるとスムーズに回れます。  \n- 雨雲があるため、傘の携帯は必須。風が強い場合は帽子や軽い上着で調整してください。\n\n---\n\n以上を参考に、快適かつ安全なアフタヌーンをお楽しみください!",
        "tool_calls": []
      }
    }
  ]
}

自然文の最終回答まで到達しました。現在日時が夜のものになっていますが、これはあくまで「回答生成時点での日時」であり、ユーザーの「今日の午後」という要望を満たすために、回答内では午後の過ごし方を提案しています。

Discussion

玖亖伍玖亖伍

Hello. Your article is excellent and I’ve learnt a great deal from it; I’m already a loyal reader of yours.
Would you mind sharing the implementation of CompatMagenticWorkflowBuilder and WithVerifiedFactsProvider?