HarnessAgent を試す - Microsoft Agent Framework (C#)
はじめに
Microsoft Agent Framework が無事 GA してしばらく経ちましたが、その上に乗っかる形で実験的な拡張パッケージがちょこちょこ追加されています。今回はその中でも個人的に気になった Microsoft.Agents.AI.Harness を軽く触ってみましょう。
ざっくり言うと、ツールをたくさん呼びながら長時間ぐるぐる回るタイプのエージェントを書くときに、毎回手で組み立てていた定番のパイプラインをワンライナーで作ってくれる、というパッケージです。Agent Framework 自体は GA していますが、この Microsoft.Agents.AI.Harness 自体はまだプレビュー扱いで、関連の AIContextProvider 群もまとめて実験的機能 (MAAI001) として提供されています。
公式リポジトリはこちらです。
HarnessAgent ってなに?
HarnessAgent は Microsoft.Agents.AI.Harness パッケージで提供されているエージェントで、AIAgent を継承して内部で IChatClient をラップする実装になっています。IChatClient に対して、長時間ループ向けの定番パイプラインをまとめて組み立ててくれるのが本体の仕事です。
Harness 関連の作業は、以下の PR を起点に進んでいます。
具体的にどんなものを積み上げてくれるかというと、ざっと以下の 3 つです。
-
FunctionInvokingChatClientで、ツール呼び出しの自動ループを回す -
PerServiceCallChatHistoryPersistingChatClientで、ツール呼び出しループ中の各サービス呼び出しごとに履歴を永続化する -
AIContextProviderChatClientとCompactionProvider。長時間タスクで履歴が爆発しないように、呼び出しごとにコンテキストウィンドウを圧縮してくれます
自分で組もうと思えば組めるパイプラインなのですが、毎回これを書くのは正直しんどい、というかどう組むのが定石なのかを毎回考えるのが面倒なので、まとめて面倒見てくれるのは助かります。
組み立て口としては IChatClient の拡張メソッド chatClient.AsHarnessAgent(maxContextWindowTokens, maxOutputTokens, new HarnessAgentOptions { ... }) が用意されているので、IChatClient さえ手元にあれば 1 行でエージェントを作れます。HarnessAgentOptions で渡せるのは大まかに以下のあたりです。
-
Name/Description/Idといった識別系 -
ChatOptions(Instructions、Tools、MaxOutputTokensなど) -
ChatHistoryProvider(デフォルトはInMemoryChatHistoryProviderに圧縮戦略付きの reducer をセットしたもの) -
AIContextProviders(後述するTodoProviderなどを追加で噛ませられる)
加えて、本体パッケージ Microsoft.Agents.AI 側の Harness 名前空間に、長時間タスク向けの AIContextProvider がいくつか同梱されています。
-
TodoProvider:TodoList_Add/TodoList_Complete/TodoList_Remove/TodoList_GetRemaining/TodoList_GetAllといったツールをエージェントに渡してタスクを管理させるためのもの -
AgentModeProvider: plan / execute モードを切り替えるツールを提供 -
FileMemoryProvider/FileAccessProvider: ファイルベースのメモリやデータアクセス -
SubAgentsProvider: サブエージェントへ仕事を委譲するためのもの - ツール呼び出しの承認フローを差し込む
UseToolApproval拡張メソッド
こうやって並べてみると、Todo の管理、plan / execute モードの切り替え、ファイルベースのメモ、サブエージェントへの委譲、ツール呼び出しの承認と、GitHub Copilot などのコーディングエージェントを触っているとよく見るような機能が部品として一通り揃っているのが分かります。今回はその中から HarnessAgent と TodoProvider を組み合わせるシンプルな例を試してみましょう。
プロジェクトの準備
ソリューション AgentFrameworkHarnessLab.slnx を用意して、その下にコンソールアプリのプロジェクトを 2 つ作っていきます。
-
HelloHarness— まずは素のHarnessAgentを動かす -
HarnessWithTodo—TodoProviderを組み合わせて長時間タスク風に動かす
それぞれのプロジェクトで使う NuGet パッケージは以下の通りです。
-
Microsoft.Agents.AI(1.6.1) -
Microsoft.Agents.AI.OpenAI(1.6.1) -
Microsoft.Agents.AI.Harness(1.6.1-preview.260514.1) Azure.IdentityMicrosoft.Extensions.Configuration.UserSecrets
Microsoft.Agents.AI.Harness だけプレビュー版なので、NuGet で取得する際は prerelease を有効にしてください。
認証は AzureCliCredential を使うので、事前に az login しておきます。Foundry のエンドポイントとデプロイ名は記事では直書きせず、User Secrets から読み込む形にしておきます。各プロジェクトで以下のように設定しておいてください。
dotnet user-secrets init
dotnet user-secrets set "Endpoint" "<Foundry のエンドポイント URL>"
dotnet user-secrets set "DeploymentName" "<モデルのデプロイ名>"
シンプルな HarnessAgent を動かす
まずは HelloHarness プロジェクトで素の HarnessAgent を動かしてみます。天気を返すだけの単純なツールを用意して、エージェントに 2 回ほど質問してセッションが効いているかも見てみましょう。
#pragma warning disable MAAI001 // Microsoft.Agents.AI.Harness は実験的機能
#pragma warning disable OPENAI001 // OpenAIClient の BearerTokenPolicy オーバーロードは実験的
using System.ClientModel.Primitives;
using System.ComponentModel;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using OpenAI;
var config = new ConfigurationBuilder()
.AddUserSecrets<Program>()
.Build();
var endpoint = config["Endpoint"] ?? throw new InvalidOperationException("Endpoint is not set.");
var deploymentName = config["DeploymentName"] ?? throw new InvalidOperationException("DeploymentName is not set.");
const int maxContextWindowTokens = 1_050_000;
const int maxOutputTokens = 128_000;
// Foundry の v1 エンドポイントに対して AzureCliCredential のトークンを乗せた OpenAIClient を組み立てる
IChatClient chatClient = new OpenAIClient(
new BearerTokenPolicy(new AzureCliCredential(), "https://cognitiveservices.azure.com/.default"),
new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
.GetChatClient(deploymentName)
.AsIChatClient();
// 天気を返すだけのサンプルツール
AITool getWeatherTool = AIFunctionFactory.Create(
([Description("地名 (例: 広島, 東京)")] string location) =>
{
Console.WriteLine($"[tool] get_weather: location={location}");
return location switch
{
"広島" => "晴れ、気温 22 度。",
"東京" => "曇り、気温 18 度。",
_ => "情報がありません。",
};
},
name: "get_weather",
description: "指定した地名の現在の天気を返します。");
// AsHarnessAgent で長時間ループ向けのパイプライン入りエージェントを一発生成
AIAgent agent = chatClient.AsHarnessAgent(
maxContextWindowTokens,
maxOutputTokens,
new HarnessAgentOptions
{
Name = "WeatherAgent",
Description = "ツールを使って各地の天気を答えるエージェント。",
ChatOptions = new ChatOptions
{
Instructions = "あなたは天気を案内するアシスタントです。" +
"ユーザーから地名を聞かれたら get_weather ツールを使って天気を取得し、" +
"結果を日本語で簡潔に伝えてください。",
Tools = [getWeatherTool],
},
});
AgentSession session = await agent.CreateSessionAsync();
foreach (var question in new[] { "広島の天気を教えて。", "東京は?" })
{
Console.WriteLine($"=== User: {question} ===");
var response = await agent.RunAsync(question, session);
Console.WriteLine($"Agent: {response.Text}");
Console.WriteLine();
}
ポイントだけ補足しておきます。
IChatClient の組み立てに AzureOpenAIClient ではなく OpenAIClient + BearerTokenPolicy を使っているのは、Foundry の v1 エンドポイントだと AzureOpenAIClient のルーティングと合わないケースがあるためです。OpenAIClient のコンストラクタに BearerTokenPolicy を渡して、AzureCliCredential から取得したトークンをそのまま乗せています(このコンストラクタは OPENAI001 で実験的扱いです)。なお BearerTokenPolicy に渡している https://cognitiveservices.azure.com/.default は Foundry のエンドポイント URL ではなく、Microsoft Entra ID のスコープ識別子です。
エージェントを組み立てている AsHarnessAgent の引数で maxContextWindowTokens と maxOutputTokens を渡しているのは、HarnessAgent 内部でコンテキストウィンドウを圧縮する際の閾値になっているからです。今回はそこそこ大きめのモデルを使うつもりで雑に値を入れていますが、実運用では使うモデルのコンテキストウィンドウとアウトプット上限に合わせて調整するのが良いでしょう。
実行すると以下のような結果になりました。
=== User: 広島の天気を教えて。 ===
[tool] get_weather: location=広島
Agent: 広島の現在の天気は晴れ、気温は22度です。
=== User: 東京は? ===
[tool] get_weather: location=東京
Agent: 東京の現在の天気は曇り、気温は18度です。
2 回目の「東京は?」というやや雑な質問もちゃんと天気の話として解釈してくれていますね。セッションを跨いで会話の文脈が保持されている、というのが分かります。HarnessAgent と AgentSession の組み合わせで会話履歴の保持を裏で全部やってくれていて、しかも入力バジェット (maxContextWindowTokens - maxOutputTokens) に近付いてきたら自動でコンテキストの圧縮も走るので、こちらの Program.cs にはチャット履歴を扱うコードがほぼ存在しない、というのもいいですね。
TodoProvider と組み合わせる
次は、ちょっと長めのタスクを TodoProvider と組み合わせて試してみます。複数都市の気温を取得して比較してもらう、という仕事をエージェントに頼んで、その過程で Todo を積みながら処理させる感じです。
HarnessWithTodo プロジェクトを作って、以下のような Program.cs を書きます。
#pragma warning disable MAAI001 // Microsoft.Agents.AI.Harness と TodoProvider は実験的機能
#pragma warning disable OPENAI001 // OpenAIClient の BearerTokenPolicy オーバーロードは実験的
using System.ClientModel.Primitives;
using System.ComponentModel;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using OpenAI;
var config = new ConfigurationBuilder()
.AddUserSecrets<Program>()
.Build();
var endpoint = config["Endpoint"] ?? throw new InvalidOperationException("Endpoint is not set.");
var deploymentName = config["DeploymentName"] ?? throw new InvalidOperationException("DeploymentName is not set.");
const int maxContextWindowTokens = 1_050_000;
const int maxOutputTokens = 128_000;
IChatClient chatClient = new OpenAIClient(
new BearerTokenPolicy(new AzureCliCredential(), "https://cognitiveservices.azure.com/.default"),
new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
.GetChatClient(deploymentName)
.AsIChatClient();
// 都市ごとの気温を返すサンプルツール
AITool getTemperatureTool = AIFunctionFactory.Create(
([Description("都市名 (例: 広島, 東京, 品川)")] string city) =>
{
Console.WriteLine($"[tool] get_temperature: city={city}");
return city switch
{
"広島" => 22,
"東京" => 18,
"品川" => 19,
_ => 0,
};
},
name: "get_temperature",
description: "指定した都市の現在の気温 (度) を返します。");
AIAgent agent = chatClient.AsHarnessAgent(
maxContextWindowTokens,
maxOutputTokens,
new HarnessAgentOptions
{
Name = "TempAgent",
Description = "都市の気温を調べて比較するエージェント。",
// TodoProvider を噛ませると TodoList_Add などのツールがエージェントの手に入る
AIContextProviders = [ new TodoProvider() ],
ChatOptions = new ChatOptions
{
Instructions =
"""
あなたは都市の気温を調べて比較するアシスタントです。
ユーザーから複数都市の比較を頼まれたら、まず TodoList_Add で
各都市の気温取得タスクを作り、それぞれ取得しながら
TodoList_Complete で消化していってください。
最後に結果を日本語で要約してください。
""",
Tools = [getTemperatureTool],
},
});
AgentSession session = await agent.CreateSessionAsync();
var question = "広島と東京と品川の気温を調べて、気温が高い順に並べて教えてください。";
Console.WriteLine($"=== User: {question} ===");
var response = await agent.RunAsync(question, session);
Console.WriteLine($"Agent: {response.Text}");
// 実行後に TodoProvider 経由でセッション内の Todo 状態を覗いてみる
var todoProvider = agent.GetService<TodoProvider>();
if (todoProvider is not null)
{
var all = await todoProvider.GetAllTodosAsync(session);
var remaining = await todoProvider.GetRemainingTodosAsync(session);
Console.WriteLine();
Console.WriteLine($"残 Todo: {remaining.Count} / 全 Todo: {all.Count}");
foreach (var item in all)
{
Console.WriteLine($" - [{(item.IsComplete ? "x" : " ")}] {item.Title}");
}
}
ポイントは HarnessAgentOptions.AIContextProviders に new TodoProvider() を渡しているところと、Instructions で「TodoList_Add してから TodoList_Complete してね」という段取りを伝えているところです。TodoProvider を噛ませると TodoList_Add / TodoList_Complete などのツールが自動でエージェントの手に入るので、こちら側で「ツールとして登録」のような手続きは不要です。
実行結果は以下のようになりました。
=== User: 広島と東京と品川の気温を調べて、気温が高い順に並べて教えてください。 ===
[tool] get_temperature: city=広島
[tool] get_temperature: city=東京
[tool] get_temperature: city=品川
Agent: 現在の気温は以下の通りです。
1. 広島:22℃
2. 品川:19℃
3. 東京:18℃
気温が高い順に並べると、広島 → 品川 → 東京 です。
残 Todo: 0 / 全 Todo: 4
- [x] 広島の現在の気温を取得
- [x] 東京の現在の気温を取得
- [x] 品川の現在の気温を取得
- [x] 取得した3都市の気温を比較して高い順に並べる
裏で TodoList_Add がちゃんと 4 件積まれていて、エージェントが処理を進めながら順番に TodoList_Complete を呼んで消化していった様子が見えますね。最終的に全 Todo がチェック済みになっているのもいい感じです。
今回は短いタスクなのでありがたみは薄いですが、もっと長くて複雑なタスクになると「今どこまで進んでいて、何が残っているか」がセッション内に構造化された形で残るので、途中で止めて再開、みたいなユースケースとも相性が良さそうです。
ちなみに最後の agent.GetService<TodoProvider>() のところで分かるように、Provider はエージェントから普通に取り出せるので、UI 側で進捗を可視化するみたいなことも出来ます。
Harness にはこれ以外にも AgentModeProvider / FileMemoryProvider / FileAccessProvider / SubAgentsProvider と、UseToolApproval 拡張メソッドが用意されているので、残りもそれぞれ軽く触ってみましょう。
AgentModeProvider でモード切り替え
AgentModeProvider を組み込むと、エージェントに AgentMode_Set / AgentMode_Get ツールが渡され、現在のモード名がシステムプロンプトに自動で差し込まれます。標準では plan (対話的にプラン作成) と execute (自律実行) の 2 つが用意されていて、AgentModeProvider.SetMode でプログラム側から強制的に切り替えることも出来ます。
ここではせっかくなので TodoProvider と組み合わせて、plan モードでは Todo に計画を積むだけ、execute モードに切り替えてから Todo を消化しながら実際にツールを呼んで結果をまとめる、というよくあるエージェントの動きを再現してみます。ツールは HelloHarness で使ったのと同じ get_weather を使い回します。
// 天気を返すツール (HelloHarness と同じ。execute モードのときに呼ばれる)
AITool getWeatherTool = AIFunctionFactory.Create(
([Description("地名 (例: 広島, 東京, 品川)")] string location) =>
{
Console.WriteLine($"[tool] get_weather: location={location}");
return location switch
{
"広島" => "晴れ、気温 22 度。",
"東京" => "曇り、気温 18 度。",
"品川" => "雨、気温 16 度。",
_ => "情報がありません。",
};
},
name: "get_weather",
description: "指定した地名の現在の天気を返します。");
var modeProvider = new AgentModeProvider();
var todoProvider = new TodoProvider();
AIAgent agent = chatClient.AsHarnessAgent(
maxContextWindowTokens,
maxOutputTokens,
new HarnessAgentOptions
{
Name = "WeatherPlannerAgent",
AIContextProviders = [ modeProvider, todoProvider ],
ChatOptions = new ChatOptions
{
Instructions =
"""
あなたは天気を案内するアシスタントです。get_weather ツールが使えます。
plan モードのときは、ユーザーの質問に答えるために必要な作業を TodoList_Add で 1 タスク 1 地名の形で Todo に積んでください。get_weather は絶対に呼ばないでください。
execute モードのときは、TodoList_GetRemaining で残タスクを確認し、各タスクに対応する地名で get_weather を呼んだあと TodoList_Complete で消化していってください。すべての Todo が片付いたら、結果を日本語の表にまとめて返してください。
""",
Tools = [getWeatherTool],
},
});
AgentSession session = await agent.CreateSessionAsync();
Console.WriteLine($"初期モード: {modeProvider.GetMode(session)}");
var planResp = await agent.RunAsync("広島、東京、品川の現在の天気を教えてください。", session);
Console.WriteLine($"Agent: {planResp.Text}");
// plan モードでどんな Todo が積まれたか覗いてみる
var afterPlan = await todoProvider.GetAllTodosAsync(session);
Console.WriteLine($"-- plan 後の Todo ({afterPlan.Count} 件) --");
foreach (var item in afterPlan)
{
Console.WriteLine($" - [{(item.IsComplete ? "x" : " ")}] {item.Title}");
}
// プログラム側からモードを切り替える
modeProvider.SetMode(session, "execute");
Console.WriteLine($"モード切替後: {modeProvider.GetMode(session)}");
var execResp = await agent.RunAsync("さっきの計画に従って実際に調べてください。", session);
Console.WriteLine($"Agent: {execResp.Text}");
var afterExec = await todoProvider.GetAllTodosAsync(session);
Console.WriteLine($"-- execute 後の Todo ({afterExec.Count} 件) --");
foreach (var item in afterExec)
{
Console.WriteLine($" - [{(item.IsComplete ? "x" : " ")}] {item.Title}");
}
実行結果は以下のようになりました。
初期モード: plan
=== User (plan): 広島、東京、品川の現在の天気を教えてください。 ===
Agent: 天気を確認するための Todo を作成しました。
- 広島
- 東京
- 品川
このまま実行してよければ、execute モードに切り替えて取得します。
-- plan 後の Todo (3 件) --
- [ ] 広島
- [ ] 東京
- [ ] 品川
モード切替後: execute
=== User (execute): さっきの計画に従って実際に調べてください。 ===
[tool] get_weather: location=広島
[tool] get_weather: location=東京
[tool] get_weather: location=品川
Agent: | 地名 | 現在の天気 | 気温 |
|---|---|---:|
| 広島 | 晴れ | 22℃ |
| 東京 | 曇り | 18℃ |
| 品川 | 雨 | 16℃ |
-- execute 後の Todo (3 件) --
- [x] 広島
- [x] 東京
- [x] 品川
plan モードでは get_weather を呼ばずに Todo を 3 件積むだけで止まり、execute モードに切り替わってから初めてツールが呼ばれて、それと並行して Todo が消化されているのが分かりますね。モードと Todo の組み合わせで、こういう「まず計画 → 確認 → 実行」みたいなワークフローを素直に表現出来ます。AgentMode_Set ツールをエージェント自身に呼ばせれば、ユーザー承認を挟んで自分でモード遷移する作り込みも出来ます。
FileMemoryProvider でセッション内のファイルメモリ
FileMemoryProvider は、エージェントに FileMemory_SaveFile / FileMemory_ReadFile / FileMemory_ListFiles などのツールを渡して、セッションスコープの「ファイル形式の長期記憶」を実現します。ストレージは AgentFileStore 抽象越しなので、FileSystemAgentFileStore を渡せばローカルファイル、InMemoryAgentFileStore を渡せばインメモリ、という風に切り替えられます。
var memoryRoot = Path.Combine(AppContext.BaseDirectory, "memories");
Directory.CreateDirectory(memoryRoot);
var fileStore = new FileSystemAgentFileStore(memoryRoot);
AIAgent agent = chatClient.AsHarnessAgent(
maxContextWindowTokens,
maxOutputTokens,
new HarnessAgentOptions
{
Name = "MemoryAgent",
AIContextProviders = [ new FileMemoryProvider(fileStore) ],
ChatOptions = new ChatOptions
{
Instructions =
"""
あなたはユーザーの好みを記録するアシスタントです。
ユーザーから情報を教えられたら FileMemory_SaveFile で記録してください。
ファイル名は user_profile.md にしてください。
何かを聞かれたら、まず FileMemory_ListFiles と FileMemory_ReadFile で
既存のメモを確認してから答えてください。回答は日本語で。
""",
},
});
AgentSession session = await agent.CreateSessionAsync();
string[] turns =
[
"私は広島出身で、好きな食べ物はお好み焼きです。覚えておいてください。",
"私の好きな食べ物を覚えていますか?",
];
foreach (var turn in turns)
{
Console.WriteLine($"=== User: {turn} ===");
var response = await agent.RunAsync(turn, session);
Console.WriteLine($"Agent: {response.Text}");
}
// 保存されたファイルを直接覗いてみる
Console.WriteLine("--- 保存されたメモリファイル ---");
foreach (var file in Directory.EnumerateFiles(memoryRoot, "*", SearchOption.AllDirectories))
{
var rel = Path.GetRelativePath(memoryRoot, file);
Console.WriteLine($"[{rel}]");
Console.WriteLine(File.ReadAllText(file));
}
「広島出身でお好み焼きが好き」と教えたあとに「好きな食べ物覚えてる?」と聞いてみた実行結果が以下です。
=== User: 私は広島出身で、好きな食べ物はお好み焼きです。覚えておいてください。 ===
Agent: はい、覚えておきます。
- 出身: 広島
- 好きな食べ物: お好み焼き
=== User: 私の好きな食べ物を覚えていますか? ===
Agent: はい、覚えています。
好きな食べ物はお好み焼きです。
--- 保存されたメモリファイル ---
[memories.md]
# Memory Index
- **user_profile.md**: ユーザーのプロフィール情報
[user_profile.md]
- 出身: 広島
- 好きな食べ物: お好み焼き
user_profile.md 本体に加えて、メモリのインデックスファイル memories.md も自動で更新されているのが分かります。エージェントが次のターンで自分のメモを思い出しやすい仕掛けですね。
FileAccessProvider で共有フォルダ越しのデータ処理
FileAccessProvider は FileMemoryProvider と似ていますが、こちらはセッションを跨いで共有される、持続的なフォルダを扱うための Provider です。入力データの読み取りと、出力ファイルの書き出しが主なユースケースになります。
事前に sales.csv を置いておいたフォルダを共有フォルダとして渡して、要約レポートを書き出させてみます。
// 共有フォルダを準備して、入力 CSV を 1 つ置いておく
var dataFolder = Path.Combine(AppContext.BaseDirectory, "data");
Directory.CreateDirectory(dataFolder);
File.WriteAllText(Path.Combine(dataFolder, "sales.csv"),
"""
city,sales
広島,1200
東京,3400
品川,800
""");
var fileStore = new FileSystemAgentFileStore(dataFolder);
AIAgent agent = chatClient.AsHarnessAgent(
maxContextWindowTokens,
maxOutputTokens,
new HarnessAgentOptions
{
Name = "DataAgent",
AIContextProviders = [ new FileAccessProvider(fileStore) ],
ChatOptions = new ChatOptions
{
Instructions =
"""
あなたはデータ分析アシスタントです。
FileAccess_ListFiles と FileAccess_ReadFile で入力ファイルを読み取り、
分析した結果を FileAccess_SaveFile で summary.md に書き出してください。
""",
},
});
AgentSession session = await agent.CreateSessionAsync();
var question = "sales.csv の内容を読んで、売上が多い都市の順に並べた要約を summary.md として保存してください。";
Console.WriteLine($"=== User: {question} ===");
var response = await agent.RunAsync(question, session);
Console.WriteLine($"Agent: {response.Text}");
// 共有フォルダにどんなファイルが出来上がったか確認する
Console.WriteLine("--- 共有フォルダの中身 ---");
foreach (var file in Directory.EnumerateFiles(dataFolder, "*", SearchOption.AllDirectories))
{
var rel = Path.GetRelativePath(dataFolder, file);
Console.WriteLine($"[{rel}]");
Console.WriteLine(File.ReadAllText(file));
}
実行結果は以下のようになりました。
=== User: sales.csv の内容を読んで、売上が多い都市の順に並べた要約を summary.md として保存してください。 ===
Agent: sales.csv を分析し、売上順の要約を summary.md に保存しました。
--- 共有フォルダの中身 ---
[sales.csv]
city,sales
広島,1200
東京,3400
品川,800
[summary.md]
# 売上要約
売上が多い都市の順に並べると以下の通りです。
1. 東京: 3400
2. 広島: 1200
3. 品川: 800
入力ファイルにはちゃんと触らず、別ファイルとして summary.md を書き出してくれています。FileMemoryProvider と違ってインデックスファイルを作ったりはせず、フォルダの中身そのままを使う形なので、データ I/O の橋渡しとして使うとシンプルに使えそうです。
SubAgentsProvider で他エージェントへ仕事を委譲
SubAgentsProvider を組み込むと、親エージェントから別のエージェントへタスクを委譲するためのツール群がエージェントの手に入ります。SubAgentsProvider のコンストラクタに渡したエージェントの名前が選択肢になります。
主なツールは以下のあたりです。
-
SubAgents_StartTask: 指定したサブエージェントに対してタスクを開始する (タスク ID が返る) -
SubAgents_WaitForFirstCompletion: 指定したタスクのうち最初に完了したものを待つ -
SubAgents_GetTaskResults: 完了したサブタスクの結果を取得する -
SubAgents_GetAllTasks: 現在のサブタスク一覧と状態を取得する -
SubAgents_ContinueTask: 完了したサブタスクのセッションに追加の入力を送って続行する -
SubAgents_ClearCompletedTask: 完了したサブタスクを片付けてメモリを解放する
タスク開始はブロックせず並列で走る作りなので、典型的な使い方は「SubAgents_StartTask で複数を並列に投げる → SubAgents_WaitForFirstCompletion で 1 つずつ完了を待つ → SubAgents_GetTaskResults で結果を回収する → SubAgents_ClearCompletedTask で片付ける」という流れになります。
get_temperature ツールは HarnessWithTodo セクションのものをそのまま流用しています。
// サブエージェント側 (気温取得専任)
AIAgent temperatureAgent = chatClient.AsHarnessAgent(
maxContextWindowTokens,
maxOutputTokens,
new HarnessAgentOptions
{
Name = "TemperatureAgent",
Description = "都市名を 1 つもらって、その気温 (度) を整数で答えるエージェント。",
ChatOptions = new ChatOptions
{
Instructions = "あなたは気温を答えるアシスタントです。get_temperature ツールで気温を取得して、数字だけ簡潔に答えてください。",
Tools = [getTemperatureTool],
},
});
// 親エージェント側
AIAgent parentAgent = chatClient.AsHarnessAgent(
maxContextWindowTokens,
maxOutputTokens,
new HarnessAgentOptions
{
Name = "Coordinator",
AIContextProviders = [ new SubAgentsProvider([ temperatureAgent ]) ],
ChatOptions = new ChatOptions
{
Instructions =
"""
あなたはコーディネーターです。
複数都市の気温を聞かれたら、TemperatureAgent に SubAgents_StartTask で 1 都市ずつタスクを投げて、
SubAgents_WaitForFirstCompletion と SubAgents_GetTaskResults で結果を集めてから、
日本語で表形式に整理して答えてください。
""",
},
});
AgentSession session = await parentAgent.CreateSessionAsync();
var question = "広島、東京、品川の現在の気温を表で教えてください。";
Console.WriteLine($"=== User: {question} ===");
var response = await parentAgent.RunAsync(question, session);
Console.WriteLine($"Agent: {response.Text}");
実行結果は以下のようになりました。
=== User: 広島、東京、品川の現在の気温を表で教えてください。 ===
[tool] get_temperature: city=広島
[tool] get_temperature: city=東京
[tool] get_temperature: city=品川
Agent: 以下の通りです。
| 都市 | 現在の気温 |
|---|---:|
| 広島 | 22℃ |
| 東京 | 18℃ |
| 品川 | 19℃ |
親エージェントは自分で get_temperature ツールを持っていないのに、サブエージェント越しにちゃんと結果を集めて表に整形してくれています。役割の違うエージェントを組み合わせるときに便利そうですね。
UseToolApproval でツール承認に「次回は聞かない」を足す
最後は Provider ではなく、AIAgentBuilder 用の拡張メソッド UseToolApproval を見ていきます。
Agent Framework 本体には、もともと「ツール呼び出しの承認フロー」が用意されています。ざっくり言うと、ApprovalRequiredAIFunction で危険なツールをラップしておくと、エージェントがそのツールを呼ぼうとしたタイミングで実行はされず、応答メッセージの中に ToolApprovalRequestContent が混じってきます。呼び出し側はそれを見て承認するかどうかを判断して、request.CreateResponse(approved: true/false) で作ったレスポンスをセッションに返してあげる、という流れです。承認要求が無くなるまでこのやり取りを while で回す必要があるので、ハンドリングのループは自前で書きます。ApprovalRequiredAIFunction まわりの基本的な動きは、以前の記事で詳しめに触れているのでそちらも参考にしてください。
ただし、この標準のフローは「呼び出しのたびに承認要求が来るので毎回 while ループでハンドリングして返す」仕様なので、同じツールを何度も呼ぶシナリオではユーザーが疲れてしまいます。そこで UseToolApproval を AIAgentBuilder に噛ませると、承認レスポンスに「このツールは今後聞かないで」「同じ引数なら聞かないで」という追加のフラグを乗せられるようになり、そのルールが AgentSession に記録されて、次回以降の RunAsync ではそもそも ToolApprovalRequestContent が来なくなります。GitHub Copilot の auto approve に近い動きですね。なお、初回の承認ループ自体は引き続き自前で回す必要がある点には注意してください。
具体的には、ToolApprovalRequestContent 用の CreateAlwaysApproveToolResponse / CreateAlwaysApproveToolWithArgumentsResponse という拡張メソッドが用意されていて、これらで作ったレスポンスを返すと UseToolApproval のミドルウェアがルールをセッションに記録してくれる、という形になっています。
実際に deploy という危険ツールを ApprovalRequiredAIFunction でラップして、初回だけ「次回からこの deploy は聞かないで」の応答を返し、2 回目はそのまま実行される様子を見てみます。
// 承認が必要なツール
AITool deployTool = new ApprovalRequiredAIFunction(
AIFunctionFactory.Create(
([Description("環境名 (例: prod)")] string environment) =>
{
Console.WriteLine($"[tool] deploy: environment={environment}");
return $"{environment} 環境へのデプロイが完了しました。";
},
name: "deploy",
description: "指定した環境にデプロイします。"));
AIAgent agent = chatClient
.AsHarnessAgent(
maxContextWindowTokens,
maxOutputTokens,
new HarnessAgentOptions
{
Name = "DeployAgent",
ChatOptions = new ChatOptions
{
Instructions = "あなたはデプロイ担当アシスタントです。ユーザーから依頼されたら deploy ツールを呼んでください。",
Tools = [deployTool],
},
})
.AsBuilder()
.UseToolApproval()
.Build();
// 1 回目: 承認要求が来るので AlwaysApprove で返す
AgentSession session = await agent.CreateSessionAsync();
var response = await agent.RunAsync("prod 環境にデプロイしてください。", session);
var approvalRequests = response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.ToList();
while (approvalRequests.Count > 0)
{
var replies = approvalRequests.Select(req =>
{
// 「次回からこのツールは聞かないで」ルールを記録
var always = req.CreateAlwaysApproveToolResponse();
return new ChatMessage(ChatRole.User, [always]);
}).ToList();
response = await agent.RunAsync(replies, session);
approvalRequests = response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.ToList();
}
Console.WriteLine($"Agent: {response.Text}");
// 2 回目: ルールが効いていれば承認要求は来ないはず
var response2 = await agent.RunAsync("staging 環境にもデプロイしてください。", session);
var approvalRequests2 = response2.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.ToList();
Console.WriteLine($"承認要求の数: {approvalRequests2.Count}");
Console.WriteLine($"Agent: {response2.Text}");
prod に承認してデプロイしたあと、続けて staging にもデプロイを頼んでみると以下のようになりました。
=== User: prod 環境にデプロイしてください。 ===
[承認] deploy を承認します (今後同じツールは自動承認)
[tool] deploy: environment=prod
Agent: prod 環境へのデプロイが完了しました。
=== User: staging 環境にもデプロイしてください。 ===
[tool] deploy: environment=staging
承認要求の数: 0
Agent: staging 環境へのデプロイが完了しました。
2 回目の staging へのデプロイでは承認要求がスキップされて、いきなり deploy ツールが呼ばれているのが分かります。UseToolApproval を 1 行噛ませるだけでこの動きが手に入るのは便利ですね。
まとめ
ということで、今回は Microsoft.Agents.AI.Harness を軽く触ってみました。ポイントをまとめると以下のような感じです。
-
HarnessAgentは、長時間ループ向けの定番パイプライン(function invocation、per-service-call の永続化、コンテキスト圧縮)を一発で組み立ててくれる便利エージェント -
TodoProviderをはじめとするAIContextProvider群が、長時間タスクで欲しくなる状態管理機能(Todo、モード切替、ファイルメモリ、サブエージェント、ツール承認)を提供してくれる - どれも実験的機能 (
MAAI001) なので API は変わる可能性あり
「自前で FunctionInvokingChatClient の上にあれこれ積み上げていたコードをすっきりさせたい人」とか「planning しながら長時間ループを回すエージェントを書きたい人」にはちょうど良い道具箱だなと思いました。プレビュー段階ではありますが、Agent Framework の今後の方向性を覗き見るという意味でも面白いパッケージなので、興味のある方はぜひ触ってみてください。
それでは、良い Harness ライフを!
Discussion