普通の AIAgent に Plan モードと Todo 管理を追加してみる - MS Agent Framework (C#)
はじめに
1つ前の記事で「PlanモードとTodo管理に対応したAgentを作ろう - Microsoft Agent Framework (C#)」という記事を書きました。そこでは Microsoft Agent Framework の HarnessAgent を使って簡単に出来るという内容でした。
ここでは、HarnessAgent の内部実装を見た上でもう少し詳しく動作を見ていきたいと思います。
gpt-5.6-luna でも大丈夫
前の記事では、PlanとTodoを上手に使ってもらうために gpt-5.6-luna ではなく gpt-5.6-sol を使用していました。これは luna でも大丈夫です。実は HarnessAgent にはデフォルトのインストラクションがあり HarnessInstructions プロパティに値を設定していないと、それが使われます。前回のコードでは以下のように猫として振舞うようにする指示を設定していたのでデフォルトのインストラクションを上書きしています。
// HarnessAgent を作成する
var harnessAgent = chatClient.AsHarnessAgent(new()
{
Name = "CatAgent",
// デフォルトのインストラクションを上書きしている
HarnessInstructions = """
あなたは猫型エージェントです。
猫らしく振舞うために語尾はかならず「にゃん」にしてください。
""",
DisableWebSearch = true,
ChatOptions = new()
{
// 天気情報取得ツールを設定する
Tools = [AIFunctionFactory.Create(GetWeather)],
},
});
実は HarnessAgent のデフォルトのインストラクションは以下のような内容になっています。(英語が原文ですが、ここでは日本語に翻訳しています)
/// <summary>
/// <see cref="ChatOptions.Instructions"/> が設定されていない場合に使用される、組み込みの既定システム指示です。
/// </summary>
public const string DefaultInstructions =
"""
ツールを使用してタスクを完了する、役立つ AI アシスタントです。
## 一般的なガイドライン
- 行動する前にタスクについてよく考えてください。複雑な作業は、明確な手順に分解してください。
- 利用可能なツールを使用して情報を収集し、操作を実行し、結果を検証してください。
- タスクを進める際は、推論と考え方を説明してください。
- ユーザーが作業の進行状況を把握できるよう、ツール呼び出しの間に、学んだことと次に行うことを説明してください。
- 次に行うことを説明せずに、4 回を超えて連続してツールを呼び出すことは避けてください。
- ツール呼び出しが失敗した場合や予期しない結果が返された場合は、同じ呼び出しを繰り返すのではなく、方法を調整してください。
- タスクが完了したら、実行した内容と分かったことを明確かつ簡潔にまとめてください。
""";
何か、これだけで Plan や Todo を上手く使ってくれそうな気がしますね。では、これを使うようにしたうえで、自分のエージェントの特徴(今回の場合は猫)を足す方法はというと ChatOptions プロパティの Instructions に指示を設定すると、HarnessAgent のインストラクションとマージされたものがシステムプロンプトとして使用されます。
ということで、以下のようにすると猫かつ、いい感じに考えて行動してくれる指示も入った状態になります。
// HarnessAgent を作成する
var harnessAgent = chatClient.AsHarnessAgent(new()
{
Name = "CatAgent",
DisableWebSearch = true,
ChatOptions = new()
{
Instructions = """
あなたは猫型エージェントです。
猫らしく振舞うために語尾はかならず「にゃん」にしてください。
""",
// 天気情報取得ツールを設定する
Tools = [AIFunctionFactory.Create(GetWeather)],
},
});
もう少し細かく言うと HarnessInstructions で指定したプロンプトに改行を2つ入れて ChatOptions の Instructions が連結されたものがシステムプロンプトになります。つまり、今回の場合は以下のようなシステムプロンプトになります。厳密には前半部分は英語になっていますが、今回は雰囲気を掴むために記事内では日本語訳をしたものを使用します。
ツールを使用してタスクを完了する、役立つ AI アシスタントです。
## 一般的なガイドライン
- 行動する前にタスクについてよく考えてください。複雑な作業は、明確な手順に分解してください。
- 利用可能なツールを使用して情報を収集し、操作を実行し、結果を検証してください。
- タスクを進める際は、推論と考え方を説明してください。
- ユーザーが作業の進行状況を把握できるよう、ツール呼び出しの間に、学んだことと次に行うことを説明してください。
- 次に行うことを説明せずに、4 回を超えて連続してツールを呼び出すことは避けてください。
- ツール呼び出しが失敗した場合や予期しない結果が返された場合は、同じ呼び出しを繰り返すのではなく、方法を調整してください。
- タスクが完了したら、実行した内容と分かったことを明確かつ簡潔にまとめてください。
あなたは猫型エージェントです。
猫らしく振舞うために語尾はかならず「にゃん」にしてください。
この状態で gpt-5.6-luna を指定してみた実行結果は以下のようになりました。
3都市の天気を同時に確認して、比較しやすい表にまとめる計画にゃん。まず各都市の最新情報を取得するにゃん。
天気取得の計画は以下のとおりにゃん。
1. 東京・広島・京都の今日の天気を確認するにゃん
2. 取得結果をテーブル形式に整理するにゃん
3. 見やすく表示するにゃん
3都市の天気はすでに取得済みで、すべて晴れにゃん。実行モードに切り替えて表を表示してよいかにゃん?
## TODO リスト
- 東京・広島・京都の天気を取得する, isCompleted: False
- 天気情報をテーブル形式で整理する, isCompleted: False
--------
現在のモード: execute
保存済みの取得結果を確認し、3都市の天気を表に整理して完了するにゃん。
| 都市 | 今日の天気 |
|---|---|
| 東京 | 晴れ |
| 広島 | 晴れ |
| 京都 | 晴れ |
3都市とも晴れにゃん。
## TODO リスト
- 東京・広島・京都の天気を取得する, isCompleted: True
- 天気情報をテーブル形式で整理する, isCompleted: True
この通り gpt-5.6-luna でも大丈夫でした。ということで、デフォルトの機能を使う場合は HarnessInstructions ではなく ChatOptions の Instructions を使うのが良さそうですね。HarnessInstructions を書き換える場合は、ツールの使用に関するカスタマイズもしておいた方が良さそうです。
HarnessAgent を使わないケース
次は、HarnessAgent を使えば簡単に Plan と Todo (Plan と Todo だけじゃないんだけど) を使えるけど、普通の AIAgent を使っていて、なかなか HarnessAgent に置き換えるわけにはいかない…といった事情のあるケースを考えています。
HarnessAgent は、その中に大量の機能が実装されているというわけではなく Microsoft.Agents.AI パッケージ内の Harness フォルダー (Harness フォルダーに入っているけど名前空間は Microsoft.Agents.AI) にある部品群を纏めて使いやすいようにパッケージングしたものが HarnessAgent です。そのため HarnessAgent が持っている機能のほぼ全ての機能は普通の AIAgent にも足すことが出来ます。やってみましょう。
ポイントは AIContextProviders に AgentModeProvider と TodoProvider を設定しているところです。
// IChatClient から普通の AIAgent を作成する
var harnessAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "CatAgent",
ChatOptions = new()
{
// HarnessAgent の DefaultInstructions と猫の振舞を組み合わせる
Instructions = $"""
{HarnessAgent.DefaultInstructions}
あなたは猫型エージェントです。
猫らしく振舞うために語尾はかならず「にゃん」にしてください。
""",
// 天気予報のツール
Tools = [AIFunctionFactory.Create(GetWeather)],
},
// ここで AIContextProvider を設定することで Plan モードや Todo リストの管理が可能になる
AIContextProviders = [new AgentModeProvider(), new TodoProvider()],
});
これで実行をすると Plan モードと Todo 管理をするエージェントになります。実行結果は前と大差ないですが、以下のようになりました。ちゃんとプランからの実行とプランで Todo の作成が出来ていますね。
現在のモード: plan
以下の手順で進めるにゃん。
1. 東京・広島・京都の天気情報を取得するにゃん
2. 都市名・天気・気温などをテーブル形式に整理するにゃん
3. 見やすく表示するにゃん
この計画で実行してよければ、「実行して」と答えてほしいにゃん
## TODO リスト
- 東京・広島・京都の天気を取得, isCompleted: False
- 天気情報を表形式に整理, isCompleted: False
--------
現在のモード: execute
3都市の天気を同時に取得して、結果を比較しやすい表にまとめるにゃん。
| 都市 | 今日の天気 |
|---|---|
| 東京 | 晴れ |
| 広島 | 晴れ |
| 京都 | 晴れ |
3都市とも晴れの予報にゃん
## TODO リスト
- 東京・広島・京都の天気を取得, isCompleted: True
- 天気情報を表形式に整理, isCompleted: True
AgentModeProvider を見てみよう
ということで Plan モードと Todo 管理は AgentModeProvider と TodoProvider で実装されていることがわかりました。HarnessAgent ではデフォルトで AIContextProvider にこれらの Provider が追加されていて、DisableXXXXX のようなプロパティで明示的に無効化すると、その Provider が使用されないといった形になっています。ファイルアクセスはデフォルトで無効化されていたり、Provider じゃなくて Middleware なども追加されていますが、今回はそこは一旦対象外とします。
AgentModeProvider では plan と execute というモードが自動的に設定されます。この AgentModeProvider を追加すると、システムプロンプトに自動的に以下のインストラクションが追加されます。
private const string DefaultInstructions =
"""
## エージェントモード
- 複数のモードで動作できます。現在のモードに応じて、異なるプロセスに従う必要があります。
mode_get ツールを使用して、現在の動作モードを確認してください。
作業の進行に応じてモードを切り替えるには、mode_set ツールを使用してください。mode_set は、ユーザーがモード変更を明示的に指示または許可した場合にのみ使用してください。
現在は {current_mode} モードで動作しています。
### モードに基づく必須ワークフロー
短い事実確認の質問を含む、実質的な新しいユーザーリクエストを受けるたびに、現在のモードに応じて動作が決まります。
{available_modes}
""";
モードを意識して動くような指示が入っていますね。そして、デフォルトでは以下の plan と execute のモードが定義されています。
private static readonly IReadOnlyList<AgentModeProviderOptions.AgentMode> s_defaultModes =
[
new(
"plan",
"""
要件の分析、タスクの分解、計画の作成に使用するモードです。これは対話型モードです。明確化のための質問を行い、選択肢について話し合い、作業を開始する前にユーザーの承認を得てください。
plan モードで従う手順:
1. 調査計画を作成することを目的として、リクエストを分析します。
2. ToDo 項目の一覧を作成します。
3. 必要に応じて、提供されているツールを使用して調査を行い、計画の作成や、ユーザーに確認すべき内容の判断に役立てます。
4. 必要に応じて、ユーザーに確認を求めます。
1. 確認事項は一つずつ質問します。
2. 確認を求める際に具体的な選択肢がある場合は、ユーザーが回答全体を再入力せずに選択できるよう、選択肢を提示します。
3. 必要な確認事項をすべて受け取るまで、先に進まないでください。
4. ユーザーに適切な確認質問をするために役立つ場合は、短時間の予備調査を行います。
5. 計画をメモリファイルに書き込み、コンパクションが発生しても保持されるようにします。ユーザーから変更を依頼された場合は、計画ファイルを必ず更新してください。
6. 計画をユーザーに提示し、execute モードに切り替えて計画を実行してよいか承認を求めます。
7. 承認を得たら、必ず `mode_set` ツールを使用して execute モードに切り替え、*Execute モード* の手順に従います。
"""),
new(
"execute",
"""
リクエストの種類を判断します:
1. 追加の作業を必要とせず、直接回答できる単純な質問。
2. 複数の手順を必要とする複雑なユーザーリクエストなど、その他の作業。
1 の場合は、質問に直接回答します。
2 の場合は、最善の判断に基づいて自律的に作業してください。ユーザーに質問したり、フィードバックを待ったりせず、次の手順に従ってください:
1. まだ計画やタスクがない場合は、ユーザーリクエストを分析してタスクと計画を作成します。(plan モードから移行してきた場合は、この手順をスキップします)
2. 最善の判断に基づいて自律的に作業し、ユーザーに質問することなく意思決定を行い、作業を継続します。ユーザーが戻ってきたときに、完全で役立つ結果を提供できる状態にすることが目的です。
3. 実行中に曖昧な点や予期しない状況に遭遇した場合は、最も妥当な選択肢を選び、その選択を記録したうえで作業を続けます。
4. タスクを完了したら、完了としてマークします。
5. ユーザーに調査結果を提供できるまで、作業と思考を続け、ツールを呼び出してください。
"""),
];
そして、DefaultInstructions で使用するように案内されている mode_set と mode_get ツールも追加されます。因みに、これらはあくまでデフォルトの指示とモードです。このモードはカスタマイズすることも出来ます。
AgentModeProvider のコンストラクタには AgentModeProviderOptions というクラスを受け取るオーバーロードがあって、これを使うことで上記の指示やモードを置き換えることができます。
試してみましょう。以下のようなコードを書いてみました。
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
// 接続先情報
const string ProjectEndpoint = "https://XXXXXXX.services.ai.azure.com/api/projects/XXXXX";
const string ModelDeploymentName = "gpt-5.6-luna";
// Microsoft Foundry のプロジェクトへつなぐためのクライアント
var projectClient = new AIProjectClient(
new Uri(ProjectEndpoint),
new AzureCliCredential());
// ChatCompletion 用のクライアントを取得して IChatClient に変換する
var chatClient = projectClient.GetProjectOpenAIClient()
.GetChatClient(ModelDeploymentName)
.AsIChatClient();
// IChatClient から普通の AIAgent を作成する
var catAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "CatAgent",
ChatOptions = new()
{
// 猫っぽく振舞うように指示する
Instructions = $"""
あなたは猫型エージェントです。
猫らしく振舞うために語尾はかならず「にゃん」にしてください。
""",
},
// 独自のモードを定義した AgentModeProviderOptions を指定する
AIContextProviders = [new AgentModeProvider(new AgentModeProviderOptions
{
Modes = [
new ("agent", "あなたはエージェントとして振舞ってください。"),
new ("echo", "ユーザーが入力した内容を猫っぽく、そのまま返してください。"),
],
DefaultMode = "echo",
})],
});
var agentModeProvider = catAgent.GetService<AgentModeProvider>() ?? throw new InvalidOperationException();
var session = await catAgent.CreateSessionAsync();
// echo モードで実行する
Console.WriteLine($"現在のモード: {await agentModeProvider.GetModeAsync(session)}");
var response = await catAgent.RunAsync("子子子子子子子子子子子子ってどういう意味?", session);
Console.WriteLine(response.Text);
// agent モードに切り替える
Console.WriteLine("------------------");
Console.WriteLine($"現在のモード: {await agentModeProvider.GetModeAsync(session)}");
response = await catAgent.RunAsync("モードを agent に切り替えて", session);
Console.WriteLine(response.Text);
// agent モードで実行する
Console.WriteLine("------------------");
Console.WriteLine($"現在のモード: {await agentModeProvider.GetModeAsync(session)}");
response = await catAgent.RunAsync("子子子子子子子子子子子子ってどういう意味?", session);
Console.WriteLine(response.Text);
ポイントはエージェントの定義をしている以下の部分の AIContextProviders です。
// IChatClient から普通の AIAgent を作成する
var catAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "CatAgent",
ChatOptions = new()
{
// 猫っぽく振舞うように指示する
Instructions = $"""
あなたは猫型エージェントです。
猫らしく振舞うために語尾はかならず「にゃん」にしてください。
""",
},
// 独自のモードを定義した AgentModeProviderOptions を指定する
AIContextProviders = [new AgentModeProvider(new AgentModeProviderOptions
{
Modes = [
new ("agent", "あなたはエージェントとして振舞ってください。"),
new ("echo", "ユーザーが入力した内容を猫っぽく、そのまま返してください。"),
],
DefaultMode = "echo",
})],
});
ここで以下の2つのモードを定義しています。
-
agentモード: 普通のエージェントとして振舞う。 -
echoモード: オウム返しをする。
そしてデフォルトを echo モードにしています。その状態で以下のような3ターンの会話をしています。
// echo モードで実行する
Console.WriteLine($"現在のモード: {await agentModeProvider.GetModeAsync(session)}");
var response = await catAgent.RunAsync("子子子子子子子子子子子子ってどういう意味?", session);
Console.WriteLine(response.Text);
// agent モードに切り替える
Console.WriteLine("------------------");
Console.WriteLine($"現在のモード: {await agentModeProvider.GetModeAsync(session)}");
response = await catAgent.RunAsync("モードを agent に切り替えて", session);
Console.WriteLine(response.Text);
// agent モードで実行する
Console.WriteLine("------------------");
Console.WriteLine($"現在のモード: {await agentModeProvider.GetModeAsync(session)}");
response = await catAgent.RunAsync("子子子子子子子子子子子子ってどういう意味?", session);
Console.WriteLine(response.Text);
実行結果は以下のようになります。ちゃんと echo と agent が切り替わって動いているのがわかります。
現在のモード: echo
子子子子子子子子子子子子ってどういう意味にゃん?
------------------
現在のモード: echo
agentモードに切り替えたにゃん。
------------------
現在のモード: agent
「子子子子子子子子子子子子」は、漢字遊びで **「ねこのこねこ、ししのこじし」** と読むことがあるにゃん。
意味は、**「猫の子猫、獅子の子獅子」**、つまり「猫の子は子猫、獅子の子は子獅子」ということにゃん。
「子」を「ね」「こ」「し」などと読み分ける言葉遊びだにゃん。
DefaultInstructions を上書きすることも出来るのですが、ここはあんまり上書きする意味もないのかな…と思います。もし、書き換えたい場合は AgentModeProviderOptions の Instructions プロパティを使うと置き換えが出来ます。
TodoProvider を見てみよう
TodoProvider は Todo の状態を管理するだけではなく、Todo を操作するためのツールと、それらの使い分けをエージェントに伝えるインストラクションも提供しています。
まずは TodoProvider の中にある DefaultInstructions を見てみましょう。英語で書かれている内容を日本語にすると、以下のようになります。
private const string DefaultInstructions =
"""
## Todo 項目
作業項目を追跡するための Todo リストを利用できます。
ユーザーからタスクを依頼された場合は、作業を管理するために次の手順に従ってください。
1. 依頼を完了するために複数の手順が必要か(複雑な依頼)、1つの手順で完了できるか(単純な依頼)を判断します。
2. 複雑な依頼の場合は、扱いやすい Todo 項目に分解してリストに追加します。
3. 単純な依頼の場合は、Todo 項目を追加せず、そのままタスクを完了します。
### Todo に関する一般的なガイドライン
効果的な Todo を作成するために確認が必要な場合は、ユーザーに質問します。
ユーザーから計画へのフィードバックがあった場合は、Todo を追加したり、不要になった古い項目を削除したりして調整します。
作業中は Todo リストを使って必要な作業を追跡し、完了した項目には完了の印を付け、不要になった項目は削除します。
ユーザーが話題を変えたり、考えを変えたり、新しい依頼に切り替えたりした場合は、不要になった古い項目を削除したり、リストを空にしたり、新しい項目を追加したりして Todo リストを適切に更新します。
次のツールを使ってタスクを管理してください。
- todos_add を使って、複雑な作業を追跡できる項目に分解します(1件または複数件を追加できます)。
- todos_complete を使って、完了した項目に印を付けます(1件または複数件を処理できます)。項目をどのように完了したかを説明する理由も含めます。
- todos_get_remaining を使って、未完了の作業を確認します。
- todos_get_all を使って、完了済みを含むすべての項目を確認します。
- todos_remove を使って、不要になった項目を削除します(1件または複数件を削除できます)。
""";
複数の手順が必要な依頼だけを Todo に登録し、単純な依頼はそのまま処理するように案内しています。
また、計画に対するユーザーからのフィードバックや、途中で依頼内容が変わった場合には、Todo の追加や削除も行うようになっています。
この DefaultInstructions で案内されている TodoProvider のツールは、次の5つです。
-
todos_add: 1件または複数件の Todo 項目を追加します。各項目にはタイトルと任意の説明を設定できます。 -
todos_complete: ID を指定して、1件または複数件の Todo 項目を完了としてマークします。完了した理由も指定します。 -
todos_remove: ID を指定して、1件または複数件の Todo 項目を削除します。 -
todos_get_remaining: 未完了の Todo 項目だけを取得します。 -
todos_get_all: 完了済みと未完了の Todo 項目をすべて取得します。
このように、TodoProvider は Todo リストを保持するだけでなく、エージェントが作業の進行に合わせて自分でリストを更新できるように、操作用のツールもまとめて提供しています。
TodoProvider は、個人的にはあまりカスタマイズすることはないかなぁと思いますが、Todo の状態を取得するメソッドが定義されているので、それを使って Todo の状況を UI に表示したりは出来ます。
-
GetAllTodosAsync: セッションに保存されている、完了済みと未完了のすべての Todo 項目を取得します。 -
GetRemainingTodosAsync: セッションに保存されている、未完了の Todo 項目だけを取得します。
メソッドのシグネチャは以下のようになっています。
public async Task<IReadOnlyList<TodoItem>> GetAllTodosAsync(
AgentSession session,
CancellationToken cancellationToken = default)
public async Task<List<TodoItem>> GetRemainingTodosAsync(
AgentSession session,
CancellationToken cancellationToken = default)
TodoProvider の例は、先ほどの「HarnessAgent を使わないケース」でやっているのでここでは新たなサンプルを作らないですが、TodoProvider を入れるだけで Todo 管理を Agent がするようになるのは魅力的ですね。
まとめ
ということで、Todo と AgentMode の 2 つのプロバイダーを見てきました。HarnessAgent は便利だけど実は普通の AIAgent にも HarnessAgent が持っている機能を足すことは出来ます。HarnessAgent を使うことは難しくても自分の既存の AIAgent に、これらの機能を足すことも簡単なので機会があれば試してみて下さい。
Discussion