feat: 标准化 Agent AG-UI 与审批运行时

- 新增 AG-UI 事件投影与协议编码模块

- 支持 Turn 级审批作用域和受信任动态审批策略
This commit is contained in:
2026-08-19 21:50:58 +08:00
parent a34ca9271e
commit 49a7de34bb
14 changed files with 1043 additions and 13 deletions

36
easy-agents-agui/pom.xml Normal file
View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents</artifactId>
<version>${revision}</version>
</parent>
<name>easy-agents-agui</name>
<artifactId>easy-agents-agui</artifactId>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-agent-runtime</artifactId>
</dependency>
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-agui</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,84 @@
package com.easyagents.agui;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.agentscope.core.agui.model.AguiMessage;
import java.util.List;
import java.util.Objects;
/**
* AgentScope 1.0.12 尚未提供的 AG-UI 标准线级事件。
*
* <p>该补充层只覆盖当前官方扩展缺失的标准事件,不复制 AG-UI 事件枚举。</p>
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = AguiExtendedEvent.RunError.class, name = "RUN_ERROR"),
@JsonSubTypes.Type(value = AguiExtendedEvent.MessagesSnapshot.class, name = "MESSAGES_SNAPSHOT")
})
public sealed interface AguiExtendedEvent
permits AguiExtendedEvent.RunError, AguiExtendedEvent.MessagesSnapshot {
/**
* 运行失败事件。
*
* @param threadId AG-UI 线程 ID
* @param runId AG-UI 运行 ID
* @param message 可安全展示的错误消息
* @param code 稳定错误码
*/
record RunError(String threadId, String runId, String message, String code)
implements AguiExtendedEvent {
/**
* 创建运行失败事件。
*
* @param threadId AG-UI 线程 ID
* @param runId AG-UI 运行 ID
* @param message 可安全展示的错误消息
* @param code 稳定错误码
*/
@JsonCreator
public RunError(
@JsonProperty("threadId") String threadId,
@JsonProperty("runId") String runId,
@JsonProperty("message") String message,
@JsonProperty("code") String code) {
this.threadId = Objects.requireNonNull(threadId, "threadId cannot be null");
this.runId = Objects.requireNonNull(runId, "runId cannot be null");
this.message = Objects.requireNonNull(message, "message cannot be null");
this.code = code;
}
}
/**
* 消息全量快照事件。
*
* @param threadId AG-UI 线程 ID
* @param runId AG-UI 运行 ID
* @param messages 消息快照
*/
record MessagesSnapshot(String threadId, String runId, List<AguiMessage> messages)
implements AguiExtendedEvent {
/**
* 创建消息全量快照事件。
*
* @param threadId AG-UI 线程 ID
* @param runId AG-UI 运行 ID
* @param messages 消息快照
*/
@JsonCreator
public MessagesSnapshot(
@JsonProperty("threadId") String threadId,
@JsonProperty("runId") String runId,
@JsonProperty("messages") List<AguiMessage> messages) {
this.threadId = Objects.requireNonNull(threadId, "threadId cannot be null");
this.runId = Objects.requireNonNull(runId, "runId cannot be null");
this.messages = messages == null ? List.of() : List.copyOf(messages);
}
}
}

View File

@@ -0,0 +1,39 @@
package com.easyagents.agui;
import io.agentscope.core.agui.AguiException;
import io.agentscope.core.util.JsonException;
import io.agentscope.core.util.JsonUtils;
/**
* 将 AG-UI 事件编码为 JSON 或 SSE 数据帧。
*
* <p>编码器无可变状态,可安全跨请求复用。</p>
*/
public final class AguiProtocolEventEncoder {
/**
* 编码为 JSON。
*
* @param event AG-UI 官方事件或补充标准事件
* @return JSON 文本
* @throws AguiException.EncodingException 序列化失败时抛出
*/
public String encodeToJson(Object event) {
try {
return JsonUtils.getJsonCodec().toJson(event);
} catch (JsonException exception) {
throw new AguiException.EncodingException("Failed to encode AG-UI event", exception);
}
}
/**
* 编码为 SSE data 帧。
*
* @param event AG-UI 官方事件或补充标准事件
* @return 完整 SSE data 帧
* @throws AguiException.EncodingException 序列化失败时抛出
*/
public String encode(Object event) {
return "data: " + encodeToJson(event) + "\n\n";
}
}

