Cline で作った Devin 風ワークフローを Microsoft Agent Framework で再構築する② - 実装編

前回は Kanban のエージェントループと Microsoft Agent Framework (以下 MAF と表記します) の対応機能を確認しました。

今回からは MAF での実装を進めていきます。まず初めに環境のセットアップを行い、簡単なサンプルを実行して動作確認した後に、 前シリーズで作成したプロジェクト内に MAF の Agent と Workflow を作成するところまで実行していきます。

この記事で使用している実行環境は以下のようになっています。

  • VS Code (Windows 版)・・・バージョン 1.141.0
  • C# Dev Kit (Windows 版)・・・バージョン 3.40.210
  • .NET SDK (Windows 版)・・・バージョン 10.0.401

※記事の後半で実装しているエージェントループのサンプルを実行するには、Ollama をインストールする必要があります。

C# 実行環境のセットアップ

まずはじめに VS Code で C# を使用できるように設定していきます。

VS Code を起動して、左側にある拡張機能ボタンを押します。拡張機能画面の上部にある検索欄に「c#」などと入力して、表示された拡張機能から「C# Dev Kit」を選択してインストールします。この機能をインストールすると関連する拡張機能もインストールされます。

次に VS Code で Ctrl + @ と入力して表示されるターミナル画面か、Windows のターミナル画面で以下のように入力して、.NET SDK をインストールします。

winget install Microsoft.DotNet.SDK.10

WSL2 (Ubuntu) の場合は以下のコマンドでインストールできます。(macOS や 他の Linux の場合はこちらのページからインストーラーまたはスクリプトをダウンロードして実行してください。)

sudo apt update
sudo apt install -y dotnet-sdk-10.0

コマンドを実行するとインストーラーが起動するので、しばらく待ちます。

インストールが完了したら以下のコマンドを入力して、バージョン確認を行います。「10.0.xxx」などと表示されたら正常にインストールされています。

dotnet --version

次にコンパイラーの動作確認を行います。何か適当なフォルダを作成して VS Code で開きます。VS Code のターミナル画面に以下のコマンドを入力してサンプルプロジェクトを作成します。

dotnet new console

ターミナル画面に「dotnet run」と入力して「Hello C#!」と表示されれば正常にコンパイラーが動作しています。

PS D:\MAMP\htdocs\agent-framework\c-sharp-test> dotnet run
Hello C#!
デバッグ設定

C# でソースコードレベルのデバッグが行えるように設定していきます。

以下のようなデバッグ方法があります。ご利用の環境に合わせてお選びください。

  • 設定ファイルを使用しない方法
  • 設定ファイル (launch.json) を使用する方法
    • C# Dev Kit を使用する場合
    • C# 拡張機能(coreclr)を使用する場合
  • 単一のファイルをデバッグする方法
設定ファイルを使用しない方法

最も簡単な方法です。C# Dev Kit をインストールしたあとすぐに使用できます。

デバッグしたいソースコードを開いて、エディタ画面で行番号の左側をクリックしてブレークポイントを設定します。

「F5」キーを押すとデバッグが開始されますので、デバッガーの選択から「C#」を選択します。

起動構成の選択から「C#: スタートアッププロジェクトの起動」を選択します。

ソースコードがコンパイルされて、ブレークポイントで停止します。

設定ファイル (launch.json) を使用する方法

プロジェクトフォルダ内に複数の言語などが含まれている場合は、設定ファイルの項目で切り替えられた方が便利かもしれません。そのような用途の場合に使用できる設定方法をご紹介します。

C# プロジェクトで設定ファイルを使用してデバッグを行うには以下の方法があります。順番にご紹介します。

  • C# Dev Kit を使用する場合
  • C# 拡張機能(coreclr)を使用する場合

C# Dev Kit を使用する場合

最初に行ったデバッグ方法を設定ファイルで記述すると、以下のようになります。「projectPath」項目にプロジェクト名を記述する必要があるので、プロジェクトごとに projectPath 項目を修正する必要があります。

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "C# Debug",
            "type": "dotnet",
            "request": "launch",
            "projectPath": "${workspaceFolder}/c-sharp-test.csproj",
        }
    ]
}

C# 拡張機能(coreclr)を使用する場合

