このチュートリアルでは、.NETで AG-UI を使用して、人間のループ内承認ワークフローを実装する方法について説明します。 .NET実装では、Microsoft.Extensions.AI のApprovalRequiredAIFunctionを使用して、承認要求をクライアントが処理し応答するための AG-UI「クライアントツール呼び出し」に変換します。
概要
C# AG-UI 承認パターンは次のように動作します。
-
サーバー: 関数を
ApprovalRequiredAIFunctionでラップし、承認が必要とマークします -
ミドルウェア: エージェントから
FunctionApprovalRequestContentをインターセプトし、クライアント ツール呼び出しに変換します - クライアント: ツールの呼び出しを受信し、承認 UI を表示し、承認応答をツールの結果として送信します
-
ミドルウェア: 承認応答を解き、それを
FunctionApprovalResponseContentに変換します。 - エージェント: ユーザーの承認の決定を使用して実行を続行します
[前提条件]
- デプロイされたモデルを含むAzure OpenAIリソース
- 環境変数:
AZURE_OPENAI_ENDPOINTAZURE_OPENAI_DEPLOYMENT_NAME
- バックエンド ツールのレンダリングについて
サーバーの実装
承認が必要なツールの定義
関数を作成し、 ApprovalRequiredAIFunctionでラップします。
using System.ComponentModel;
using Microsoft.Extensions.AI;
[Description("Send an email to a recipient.")]
static string SendEmail(
[Description("The email address to send to")] string to,
[Description("The subject line")] string subject,
[Description("The email body")] string body)
{
return $"Email sent to {to} with subject '{subject}'";
}
// Create approval-required tool
#pragma warning disable MEAI001 // Type is for evaluation purposes only
AITool[] tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SendEmail))];
#pragma warning restore MEAI001
承認モデルを作成する
承認要求と応答のモデルを定義します。
using System.Text.Json.Serialization;
public sealed class ApprovalRequest
{
[JsonPropertyName("approval_id")]
public required string ApprovalId { get; init; }
[JsonPropertyName("function_name")]
public required string FunctionName { get; init; }
[JsonPropertyName("function_arguments")]
public JsonElement? FunctionArguments { get; init; }
[JsonPropertyName("message")]
public string? Message { get; init; }
}
public sealed class ApprovalResponse
{
[JsonPropertyName("approval_id")]
public required string ApprovalId { get; init; }
[JsonPropertyName("approved")]
public required bool Approved { get; init; }
}
[JsonSerializable(typeof(ApprovalRequest))]
[JsonSerializable(typeof(ApprovalResponse))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
internal partial class ApprovalJsonContext : JsonSerializerContext
{
}
承認ミドルウェアを実装する
Microsoft.Extensions.AI 承認の種類と AG-UI プロトコルの間で変換するミドルウェアを作成します。
Important
承認応答を変換した後、 request_approval ツールの呼び出しとその結果の両方をメッセージ履歴から削除する必要があります。 それ以外の場合、OpenAI Azureは"tool_callsの後に各 'tool_call_id' に応答するツール メッセージが続く必要があります" というエラーを返します。
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
// Get JsonSerializerOptions from the configured HTTP JSON options
var jsonOptions = app.Services.GetRequiredService<IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>().Value;
var agent = baseAgent
.AsBuilder()
.Use(runFunc: null, runStreamingFunc: (messages, session, options, innerAgent, cancellationToken) =>
HandleApprovalRequestsMiddleware(
messages,
session,
options,
innerAgent,
jsonOptions.SerializerOptions,
cancellationToken))
.Build();
static async IAsyncEnumerable<AgentResponseUpdate> HandleApprovalRequestsMiddleware(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
AIAgent innerAgent,
JsonSerializerOptions jsonSerializerOptions,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// Process messages: Convert approval responses back to agent format
var modifiedMessages = ConvertApprovalResponsesToFunctionApprovals(messages, jsonSerializerOptions);
// Invoke inner agent
await foreach (var update in innerAgent.RunStreamingAsync(
modifiedMessages, session, options, cancellationToken))
{
// Process updates: Convert approval requests to client tool calls
await foreach (var processedUpdate in ConvertFunctionApprovalsToToolCalls(update, jsonSerializerOptions))
{
yield return processedUpdate;
}
}
// Local function: Convert approval responses from client back to FunctionApprovalResponseContent
static IEnumerable<ChatMessage> ConvertApprovalResponsesToFunctionApprovals(
IEnumerable<ChatMessage> messages,
JsonSerializerOptions jsonSerializerOptions)
{
// Look for "request_approval" tool calls and their matching results
Dictionary<string, FunctionCallContent> approvalToolCalls = [];
FunctionResultContent? approvalResult = null;
foreach (var message in messages)
{
foreach (var content in message.Contents)
{
if (content is FunctionCallContent { Name: "request_approval" } toolCall)
{
approvalToolCalls[toolCall.CallId] = toolCall;
}
else if (content is FunctionResultContent result && approvalToolCalls.ContainsKey(result.CallId))
{
approvalResult = result;
}
}
}
// If no approval response found, return messages unchanged
if (approvalResult == null)
{
return messages;
}
// Deserialize the approval response
if ((approvalResult.Result as JsonElement?)?.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse))) is not ApprovalResponse response)
{
return messages;
}
// Extract the original function call details from the approval request
var originalToolCall = approvalToolCalls[approvalResult.CallId];
if (originalToolCall.Arguments?.TryGetValue("request", out JsonElement request) != true ||
request.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalRequest))) is not ApprovalRequest approvalRequest)
{
return messages;
}
// Deserialize the function arguments from JsonElement
var functionArguments = approvalRequest.FunctionArguments is { } args
? (Dictionary<string, object?>?)args.Deserialize(
jsonSerializerOptions.GetTypeInfo(typeof(Dictionary<string, object?>)))
: null;
var originalFunctionCall = new FunctionCallContent(
callId: response.ApprovalId,
name: approvalRequest.FunctionName,
arguments: functionArguments);
var functionApprovalResponse = new FunctionApprovalResponseContent(
response.ApprovalId,
response.Approved,
originalFunctionCall);
// Replace/remove the approval-related messages
List<ChatMessage> newMessages = [];
foreach (var message in messages)
{
bool hasApprovalResult = false;
bool hasApprovalRequest = false;
foreach (var content in message.Contents)
{
if (content is FunctionResultContent { CallId: var callId } && callId == approvalResult.CallId)
{
hasApprovalResult = true;
break;
}
if (content is FunctionCallContent { Name: "request_approval", CallId: var reqCallId } && reqCallId == approvalResult.CallId)
{
hasApprovalRequest = true;
break;
}
}
if (hasApprovalResult)
{
// Replace tool result with approval response
newMessages.Add(new ChatMessage(ChatRole.User, [functionApprovalResponse]));
}
else if (hasApprovalRequest)
{
// Skip the request_approval tool call message
continue;
}
else
{
newMessages.Add(message);
}
}
return newMessages;
}
// Local function: Convert FunctionApprovalRequestContent to client tool calls
static async IAsyncEnumerable<AgentResponseUpdate> ConvertFunctionApprovalsToToolCalls(
AgentResponseUpdate update,
JsonSerializerOptions jsonSerializerOptions)
{
// Check if this update contains a FunctionApprovalRequestContent
FunctionApprovalRequestContent? approvalRequestContent = null;
foreach (var content in update.Contents)
{
if (content is FunctionApprovalRequestContent request)
{
approvalRequestContent = request;
break;
}
}
// If no approval request, yield the update unchanged
if (approvalRequestContent == null)
{
yield return update;
yield break;
}
// Convert the approval request to a "client tool call"
var functionCall = approvalRequestContent.FunctionCall;
var approvalId = approvalRequestContent.Id;
// Serialize the function arguments as JsonElement
var argsElement = functionCall.Arguments?.Count > 0
? JsonSerializer.SerializeToElement(functionCall.Arguments, jsonSerializerOptions.GetTypeInfo(typeof(IDictionary<string, object?>)))
: (JsonElement?)null;
var approvalData = new ApprovalRequest
{
ApprovalId = approvalId,
FunctionName = functionCall.Name,
FunctionArguments = argsElement,
Message = $"Approve execution of '{functionCall.Name}'?"
};
var approvalJson = JsonSerializer.Serialize(approvalData, jsonSerializerOptions.GetTypeInfo(typeof(ApprovalRequest)));
// Yield a tool call update that represents the approval request
yield return new AgentResponseUpdate(ChatRole.Assistant, [
new FunctionCallContent(
callId: approvalId,
name: "request_approval",
arguments: new Dictionary<string, object?> { ["request"] = approvalJson })
]);
}
}
クライアントの実装
Client-Side ミドルウェアを実装する
クライアントには、両方を処理する 双方向ミドルウェア が必要です。
-
受信:
request_approvalツール呼び出しのFunctionApprovalRequestContentへの変換 -
送信:
FunctionApprovalResponseContentをツールの出力結果に変換する
Important
AdditionalPropertiesオブジェクトのAIContentを使用して、承認要求と応答の相関関係を追跡し、外部状態ディクショナリを回避します。
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.AGUI;
using Microsoft.Extensions.AI;
// Get JsonSerializerOptions from the client
var jsonSerializerOptions = JsonSerializerOptions.Default;
#pragma warning disable MEAI001 // Type is for evaluation purposes only
// Wrap the agent with approval middleware
var wrappedAgent = agent
.AsBuilder()
.Use(runFunc: null, runStreamingFunc: (messages, session, options, innerAgent, cancellationToken) =>
HandleApprovalRequestsClientMiddleware(
messages,
session,
options,
innerAgent,
jsonSerializerOptions,
cancellationToken))
.Build();
static async IAsyncEnumerable<AgentResponseUpdate> HandleApprovalRequestsClientMiddleware(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
AIAgent innerAgent,
JsonSerializerOptions jsonSerializerOptions,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// Process messages: Convert approval responses back to tool results
var processedMessages = ConvertApprovalResponsesToToolResults(messages, jsonSerializerOptions);
// Invoke inner agent
await foreach (var update in innerAgent.RunStreamingAsync(processedMessages, session, options, cancellationToken))
{
// Process updates: Convert tool calls to approval requests
await foreach (var processedUpdate in ConvertToolCallsToApprovalRequests(update, jsonSerializerOptions))
{
yield return processedUpdate;
}
}
// Local function: Convert FunctionApprovalResponseContent back to tool results
static IEnumerable<ChatMessage> ConvertApprovalResponsesToToolResults(
IEnumerable<ChatMessage> messages,
JsonSerializerOptions jsonSerializerOptions)
{
List<ChatMessage> processedMessages = [];
foreach (var message in messages)
{
List<AIContent> convertedContents = [];
bool hasApprovalResponse = false;
foreach (var content in message.Contents)
{
if (content is FunctionApprovalResponseContent approvalResponse)
{
hasApprovalResponse = true;
// Get the original request_approval CallId from AdditionalProperties
if (approvalResponse.AdditionalProperties?.TryGetValue("request_approval_call_id", out string? requestApprovalCallId) == true)
{
var response = new ApprovalResponse
{
ApprovalId = approvalResponse.Id,
Approved = approvalResponse.Approved
};
var responseJson = JsonSerializer.SerializeToElement(response, jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse)));
var toolResult = new FunctionResultContent(
callId: requestApprovalCallId,
result: responseJson);
convertedContents.Add(toolResult);
}
}
else
{
convertedContents.Add(content);
}
}
if (hasApprovalResponse && convertedContents.Count > 0)
{
processedMessages.Add(new ChatMessage(ChatRole.Tool, convertedContents));
}
else
{
processedMessages.Add(message);
}
}
return processedMessages;
}
// Local function: Convert request_approval tool calls to FunctionApprovalRequestContent
static async IAsyncEnumerable<AgentResponseUpdate> ConvertToolCallsToApprovalRequests(
AgentResponseUpdate update,
JsonSerializerOptions jsonSerializerOptions)
{
FunctionCallContent? approvalToolCall = null;
foreach (var content in update.Contents)
{
if (content is FunctionCallContent { Name: "request_approval" } toolCall)
{
approvalToolCall = toolCall;
break;
}
}
if (approvalToolCall == null)
{
yield return update;
yield break;
}
if (approvalToolCall.Arguments?.TryGetValue("request", out JsonElement request) != true ||
request.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalRequest))) is not ApprovalRequest approvalRequest)
{
yield return update;
yield break;
}
var functionArguments = approvalRequest.FunctionArguments is { } args
? (Dictionary<string, object?>?)args.Deserialize(
jsonSerializerOptions.GetTypeInfo(typeof(Dictionary<string, object?>)))
: null;
var originalFunctionCall = new FunctionCallContent(
callId: approvalRequest.ApprovalId,
name: approvalRequest.FunctionName,
arguments: functionArguments);
// Yield the original tool call first (for message history)
yield return new AgentResponseUpdate(ChatRole.Assistant, [approvalToolCall]);
// Create approval request with CallId stored in AdditionalProperties
var approvalRequestContent = new FunctionApprovalRequestContent(
approvalRequest.ApprovalId,
originalFunctionCall);
// Store the request_approval CallId in AdditionalProperties for later retrieval
approvalRequestContent.AdditionalProperties ??= new Dictionary<string, object?>();
approvalRequestContent.AdditionalProperties["request_approval_call_id"] = approvalToolCall.CallId;
yield return new AgentResponseUpdate(ChatRole.Assistant, [approvalRequestContent]);
}
}
#pragma warning restore MEAI001
承認要求の処理と応答の送信
使用しているコードは承認要求を処理し、承認が不要になるまで自動的に続行されます。
承認要求の処理と応答の送信
使用しているコードは、承認要求を処理します。
FunctionApprovalRequestContentを受信するときは、応答の AdditionalProperties に request_approval CallId を格納します。
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.AGUI;
using Microsoft.Extensions.AI;
#pragma warning disable MEAI001 // Type is for evaluation purposes only
List<AIContent> approvalResponses = [];
List<FunctionCallContent> approvalToolCalls = [];
do
{
approvalResponses.Clear();
approvalToolCalls.Clear();
await foreach (AgentResponseUpdate update in wrappedAgent.RunStreamingAsync(
messages, session, cancellationToken: cancellationToken))
{
foreach (AIContent content in update.Contents)
{
if (content is FunctionApprovalRequestContent approvalRequest)
{
DisplayApprovalRequest(approvalRequest);
// Get user approval
Console.Write($"\nApprove '{approvalRequest.FunctionCall.Name}'? (yes/no): ");
string? userInput = Console.ReadLine();
bool approved = userInput?.ToUpperInvariant() is "YES" or "Y";
// Create approval response and preserve the request_approval CallId
var approvalResponse = approvalRequest.CreateResponse(approved);
// Copy AdditionalProperties to preserve the request_approval_call_id
if (approvalRequest.AdditionalProperties != null)
{
approvalResponse.AdditionalProperties ??= new Dictionary<string, object?>();
foreach (var kvp in approvalRequest.AdditionalProperties)
{
approvalResponse.AdditionalProperties[kvp.Key] = kvp.Value;
}
}
approvalResponses.Add(approvalResponse);
}
else if (content is FunctionCallContent { Name: "request_approval" } requestApprovalCall)
{
// Track the original request_approval tool call
approvalToolCalls.Add(requestApprovalCall);
}
else if (content is TextContent textContent)
{
Console.Write(textContent.Text);
}
}
}
// Add both messages in correct order
if (approvalResponses.Count > 0 && approvalToolCalls.Count > 0)
{
messages.Add(new ChatMessage(ChatRole.Assistant, approvalToolCalls.ToArray()));
messages.Add(new ChatMessage(ChatRole.User, approvalResponses.ToArray()));
}
}
while (approvalResponses.Count > 0);
#pragma warning restore MEAI001
static void DisplayApprovalRequest(FunctionApprovalRequestContent approvalRequest)
{
Console.WriteLine();
Console.WriteLine("============================================================");
Console.WriteLine("APPROVAL REQUIRED");
Console.WriteLine("============================================================");
Console.WriteLine($"Function: {approvalRequest.FunctionCall.Name}");
if (approvalRequest.FunctionCall.Arguments != null)
{
Console.WriteLine("Arguments:");
foreach (var arg in approvalRequest.FunctionCall.Arguments)
{
Console.WriteLine($" {arg.Key} = {arg.Value}");
}
}
Console.WriteLine("============================================================");
}
相互作用の例
User (:q or quit to exit): Send an email to user@example.com about the meeting
[Run Started - Thread: thread_abc123, Run: run_xyz789]
============================================================
APPROVAL REQUIRED
============================================================
Function: SendEmail
Arguments: {"to":"user@example.com","subject":"Meeting","body":"..."}
Message: Approve execution of 'SendEmail'?
============================================================
[Waiting for approval to execute SendEmail...]
[Run Finished - Thread: thread_abc123]
Approve this action? (yes/no): yes
[Sending approval response: APPROVED]
[Run Resumed - Thread: thread_abc123]
Email sent to user@example.com with subject 'Meeting'
[Run Finished]
主要概念
クライアント ツール パターン
C# の実装では、"クライアント ツール呼び出し" パターンを使用します。
-
承認要求 →ツールの呼び出し (承認の詳細を含む
"request_approval") - ユーザーの決定を含む承認応答→ ツールの結果
- Middleware → Microsoft.Extensions.AI 型と AG-UI プロトコルの間で変換されます
これにより、エージェント フレームワークの承認モデルとの一貫性を維持しながら、標準の ApprovalRequiredAIFunction パターンを HTTP+SSE 境界を越えて動作できます。
双方向ミドルウェア パターン
サーバーとクライアントの両方のミドルウェアは、一貫した 3 段階のパターンに従います。
- メッセージの処理: 受信メッセージを変換する (FunctionApprovalResponseContent またはツールの結果→承認応答)
- 内部エージェントの呼び出し: 処理されたメッセージを使用して内部エージェントを呼び出す
- プロセスアップデート: 送信される更新の変換(FunctionApprovalRequestContent → ツール呼び出し、またはその逆)
状態追跡 (State Tracking) を AdditionalProperties で実施します
この実装では、外部ディクショナリの代わりに、AdditionalProperties オブジェクトのAIContentを使用してメタデータを追跡します。
-
クライアント:
request_approval_call_idをFunctionApprovalRequestContent.AdditionalPropertiesに格納します。 -
応答の保持:
AdditionalPropertiesを要求から応答にコピーして、相関関係を維持します -
変換: 格納されている CallId を使用して、適切に関連付けられた値を作成します。
FunctionResultContent
これにより、すべての相関関係データがコンテンツ オブジェクト自体に保持されるため、外部状態管理が不要になります。
Server-Side メッセージのクリーンアップ
サーバー ミドルウェアは、処理後に承認プロトコル メッセージを削除する必要があります。
- Problem: Azure OpenAI では、すべてのツール呼び出しに一致するツールの結果が必要です
-
解決策: 承認応答を変換した後、
request_approvalツールの呼び出しとその結果メッセージの両方を削除します - 理由: "tool_callsの後にツール メッセージが続く必要があります" というエラーを防ぎます
次のステップ
このチュートリアルでは、ユーザーが実行する前にツールの実行を承認する必要がある AG UI を使用して、ループ内の人間のワークフローを実装する方法について説明します。 これは、財務トランザクション、データの変更、重大な影響を及ぼすアクションなどの機密性の高い操作に不可欠です。
[前提条件]
開始する前に、 バックエンド ツールレンダリング のチュートリアルを完了していることを確認し、次のことを理解してください。
- 関数ツールを作成する方法
- AG-UIがツールイベントをストリームする方法
- 基本的なサーバーとクライアントのセットアップ
Human-in-the-Loop とは
Human-in-the-Loop (HITL) は、エージェントが特定の操作を実行する前にユーザーの承認を要求するパターンです。 AG-UI の場合:
- エージェントが通常どおりツール呼び出しを生成する
- サーバーは、すぐに実行するのではなく、承認要求をクライアントに送信します。
- クライアントは要求を表示し、ユーザーにメッセージを表示します。
- ユーザーがアクションを承認または拒否する
- サーバーは応答を受信し、それに応じて処理を続行します。
メリット
- 安全性: 意図しないアクションが実行されないようにする
- 透明性:ユーザーはエージェントが何をしたいかを正確に確認します
- 制御: ユーザーは機密性の高い操作に関して最終的な決定を下します
- コンプライアンス: 人による監視に関する規制要件を満たす
承認のためのマーキング ツール
ツールの承認を要求するには、approval_mode デコレーターで @tool パラメーターを使用します。
from agent_framework import tool
from typing import Annotated
from pydantic import Field
@tool(approval_mode="always_require")
def send_email(
to: Annotated[str, Field(description="Email recipient address")],
subject: Annotated[str, Field(description="Email subject line")],
body: Annotated[str, Field(description="Email body content")],
) -> str:
"""Send an email to the specified recipient."""
# Send email logic here
return f"Email sent to {to} with subject '{subject}'"
@tool(approval_mode="always_require")
def delete_file(
filepath: Annotated[str, Field(description="Path to the file to delete")],
) -> str:
"""Delete a file from the filesystem."""
# Delete file logic here
return f"File {filepath} has been deleted"
承認モード
-
always_require: 常に実行前に承認を要求する -
never_require: 承認を要求しない (既定の動作) -
conditional: 特定の条件に基づいて承認を要求する (カスタム ロジック)
Human-in-the-Loop を使用したサーバーの作成
承認が必要なツールを使用した完全なサーバー実装を次に示します。
"""AG-UI server with human-in-the-loop."""
import os
from typing import Annotated
from agent_framework import Agent, tool
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework_ag_ui import AgentFrameworkAgent, add_agent_framework_fastapi_endpoint
from azure.identity import AzureCliCredential
from fastapi import FastAPI
from pydantic import Field
# Tools that require approval
@tool(approval_mode="always_require")
def transfer_money(
from_account: Annotated[str, Field(description="Source account number")],
to_account: Annotated[str, Field(description="Destination account number")],
amount: Annotated[float, Field(description="Amount to transfer")],
currency: Annotated[str, Field(description="Currency code")] = "USD",
) -> str:
"""Transfer money between accounts."""
return f"Transferred {amount} {currency} from {from_account} to {to_account}"
@tool(approval_mode="always_require")
def cancel_subscription(
subscription_id: Annotated[str, Field(description="Subscription identifier")],
) -> str:
"""Cancel a subscription."""
return f"Subscription {subscription_id} has been cancelled"
# Regular tools (no approval required)
@tool
def check_balance(
account: Annotated[str, Field(description="Account number")],
) -> str:
"""Check account balance."""
# Simulated balance check
return f"Account {account} balance: $5,432.10 USD"
# Read required configuration
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
deployment_name = os.environ.get("AZURE_OPENAI_CHAT_COMPLETION_MODEL")
if not endpoint:
raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required")
if not deployment_name:
raise ValueError("AZURE_OPENAI_CHAT_COMPLETION_MODEL environment variable is required")
chat_client = OpenAIChatCompletionClient(
model=deployment_name,
azure_endpoint=endpoint,
api_version=os.getenv("AZURE_OPENAI_API_VERSION"),
credential=AzureCliCredential(),
)
# Create agent with tools
agent = Agent(
name="BankingAssistant",
instructions="You are a banking assistant. Help users with their banking needs. Always confirm details before performing transfers.",
client=chat_client,
tools=[transfer_money, cancel_subscription, check_balance],
)
# Wrap agent to enable human-in-the-loop
wrapped_agent = AgentFrameworkAgent(
agent=agent,
require_confirmation=True, # Enable human-in-the-loop
)
# Create FastAPI app
app = FastAPI(title="AG-UI Banking Assistant")
add_agent_framework_fastapi_endpoint(app, wrapped_agent, "/")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8888)
主要概念
-
AgentFrameworkAgentラッパー: human-in-the-loop などの AG-UI プロトコル機能を有効にします -
require_confirmation=True: マークされたツールの承認ワークフローをアクティブ化します -
ツール レベルの制御:
approval_mode="always_require"でマークされたツールのみが承認を要求します
承認の中断について
ツールで承認が必要な場合は、正規の AG-UI 割り込みで実行が完了します。
承認中断
{
"type": "RUN_FINISHED",
"threadId": "thread-1",
"runId": "run-1",
"outcome": {
"type": "interrupt",
"interrupts": [
{
"id": "approval-1",
"reason": "tool_call",
"message": "Approve tool call transfer_money?",
"toolCallId": "call-1",
"responseSchema": {
"type": "object",
"properties": {
"accepted": { "type": "boolean" },
"arguments": { "type": "object" }
},
"required": ["accepted"]
},
"metadata": {
"agent_framework": {
"type": "function_approval_request",
"function_call": {
"call_id": "call-1",
"name": "transfer_money",
"arguments": {
"from_account": "1234567890",
"to_account": "0987654321",
"amount": 500.00,
"currency": "USD"
}
}
}
}
}
]
}
}
ツールの承認割り込みでは、 reason: "tool_call" が使用され、 toolCallIdが含まれます。
ChatResponseUpdateからの最後のAGUIChatClientでは、outcomeとinterruptsの値がadditional_propertiesに保持されます。
Interrupt および ResumeEntry は、エージェント フレームワーク固有のモデルではなく、 ag_ui.coreからのプロトコルの種類です。
履歴書の形式
正規の resume 配列で同じスレッドを再開します。
accepted: falseを使用して、エージェントの続行を許可しながら操作を拒否します。 ペイロードなしで status: "cancelled" を使用して、中断された実行を取り消します。
{
"threadId": "thread-1",
"messages": [],
"resume": [
{
"interruptId": "approval-1",
"status": "resolved",
"payload": {
"accepted": true
}
}
]
}
認可サポートを持つクライアント
承認要求を処理する AGUIChatClient を使用するクライアントを次に示します。
"""AG-UI client with human-in-the-loop support."""
import asyncio
import os
from agent_framework import Agent
from agent_framework_ag_ui import AGUIChatClient
def display_approval_request(update) -> None:
"""Display approval request details to the user."""
print("\n\033[93m" + "=" * 60 + "\033[0m")
print("\033[93mAPPROVAL REQUIRED\033[0m")
print("\033[93m" + "=" * 60 + "\033[0m")
# Display tool call details from update contents
for i, content in enumerate(update.contents, 1):
if content.type == "function_approval_request":
function_call = content.function_call
print(f"\nAction {i}:")
print(f" Tool: \033[95m{function_call.name}\033[0m")
print(f" Arguments:")
for key, value in (function_call.arguments or {}).items():
print(f" {key}: {value}")
print("\n\033[93m" + "=" * 60 + "\033[0m")
async def main():
"""Main client loop with approval handling."""
server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:8888/")
print(f"Connecting to AG-UI server at: {server_url}\n")
# Create AG-UI chat client
chat_client = AGUIChatClient(endpoint=server_url)
# Create agent with the chat client
agent = Agent(
name="ClientAgent",
client=chat_client,
instructions="You are a helpful assistant.",
)
# Get a thread for conversation continuity
thread = agent.create_session()
try:
while True:
message = input("\nUser (:q or quit to exit): ")
if not message.strip():
continue
if message.lower() in (":q", "quit"):
break
print("\nAssistant: ", end="", flush=True)
pending_interrupts = []
async for update in agent.run(message, session=thread, stream=True):
# Check if this update carries an approval request.
if any(content.type == "function_approval_request" for content in update.contents):
display_approval_request(update)
if update.text:
print(f"\033[96m{update.text}\033[0m", end="", flush=True)
properties = update.additional_properties or {}
outcome = properties.get("outcome")
if isinstance(outcome, dict) and outcome.get("type") == "interrupt":
pending_interrupts = outcome.get("interrupts", [])
if pending_interrupts:
resume_entries = []
for interrupt in pending_interrupts:
prompt = interrupt.get("message", "Approve this action?")
user_choice = input(f"\n{prompt} (yes/no): ").strip().lower()
resume_entries.append({
"interruptId": interrupt["id"],
"status": "resolved",
"payload": {"accepted": user_choice in ("yes", "y")},
})
print("\nAssistant: ", end="", flush=True)
async for update in agent.run(
[],
session=thread,
stream=True,
options={
"available_interrupts": pending_interrupts,
"resume": resume_entries,
},
):
if update.text:
print(f"\033[96m{update.text}\033[0m", end="", flush=True)
print()
except KeyboardInterrupt:
print("\n\nExiting...")
except Exception as e:
print(f"\n\033[91mError: {e}\033[0m")
if __name__ == "__main__":
asyncio.run(main())
相互作用の例
サーバーとクライアントが実行されている場合:
User (:q or quit to exit): Transfer $500 from account 1234567890 to account 0987654321
[Run Started]
============================================================
APPROVAL REQUIRED
============================================================
Action 1:
Tool: transfer_money
Arguments:
from_account: 1234567890
to_account: 0987654321
amount: 500.0
currency: USD
============================================================
Approve this action? (yes/no): yes
[Sending approval response: True]
[Tool Result: Transferred 500.0 USD from 1234567890 to 0987654321]
The transfer of $500 from account 1234567890 to account 0987654321 has been completed successfully.
[Run Finished]
ユーザーが拒否した場合:
Approve this action? (yes/no): no
[Sending approval response: False]
I understand. The transfer has been cancelled and no money was moved.
[Run Finished]
カスタム確認メッセージ
サーバーからの承認割り込みをレンダリングするときに、AG-UI クライアント UI で承認メッセージと確認メッセージをカスタマイズします。 Python AgentFrameworkAgentは、承認要求と割り込みメタデータを公開します。サーバー側の確認戦略オブジェクトは取得しません。
ベスト プラクティス
ツールの説明を明確にする
ユーザーが承認内容を理解できるように、詳細な説明を入力します。
@tool(approval_mode="always_require")
def delete_database(
database_name: Annotated[str, Field(description="Name of the database to permanently delete")],
) -> str:
"""
Permanently delete a database and all its contents.
WARNING: This action cannot be undone. All data in the database will be lost.
Use with extreme caution.
"""
# Implementation
pass
詳細な承認
バッチ処理ではなく、個々の機密性の高いアクションの承認を要求します。
# Good: Individual approval per transfer
@tool(approval_mode="always_require")
def transfer_money(...): pass
# Avoid: Batching multiple sensitive operations
# Users should approve each operation separately
有益な議論
説明的なパラメーター名を使用し、コンテキストを指定します。
@tool(approval_mode="always_require")
def purchase_item(
item_name: Annotated[str, Field(description="Name of the item to purchase")],
quantity: Annotated[int, Field(description="Number of items to purchase")],
price_per_item: Annotated[float, Field(description="Price per item in USD")],
total_cost: Annotated[float, Field(description="Total cost including tax and shipping")],
) -> str:
"""Purchase items from the store."""
pass
タイムアウト処理
承認要求に適切なタイムアウトを設定します。
# Client side
async with httpx.AsyncClient(timeout=120.0) as client: # 2 minutes for user to respond
# Handle approval
pass
選択的承認
承認が必要なツールと必要でないツールを組み合わせることができます。
# No approval needed for read-only operations
@tool
def get_account_balance(...): pass
@tool
def list_transactions(...): pass
# Approval required for write operations
@tool(approval_mode="always_require")
def transfer_funds(...): pass
@tool(approval_mode="always_require")
def close_account(...): pass
バッチ承認と取り消し
1 つのモデル応答には、承認が必要なツールと、承認を必要としないツールの両方を含めることができます。 目に見える割り込みを解決すると、承認の決定に従って、そのバッチからの他のツール呼び出しも完了します。 たとえば、承認が必要な兄弟要素が拒否された場合でも、never_require の兄弟要素は実行され、その TOOL_CALL_RESULT は再開後の実行でストリーミングされます。
status: "cancelled"でキャンセルすると、承認の再開が中止され、スレッドのキューに登録された承認状態がクリアされます。
それ以降の要求では、取り消されたバッチから古いツール呼び出しを再表面化したり、実行したりすることはできません。
次のステップ
その他のリソース
Go は、承認必須のツールを備えた AG-UI の human-in-the-loop フローをサポートしています。 関数ツールを tool.ApprovalRequiredFuncでラップし、 aguiproviderを使用してエージェントをホストします。
approveExpense := functool.MustNew(functool.Config{
Name: "approve_expense_report",
Description: "Approve the expense report.",
}, func(ctx context.Context, expenseReportID string) (string, error) {
return fmt.Sprintf("Expense report %s approved", expenseReportID), nil
})
a := foundryprovider.NewAgent(endpoint, token, foundryprovider.ModelDeployment(model), foundryprovider.AgentConfig{
Config: agent.Config{
Tools: []tool.Tool{tool.ApprovalRequiredFunc(approveExpense)},
},
})
Tip
完全な実行可能な例については、 AG-UI human-in-the-loop サンプル を参照してください。