在AG-UI详解-04:如何构建一个兼容AG-UI协议的Agent中,我通过在ASP.NET Core应用中注册了一个绑定到IChatClient的路由中间件,构建了一个兼容AG-UI协议的Agent后端。虽然AG-UI协议定义的很多功能未曾实现,但是它与MAF与AG-UI集成的实现原理本质上是一致的。现在我们来看看微软官方是如何将一个MAF AIAgent暴露成一个兼容AG-UI的后端服务的。
1. 基于AG-UI的路由注册
和我的做法一样,微软官方也是通过在ASP.NET Core应用中注册相应路由终结点的方式来构建兼容AG-UI兼容Agent后端服务的。相应的API定义在Microsoft.Agents.AI.Hosting.AGUI.AspNetCore这个NuGet包中。值得一提的是,这部分的内容在最近发生了重大的改变,主要体现在针对AG-UI基础功能的依赖全部转移到AG-UI官方SDK上(之前微软自行实现了一套)。包含最新API的NuGet尚未发布,所以如下的实例使用的是直接下载的源代码,而不是应用的NuGet包。
路由注册实现在这个MapAGUIServer扩展方法上,和我在AG-UI详解-04:如何构建一个兼容AG-UI协议的Agent中演示的不同之处在于,这里绑定的是一个AIAgent对象,而我使用的是一个IChatClient对象。另一个AddAGUIServer扩展方法用于注册所需的依赖服务,目前只要体现在针对AG-UI兼容JsonOptions的设置。
csharp
public static IServiceCollection AddAGUIServer(this IServiceCollection services);
public static IEndpointConventionBuilder MapAGUIServer(
this IEndpointRouteBuilder endpoints,
[StringSyntax("route")] string pattern,
AIAgent aiAgent);
我们指定的AIAgent最终将会使用如下这个AIHostAgent类型的Agent中间件进行封装,其目的在于利用注册的AgentSessionStore上下Session的持久化。如果没有注册AgentSessionStore,默认会创建一个不具有持久化能力的NoopAgentSessionStore对象,并以IsolationKeyScopedAgentSessionStore作进一步封装。
csharp
public class AIHostAgent : DelegatingAIAgent
{
private readonly AgentSessionStore _sessionStore;
public AIHostAgent(AIAgent innerAgent, AgentSessionStore sessionStore)
: base(innerAgent)
=>_sessionStore = sessionStore
public ValueTask<AgentSession> GetOrCreateSessionAsync(string conversationId, CancellationToken cancellationToken = default)
=> _sessionStore.GetSessionAsync(this.InnerAgent, conversationId, cancellationToken);
public ValueTask SaveSessionAsync(string conversationId, AgentSession session, CancellationToken cancellationToken = default)
=>_sessionStore.SaveSessionAsync(this.InnerAgent, conversationId, session, cancellationToken);
}
public abstract class AgentSessionStore
{
public abstract ValueTask SaveSessionAsync(
AIAgent agent,
string conversationId,
AgentSession session,
CancellationToken cancellationToken = default);
public abstract ValueTask<AgentSession> GetSessionAsync(
AIAgent agent,
string conversationId,
CancellationToken cancellationToken = default);
}
总体来说,注册的路由终结点会采用如下的流程来处理请求:
- 提取作为输入的
RunAgentInput作为当前请求上下文; - 将
RunAgentInput携带的ThreadId(如果没有就创建一个新的)作为输入调用AIHostAgent的GetOrCreateSessionAsync方法创建或者提取AgentSession对象; - 将
RunAgentInput携带的AGUIMessage列表转换成ChatMessage列表,连同AgentSession作为参数调用AIHostAgent的RunStreamingAsync方法。 - 将作为返回值的
IAsyncEnumerable<AgentResponseUpdate>转换成IAsyncEnumerable<ChatResponseUpdate>; - 再将
IAsyncEnumerable<ChatResponseUpdate>转换成IAsyncEnumerable<BaseEvent> - 按照
SSE协议将BaseEvent序列化成JSON依次输出。
上述流程作核心的部分莫过于将IAsyncEnumerable<ChatResponseUpdate>转换成IAsyncEnumerable<BaseEvent>,AG-UI详解-04:如何构建一个兼容AG-UI协议的Agent也提供了对这一流程的模拟,只是我作为演示仅仅考虑了少数几种AIContent类型而已。接下来我们通过实例演示的方式,看看针对不同应用场景生成的AIContent最终都转换成怎样的AG-UI事件。
2. 常规调用
我们看看最常规的调用会生成怎样的AG-UI事件。如下面的演示程序所示,我们针对OpenAIClient创建了一个AIAgent,并将其作为参数调用了上述的MapAGUIServer扩展方法注册了针对根路径的AG-UI路由。我们直接创建对应的RunAgentInput对象作为输入,并利用HttpClient将输入序列化成JSON发送到AG-UI服务应用的监听地址,并将相应的内容直接输出到控制台上。
csharp
using AGUI.Abstractions;
using DotNetEnv;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Chat;
using System.ClientModel;
Env.Load();
var endpoint = Environment.GetEnvironmentVariable("OPENAI_BASE_URL")!;
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")!;
var model = Environment.GetEnvironmentVariable("MODEL")!;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://localhost:5566");
builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUIServer();
var app = builder.Build();
var agent = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
.GetChatClient(model)
.AsIChatClient()
.AsAIAgent();
app.MapAGUIServer("/", agent);
await app.StartAsync();
var httpClient = new HttpClient();
var input = new RunAgentInput
{
ThreadId = "thread-123",
RunId = $"run-123",
Messages = [new AGUIUserMessage { Content = "三苏故里是哪个城市?" }]
};
var response = await httpClient.PostAsJsonAsync("http://localhost:5566", input);
var content = await response.Content.ReadAsStringAsync();
Console.WriteLine(content);
Console.ReadLine();
输出
data: {"type":"RUN_STARTED","threadId":"thread-123","runId":"run-123"}
data: {"type":"TEXT_MESSAGE_START","messageId":"chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo","role":"assistant","name":"","rawEvent":{"authorName":"","role":"assistant","contents":[{"$type":"text","text":"","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo","messageId":"chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo","conversationId":null,"createdAt":"2026-07-23T12:23:29+00:00","finishReason":null,"modelId":"","continuationToken":null}}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo","delta":"四川","rawEvent":{"authorName":"","role":"assistant","contents":[{"$type":"text","text":"四川","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo","messageId":"chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo","conversationId":null,"createdAt":"2026-07-23T12:23:29+00:00","finishReason":null,"modelId":"","continuationToken":null}}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo","delta":"眉","rawEvent":{"authorName":"","role":"assistant","contents":[{"$type":"text","text":"眉","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo","messageId":"chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo","conversationId":null,"createdAt":"2026-07-23T12:23:29+00:00","finishReason":null,"modelId":"","continuationToken":null}}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo","delta":"山","rawEvent":{"authorName":"","role":"assistant","contents":[{"$type":"text","text":"山","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo","messageId":"chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo","conversationId":null,"createdAt":"2026-07-23T12:23:29+00:00","finishReason":null,"modelId":"","continuationToken":null}}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo","delta":"。","rawEvent":{"authorName":"","role":"assistant","contents":[{"$type":"text","text":"。","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo","messageId":"chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo","conversationId":null,"createdAt":"2026-07-23T12:23:29+00:00","finishReason":null,"modelId":"","continuationToken":null}}
data: {"type":"TEXT_MESSAGE_END","messageId":"chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo"}
data: {"type":"RUN_FINISHED","threadId":"thread-123","runId":"run-123","outcome":{"type":"success"}}
整个输出体现了如下的AG-UI事件流:
- RUN_STARTED: Agent开始执行;
- TEXT_MESSAGE_START :一条ID为
chatcmpl-E4mkT04E7giWgNq5XOX6lHmALqYuo开始传输; - TEXT_MESSAGE_CONTENT:消息的内容以增量的形式实时传输;
- TEXT_MESSAGE_END:本条消息传输完毕;
- RUN_FINISHED: Agent执行正常完成。
3. 工具调用(Agent端)
我们进一步来看看如果涉及到工具调用,又会输出怎样的AG-UI事件。如下面的演示程序所示,我们创建了一个用来返回指定城市天气信息的工具函数GetWeather,并注册到创建的AIAgent对象上。并且提了一个必须调用此工具才能回答的问题:目前眉山下雨吗?你只需要回答是或者否。
csharp
using AGUI.Abstractions;
using DotNetEnv;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
using OpenAI;
using System.ClientModel;
using System.ComponentModel;
Env.Load();
var endpoint = Environment.GetEnvironmentVariable("OPENAI_BASE_URL")!;
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")!;
var model = Environment.GetEnvironmentVariable("MODEL")!;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://localhost:5566");
builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUIServer();
var app = builder.Build();
var tool = AIFunctionFactory.Create(GetWeather, nameof(GetWeather));
var agent = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
.GetChatClient(model)
.AsIChatClient()
.AsAIAgent(tools: [tool]);
app.MapAGUIServer("/", agent);
await app.StartAsync();
var httpClient = new HttpClient();
var input = new RunAgentInput
{
ThreadId = "thread-123",
RunId = $"run-123",
Messages = [new AGUIUserMessage { Content = "目前眉山下雨吗?你只需要回答是或者否。" }]
};
var response = await httpClient.PostAsJsonAsync("http://localhost:5566", input);
var content = await response.Content.ReadAsStringAsync();
Console.WriteLine(content);
Console.ReadLine();
[Description("获取指定城市天气")]
static string GetWeather([Description("城市名称")] string city) => $"晴,气温55摄氏度";
输出:
data: {"type":"RUN_STARTED","threadId":"thread-123","runId":"run-123"}
data: {"type":"TOOL_CALL_START","parentMessageId":"","toolCallId":"call_czyxLE8NKL2q4h116WmiTYxB","toolCallName":"GetWeather","rawEvent":{"authorName":"","role":"assistant","contents":[{"$type":"functionCall","name":"GetWeather","arguments":{"city":"眉山"},"informationalOnly":false,"callId":"call_czyxLE8NKL2q4h116WmiTYxB","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"","messageId":"","conversationId":null,"createdAt":"1970-01-01T00:00:00+00:00","finishReason":"tool_calls","modelId":"","continuationToken":null}}
data: {"type":"TOOL_CALL_ARGS","toolCallId":"call_czyxLE8NKL2q4h116WmiTYxB","delta":"{\"city\":\"眉山\"}","rawEvent":{"authorName":"","role":"assistant","contents":[{"$type":"functionCall","name":"GetWeather","arguments":{"city":"眉山"},"informationalOnly":false,"callId":"call_czyxLE8NKL2q4h116WmiTYxB","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"","messageId":"","conversationId":null,"createdAt":"1970-01-01T00:00:00+00:00","finishReason":"tool_calls","modelId":"","continuationToken":null}}
data: {"type":"TOOL_CALL_END","toolCallId":"call_czyxLE8NKL2q4h116WmiTYxB","rawEvent":{"authorName":"","role":"assistant","contents":[{"$type":"functionCall","name":"GetWeather","arguments":{"city":"眉山"},"informationalOnly":false,"callId":"call_czyxLE8NKL2q4h116WmiTYxB","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"","messageId":"","conversationId":null,"createdAt":"1970-01-01T00:00:00+00:00","finishReason":"tool_calls","modelId":"","continuationToken":null}}
data: {"type":"TOOL_CALL_RESULT","messageId":"call_czyxLE8NKL2q4h116WmiTYxB","toolCallId":"call_czyxLE8NKL2q4h116WmiTYxB","content":"\"晴,气温55摄氏度\"","rawEvent":{"authorName":"","role":"tool","contents":[{"$type":"functionResult","result":"晴,气温55摄氏度","callId":"call_czyxLE8NKL2q4h116WmiTYxB","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"9011115d625947f1b24a3a536b8d424a","messageId":"9011115d625947f1b24a3a536b8d424a","conversationId":null,"createdAt":"2026-07-23T13:18:20.3295097+00:00","finishReason":null,"modelId":null,"continuationToken":null}}
data: {"type":"TEXT_MESSAGE_START","messageId":"chatcmpl-E4nbYTrEOsAv8gFlv2xEaPbEPzygG","role":"assistant","name":"","rawEvent":{"authorName":"","role":"assistant","contents":[{"$type":"text","text":"","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"chatcmpl-E4nbYTrEOsAv8gFlv2xEaPbEPzygG","messageId":"chatcmpl-E4nbYTrEOsAv8gFlv2xEaPbEPzygG","conversationId":null,"createdAt":"2026-07-23T13:18:20+00:00","finishReason":null,"modelId":"","continuationToken":null}}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"chatcmpl-E4nbYTrEOsAv8gFlv2xEaPbEPzygG","delta":"否","rawEvent":{"authorName":"","role":"assistant","contents":[{"$type":"text","text":"否","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"chatcmpl-E4nbYTrEOsAv8gFlv2xEaPbEPzygG","messageId":"chatcmpl-E4nbYTrEOsAv8gFlv2xEaPbEPzygG","conversationId":null,"createdAt":"2026-07-23T13:18:20+00:00","finishReason":null,"modelId":"","continuationToken":null}}
data: {"type":"TEXT_MESSAGE_END","messageId":"chatcmpl-E4nbYTrEOsAv8gFlv2xEaPbEPzygG"}
data: {"type":"RUN_FINISHED","threadId":"thread-123","runId":"run-123","outcome":{"type":"success"}}
本地调用体现了如下的AG-UI事件流:
- RUN_STARTED:Agent开始执行;
- TOOL_CALL_START :开始传输针对
GetWeather的工具调用; - TOOL_CALL_ARGS :传入工具调用的参数(
"city":"眉山"); - TOOL_CALL_END:工具调用传输结束;
- TOOL_CALL_RESULT:传输工具执行的结果("晴,气温55摄氏度");
- TEXT_MESSAGE_START:开始传输LLM最终的答复的文本消息;
- TEXT_MESSAGE_CONTENT:传入消息的内容("否")
- TEXT_MESSAGE_END:文本消息传输结束;
- RUN_FINISHED:Agent执行正常完成。
4. 推理
我们在上面这个演示实例中进一步引入推理。由于我们无法控制LLM输出推理的内容,所以我们定义了如下这个名为ReasoningChatClient的ChatClient中间件,使用它来输出一个表示推理的TextReasoningContent对象。我们将它注册在AIAgent的ChatClient管道中。
csharp
var agent = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
.GetChatClient(model)
.AsIChatClient()
.AsBuilder()
.Use(innerClient=>new ReasoningChatClient(innerClient))
.Build()
.AsAIAgent(tools: [tool]);
class ReasoningChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient)
{
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation]CancellationToken cancellationToken = default)
{
await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken))
{
yield return update;
}
if (messages.Any(it => it.Role == ChatRole.Tool))
{
yield return new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("极端天气,真实度存疑")]);
}
}
}
输出:
data: {"type":"RUN_STARTED","threadId":"thread-123","runId":"run-123"}
data: {"type":"TOOL_CALL_START","parentMessageId":"","toolCallId":"call_uZoyZo4rJYuK7zWmpB65AF3M","toolCallName":"GetWeather","rawEvent":{"authorName":null,"role":"assistant","contents":[{"$type":"functionCall","name":"GetWeather","arguments":{"city":"眉山"},"informationalOnly":false,"callId":"call_uZoyZo4rJYuK7zWmpB65AF3M","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"","messageId":"","conversationId":null,"createdAt":"1970-01-01T00:00:00+00:00","finishReason":"tool_calls","modelId":"","continuationToken":null}}
data: {"type":"TOOL_CALL_ARGS","toolCallId":"call_uZoyZo4rJYuK7zWmpB65AF3M","delta":"{\"city\":\"眉山\"}","rawEvent":{"authorName":null,"role":"assistant","contents":[{"$type":"functionCall","name":"GetWeather","arguments":{"city":"眉山"},"informationalOnly":false,"callId":"call_uZoyZo4rJYuK7zWmpB65AF3M","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"","messageId":"","conversationId":null,"createdAt":"1970-01-01T00:00:00+00:00","finishReason":"tool_calls","modelId":"","continuationToken":null}}
data: {"type":"TOOL_CALL_END","toolCallId":"call_uZoyZo4rJYuK7zWmpB65AF3M","rawEvent":{"authorName":null,"role":"assistant","contents":[{"$type":"functionCall","name":"GetWeather","arguments":{"city":"眉山"},"informationalOnly":false,"callId":"call_uZoyZo4rJYuK7zWmpB65AF3M","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"","messageId":"","conversationId":null,"createdAt":"1970-01-01T00:00:00+00:00","finishReason":"tool_calls","modelId":"","continuationToken":null}}
data: {"type":"TOOL_CALL_RESULT","messageId":"call_uZoyZo4rJYuK7zWmpB65AF3M","toolCallId":"call_uZoyZo4rJYuK7zWmpB65AF3M","content":"\"晴,气温55摄氏度\"","rawEvent":{"authorName":null,"role":"tool","contents":[{"$type":"functionResult","result":"晴,气温55摄氏度","callId":"call_uZoyZo4rJYuK7zWmpB65AF3M","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"0abc336f1844454a9d4bac380c590723","messageId":"0abc336f1844454a9d4bac380c590723","conversationId":null,"createdAt":"2026-07-23T13:40:18.3569533+00:00","finishReason":null,"modelId":null,"continuationToken":null}}
data: {"type":"TEXT_MESSAGE_START","messageId":"chatcmpl-E4nwojNkIkTtwWdR2f3p2umffcxgU","role":"assistant","rawEvent":{"authorName":null,"role":"assistant","contents":[{"$type":"text","text":"","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"chatcmpl-E4nwojNkIkTtwWdR2f3p2umffcxgU","messageId":"chatcmpl-E4nwojNkIkTtwWdR2f3p2umffcxgU","conversationId":null,"createdAt":"2026-07-23T13:40:18+00:00","finishReason":null,"modelId":"","continuationToken":null}}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"chatcmpl-E4nwojNkIkTtwWdR2f3p2umffcxgU","delta":"否","rawEvent":{"authorName":null,"role":"assistant","contents":[{"$type":"text","text":"否","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"chatcmpl-E4nwojNkIkTtwWdR2f3p2umffcxgU","messageId":"chatcmpl-E4nwojNkIkTtwWdR2f3p2umffcxgU","conversationId":null,"createdAt":"2026-07-23T13:40:18+00:00","finishReason":null,"modelId":"","continuationToken":null}}
data: {"type":"TEXT_MESSAGE_END","messageId":"chatcmpl-E4nwojNkIkTtwWdR2f3p2umffcxgU","rawEvent":{"authorName":null,"role":"assistant","contents":[{"$type":"reasoning","text":"极端天气,真实度存疑","protectedData":null,"annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":null,"messageId":null,"conversationId":null,"createdAt":null,"finishReason":null,"modelId":null,"continuationToken":null}}
data: {"type":"REASONING_START","messageId":"msg_rTPEmtDr8zDc2X4ZTFH2sk35"}
data: {"type":"REASONING_MESSAGE_START","messageId":"msg_rTPEmtDr8zDc2X4ZTFH2sk35","role":"reasoning"}
data: {"type":"REASONING_MESSAGE_CONTENT","messageId":"msg_rTPEmtDr8zDc2X4ZTFH2sk35","delta":"极端天气,真实度存疑"}
data: {"type":"REASONING_MESSAGE_END","messageId":"msg_rTPEmtDr8zDc2X4ZTFH2sk35"}
data: {"type":"REASONING_END","messageId":"msg_rTPEmtDr8zDc2X4ZTFH2sk35"}
data: {"type":"RUN_FINISHED","threadId":"thread-123","runId":"run-123","outcome":{"type":"success"}}
由于ReasoningChatClient是在LLM的应答之后输出的推理内容,所以会看来基于推理的AG-UI事件出现在事件流的末端:
- REASONING_START: 开始推理流程;
- REASONING_MESSAGE_START: 开始传输指定的推理消息;
- REASONING_MESSAGE_CONTENT: 传输推理消息的内容;
- REASONING_MESSAGE_END: 指定推理消息传输结束;
- REASONING_END: 推理流程结束。
我觉得这里的实现可能是有问题的。按照AG-UI协议文档的说明,作为REASONING_START/REASONING_END和REASONING_MESSAGE_START/REASONING_MESSAGE_END分别作为整个推理流程和单条推理消息的边界,所以它们的messageId并不是一回事,我查看了实现代码,貌似他们将两者混为一谈了。
5. 中断
我们最后来看看如果涉及人机交互导致Agent执行中断,AG-UI事件流又会出现什么变化。为此我们使用ApprovalRequiredAIFunction将注册的工具进行封装,这样会因工具审批导致中断。
csharp
var tool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather, nameof(GetWeather)));
输出:
data: {"type":"RUN_STARTED","threadId":"thread-123","runId":"run-123"}
data: {"type":"TOOL_CALL_START","parentMessageId":"","toolCallId":"call_lWBq3b0a2Qs42WXwtnaeyByk","toolCallName":"GetWeather","rawEvent":{"authorName":"","role":"assistant","contents":[{"$type":"toolApprovalRequest","toolCall":{"$type":"functionCall","name":"GetWeather","arguments":{"city":"眉山"},"informationalOnly":false,"callId":"call_lWBq3b0a2Qs42WXwtnaeyByk","annotations":null,"additionalProperties":null},"requestId":"ficc_call_lWBq3b0a2Qs42WXwtnaeyByk","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"","messageId":"","conversationId":null,"createdAt":"1970-01-01T00:00:00+00:00","finishReason":"tool_calls","modelId":"","continuationToken":null}}
data: {"type":"TOOL_CALL_ARGS","toolCallId":"call_lWBq3b0a2Qs42WXwtnaeyByk","delta":"{\"city\":\"眉山\"}","rawEvent":{"authorName":"","role":"assistant","contents":[{"$type":"toolApprovalRequest","toolCall":{"$type":"functionCall","name":"GetWeather","arguments":{"city":"眉山"},"informationalOnly":false,"callId":"call_lWBq3b0a2Qs42WXwtnaeyByk","annotations":null,"additionalProperties":null},"requestId":"ficc_call_lWBq3b0a2Qs42WXwtnaeyByk","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"","messageId":"","conversationId":null,"createdAt":"1970-01-01T00:00:00+00:00","finishReason":"tool_calls","modelId":"","continuationToken":null}}
data: {"type":"TOOL_CALL_END","toolCallId":"call_lWBq3b0a2Qs42WXwtnaeyByk","rawEvent":{"authorName":"","role":"assistant","contents":[{"$type":"toolApprovalRequest","toolCall":{"$type":"functionCall","name":"GetWeather","arguments":{"city":"眉山"},"informationalOnly":false,"callId":"call_lWBq3b0a2Qs42WXwtnaeyByk","annotations":null,"additionalProperties":null},"requestId":"ficc_call_lWBq3b0a2Qs42WXwtnaeyByk","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"","messageId":"","conversationId":null,"createdAt":"1970-01-01T00:00:00+00:00","finishReason":"tool_calls","modelId":"","continuationToken":null}}
data: {"type":"RUN_FINISHED","threadId":"thread-123","runId":"run-123","outcome":{"type":"interrupt","interrupts":[{"id":"ficc_call_lWBq3b0a2Qs42WXwtnaeyByk","reason":"tool_call","message":"Approval required for tool call: GetWeather","toolCallId":"call_lWBq3b0a2Qs42WXwtnaeyByk","responseSchema":{"type":"object","properties":{"approved":{"type":"boolean"}},"required":["approved"]}}]}}
按照AG-UI协议的描述,中断体现在RUN_FINISHED事件的outcome字段上。从最后输出的RUN_FINISHED事件内容可以看出,outcome字段具有如下的设置:
- type:interrupt
- interrupts :
- id: ficc_call_lWBq3b0a2Qs42WXwtnaeyByk
- reason:tool_call
- message: Approval required for tool call: GetWeather
- toolCallId: call_lWBq3b0a2Qs42WXwtnaeyByk
- responseSchema: {"type":"object","properties":{"approved":{"type":"boolean"}},"required":"approved"}
6. 工具调用(客户端)
我们最后来看看如果工具不是直接注册到承载AIAgent上,都是直接由客户端提供的情况下,按照我们的认知,此时工具调用应该传输给前端应用,并在应用端本地执行。我们看看最终会得到怎样的AG-UI事件流。
为此我们对演示程序做了如下的改动。我们创建的AIAgent不再有任何的工具注册。取而代之的是,我们根据AIFunction创建的AGUITool对象被添加到RunAgentInput对象的Tools列表中。
csharp
using AGUI.Abstractions;
using DotNetEnv;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
using OpenAI;
using System.ClientModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
Env.Load();
var endpoint = Environment.GetEnvironmentVariable("OPENAI_BASE_URL")!;
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")!;
var model = Environment.GetEnvironmentVariable("MODEL")!;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://localhost:5566");
builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUIServer();
var app = builder.Build();
var agent = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
.GetChatClient(model)
.AsIChatClient()
.AsAIAgent();
app.MapAGUIServer("/", agent);
await app.StartAsync();
var httpClient = new HttpClient();
var tool = AIFunctionFactory.Create(GetWeather, nameof(GetWeather));
var aguiTool = new AGUITool
{
Name = tool.Name,
Description = tool.Description,
Parameters = tool.JsonSchema
};
var input = new RunAgentInput
{
ThreadId = "thread-123",
RunId = $"run-123",
Tools = [aguiTool],
Messages = [new AGUIUserMessage { Content = "目前眉山下雨吗?你只需要回答是或者否。" }]
};
var response = await httpClient.PostAsJsonAsync("http://localhost:5566", input);
var content = await response.Content.ReadAsStringAsync();
Console.WriteLine(content);
Console.ReadLine();
[Description("获取指定城市天气")]
static string GetWeather([Description("城市名称")] string city) => $"晴,气温55摄氏度";
输出:
data: {"type":"RUN_STARTED","threadId":"thread-123","runId":"run-123"}
data: {"type":"TOOL_CALL_START","parentMessageId":"","toolCallId":"call_xG9kRZiGtJpM2LJGHdKEqFUX","toolCallName":"GetWeather","rawEvent":{"authorName":null,"role":"assistant","contents":[{"$type":"functionCall","name":"GetWeather","arguments":{"city":"眉山"},"informationalOnly":false,"callId":"call_xG9kRZiGtJpM2LJGHdKEqFUX","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"","messageId":"","conversationId":null,"createdAt":"1970-01-01T00:00:00+00:00","finishReason":"tool_calls","modelId":"","continuationToken":null}}
data: {"type":"TOOL_CALL_ARGS","toolCallId":"call_xG9kRZiGtJpM2LJGHdKEqFUX","delta":"{\"city\":\"眉山\"}","rawEvent":{"authorName":null,"role":"assistant","contents":[{"$type":"functionCall","name":"GetWeather","arguments":{"city":"眉山"},"informationalOnly":false,"callId":"call_xG9kRZiGtJpM2LJGHdKEqFUX","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"","messageId":"","conversationId":null,"createdAt":"1970-01-01T00:00:00+00:00","finishReason":"tool_calls","modelId":"","continuationToken":null}}
data: {"type":"TOOL_CALL_END","toolCallId":"call_xG9kRZiGtJpM2LJGHdKEqFUX","rawEvent":{"authorName":null,"role":"assistant","contents":[{"$type":"functionCall","name":"GetWeather","arguments":{"city":"眉山"},"informationalOnly":false,"callId":"call_xG9kRZiGtJpM2LJGHdKEqFUX","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"","messageId":"","conversationId":null,"createdAt":"1970-01-01T00:00:00+00:00","finishReason":"tool_calls","modelId":"","continuationToken":null}}
data: {"type":"RUN_FINISHED","threadId":"thread-123","runId":"run-123","outcome":{"type":"success"}}
输出体现的AG-UI事件流:
- RUN_STARTED: Agent开始执行;
- TOOL_CALL_START : 针对客户端工具
GetWeather的工具调用开始传输; - TOOL_CALL_ARGS :传输工具调用的参数(
{"city":"眉山"}); - RUN_FINISHED:Agent执行正常完成。
此后客户端应该执行工具函数,然后将结果转换成AGUIToolMessage添加在作为输入的RunAgentInput的Messages列表中,再次发起调用。