はじめに
前回の記事では、Microsoft.Extensions.AI(MEAI)から Bedrock AgentCore Gateway の Web Search を呼び出す実装を紹介しました。
今回は、この実装を Microsoft Agent Framework(MAF)に移行します。
前編では、単純な載せ替えから始めて、MAF の標準構成への整理までを扱います。
本記事は3部構成の前編です。
- 前編(本記事): MEAI 版からの移行と、MAF 標準構成への整理
- 中編: file-based app のプロジェクト化と、Aspire 配下での観測
- 後編: ツールの追加と Human-in-the-loop
Agent Framework の概要と移行の利点
Microsoft Agent Framework は、Semantic Kernel と AutoGen の後継にあたる、AI エージェント構築のための .NET / Python 向けライブラリです。
MEAI の上位に位置づけられており、MEAI の IChatClient や AIFunction をそのまま土台として使います。
このため移行といっても別世界に引っ越すわけではなく、MEAI で書いたコードの多くは書き換えずにそのまま転用できます。
移行によって得られるものは、次の三つです。
- ツール実行ループの削除:
UseFunctionInvocation()ミドルウェア(実体はFunctionInvokingChatClient)が、ツールの実行と会話への積み直しを肩代わりします。BedrockChatClientは「Bedrock Converse API と MEAI のコンテンツ型を相互変換する薄い層」だけになります - 会話管理の標準化:AgentSession が会話履歴を管理します。マルチターン化するときも、セッションを使い回すだけで済みます。シリアライズ機構を持つため、アプリの再起動をまたぐ会話の永続化にも対応します1
- HIL などの周辺機能:ツール実行前の人間承認(
ApprovalRequiredAIFunction)、マルチエージェントオーケストレーション、ワークフローなどが標準で用意されています
なお MAF の Bedrock 向け公式プロバイダーは、記事執筆時点で .NET には提供されていません2。このため前回作成した BedrockChatClient は引き続き利用する必要がありますが、ツール実行ループの責務は大きく減ります。
Step 1: 単純移行
まずは前回のフルサンプルを、構成を変えずに MAF へ載せ替えます。
ツールは前回と同じく AgentCoreWebSearchClient(MCP で Gateway に接続する薄いクライアント)を関数でラップしたものを使います。
パッケージの変更
Microsoft.Extensions.AI の代わりに Microsoft.Agents.AI を参照します。
#:package AWSSDK.BedrockRuntime@4.0.100.3
#:package AWSSDK.SSO@4.0.100.3
#:package AWSSDK.SSOOIDC@4.0.100.3
-#:package Microsoft.Extensions.AI@10.7.0
+#:package Microsoft.Agents.AI@1.13.0
#:package ModelContextProtocol@2.0.0-preview.2
+#:property PublishAot=false
#:property PublishAot=false を足しているのは、file-based app が dotnet publish 時や、プロジェクトへの変換時に Native AOT を無効にするためです。
Microsoft.Agents.AI は MEAI に依存しているため、Microsoft.Extensions.AI のクラス群はそのまま使えます。また、MCP の C# SDK は前回と同じ 2.0.0-preview.2 を使います(安定版は 1.4 系ですが、クライアント側の API は同じ形で使えます)。
ツール定義の変更
MEAI 版では AIFunction を継承した ToolAIFunction クラスを作り、Name / Description / JsonSchema / InvokeCoreAsync を手書きしていました。
MAF では、[Description] 属性を付けた普通のメソッドを AIFunctionFactory.Create() に渡すだけで済みます。
var webSearchClient = new AgentCoreWebSearchClient(GatewayUrl);
[Description("キーワードで最新情報をWeb検索します。")]
async Task<string> WebSearchAsync(
[Description("検索クエリ")] string query,
[Description("取得する検索結果の最大件数(デフォルト5件)")] int maxResults = 5)
=> await webSearchClient.SearchAsync(query, maxResults);
エージェントの組み立て
MEAI 版で ChatOptions.Tools に登録して GetStreamingResponseAsync を呼んでいた部分は、AIAgent の組み立てに置き換わります。
AIAgent agent = new ChatClientBuilder(bedrockChatClient)
.UseFunctionInvocation()
.Build()
.AsAIAgent(
name: "WebSearchAgent",
instructions: "必要に応じて WebSearch ツールで最新情報を検索してから回答してください。",
tools: [AIFunctionFactory.Create(WebSearchAsync, name: "WebSearch")]);
AgentSession session = await agent.CreateSessionAsync();
await foreach (var update in agent.RunStreamingAsync(userInput, session))
{
foreach (var content in update.Contents)
{
switch (content)
{
case FunctionCallContent call:
Console.WriteLine($"\n[tool call: {call.Name}]");
break;
case FunctionResultContent:
Console.WriteLine("[tool result received]\n");
break;
case TextContent text when !string.IsNullOrEmpty(text.Text):
Console.Write(text.Text);
break;
}
}
}
MEAI 版では、ツール実行中に画面が止まって見える問題への対処として、ツールの実装の先頭と末尾に Console.WriteLine を仕込んでいました。
MAF では、ツール呼び出しの決定(FunctionCallContent)と結果の受領(FunctionResultContent)がストリーム更新に自然に流れてくるため、それを拾うだけで進捗を表示できます。
ツールの実装に表示用のコードを混ぜる必要がなくなり、責務がきれいに分かれます。
BedrockChatClient からツール実行ループを取り除く
ツールを実行するのは UseFunctionInvocation() の仕事になったため、BedrockChatClient はツールを実行しません。
LLM がツール呼び出しを要求したら、FunctionCallContent に変換して返すだけです。
// ストリーミング中にツール呼び出しブロックが完成したとき:
// ツールを自分で実行せず、FunctionCallContent を返すだけ。
// 実際の実行は UseFunctionInvocation() が担う。
yield return new ChatResponseUpdate
{
Role = ChatRole.Assistant,
Contents = [new FunctionCallContent(toolUseId, toolName, arguments)]
};
代わりに変換の仕事が増えます。UseFunctionInvocation() がツールを実行すると、会話履歴に FunctionCallContent(アシスタントのツール要求)と FunctionResultContent(ツールの実行結果)が積まれます。BedrockChatClient は、これらを Bedrock の ToolUseBlock / ToolResultBlock に変換して送り返す必要があります。
case FunctionCallContent call:
blocks.Add(new ContentBlock
{
ToolUse = new ToolUseBlock
{
ToolUseId = call.CallId,
Name = call.Name,
Input = ToDocument(call.Arguments)
}
});
break;
case FunctionResultContent result:
blocks.Add(new ContentBlock
{
ToolResult = new ToolResultBlock
{
ToolUseId = result.CallId,
Content = [new() { Text = result.Result?.ToString() ?? string.Empty }]
}
});
break;
結果 MEAI 版で二重に持っていたツール実行ループ(非ストリーミング版とストリーミング版)が消え、代わりにコンテンツ型の変換が増えることになります。
ループの状態管理(MaxToolCalls、continueLoop、途中で組み立てるツール結果メッセージ)がなくなるぶん、テストも書きやすくなるはずです。
step1.cs
#:package AWSSDK.BedrockRuntime@4.0.100.3
#:package AWSSDK.SSO@4.0.100.3
#:package AWSSDK.SSOOIDC@4.0.100.3
#:package Microsoft.Agents.AI@1.13.0
#:package ModelContextProtocol@2.0.0-preview.2
#:property PublishAot=false
// ═══════════════════════════════════════════════════════════
// Step 1: 単純移行
// MEAI版の構成(AgentCoreWebSearchClient + ラッパー関数)を温存したまま、
// ツール実行ループを UseFunctionInvocation() に、会話管理を AgentSession に置き換える。
// 実行: dotnet run --file step1.cs -- "検索したい内容"
// ═══════════════════════════════════════════════════════════
using System.ComponentModel;
using System.Text;
using System.Text.Json;
using Amazon;
using Amazon.BedrockRuntime;
using Amazon.BedrockRuntime.Model;
using Amazon.Runtime.Documents;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
// ── ① 設定(自分の環境に合わせて書き換える)──
const string AwsProfile = "ct-lab";
const string ModelId = "global.anthropic.claude-sonnet-5";
const string GatewayUrl = "https://xxxxxx.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp";
// ── ② クエリはコマンドライン引数で受け取る ──
var userInput = args.Length > 0 ? string.Join(" ", args) : string.Empty;
if (string.IsNullOrWhiteSpace(userInput))
{
Console.WriteLine("Usage: dotnet run --file step1.cs -- <query>");
return;
}
// ── ③ Bedrock 用の IChatClient を作成 ──
Environment.SetEnvironmentVariable("AWS_PROFILE", AwsProfile);
using var bedrock = new AmazonBedrockRuntimeClient(RegionEndpoint.USEast1);
IChatClient bedrockChatClient = new BedrockChatClient(bedrock, ModelId);
// ── ④ Web Search ツールを定義(MEAI版の構成を温存)──
// MEAI版では AIFunction を継承した専用クラスを書いていたが、
// MAF では [Description] 付きの普通のメソッドを AIFunctionFactory.Create に渡すだけでよい。
var webSearchClient = new AgentCoreWebSearchClient(GatewayUrl);
[Description("キーワードで最新情報をWeb検索します。")]
async Task<string> WebSearchAsync(
[Description("検索クエリ")] string query,
[Description("取得する検索結果の最大件数(デフォルト5件)")] int maxResults = 5)
=> await webSearchClient.SearchAsync(query, maxResults);
// ── ⑤ AIAgent を組み立てる ──
// UseFunctionInvocation() がツールの実行と会話への積み直しを肩代わりする。
// BedrockChatClient 側のツール実行ループが丸ごと不要になる。
AIAgent agent = new ChatClientBuilder(bedrockChatClient)
.UseFunctionInvocation()
.Build()
.AsAIAgent(
name: "WebSearchAgent",
instructions: "必要に応じて WebSearch ツールで最新情報を検索してから回答してください。",
tools: [AIFunctionFactory.Create(WebSearchAsync, name: "WebSearch")]);
// セッションを介した会話管理。マルチターン化する場合はこの session を使い回す。
AgentSession session = await agent.CreateSessionAsync();
// ── ⑥ ストリーミング実行 ──
Console.Write("Assistant> ");
await foreach (var update in agent.RunStreamingAsync(userInput, session))
{
foreach (var content in update.Contents)
{
switch (content)
{
// ツール呼び出しの決定はストリーム更新に自然に流れてくるため、
// ツール実装に進捗表示コードを混ぜる必要がない。
case FunctionCallContent call:
var callArgs = call.Arguments is null
? string.Empty
: string.Join(", ", call.Arguments.Select(kv => $"{kv.Key}={kv.Value}"));
Console.WriteLine($"\n[tool call: {call.Name}({callArgs})]");
break;
case FunctionResultContent:
Console.WriteLine("[tool result received]\n");
break;
case TextContent text when !string.IsNullOrEmpty(text.Text):
Console.Write(text.Text);
break;
}
}
}
Console.WriteLine();
// ══ AgentCore Gateway(MCP) の Web Search を呼ぶ薄いクライアント(前回記事と同じ)══
public sealed class AgentCoreWebSearchClient(string gatewayUrl)
{
public async Task<string> SearchAsync(string query, int maxResults, CancellationToken ct = default)
{
await using var client = await McpClient.CreateAsync(
new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(gatewayUrl),
TransportMode = HttpTransportMode.StreamableHttp
}),
cancellationToken: ct);
var result = await client.CallToolAsync(
"web-search___WebSearch",
new Dictionary<string, object?> { ["query"] = query, ["maxResults"] = maxResults },
cancellationToken: ct);
return string.Join("\n", result.Content
.OfType<ModelContextProtocol.Protocol.TextContentBlock>()
.Select(x => x.Text ?? string.Empty));
}
}
// ══ Bedrock Converse API を IChatClient として使う最小実装 ══
// MEAI版との最大の違い: ツール呼び出しの実行・ループを持たない。
// FunctionCallContent / FunctionResultContent を右から左に変換するだけの薄い層になる。
public sealed class BedrockChatClient(IAmazonBedrockRuntime bedrock, string modelId) : IChatClient
{
public async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> chatMessages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
var tools = options?.Tools?.OfType<AIFunction>().ToList() ?? new List<AIFunction>();
var toolConfig = BuildToolConfig(tools);
var messages = ToBedrockMessages(chatMessages);
var response = await bedrock.ConverseAsync(
new ConverseRequest { ModelId = modelId, Messages = messages, ToolConfig = toolConfig },
cancellationToken);
var contents = new List<AIContent>();
foreach (var block in response.Output.Message.Content)
{
if (block.Text is not null)
contents.Add(new TextContent(block.Text));
if (block.ToolUse is not null)
contents.Add(new FunctionCallContent(
block.ToolUse.ToolUseId, block.ToolUse.Name, ToArguments(block.ToolUse.Input)));
}
return new ChatResponse(new ChatMessage(ChatRole.Assistant, contents));
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> chatMessages,
ChatOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var tools = options?.Tools?.OfType<AIFunction>().ToList() ?? new List<AIFunction>();
var toolConfig = BuildToolConfig(tools);
var messages = ToBedrockMessages(chatMessages);
var streamResponse = await bedrock.ConverseStreamAsync(
new ConverseStreamRequest { ModelId = modelId, Messages = messages, ToolConfig = toolConfig },
cancellationToken);
var currentToolUseId = string.Empty;
var currentToolName = string.Empty;
var currentToolInputJson = new StringBuilder();
await foreach (var evt in streamResponse.Stream)
{
if (evt is ContentBlockStartEvent startEvt && startEvt.Start?.ToolUse != null)
{
currentToolUseId = startEvt.Start.ToolUse.ToolUseId;
currentToolName = startEvt.Start.ToolUse.Name;
currentToolInputJson.Clear();
}
else if (evt is ContentBlockDeltaEvent deltaEvt)
{
if (deltaEvt.Delta?.Text != null)
{
yield return new ChatResponseUpdate
{
Role = ChatRole.Assistant,
Contents = [new TextContent(deltaEvt.Delta.Text)]
};
}
else if (deltaEvt.Delta?.ToolUse != null)
{
currentToolInputJson.Append(deltaEvt.Delta.ToolUse.Input);
}
}
else if (evt is ContentBlockStopEvent && !string.IsNullOrEmpty(currentToolUseId))
{
Document inputDoc;
try { inputDoc = ToDocument(JsonDocument.Parse(currentToolInputJson.ToString()).RootElement); }
catch { inputDoc = new Document(new Dictionary<string, Document>()); }
// ツールを自分で実行せず、FunctionCallContent を返すだけ。
// 実際の実行は UseFunctionInvocation() が担う。
yield return new ChatResponseUpdate
{
Role = ChatRole.Assistant,
Contents = [new FunctionCallContent(currentToolUseId, currentToolName, ToArguments(inputDoc))]
};
currentToolUseId = string.Empty;
currentToolName = string.Empty;
currentToolInputJson.Clear();
}
}
}
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public void Dispose()
{
}
// 会話履歴(テキスト・ツール呼び出し・ツール結果)を Bedrock の Message へ変換
private static List<Message> ToBedrockMessages(IEnumerable<ChatMessage> chatMessages)
{
var messages = new List<Message>();
foreach (var m in chatMessages)
{
var blocks = new List<ContentBlock>();
foreach (var content in m.Contents)
{
switch (content)
{
case TextContent text when !string.IsNullOrEmpty(text.Text):
blocks.Add(new ContentBlock { Text = text.Text });
break;
case FunctionCallContent call:
blocks.Add(new ContentBlock
{
ToolUse = new ToolUseBlock
{
ToolUseId = call.CallId,
Name = call.Name,
Input = ToDocument(call.Arguments)
}
});
break;
case FunctionResultContent result:
blocks.Add(new ContentBlock
{
ToolResult = new ToolResultBlock
{
ToolUseId = result.CallId,
Content = new List<ToolResultContentBlock>
{
new() { Text = result.Result?.ToString() ?? string.Empty }
}
}
});
break;
}
}
if (blocks.Count == 0) continue;
// Bedrock は user/assistant の2ロールしか持たないため、
// ツール結果(Toolロール)は user 発話として送る。
var role = m.Role == ChatRole.Assistant ? ConversationRole.Assistant : ConversationRole.User;
messages.Add(new Message { Role = role, Content = blocks });
}
return messages;
}
// ツール定義(名前・説明・スキーマ)を Bedrock に渡す形へ変換
private static ToolConfiguration? BuildToolConfig(List<AIFunction> tools) =>
tools.Count == 0 ? null : new ToolConfiguration
{
Tools = tools.Select(t => new Tool
{
ToolSpec = new ToolSpecification
{
Name = t.Name,
Description = string.IsNullOrEmpty(t.Description) ? t.Name : t.Description,
InputSchema = new ToolInputSchema { Json = ToDocument(t.JsonSchema) }
}
}).ToList()
};
// JsonElement → Bedrock Document の明示的な変換
private static Document ToDocument(JsonElement element) => element.ValueKind switch
{
JsonValueKind.Object => new Document(element.EnumerateObject()
.ToDictionary(p => p.Name, p => ToDocument(p.Value))),
JsonValueKind.Array => new Document(element.EnumerateArray().Select(ToDocument).ToList()),
JsonValueKind.String => new Document(element.GetString()),
JsonValueKind.Number => new Document(element.GetInt32()),
JsonValueKind.True => new Document(true),
JsonValueKind.False => new Document(false),
_ => new Document()
};
// FunctionCallContent.Arguments(辞書)→ Bedrock Document への変換
private static Document ToDocument(IDictionary<string, object?>? arguments)
{
if (arguments is null) return new Document(new Dictionary<string, Document>());
var json = JsonSerializer.SerializeToElement(arguments);
return ToDocument(json);
}
// Bedrock Document → FunctionCallContent.Arguments 用の辞書に変換
private static Dictionary<string, object?> ToArguments(Document input)
{
var arguments = new Dictionary<string, object?>();
if (!input.IsDictionary()) return arguments;
foreach (var kvp in input.AsDictionary())
{
arguments[kvp.Key] =
kvp.Value.IsString() ? kvp.Value.AsString() :
kvp.Value.IsInt() ? kvp.Value.AsInt() :
kvp.Value.ToString();
}
return arguments;
}
}
Step 2: MAF 標準構成への整理
Step 1 では前回の構成を温存しましたが、MAFでは AgentCoreWebSearchClient と WebSearchAsync のラッパーは丸ごと不要です。
MCP クライアントライブラリの McpClientTool は AIFunction を実装しているため、Gateway から取得したツールをそのまま tools: に渡せます。
await using var mcpClient = await McpClient.CreateAsync(
new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(GatewayUrl),
TransportMode = HttpTransportMode.StreamableHttp
}));
IList<McpClientTool> mcpTools = await mcpClient.ListToolsAsync();
AIAgent agent = new ChatClientBuilder(bedrockChatClient)
.UseFunctionInvocation()
.Build()
.AsAIAgent(
name: "WebSearchAgent",
instructions: "必要に応じてツールで最新情報を検索してから回答してください。",
tools: [.. mcpTools.Cast<AITool>()]);
Step 1 との違いは三つあります。
- ツールのラッパークラスとラッパー関数が消えます
- Gateway に複数のターゲットを登録した場合、
ListToolsAsync()が利用可能なツールを列挙するため、ツールを追加するたびにコードを書く必要がなくなります - LLM から見えるツール名が、Gateway の命名規則に従った
web-search___WebSearchとなります。ただし、これはMcpClientTool.WithName()で好きな名前に差し替えられます
// tools: [.. mcpTools.Cast<AITool>()] の代わりに、名前を差し替えてから渡す。
// 実際の呼び出し先(Gateway側のツール)は変わらず、LLM に見せる名前だけが変わる。
var tools = mcpTools
.Select(t => t.Name == "web-search___WebSearch" ? t.WithName("WebSearch") : t)
.Cast<AITool>()
.ToList();
Step 1 ではツール呼び出しのたびに MCP 接続を張り直していました。これは、CLI のような短命なプロセスでは途中で接続が切れることを気にする必要がないためです。
ただし、WebApi のような長命なプロセスでは、事情が変わります。
mcpClient をシングルトンとして DI コンテナに登録したくなりますが、その接続は Gateway 側の都合(アイドルタイムアウトによるセッション破棄、認証トークンの期限切れ、Gateway の再デプロイなど)でいつ切れてもおかしくありません。
ここでは対応として、McpClientを直接シングルトン登録するのではなく、呼び出しを介して初めて接続し、失敗したら破棄して次回呼び出し時に張り直す、という薄いラッパーを挟んでいます。
public sealed class McpToolProvider(string gatewayUrl) : IAsyncDisposable
{
private readonly SemaphoreSlim _gate = new(1, 1);
private McpClient? _client;
public async Task<IList<McpClientTool>> GetToolsAsync(CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
var client = await GetOrCreateClientAsync(ct);
try
{
return await client.ListToolsAsync(cancellationToken: ct);
}
catch
{
// 接続が死んでいた可能性があるので、次回のために作り直す
await DisposeClientAsync();
throw;
}
}
finally
{
_gate.Release();
}
}
private async Task<McpClient> GetOrCreateClientAsync(CancellationToken ct)
{
if (_client is not null) return _client;
_client = await McpClient.CreateAsync(
new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(gatewayUrl),
TransportMode = HttpTransportMode.StreamableHttp
}),
cancellationToken: ct);
return _client;
}
private async Task DisposeClientAsync()
{
if (_client is null) return;
await _client.DisposeAsync();
_client = null;
}
public async ValueTask DisposeAsync() => await DisposeClientAsync();
}
SemaphoreSlim で守っているのは、Blazor Server などでは複数の回線(Circuit)が同じシングルトンサービスを同時に呼びだし、再接続中に別の回線が古い(すでに破棄した)_client を掴んでしまう競合を防ぎます。
呼び出しが失敗した回はエラーとして呼び出し元に返し、次の呼び出しで再接続する設計にしているため、失敗した瞬間の応答は遅くなります。
可用性をさらに上げるなら、失敗を呼び出し元に返す前にその場で 1 回だけ再接続してリトライする、といった変更を加える余地があります。
step2.cs
#:package AWSSDK.BedrockRuntime@4.0.100.3
#:package AWSSDK.SSO@4.0.100.3
#:package AWSSDK.SSOOIDC@4.0.100.3
#:package Microsoft.Agents.AI@1.13.0
#:package ModelContextProtocol@2.0.0-preview.2
#:property PublishAot=false
// ═══════════════════════════════════════════════════════════
// Step 2: MAF 標準構成
// McpClientTool は AIFunction を実装しているため、
// ListToolsAsync() で取得したツールをそのまま tools: に渡す。
// Step 1 のラッパークラス・ラッパー関数は不要になる。
// 実行: dotnet run --file step2.cs -- "検索したい内容"
// ═══════════════════════════════════════════════════════════
using System.Text;
using System.Text.Json;
using Amazon;
using Amazon.BedrockRuntime;
using Amazon.BedrockRuntime.Model;
using Amazon.Runtime.Documents;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
// ── ① 設定(自分の環境に合わせて書き換える)──
const string AwsProfile = "ct-lab";
const string ModelId = "global.anthropic.claude-sonnet-5";
const string GatewayUrl = "https://xxxxxx.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp";
// ── ② クエリはコマンドライン引数で受け取る ──
var userInput = args.Length > 0 ? string.Join(" ", args) : string.Empty;
if (string.IsNullOrWhiteSpace(userInput))
{
Console.WriteLine("Usage: dotnet run --file step2.cs -- <query>");
return;
}
// ── ③ Bedrock 用の IChatClient を作成 ──
Environment.SetEnvironmentVariable("AWS_PROFILE", AwsProfile);
using var bedrock = new AmazonBedrockRuntimeClient(RegionEndpoint.USEast1);
IChatClient bedrockChatClient = new BedrockChatClient(bedrock, ModelId);
// ── ④ AgentCore Gateway (MCP) に接続し、ツールをそのまま取得する ──
// Step 1 と違い、接続はエージェントの実行中ずっと保持する必要がある。
await using var mcpClient = await McpClient.CreateAsync(
new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(GatewayUrl),
TransportMode = HttpTransportMode.StreamableHttp
}));
IList<McpClientTool> mcpTools = await mcpClient.ListToolsAsync();
Console.WriteLine($"[MCP] {mcpTools.Count}件のツールをGatewayから取得: {string.Join(", ", mcpTools.Select(t => t.Name))}");
// ── ⑤ AIAgent を組み立てる ──
// LLM に見せる名前だけを WithName() で差し替える。実際の呼び出し先(Gateway側のツール)は変わらない。
// Gateway に複数ターゲットを登録すれば、コードを変えずにツールが増える。
var tools = mcpTools
.Select(t => t.Name == "web-search___WebSearch" ? t.WithName("WebSearch") : t)
.Cast<AITool>()
.ToList();
AIAgent agent = new ChatClientBuilder(bedrockChatClient)
.UseFunctionInvocation()
.Build()
.AsAIAgent(
name: "WebSearchAgent",
instructions: "必要に応じてツールで最新情報を検索してから回答してください。",
tools: tools);
AgentSession session = await agent.CreateSessionAsync();
// ── ⑥ ストリーミング実行 ──
Console.Write("Assistant> ");
await foreach (var update in agent.RunStreamingAsync(userInput, session))
{
foreach (var content in update.Contents)
{
switch (content)
{
case FunctionCallContent call:
var callArgs = call.Arguments is null
? string.Empty
: string.Join(", ", call.Arguments.Select(kv => $"{kv.Key}={kv.Value}"));
Console.WriteLine($"\n[tool call: {call.Name}({callArgs})]");
break;
case FunctionResultContent:
Console.WriteLine("[tool result received]\n");
break;
case TextContent text when !string.IsNullOrEmpty(text.Text):
Console.Write(text.Text);
break;
}
}
}
Console.WriteLine();
// ══ Bedrock Converse API を IChatClient として使う最小実装 ══
// MEAI版との最大の違い: ツール呼び出しの実行・ループを持たない。
// FunctionCallContent / FunctionResultContent を右から左に変換するだけの薄い層になる。
public sealed class BedrockChatClient(IAmazonBedrockRuntime bedrock, string modelId) : IChatClient
{
public async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> chatMessages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
var tools = options?.Tools?.OfType<AIFunction>().ToList() ?? new List<AIFunction>();
var toolConfig = BuildToolConfig(tools);
var messages = ToBedrockMessages(chatMessages);
var response = await bedrock.ConverseAsync(
new ConverseRequest { ModelId = modelId, Messages = messages, ToolConfig = toolConfig },
cancellationToken);
var contents = new List<AIContent>();
foreach (var block in response.Output.Message.Content)
{
if (block.Text is not null)
contents.Add(new TextContent(block.Text));
if (block.ToolUse is not null)
contents.Add(new FunctionCallContent(
block.ToolUse.ToolUseId, block.ToolUse.Name, ToArguments(block.ToolUse.Input)));
}
return new ChatResponse(new ChatMessage(ChatRole.Assistant, contents));
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> chatMessages,
ChatOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var tools = options?.Tools?.OfType<AIFunction>().ToList() ?? new List<AIFunction>();
var toolConfig = BuildToolConfig(tools);
var messages = ToBedrockMessages(chatMessages);
var streamResponse = await bedrock.ConverseStreamAsync(
new ConverseStreamRequest { ModelId = modelId, Messages = messages, ToolConfig = toolConfig },
cancellationToken);
var currentToolUseId = string.Empty;
var currentToolName = string.Empty;
var currentToolInputJson = new StringBuilder();
await foreach (var evt in streamResponse.Stream)
{
if (evt is ContentBlockStartEvent startEvt && startEvt.Start?.ToolUse != null)
{
currentToolUseId = startEvt.Start.ToolUse.ToolUseId;
currentToolName = startEvt.Start.ToolUse.Name;
currentToolInputJson.Clear();
}
else if (evt is ContentBlockDeltaEvent deltaEvt)
{
if (deltaEvt.Delta?.Text != null)
{
yield return new ChatResponseUpdate
{
Role = ChatRole.Assistant,
Contents = [new TextContent(deltaEvt.Delta.Text)]
};
}
else if (deltaEvt.Delta?.ToolUse != null)
{
currentToolInputJson.Append(deltaEvt.Delta.ToolUse.Input);
}
}
else if (evt is ContentBlockStopEvent && !string.IsNullOrEmpty(currentToolUseId))
{
Document inputDoc;
try { inputDoc = ToDocument(JsonDocument.Parse(currentToolInputJson.ToString()).RootElement); }
catch { inputDoc = new Document(new Dictionary<string, Document>()); }
// ツールを自分で実行せず、FunctionCallContent を返すだけ。
// 実際の実行は UseFunctionInvocation() が担う。
yield return new ChatResponseUpdate
{
Role = ChatRole.Assistant,
Contents = [new FunctionCallContent(currentToolUseId, currentToolName, ToArguments(inputDoc))]
};
currentToolUseId = string.Empty;
currentToolName = string.Empty;
currentToolInputJson.Clear();
}
}
}
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public void Dispose()
{
}
// 会話履歴(テキスト・ツール呼び出し・ツール結果)を Bedrock の Message へ変換
private static List<Message> ToBedrockMessages(IEnumerable<ChatMessage> chatMessages)
{
var messages = new List<Message>();
foreach (var m in chatMessages)
{
var blocks = new List<ContentBlock>();
foreach (var content in m.Contents)
{
switch (content)
{
case TextContent text when !string.IsNullOrEmpty(text.Text):
blocks.Add(new ContentBlock { Text = text.Text });
break;
case FunctionCallContent call:
blocks.Add(new ContentBlock
{
ToolUse = new ToolUseBlock
{
ToolUseId = call.CallId,
Name = call.Name,
Input = ToDocument(call.Arguments)
}
});
break;
case FunctionResultContent result:
blocks.Add(new ContentBlock
{
ToolResult = new ToolResultBlock
{
ToolUseId = result.CallId,
Content = new List<ToolResultContentBlock>
{
new() { Text = result.Result?.ToString() ?? string.Empty }
}
}
});
break;
}
}
if (blocks.Count == 0) continue;
// Bedrock は user/assistant の2ロールしか持たないため、
// ツール結果(Toolロール)は user 発話として送る。
var role = m.Role == ChatRole.Assistant ? ConversationRole.Assistant : ConversationRole.User;
messages.Add(new Message { Role = role, Content = blocks });
}
return messages;
}
// ツール定義(名前・説明・スキーマ)を Bedrock に渡す形へ変換
private static ToolConfiguration? BuildToolConfig(List<AIFunction> tools) =>
tools.Count == 0 ? null : new ToolConfiguration
{
Tools = tools.Select(t => new Tool
{
ToolSpec = new ToolSpecification
{
Name = t.Name,
Description = string.IsNullOrEmpty(t.Description) ? t.Name : t.Description,
InputSchema = new ToolInputSchema { Json = ToDocument(t.JsonSchema) }
}
}).ToList()
};
// JsonElement → Bedrock Document の明示的な変換
private static Document ToDocument(JsonElement element) => element.ValueKind switch
{
JsonValueKind.Object => new Document(element.EnumerateObject()
.ToDictionary(p => p.Name, p => ToDocument(p.Value))),
JsonValueKind.Array => new Document(element.EnumerateArray().Select(ToDocument).ToList()),
JsonValueKind.String => new Document(element.GetString()),
JsonValueKind.Number => new Document(element.GetInt32()),
JsonValueKind.True => new Document(true),
JsonValueKind.False => new Document(false),
_ => new Document()
};
// FunctionCallContent.Arguments(辞書)→ Bedrock Document への変換
private static Document ToDocument(IDictionary<string, object?>? arguments)
{
if (arguments is null) return new Document(new Dictionary<string, Document>());
var json = JsonSerializer.SerializeToElement(arguments);
return ToDocument(json);
}
// Bedrock Document → FunctionCallContent.Arguments 用の辞書に変換
private static Dictionary<string, object?> ToArguments(Document input)
{
var arguments = new Dictionary<string, object?>();
if (!input.IsDictionary()) return arguments;
foreach (var kvp in input.AsDictionary())
{
arguments[kvp.Key] =
kvp.Value.IsString() ? kvp.Value.AsString() :
kvp.Value.IsInt() ? kvp.Value.AsInt() :
kvp.Value.ToString();
}
return arguments;
}
}
おわりに
MEAI 版からの移行で書き換えたのは、自前で持っていたループと定型コード(ツール実行ループ、手書きのツールスキーマ、進捗表示のための埋め込みコード)でした。
これらはそれぞれ UseFunctionInvocation()、AIFunctionFactory.Create() や McpClientTool の直接利用、ストリーム更新の監視に置き換わりました。
一方で、Bedrock Converse API との接続部分(BedrockChatClient)は自前のまま残りました。
MAF は IChatClient さえあればどの推論サービスでも動く設計なので、公式プロバイダーのない Bedrock でも、変換層を一枚書けば全機能に乗れます。
逆に言えば、この変換層の品質(ツール結果の往復、ストリーミングイベントの組み立て)が全体の信頼性を決めるため、ここにはテストを厚めに書いておくのがよさそうです。
中編では、file-based app をプロジェクトに変換し、Aspire 配下に置いてエージェントのツール呼び出しをダッシュボードで観測します。
-
プレビュー期の記事やサンプルでは
AgentThread/GetNewThread()という名前で登場します。GA までの間にAgentSession/CreateSessionAsync()にリネームされ、RunAsyncの引数名もthreadからsessionに変わりました。プレビュー期のコードを参考にする場合は読み替えが必要です。 ↩ -
Python 版には
BedrockChatClientが提供されています。.NET では Bedrock の OpenAI 互換エンドポイントをMicrosoft.Agents.AI.OpenAI経由で使う方法もありますが、対応モデルが限られるため本記事では Converse API を使う構成を維持します。 ↩