より細かく起動条件を設定したい場合は、以下のように設定します。この設定ではプロジェクト名を表すマクロ「${workspaceFolderBasename}」が使用できるため、単一プロジェクトであれば、プロジェクト名を毎回修正する必要がありません。

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "C# Debug (coreclr)",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build",
            "program": "${workspaceFolder}/bin/Debug/net10.0/${workspaceFolderBasename}.dll",
            "cwd": "${workspaceFolder}",
            "console": "integratedTerminal"
        }
    ]
}

この設定方法では「launch.json」ファイルの設定に、引数 (args) や 環境変数 (env) なども指定することができます。

            "args": ["--mode", "debug", "--count", "10"],
            "env": {
                "APP_ENV": "development",
                "LOG_LEVEL": "debug"
            }

サンプルで示した設定ではデバッグ実行前にビルドを行うようになっているため、以下の「.vscode\tasks.json」ファイルも追加する必要があります。

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build",
            "command": "dotnet",
            "type": "process",
            "args": [
                "build",
                "${workspaceFolder}/${workspaceFolderBasename}.csproj",
                "/property:GenerateFullPaths=true",
                "/consoleloggerparameters:NoSummary"
            ],
            "problemMatcher": "$msCompile",
            "group": {
                "kind": "build",
                "isDefault": true
            }
        }
    ]
}
単一のファイルをデバッグする方法

C# は Go などのように実行前にコンパイルが必要ですが、.NET 10 からは Python などのようにファイル単位で実行することが可能です。

設定ファイルは以下のようになります。現在開かれていて、前面にあるファイルが対象になります。

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "C# Debug Active File",
            "type": "dotnet",
            "request": "launch",
            "projectPath": "${file}"
        }
    ]
}

デバッガーを使用せずに、単一のファイルを実行したい場合はターミナル画面で以下のように入力します。

dotnet run --file app.cs
MAF のインストール

すでにあるエージェントループを徐々に置き換えていくため、既存のプロジェクトの中に MAF をインストールします。プロジェクトフォルダ内に以下のフォルダを作成します。

kanban/
└── c-sharp/
   └── agent-framework/

VS Code でターミナル画面を開き、以下のコマンドを実行します。

cd kanban\c-sharp\agent-framework
dotnet new console
dotnet add package Microsoft.Agents.AI.Foundry --prerelease

以上で.NET プロジェクトのひな型と MAF パッケージのインストールは終了です。以下のようなフォルダやファイルが作成されます。

kanban/
└── c-sharp/
   └── agent-framework/
      ├── bin/
      ├── obj/
      ├── agent-framework.csproj
      └── Program.cs

「bin」や「obj」フォルダはソース管理する場合は不要なので「.gitignore」ファイルも作成しておくと良いかもしれません。

obj
bin

ソースコードレベルのデバッグを行うために「.vscode\launch.json」ファイルに以下の設定を追加します。(単一のプロジェクトではないため「${workspaceFolderBasename}」マクロは使用できません。)

    {
        "name": "C# Debug (coreclr)",
        "type": "coreclr",
        "request": "launch",
        "preLaunchTask": "build",
        "program": "${workspaceFolder}/kanban/c-sharp/agent-framework/bin/Debug/net10.0/agent-framework.dll",
        "cwd": "${workspaceFolder}/kanban/c-sharp/agent-framework",
        "console": "integratedTerminal"
    },
    {
        "name": "C# Debug Active File",
        "type": "dotnet",
        "request": "launch",
        "projectPath": "${file}"
    },

C# コードをビルドするために「.vscode\tasks.json」ファイルも追加します。

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build",
            "command": "dotnet",
            "type": "process",
            "args": [
                "build",
                "${workspaceFolder}/kanban/c-sharp/agent-framework/agent-framework.csproj",
                "/property:GenerateFullPaths=true",
                "/consoleloggerparameters:NoSummary"
            ],
            "problemMatcher": "$msCompile",
            "group": {
                "kind": "build",
                "isDefault": true
            }
        }
    ]
}

設定に誤りがないか確認するために、「Program.cs」ファイルをエディタで開き、ブレークポイントを設定してから、デバッグ画面で「C# Debug (coreclr)」項目を選択してデバッグ実行します。

