feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离 - 增加 Agent Skill 冻结绑定与运行时消费闭环 - 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
@@ -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()) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等删除指定文件;物理文件已不存在时同步清理残留记录。
|
||||
*
|
||||
|
||||
@@ -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 时抛出带有效消息的异常。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user