feat: 完善 Agent 标准交互与安全运行时

- 接入 AG-UI 运行投影、Turn 时间线和审批隔离

- 增加 Agent Skill 冻结绑定与运行时消费闭环

- 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
2026-08-19 22:13:41 +08:00
parent 91d66e636d
commit 4e8640dcaf
241 changed files with 24382 additions and 2777 deletions

View File

@@ -78,6 +78,39 @@ public class ChatSseEmitter {
return send("needSaveMessage", envelope);
}
/**
* 发送不带私有事件包装的 SSE data 数据。
*
* @param data 已完成协议序列化的数据
* @return 发送成功时为 true
*/
public boolean sendData(String data) {
if (closed.get()) {
return false;
}
try {
emitter.send(SseEmitter.event().data(data));
return true;
} catch (IOException exception) {
markDisconnected("data", exception);
return false;
} catch (IllegalStateException exception) {
closed.compareAndSet(false, true);
LOG.warn("ChatSseEmitter data send failed, message={}, exception={}",
exception.getMessage(), exception.toString());
return false;
} catch (Exception exception) {
if (isClientDisconnected(exception)) {
markDisconnected("data", exception);
return false;
}
LOG.error("ChatSseEmitter data send unexpected failed, message={}, exception={}",
exception.getMessage(), exception.toString(), exception);
safeCompleteWithError(exception);
return false;
}
}
/** SSE 底层发送 */
private boolean send(String event, ChatEnvelope<?> envelope) {
if (closed.get()) {

View File

@@ -17,6 +17,8 @@ public class ChatAssistantAccumulator {
private final List<Map<String, Object>> chains = new ArrayList<>();
private final List<Map<String, Object>> messageChain = new ArrayList<>();
private final List<Map<String, Object>> toolMessages = new ArrayList<>();
private final Map<String, Map<String, Object>> skillInvocationStatuses = new LinkedHashMap<>();
private final Map<String, Map<String, Object>> artifacts = new LinkedHashMap<>();
private Map<String, Object> latestToolCallAssistant;
private boolean toolCallBatchOpen;
@@ -113,6 +115,63 @@ public class ChatAssistantAccumulator {
toolCallBatchOpen = false;
}
/**
* 记录可安全回放的 Skill 调用状态,并按稳定状态键原位覆盖。
*
* @param status 仅含展示白名单字段的状态
*/
public void appendSkillInvocationStatus(Map<String, Object> status) {
if (status == null || status.get("statusKey") == null) {
return;
}
String key = String.valueOf(status.get("statusKey"));
Map<String, Object> safe = new LinkedHashMap<>();
for (String field : List.of("statusKey", "status", "skillId", "skillName",
"skillDisplayName", "toolCallId", "message")) {
if (status.get(field) != null) {
safe.put(field, status.get(field));
}
}
skillInvocationStatuses.put(key, safe);
}
/**
* 记录可安全持久化并回放的 Agent 产物投影。
*
* @param artifact 产物安全字段
*/
public void appendArtifact(Map<String, Object> artifact) {
if (artifact == null || artifact.get("artifactId") == null) {
return;
}
Map<String, Object> safe = new LinkedHashMap<>();
for (String field : List.of("schemaVersion", "artifactId", "fileName", "mimeType",
"size", "sha256", "downloadUrl", "status")) {
if (artifact.get(field) != null) {
safe.put(field, artifact.get(field));
}
}
artifacts.put(String.valueOf(artifact.get("artifactId")), safe);
}
/**
* 将仍在运行的 Skill 状态收口为指定终态。
*
* @param terminalStatus FAILED 或 CANCELLED
* @param message 可恢复提示
*/
public void finalizePendingSkillInvocations(String terminalStatus, String message) {
for (Map<String, Object> status : skillInvocationStatuses.values()) {
if (!"RUNNING".equals(String.valueOf(status.get("status")))) {
continue;
}
status.put("status", terminalStatus);
if (message != null && !message.isBlank()) {
status.put("message", message);
}
}
}
/**
* 获取当前 assistant 片段的文本内容。
*
@@ -161,7 +220,24 @@ public class ChatAssistantAccumulator {
if (!finalAssistantMessage.isEmpty()) {
payloadMessageChain.add(finalAssistantMessage);
}
return ChatRuntimeHistoryPayloadHelper.buildPayload(payloadMessageChain, toolMessages, payloadChains);
Map<String, Object> payload = ChatRuntimeHistoryPayloadHelper.buildPayload(
payloadMessageChain, toolMessages, payloadChains);
if (!skillInvocationStatuses.isEmpty()) {
List<Map<String, Object>> statuses = new ArrayList<>();
for (Map<String, Object> current : skillInvocationStatuses.values()) {
Map<String, Object> copy = new LinkedHashMap<>(current);
if ("RUNNING".equals(String.valueOf(copy.get("status")))) {
copy.put("status", "INCOMPLETE");
copy.putIfAbsent("message", "技能调用未完成");
}
statuses.add(copy);
}
payload.put("skillInvocationStatuses", statuses);
}
if (!artifacts.isEmpty()) {
payload.put("artifacts", new ArrayList<>(artifacts.values()));
}
return payload;
}
private Map<String, Object> findToolChain(String id, String name) {

View File

@@ -0,0 +1,39 @@
package tech.easyflow.core.runtime;
import org.junit.Assert;
import org.junit.Test;
import java.util.List;
import java.util.Map;
/**
* Assistant 历史 payload 的 Artifact 安全投影测试。
*/
public class ChatAssistantAccumulatorArtifactTest {
/**
* 验证只持久化安全字段,并按 artifactId 原位去重。
*/
@Test
public void buildPayloadShouldPersistOnlySafeArtifactFields() {
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
accumulator.appendArtifact(Map.of(
"schemaVersion", 1,
"artifactId", "a1",
"fileName", "report.csv",
"mimeType", "text/csv",
"size", 12L,
"sha256", "abc",
"downloadUrl", "/api/v1/agent/artifacts/a1/content",
"status", "AVAILABLE",
"objectKey", "private/object/key"));
Map<String, Object> payload = accumulator.buildPayload("done");
@SuppressWarnings("unchecked")
List<Map<String, Object>> artifacts = (List<Map<String, Object>>) payload.get("artifacts");
Assert.assertEquals(1, artifacts.size());
Assert.assertEquals("a1", artifacts.get(0).get("artifactId"));
Assert.assertFalse(artifacts.get(0).containsKey("objectKey"));
}
}

View File

@@ -73,6 +73,34 @@ public class XFIleStorageServiceImpl implements FileStorageService {
return fileInfo.getUrl();
}
/**
* 使用指定前置目录上传后端本地文件。
*
* @param file 后端本地文件
* @param prePath 前置目录
* @return 文件 URL
* @throws IllegalArgumentException 文件不存在或不是普通文件时抛出
*/
@Override
public String save(File file, String prePath) {
if (file == null || !file.isFile()) {
throw new IllegalArgumentException("待上传的本地文件不存在");
}
String uploadPath = PathGeneratorUtil.generateUserPath("");
if (StringUtils.hasText(prePath)) {
String normalized = prePath.replaceAll("^/+", "").replaceAll("/+$", "");
uploadPath = "/" + normalized + uploadPath;
}
FileInfo fileInfo = fileStorageService.of(file)
.setPath(uploadPath)
.setSaveFilename(file.getName())
.upload();
if (fileInfo == null || !StringUtils.hasText(fileInfo.getUrl())) {
throw new RuntimeException("文件上传失败");
}
return fileInfo.getUrl();
}
/**
* 幂等删除指定文件;物理文件已不存在时同步清理残留记录。
*

View File

@@ -35,6 +35,26 @@ import static org.junit.Assert.assertTrue;
*/
public class XFIleStorageServiceImplTest {
/**
* 验证后端生成的本地文件可以通过统一 x-file-storage 路由上传。
*
* @throws Exception 创建临时文件或注入测试替身失败
*/
@Test
public void localFileSaveUsesRequestedPathAndFilename() throws Exception {
RecoverablePlatform platform = new RecoverablePlatform("minio-main", "attachment", "https://files/");
RecoverableStorageService delegate = new RecoverableStorageService(platform);
XFIleStorageServiceImpl service = createService(delegate);
File file = Files.createTempFile("generated-skill-", ".zip").toFile();
String url = service.save(file, "skill-imports/tenant-1");
assertEquals(file.getName(), delegate.uploadFilename);
assertTrue(delegate.uploadPath.startsWith("/skill-imports/tenant-1/"));
assertEquals("https://files/attachment" + delegate.uploadPath + file.getName(), url);
assertTrue(platform.exists);
}
/**
* 验证底层明确返回 false 时抛出带有效消息的异常。
*