特にエラーなどが起きなければ MAF のインストールは終了です。

エージェントループのサンプル

基本的なエージェントループの作成方法を理解するために、前シリーズで作成したローカル LLM に現在の時刻を問い合わせる LangGraph のサンプルを MAF で作成してみます。(以下の図は LangGraph の場合のグラフ図になっています。)

※前のセクションでご説明した、既存のプロジェクト内で実行する構成でご説明しています。新しいプロジェクトで実行したい場合は、以下のコマンドで .NET と MAF のセットアップを行ってから実行してください。

dotnet new console
dotnet add package Microsoft.Agents.AI.Foundry --prerelease

まずはローカル LLM の実行用に Ollama をインストールします。Ollama のインストーラーは こちら からダウンロードできます。

インストールが完了したら、VS Code または Windows のターミナル画面を開いて、以下のコマンドを実行します。このコマンドを実行すると「gpt-oss:20b」というローカル LLM がインストールされます。

ollama pull gpt-oss:20b

次に VS Code のターミナル画面を開いて、以下のコマンドを実行して MAF と Ollama 用の関連パッケージをプロジェクトにインストールします。(*.csproj ファイルがあるフォルダ内で実行してください。)

dotnet add package Microsoft.Agents.AI.Workflows --prerelease
dotnet add package Microsoft.Extensions.AI
dotnet add package OllamaSharp

LangGraph の場合と同じ手順でサンプルを作成していきます。最小サンプルのため環境変数などは使用せずに直打ちになっています。

まず、Ollama への接続先と使用するローカル LLM モデルを設定します。

Program.cs ファイルを開いて、以下のコードを記入します。(異なる PC 上で Ollama が起動している場合は接続先 URI 項目の「localhost」部分を Ollama が起動している PC の IP アドレスに変更してください。)

using System.ComponentModel;
using System.Text;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using OllamaSharp;

Console.OutputEncoding = Encoding.UTF8;

IChatClient chatClient = ChatClientBuilderChatClientExtensions
    .AsBuilder((IChatClient)new OllamaApiClient(
        new Uri("http://localhost:11434"),
        "gpt-oss:20b"
    ))
    .UseFunctionInvocation()
    .Build();

次に現在の時刻を取得する関数を作成します。

[Description("現在の日時を取得します。")]
static string GetCurrentTime()
{
    return DateTimeOffset.Now
        .ToString("yyyy-MM-dd HH:mm:ss zzz");
}

時刻を取得する関数から C# Function Tool を作成します。

AIFunction getCurrentTimeTool =
    AIFunctionFactory.Create(GetCurrentTime);

ローカル LLM を使用する AIAgent を作成し、先ほど作成した C# Function Tool を登録します。

AIAgent timeAgent = chatClient.AsAIAgent(
    name: "TimeAgent",
    instructions:
    """
    あなたは現在時刻を回答するエージェントです。
    ユーザーから現在日時・現在時刻について質問された場合は、
    必ず GetCurrentTime ツールを使用してください。
    ツールから取得した結果を、日本語で簡潔に回答してください。
    """,
    tools:
    [
        getCurrentTimeTool
    ]
);

MAF にも LangGraph の Conditional Edge に相当する仕組みがあります。ただし今回の構成では、AIAgent が Function Tool を直接呼び出すため、LangGraph 版で使用していた Tool Node や Tool 呼び出し判定用の Conditional Edge は必要ありません。

代わりに結果のレビューを行う AIAgent を追加します。

AIAgent reviewerAgent = chatClient.AsAIAgent(
    name: "ReviewerAgent",
    instructions:
    """
    あなたは回答内容を確認する Reviewer です。
    前段の Agent の回答を確認し、
    現在時刻に対する回答として分かりやすければ、
    その内容をそのまま最終回答として返してください。
    問題がある場合は、その理由も簡潔に説明してください。
    """
);

パーツが揃ったのでグラフを組み立てます。

var workflow = new WorkflowBuilder(timeAgent)
    .AddEdge(timeAgent, reviewerAgent)
    .WithOutputFrom(reviewerAgent)
    .Build();

最後にグラフを実行する処理を追加します。非同期で処理結果のイベントを受け取る処理も追加します。