View File

@@ -0,0 +1,249 @@
package com.easyagents.agui;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import io.agentscope.core.agui.event.AguiEvent;
import io.agentscope.core.util.JsonException;
import io.agentscope.core.util.JsonUtils;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 将 Easy-Agents 中立运行时事件有序投影为 AG-UI 事件。
*
* <p>实例绑定单个 run 且非线程安全。调用方应按运行顺序串行调用 {@link #project(AgentRuntimeEvent)}。</p>
*/
public final class AguiRuntimeEventProjector {
private static final String DEFAULT_ERROR_MESSAGE = "Agent runtime failed.";
/** AG-UI thread ID。 */
private final String threadId;
/** AG-UI run ID。 */
private final String runId;
/** 已开始但尚未收到结果的工具调用。 */
private final Set<String> knownToolCallIds = new LinkedHashSet<>();
private boolean runStarted;
private boolean terminated;
private String openMessageId;
private String openReasoningMessageId;
private long generatedMessageSequence;
/**
* 创建不含业务 Custom Event 的投影器。
*
* @param threadId AG-UI thread ID
* @param runId AG-UI run ID
*/
public AguiRuntimeEventProjector(String threadId, String runId) {
this.threadId = requireText(threadId, "threadId");
this.runId = requireText(runId, "runId");
}
/**
* 按输入顺序投影一条运行时事件。
*
* @param event Easy-Agents 运行时事件
* @return 零到多条 AG-UI 官方或补充标准事件
*/
public List<Object> project(AgentRuntimeEvent event) {
if (event == null || event.getEventType() == null || terminated) {
return List.of();
}
List<Object> output = new ArrayList<>();
switch (event.getEventType()) {
case STARTED -> startRun(output);
case MESSAGE_DELTA -> projectMessageDelta(event, output);
case REASONING_STARTED -> startReasoning(event, output);
case REASONING_DELTA -> projectReasoningDelta(event, output);
case REASONING_COMPLETED -> closeReasoning(output);
case TOOL_CALL -> projectToolCall(event, output);
case TOOL_RESULT -> projectToolResult(event, output);
case COMPLETED -> finishSuccessfully(output);
case FAILED -> finishWithError(event, "AGENT_RUNTIME_FAILED", output);
case CANCELLED -> finishWithError(event, "RUN_CANCELLED", output);
default -> {
// EasyFlow 等上层业务扩展由各自协议边界映射为 CUSTOM通用模块保持业务无关。
}
}
return List.copyOf(output);
}
/**
* 判断当前投影是否已经产生协议终态。
*
* @return 已产生 RUN_FINISHED 或 RUN_ERROR 时为 true
*/
public boolean isTerminated() {
return terminated;
}
private void startRun(List<Object> output) {
if (!runStarted) {
output.add(new AguiEvent.RunStarted(threadId, runId));
runStarted = true;
}
}
private void projectMessageDelta(AgentRuntimeEvent event, List<Object> output) {
startRun(output);
String messageId = eventMessageId(event, "assistant");
if (!Objects.equals(openMessageId, messageId)) {
closeMessage(output);
output.add(new AguiEvent.TextMessageStart(threadId, runId, messageId, "assistant"));
openMessageId = messageId;
}
String delta = stringValue(event.getPayload(), "text");
if (!delta.isEmpty()) {
output.add(new AguiEvent.TextMessageContent(threadId, runId, messageId, delta));
}
}
private void startReasoning(AgentRuntimeEvent event, List<Object> output) {
startRun(output);
if (openReasoningMessageId != null) {
return;
}
openReasoningMessageId = eventMessageId(event, "reasoning");
output.add(new AguiEvent.ReasoningMessageStart(
threadId, runId, openReasoningMessageId, "reasoning"));
}
private void projectReasoningDelta(AgentRuntimeEvent event, List<Object> output) {
startReasoning(event, output);
String delta = stringValue(event.getPayload(), "reasoning");
if (!delta.isEmpty()) {
output.add(new AguiEvent.ReasoningMessageContent(
threadId, runId, openReasoningMessageId, delta));
}
}
private void projectToolCall(AgentRuntimeEvent event, List<Object> output) {
startRun(output);
String toolCallId = firstText(event.getToolCallId(), stringValue(event.getPayload(), "toolCallId"));
if (toolCallId == null || !knownToolCallIds.add(toolCallId)) {
return;
}
String toolName = firstText(
stringValue(event.getPayload(), "toolName"),
stringValue(event.getPayload(), "name"),
"tool");
output.add(new AguiEvent.ToolCallStart(threadId, runId, toolCallId, toolName));
output.add(new AguiEvent.ToolCallArgs(
threadId, runId, toolCallId, jsonValue(event.getPayload().get("input"))));
output.add(new AguiEvent.ToolCallEnd(threadId, runId, toolCallId));
}
private void projectToolResult(AgentRuntimeEvent event, List<Object> output) {
startRun(output);
String toolCallId = firstText(event.getToolCallId(), stringValue(event.getPayload(), "toolCallId"));
if (toolCallId == null || !knownToolCallIds.contains(toolCallId)) {
return;
}
String messageId = eventMessageId(event, "tool-" + toolCallId);
String content = nullToEmpty(firstText(
stringValue(event.getPayload(), "text"),
event.getPayload().containsKey("result")
? jsonValue(event.getPayload().get("result"))
: null));
output.add(new AguiEvent.ToolCallResult(
threadId, runId, toolCallId, content, "tool", messageId));
}
private void finishSuccessfully(List<Object> output) {
startRun(output);
closeOpenFragments(output);
output.add(new AguiEvent.RunFinished(threadId, runId));
terminated = true;
}
private void finishWithError(AgentRuntimeEvent event, String code, List<Object> output) {
startRun(output);
closeOpenFragments(output);
String message = firstText(
stringValue(event.getPayload(), "message"),
stringValue(event.getPayload(), "reason"),
DEFAULT_ERROR_MESSAGE);
output.add(new AguiExtendedEvent.RunError(threadId, runId, message, code));
terminated = true;
}
private void closeOpenFragments(List<Object> output) {
closeReasoning(output);
closeMessage(output);
}
private void closeMessage(List<Object> output) {
if (openMessageId != null) {
output.add(new AguiEvent.TextMessageEnd(threadId, runId, openMessageId));
openMessageId = null;
}
}
private void closeReasoning(List<Object> output) {
if (openReasoningMessageId != null) {
output.add(new AguiEvent.ReasoningMessageEnd(
threadId, runId, openReasoningMessageId));
openReasoningMessageId = null;
}
}
private String eventMessageId(AgentRuntimeEvent event, String suffix) {
String messageId = firstText(
event.getMessageId(),
event.getMessage() == null ? null : event.getMessage().getMessageId());
if (messageId != null) {
return messageId;
}
generatedMessageSequence++;
return runId + "-" + suffix + "-" + generatedMessageSequence;
}
private static String stringValue(Map<String, Object> payload, String key) {
if (payload == null) {
return "";
}
Object value = payload.get(key);
return value instanceof String text ? text : "";
}
private static String jsonValue(Object value) {
if (value == null) {
return "{}";
}
if (value instanceof String text) {
return text;
}
try {
return JsonUtils.getJsonCodec().toJson(value);
} catch (JsonException exception) {
throw new IllegalArgumentException("Failed to encode AG-UI payload", exception);
}
}
private static String firstText(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
private static String requireText(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " cannot be blank");
}
return value;
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
}

View File

@@ -0,0 +1,43 @@
package com.easyagents.agui;
import io.agentscope.core.agui.event.AguiEvent;
import io.agentscope.core.agui.model.AguiMessage;
import org.junit.Assert;
import org.junit.Test;
import java.util.List;
/**
* {@link AguiProtocolEventEncoder} 的线级协议测试。
*/
public class AguiProtocolEventEncoderTest {
private final AguiProtocolEventEncoder encoder = new AguiProtocolEventEncoder();
/**
* 验证 AgentScope 官方事件保留 AG-UI type 字段。
*/
@Test
public void shouldEncodeOfficialEvent() {
String json = encoder.encodeToJson(new AguiEvent.RunStarted("thread-1", "run-1"));
Assert.assertTrue(json.contains("\"type\":\"RUN_STARTED\""));
Assert.assertTrue(json.contains("\"threadId\":\"thread-1\""));
}
/**
* 验证补充的失败和消息快照事件使用现代 AG-UI 标准事件名。
*/
@Test
public void shouldEncodeExtendedStandardEvents() {
String error = encoder.encodeToJson(
new AguiExtendedEvent.RunError("thread-1", "run-1", "boom", "FAILED"));
String snapshot = encoder.encodeToJson(new AguiExtendedEvent.MessagesSnapshot(
"thread-1", "run-1", List.of(AguiMessage.userMessage("message-1", "hello"))));
Assert.assertTrue(error.contains("\"type\":\"RUN_ERROR\""));
Assert.assertTrue(error.contains("\"code\":\"FAILED\""));
Assert.assertTrue(snapshot.contains("\"type\":\"MESSAGES_SNAPSHOT\""));
Assert.assertTrue(snapshot.contains("\"role\":\"user\""));
}
}

View File

@@ -0,0 +1,90 @@
package com.easyagents.agui;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import io.agentscope.core.agui.event.AguiEvent;
import org.junit.Assert;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* {@link AguiRuntimeEventProjector} 的协议顺序与终态测试。
*/
public class AguiRuntimeEventProjectorTest {
/**
* 验证文本、推理、工具和成功终态按 AG-UI 顺序投影。
*/
@Test
public void shouldProjectSuccessfulRunInProtocolOrder() {
AguiRuntimeEventProjector projector = new AguiRuntimeEventProjector("thread-1", "run-1");
List<Object> events = new ArrayList<>();
events.addAll(projector.project(event(AgentRuntimeEventType.STARTED, null, null, Map.of())));
events.addAll(projector.project(event(
AgentRuntimeEventType.REASONING_STARTED, "reasoning-1", null, Map.of())));
events.addAll(projector.project(event(
AgentRuntimeEventType.REASONING_DELTA, "reasoning-1", null, Map.of("reasoning", "分析"))));
events.addAll(projector.project(event(
AgentRuntimeEventType.REASONING_COMPLETED, "reasoning-1", null, Map.of())));
events.addAll(projector.project(event(
AgentRuntimeEventType.MESSAGE_DELTA, "message-1", null, Map.of("text", "你好"))));
events.addAll(projector.project(event(
AgentRuntimeEventType.TOOL_CALL, null, "tool-1",
Map.of("toolName", "search", "input", Map.of("q", "AG-UI")))));
events.addAll(projector.project(event(
AgentRuntimeEventType.TOOL_RESULT, null, "tool-1", Map.of("text", "done"))));
events.addAll(projector.project(event(AgentRuntimeEventType.COMPLETED, null, null, Map.of())));
Assert.assertEquals(List.of(
"RunStarted",
"ReasoningMessageStart",
"ReasoningMessageContent",
"ReasoningMessageEnd",
"TextMessageStart",
"TextMessageContent",
"ToolCallStart",
"ToolCallArgs",
"ToolCallEnd",
"ToolCallResult",
"TextMessageEnd",
"RunFinished"), events.stream().map(value -> value.getClass().getSimpleName()).toList());
Assert.assertEquals(
"reasoning", ((AguiEvent.ReasoningMessageStart) events.get(1)).role());
Assert.assertTrue(projector.isTerminated());
}
/**
* 验证失败终态不会追加成功事件,终态后的迟到事件会被丢弃。
*/
@Test
public void shouldEmitSingleErrorTerminalAndIgnoreLateEvents() {
AguiRuntimeEventProjector projector = new AguiRuntimeEventProjector("thread-1", "run-1");
List<Object> failed = projector.project(event(
AgentRuntimeEventType.FAILED, null, null, Map.of("message", "boom")));
List<Object> late = projector.project(event(AgentRuntimeEventType.COMPLETED, null, null, Map.of()));
Assert.assertEquals(2, failed.size());
Assert.assertTrue(failed.get(0) instanceof AguiEvent.RunStarted);
Assert.assertEquals(
new AguiExtendedEvent.RunError("thread-1", "run-1", "boom", "AGENT_RUNTIME_FAILED"),
failed.get(1));
Assert.assertTrue(late.isEmpty());
}
private static AgentRuntimeEvent event(
AgentRuntimeEventType type,
String messageId,
String toolCallId,
Map<String, Object> payload) {
AgentRuntimeEvent event = AgentRuntimeEvent.of(type);
event.setMessageId(messageId);
event.setToolCallId(toolCallId);
event.setPayload(payload);
return event;
}
}