await using StreamingRun run =
    await InProcessExecution.RunStreamingAsync(
        workflow,
        "現在の時間を教えてください。"
    );

await run.TrySendMessageAsync(new TurnToken(emitEvents: true));

await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
    if (evt is AgentResponseUpdateEvent update)
    {
        Console.Write(update.Update);
    }
};

以上でサンプルの作成は完了です。実行も特に問題ないようです。

ツールの呼び出しも問題ないようです。

ここまでのサンプルコードをまとめたものは、以下のようになっています。

クリックすると展開します。(Program.cs)
using System.ComponentModel;
using System.Text;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using OllamaSharp;

Console.OutputEncoding = Encoding.UTF8;

IChatClient chatClient = ChatClientBuilderChatClientExtensions
    .AsBuilder((IChatClient)new OllamaApiClient(
        new Uri("http://localhost:11434"),
        "gpt-oss:20b"
    ))
    .UseFunctionInvocation()
    .Build();

[Description("現在の日時を取得します。")]
static string GetCurrentTime()
{
    return DateTimeOffset.Now
        .ToString("yyyy-MM-dd HH:mm:ss zzz");
}

AIFunction getCurrentTimeTool =
    AIFunctionFactory.Create(GetCurrentTime);

AIAgent timeAgent = chatClient.AsAIAgent(
    name: "TimeAgent",
    instructions:
    """
    あなたは現在時刻を回答するエージェントです。
    ユーザーから現在日時・現在時刻について質問された場合は、
    必ず GetCurrentTime ツールを使用してください。
    ツールから取得した結果を、日本語で簡潔に回答してください。
    """,
    tools:
    [
        getCurrentTimeTool
    ]
);

AIAgent reviewerAgent = chatClient.AsAIAgent(
    name: "ReviewerAgent",
    instructions:
    """
    あなたは回答内容を確認する Reviewer です。
    前段の Agent の回答を確認し、
    現在時刻に対する回答として分かりやすければ、
    その内容をそのまま最終回答として返してください。
    問題がある場合は、その理由も簡潔に説明してください。
    """
);

var workflow = new WorkflowBuilder(timeAgent)
    .AddEdge(timeAgent, reviewerAgent)
    .WithOutputFrom(reviewerAgent)
    .Build();

await using StreamingRun run =
    await InProcessExecution.RunStreamingAsync(
        workflow,
        "現在の時間を教えてください。"
    );

await run.TrySendMessageAsync(new TurnToken(emitEvents: true));

await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
    if (evt is AgentResponseUpdateEvent update)
    {
        Console.Write(update.Update);
    }
};

実際にサンプルを作成してみると、意外にもコードは短くならず、むしろ少し長くなったように感じます。ただし、今回のサンプルは LangGraph 版と「同じような構造」になるように作成しています。

一方で、Workflow の構造そのものではなく「同じような機能」を実現することだけを目的とすれば、今回のような Tool Calling では Tool Node や Conditional Edge を省略できるため、コードをより簡潔にできます。

以下のコードは Workflow の構造を再現するのではなく、「現在時刻を Tool で取得して回答する」という同等機能だけを MAF で実装した場合の例です。

using System.ComponentModel;
using System.Text;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OllamaSharp;

Console.OutputEncoding = Encoding.UTF8;

var chatClient = new OllamaApiClient(
    new Uri("http://localhost:11434"),
    "gpt-oss:20b"
);

[Description("現在の日時を取得します。")]
static string GetCurrentTime()
{
    return DateTimeOffset.Now
        .ToString("yyyy-MM-dd HH:mm:ss zzz");
}

var agent = chatClient.AsAIAgent(
    instructions: """
    あなたは日時に関する質問に答えるアシスタントです。
    現在時刻が必要な場合は必ず GetCurrentTime ツールを使用してください。
    """,
    name: "TimeAgent",
    tools:
    [
        AIFunctionFactory.Create(GetCurrentTime)
    ]
);

var result = await agent.RunAsync(
    "現在の時刻を教えてください。"
);

Console.WriteLine(result);

実行も問題ないようです。

今回はここまでとします。次回は、LangGraph の Interrupt / Resume に相当する機能を MAF でどのように実装できるのか、確認するところまで進める予定です。

以上です。よろしかったらお試しください。