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

@@ -0,0 +1,112 @@
package tech.easyflow.agent.config;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Agent 五类产品级内置工具的类型化配置。
*/
public final class AgentBuiltinToolsConfig {
/** 当前配置结构版本。 */
public static final int SCHEMA_VERSION = 1;
private final ToolSwitch read;
private final ToolSwitch write;
private final ToolSwitch patch;
private final ToolSwitch shell;
private final ToolSwitch artifactPublish;
/**
* 创建内置工具配置。
*
* @param read 读取工具配置
* @param write 写入工具配置
* @param patch 补丁工具配置
* @param shell Shell 工具配置
* @param artifactPublish 产物发布工具配置
*/
public AgentBuiltinToolsConfig(ToolSwitch read,
ToolSwitch write,
ToolSwitch patch,
ToolSwitch shell,
ToolSwitch artifactPublish) {
this.read = read;
this.write = write;
this.patch = patch;
this.shell = shell;
this.artifactPublish = artifactPublish;
}
/**
* 返回新 Agent 的安全默认配置。
*
* @return 五项启用且仅 Shell 要求审批的配置
*/
public static AgentBuiltinToolsConfig newAgentDefaults() {
return new AgentBuiltinToolsConfig(
new ToolSwitch(true, false),
new ToolSwitch(true, false),
new ToolSwitch(true, false),
new ToolSwitch(true, true),
new ToolSwitch(true, false));
}
/**
* 返回旧发布快照的无扩权兼容配置。
*
* @return 五项全部禁用的配置
*/
public static AgentBuiltinToolsConfig allDisabled() {
ToolSwitch disabled = new ToolSwitch(false, false);
return new AgentBuiltinToolsConfig(disabled, disabled, disabled, disabled, disabled);
}
/** @return 读取工具配置 */
public ToolSwitch read() { return read; }
/** @return 写入工具配置 */
public ToolSwitch write() { return write; }
/** @return 补丁工具配置 */
public ToolSwitch patch() { return patch; }
/** @return Shell 工具配置 */
public ToolSwitch shell() { return shell; }
/** @return 产物发布工具配置 */
public ToolSwitch artifactPublish() { return artifactPublish; }
/**
* 转换为可写入 executionConfigJson 的稳定结构。
*
* @return 不含权限确认临时字段的安全 Map
*/
public Map<String, Object> toMap() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("schemaVersion", SCHEMA_VERSION);
result.put("read", read.toMap());
result.put("write", write.toMap());
result.put("patch", patch.toMap());
result.put("shell", shell.toMap());
result.put("artifactPublish", artifactPublish.toMap());
return result;
}
/**
* 单个内置工具的启用与审批配置。
*
* @param enabled 是否启用
* @param approvalRequired 是否要求调用前审批
*/
public record ToolSwitch(boolean enabled, boolean approvalRequired) {
/**
* 转换为持久化结构。
*
* @return 工具开关 Map
*/
public Map<String, Object> toMap() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("enabled", enabled);
result.put("approvalRequired", approvalRequired);
return result;
}
}
}

View File

@@ -0,0 +1,209 @@
package tech.easyflow.agent.config;
import org.springframework.stereotype.Component;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.service.CategoryPermissionService;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Agent 内置工具配置的默认值、兼容和权限统一解析器。
*/
@Component
public class AgentBuiltinToolsConfigResolver {
/** executionConfigJson 中的内置工具字段。 */
public static final String BUILTIN_TOOLS_KEY = "builtinTools";
/** 超级管理员关闭 Shell 审批时提交的一次性确认字段。 */
public static final String SHELL_RISK_CONFIRMATION_KEY = "shellApprovalRiskConfirmed";
private final CategoryPermissionService categoryPermissionService;
/**
* 创建解析器。
*
* @param categoryPermissionService 平台超级管理员判定服务
*/
public AgentBuiltinToolsConfigResolver(CategoryPermissionService categoryPermissionService) {
this.categoryPermissionService = categoryPermissionService;
}
/**
* 为草稿详情补齐展示默认值,但不直接写回数据库。
*
* @param source 原执行配置
* @return 带完整五项配置的副本
*/
public Map<String, Object> normalizeForDraftRead(Map<String, Object> source) {
return replaceBuiltinTools(source, parse(source, AgentBuiltinToolsConfig.newAgentDefaults()));
}
/**
* 规范化一次显式草稿保存,并校验关闭 Shell 审批的权限与风险确认。
*
* @param source 客户端提交的执行配置
* @param existingSource 更新前执行配置;新建时为 null
* @param account 当前账号
* @return 可持久化且已移除一次性确认字段的配置
*/
public Map<String, Object> normalizeForDraftSave(Map<String, Object> source,
Map<String, Object> existingSource,
LoginAccount account) {
AgentBuiltinToolsConfig incoming = parse(source, AgentBuiltinToolsConfig.newAgentDefaults());
AgentBuiltinToolsConfig existing = existingSource == null
? AgentBuiltinToolsConfig.newAgentDefaults()
: parse(existingSource, AgentBuiltinToolsConfig.newAgentDefaults());
boolean disablesShellApproval = disablesShellApproval(incoming, existing);
if (disablesShellApproval) {
if (!categoryPermissionService.isSuperAdmin(account)) {
throw new BusinessException(403, 403, "仅平台超级管理员可以关闭 Shell 调用前确认");
}
if (!riskConfirmed(source)) {
throw new BusinessException(400, 400, "关闭 Shell 调用前确认前必须完成高风险确认");
}
}
return replaceBuiltinTools(source, incoming);
}
/**
* 判断保存前后是否真实发生了 Shell 审批关闭变更。
*
* @param source 已规范化或待保存的执行配置
* @param existingSource 保存前执行配置;新建时为 null
* @return 从需审批或禁用状态切换到启用且免审批时为 true
*/
public boolean isShellApprovalDisableTransition(Map<String, Object> source,
Map<String, Object> existingSource) {
AgentBuiltinToolsConfig incoming = parse(source, AgentBuiltinToolsConfig.newAgentDefaults());
AgentBuiltinToolsConfig existing = existingSource == null
? AgentBuiltinToolsConfig.newAgentDefaults()
: parse(existingSource, AgentBuiltinToolsConfig.newAgentDefaults());
return disablesShellApproval(incoming, existing);
}
/**
* 解析草稿运行配置;缺失时使用新 Agent 默认值。
*
* @param source 执行配置
* @return 类型化配置
*/
public AgentBuiltinToolsConfig resolveDraftRuntime(Map<String, Object> source) {
return parse(source, AgentBuiltinToolsConfig.newAgentDefaults());
}
/**
* 解析发布快照;旧快照缺失内置工具字段时全部禁用。
*
* @param source 发布快照中的执行配置
* @return 类型化配置
*/
public AgentBuiltinToolsConfig resolvePublishedRuntime(Map<String, Object> source) {
return parse(source, AgentBuiltinToolsConfig.allDisabled());
}
/**
* 规范化发布运行配置;旧快照缺失字段时显式写入五项禁用结果。
*
* @param source 发布快照执行配置
* @return 无静默扩权的完整配置副本
*/
public Map<String, Object> normalizeForPublishedRuntime(Map<String, Object> source) {
return replaceBuiltinTools(source, resolvePublishedRuntime(source));
}
/**
* 判断执行配置是否显式包含内置工具结构。
*
* @param source 执行配置
* @return 包含时为 true
*/
public boolean hasBuiltinTools(Map<String, Object> source) {
return source != null && source.containsKey(BUILTIN_TOOLS_KEY);
}
private Map<String, Object> replaceBuiltinTools(Map<String, Object> source,
AgentBuiltinToolsConfig config) {
Map<String, Object> result = source == null ? new LinkedHashMap<>() : new LinkedHashMap<>(source);
result.put(BUILTIN_TOOLS_KEY, config.toMap());
return result;
}
private AgentBuiltinToolsConfig parse(Map<String, Object> source, AgentBuiltinToolsConfig fallback) {
if (source == null || !source.containsKey(BUILTIN_TOOLS_KEY)) {
return fallback;
}
Map<String, Object> raw = requireMap(source.get(BUILTIN_TOOLS_KEY), "builtinTools 必须为对象");
validateSchemaVersion(raw.get("schemaVersion"));
return new AgentBuiltinToolsConfig(
tool(raw, "read", fallback.read()),
tool(raw, "write", fallback.write()),
tool(raw, "patch", fallback.patch()),
tool(raw, "shell", fallback.shell()),
tool(raw, "artifactPublish", fallback.artifactPublish()));
}
private boolean disablesShellApproval(AgentBuiltinToolsConfig incoming,
AgentBuiltinToolsConfig existing) {
return incoming.shell().enabled()
&& !incoming.shell().approvalRequired()
&& (existing.shell().approvalRequired() || !existing.shell().enabled());
}
private void validateSchemaVersion(Object value) {
if (value == null) {
return;
}
if (!(value instanceof Number number)
|| number.doubleValue() != number.intValue()
|| number.intValue() != AgentBuiltinToolsConfig.SCHEMA_VERSION) {
throw new BusinessException("不支持的 Agent 内置工具配置版本");
}
}
private AgentBuiltinToolsConfig.ToolSwitch tool(Map<String, Object> source,
String key,
AgentBuiltinToolsConfig.ToolSwitch fallback) {
if (!source.containsKey(key)) {
return fallback;
}
Map<String, Object> raw = requireMap(source.get(key), "Agent 内置工具项必须为对象: " + key);
return new AgentBuiltinToolsConfig.ToolSwitch(
booleanValue(raw, "enabled", fallback.enabled()),
booleanValue(raw, "approvalRequired", fallback.approvalRequired()));
}
private boolean riskConfirmed(Map<String, Object> source) {
Map<String, Object> raw = mapValue(source == null ? null : source.get(BUILTIN_TOOLS_KEY));
return raw != null && Boolean.TRUE.equals(raw.get(SHELL_RISK_CONFIRMATION_KEY));
}
private boolean booleanValue(Map<String, Object> source, String key, boolean fallback) {
if (!source.containsKey(key)) {
return fallback;
}
Object value = source.get(key);
if (value instanceof Boolean bool) {
return bool;
}
throw new BusinessException("Agent 内置工具开关必须为布尔值");
}
private Map<String, Object> requireMap(Object value, String message) {
Map<String, Object> mapped = mapValue(value);
if (mapped == null) {
throw new BusinessException(message);
}
return mapped;
}
private Map<String, Object> mapValue(Object value) {
if (!(value instanceof Map<?, ?> raw)) {
return null;
}
Map<String, Object> result = new LinkedHashMap<>();
raw.forEach((key, item) -> result.put(String.valueOf(key), item));
return result;
}
}

View File

@@ -16,7 +16,9 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@EnableConfigurationProperties({
AgentRuntimeProperties.class,
AgentMediaProperties.class,
AgentDocumentProperties.class
AgentDocumentProperties.class,
AgentWorkspaceProperties.class,
AgentShellProperties.class
})
public class AgentModuleConfig {
}

View File

@@ -0,0 +1,68 @@
package tech.easyflow.agent.config;
import com.easyagents.agent.runtime.tool.operate.ControlledShellTool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* 在应用启动后报告受控 Shell 固定白名单命令的实际可用性。
*/
@Component
public class AgentShellCommandAvailabilityReporter {
private static final Logger LOG = LoggerFactory.getLogger(AgentShellCommandAvailabilityReporter.class);
/**
* 检查当前进程 PATH并报告已安装与缺失的白名单命令。
*
* @param event 应用就绪事件
*/
@EventListener(ApplicationReadyEvent.class)
public void report(ApplicationReadyEvent event) {
List<String> available = new ArrayList<>();
List<String> missing = new ArrayList<>();
for (String command : ControlledShellTool.DEFAULT_ALLOWED_COMMANDS.stream().sorted().toList()) {
(isAvailable(command) ? available : missing).add(command);
}
LOG.info("Agent controlled Shell allowlist check completed, available={}", available);
if (!missing.isEmpty()) {
LOG.warn("Agent controlled Shell commands are unavailable in this runtime: {}", missing);
}
}
/**
* 判断一个不含路径分隔符的固定命令是否存在于当前 PATH。
*
* @param command 固定白名单命令
* @return 存在可执行普通文件时为 true
*/
private boolean isAvailable(String command) {
String pathValue = System.getenv("PATH");
if (pathValue == null || pathValue.isBlank()) {
return false;
}
for (String directory : pathValue.split(java.io.File.pathSeparator)) {
if (directory == null || directory.isBlank()) {
continue;
}
try {
Path executable = Path.of(directory).resolve(command);
if (Files.isRegularFile(executable) && Files.isExecutable(executable)) {
return true;
}
} catch (InvalidPathException ignored) {
// PATH 中的无效目录仅视为不可用,避免把宿主路径写入普通日志。
}
}
return false;
}
}

View File

@@ -0,0 +1,62 @@
package tech.easyflow.agent.config;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.unit.DataSize;
import java.time.Duration;
/**
* Agent 受控 Shell 的统一平台限制。
*/
@ConfigurationProperties(prefix = "easyflow.agent.shell")
public class AgentShellProperties implements InitializingBean {
private Duration defaultTimeout = Duration.ofSeconds(60);
private Duration maxTimeout = Duration.ofSeconds(300);
private int maxCommandLength = 4_096;
private DataSize maxOutputSize = DataSize.ofMegabytes(1);
private int maxConcurrentPerInstance = 2;
/** @return 默认超时 */
public Duration getDefaultTimeout() { return defaultTimeout; }
/** @param defaultTimeout 默认超时 */
public void setDefaultTimeout(Duration defaultTimeout) { this.defaultTimeout = defaultTimeout; }
/** @return 最大超时 */
public Duration getMaxTimeout() { return maxTimeout; }
/** @param maxTimeout 最大超时 */
public void setMaxTimeout(Duration maxTimeout) { this.maxTimeout = maxTimeout; }
/** @return 命令最大字符数 */
public int getMaxCommandLength() { return maxCommandLength; }
/** @param maxCommandLength 命令最大字符数 */
public void setMaxCommandLength(int maxCommandLength) { this.maxCommandLength = maxCommandLength; }
/** @return 输出最大字节数 */
public DataSize getMaxOutputSize() { return maxOutputSize; }
/** @param maxOutputSize 输出最大字节数 */
public void setMaxOutputSize(DataSize maxOutputSize) { this.maxOutputSize = maxOutputSize; }
/** @return 单实例最大并发数 */
public int getMaxConcurrentPerInstance() { return maxConcurrentPerInstance; }
/** @param maxConcurrentPerInstance 单实例最大并发数 */
public void setMaxConcurrentPerInstance(int maxConcurrentPerInstance) {
this.maxConcurrentPerInstance = maxConcurrentPerInstance;
}
/**
* 启动期校验 Shell 限制。
*/
@Override
public void afterPropertiesSet() {
if (!positive(defaultTimeout) || !positive(maxTimeout)
|| maxTimeout.compareTo(defaultTimeout) < 0) {
throw new IllegalStateException("Shell 最大超时必须大于等于正值默认超时");
}
if (maxCommandLength <= 0 || maxOutputSize == null || maxOutputSize.toBytes() <= 0
|| maxConcurrentPerInstance <= 0) {
throw new IllegalStateException("Shell 命令长度、输出大小和并发数必须为正值");
}
}
private boolean positive(Duration value) {
return value != null && !value.isZero() && !value.isNegative();
}
}

View File

@@ -0,0 +1,84 @@
package tech.easyflow.agent.config;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.StringUtils;
import org.springframework.util.unit.DataSize;
import java.time.Duration;
/**
* Agent 单会话工作区的路径、配额与保留配置。
*/
@ConfigurationProperties(prefix = "easyflow.agent.workspace")
public class AgentWorkspaceProperties implements InitializingBean {
private String root = "./agent-workspaces";
private DataSize maxTotalSize = DataSize.ofMegabytes(512);
private DataSize maxSingleFileSize = DataSize.ofMegabytes(100);
private int maxFileCount = 2_000;
private DataSize maxReadSize = DataSize.ofMegabytes(2);
private Duration retention = Duration.ofHours(24);
private Duration cleanupInterval = Duration.ofMinutes(30);
/** @return 工作区根目录 */
public String getRoot() { return root; }
/** @param root 工作区根目录 */
public void setRoot(String root) { this.root = root; }
/** @return 单会话工作区总量上限 */
public DataSize getMaxTotalSize() { return maxTotalSize; }
/** @param maxTotalSize 单会话工作区总量上限 */
public void setMaxTotalSize(DataSize maxTotalSize) { this.maxTotalSize = maxTotalSize; }
/** @return 单文件大小上限 */
public DataSize getMaxSingleFileSize() { return maxSingleFileSize; }
/** @param maxSingleFileSize 单文件大小上限 */
public void setMaxSingleFileSize(DataSize maxSingleFileSize) { this.maxSingleFileSize = maxSingleFileSize; }
/** @return 文件数量上限 */
public int getMaxFileCount() { return maxFileCount; }
/** @param maxFileCount 文件数量上限 */
public void setMaxFileCount(int maxFileCount) { this.maxFileCount = maxFileCount; }
/** @return 单次读取大小上限 */
public DataSize getMaxReadSize() { return maxReadSize; }
/** @param maxReadSize 单次读取大小上限 */
public void setMaxReadSize(DataSize maxReadSize) { this.maxReadSize = maxReadSize; }
/** @return 工作区保留期 */
public Duration getRetention() { return retention; }
/** @param retention 工作区保留期 */
public void setRetention(Duration retention) { this.retention = retention; }
/** @return 清理周期 */
public Duration getCleanupInterval() { return cleanupInterval; }
/** @param cleanupInterval 清理周期 */
public void setCleanupInterval(Duration cleanupInterval) { this.cleanupInterval = cleanupInterval; }
/**
* 启动期校验工作区配置,避免以无界或互相矛盾的限制启动。
*/
@Override
public void afterPropertiesSet() {
if (!StringUtils.hasText(root)) {
throw new IllegalStateException("easyflow.agent.workspace.root 不能为空");
}
requirePositive(maxTotalSize, "max-total-size");
requirePositive(maxSingleFileSize, "max-single-file-size");
requirePositive(maxReadSize, "max-read-size");
if (maxTotalSize.toBytes() < maxSingleFileSize.toBytes()) {
throw new IllegalStateException("工作区总量上限不能小于单文件大小上限");
}
if (maxReadSize.toBytes() > maxSingleFileSize.toBytes()) {
throw new IllegalStateException("工作区读取上限不能大于单文件大小上限");
}
if (maxFileCount <= 0 || !positive(retention) || !positive(cleanupInterval)) {
throw new IllegalStateException("工作区文件数量、保留期和清理周期必须为正值");
}
}
private void requirePositive(DataSize value, String name) {
if (value == null || value.toBytes() <= 0) {
throw new IllegalStateException("easyflow.agent.workspace." + name + " 必须为正值");
}
}
private boolean positive(Duration value) {
return value != null && !value.isZero() && !value.isNegative();
}
}

View File

@@ -0,0 +1,46 @@
package tech.easyflow.agent.distributed;
/**
* 不透明审批 ID 对应的内部恢复路由。
*/
public class AgentApprovalRoute {
private String requestId;
private String resumeToken;
/**
* 获取内部请求 ID。
*
* @return 请求 ID
*/
public String getRequestId() {
return requestId;
}
/**
* 设置内部请求 ID。
*
* @param requestId 请求 ID
*/
public void setRequestId(String requestId) {
this.requestId = requestId;
}
/**
* 获取内部恢复令牌。
*
* @return 恢复令牌
*/
public String getResumeToken() {
return resumeToken;
}
/**
* 设置内部恢复令牌。
*
* @param resumeToken 恢复令牌
*/
public void setResumeToken(String resumeToken) {
this.resumeToken = resumeToken;
}
}

View File

@@ -84,12 +84,25 @@ public class AgentRuntimeCommandConsumer implements MQConsumerHandler {
}
try {
if (command.getAction() == AgentRuntimeCommandAction.APPROVE) {
agentRunService.approveRuntimeLocal(
command.getRequestId(), command.getResumeToken(), command.getOperatorId(), command.getUserId());
if (command.getApprovalId() == null || command.getApprovalId().isBlank()) {
agentRunService.approveRuntimeLocal(
command.getRequestId(), command.getResumeToken(),
command.getOperatorId(), command.getUserId());
} else {
agentRunService.approveAguiRuntimeLocal(
command.getRequestId(), command.getResumeToken(), command.getApprovalId(),
command.getOperatorId(), command.getUserId());
}
} else if (command.getAction() == AgentRuntimeCommandAction.REJECT) {
agentRunService.rejectRuntimeLocal(
command.getRequestId(), command.getResumeToken(), command.getReason(),
command.getOperatorId(), command.getUserId());
if (command.getApprovalId() == null || command.getApprovalId().isBlank()) {
agentRunService.rejectRuntimeLocal(
command.getRequestId(), command.getResumeToken(), command.getReason(),
command.getOperatorId(), command.getUserId());
} else {
agentRunService.rejectAguiRuntimeLocal(
command.getRequestId(), command.getResumeToken(), command.getApprovalId(), command.getReason(),
command.getOperatorId(), command.getUserId());
}
} else if (command.getAction() == AgentRuntimeCommandAction.EXPIRE) {
agentRunService.expireApprovalLocal(
command.getRequestId(), command.getResumeToken(), command.getReason());

View File

@@ -11,6 +11,7 @@ public class AgentRuntimeCommandMessage {
private String commandId;
private String requestId;
private String resumeToken;
private String approvalId;
private AgentRuntimeCommandAction action;
private String reason;
private BigInteger operatorId;
@@ -43,6 +44,24 @@ public class AgentRuntimeCommandMessage {
this.resumeToken = resumeToken;
}
/**
* 获取 AG-UI 不透明审批 ID。
*
* @return 审批 ID
*/
public String getApprovalId() {
return approvalId;
}
/**
* 设置 AG-UI 不透明审批 ID。
*
* @param approvalId 审批 ID
*/
public void setApprovalId(String approvalId) {
this.approvalId = approvalId;
}
public AgentRuntimeCommandAction getAction() {
return action;
}

View File

@@ -70,7 +70,29 @@ public class AgentRuntimeCommandProducer {
BigInteger operatorId,
String userId) {
sendAndWait(
targetNodeId, requestId, resumeToken, null,
targetNodeId, requestId, resumeToken, null, null,
AgentRuntimeCommandAction.APPROVE, null, operatorId, userId
);
}
/**
* 投递携带 AG-UI 审批 ID 的远程批准命令。
*
* @param targetNodeId 目标节点 ID
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
* @param approvalId 不透明审批 ID
* @param operatorId 操作人 ID
* @param userId 用户 ID
*/
public void sendApprove(String targetNodeId,
String requestId,
String resumeToken,
String approvalId,
BigInteger operatorId,
String userId) {
sendAndWait(
targetNodeId, requestId, resumeToken, null, approvalId,
AgentRuntimeCommandAction.APPROVE, null, operatorId, userId
);
}
@@ -92,7 +114,31 @@ public class AgentRuntimeCommandProducer {
BigInteger operatorId,
String userId) {
sendAndWait(
targetNodeId, requestId, resumeToken, null,
targetNodeId, requestId, resumeToken, null, null,
AgentRuntimeCommandAction.REJECT, reason, operatorId, userId
);
}
/**
* 投递携带 AG-UI 审批 ID 的远程拒绝命令。
*
* @param targetNodeId 目标节点 ID
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
* @param approvalId 不透明审批 ID
* @param reason 拒绝原因
* @param operatorId 操作人 ID
* @param userId 用户 ID
*/
public void sendReject(String targetNodeId,
String requestId,
String resumeToken,
String approvalId,
String reason,
BigInteger operatorId,
String userId) {
sendAndWait(
targetNodeId, requestId, resumeToken, null, approvalId,
AgentRuntimeCommandAction.REJECT, reason, operatorId, userId
);
}
@@ -110,7 +156,7 @@ public class AgentRuntimeCommandProducer {
String resumeToken,
String reason) {
sendAndWait(
targetNodeId, requestId, resumeToken, null,
targetNodeId, requestId, resumeToken, null, null,
AgentRuntimeCommandAction.EXPIRE, reason, null, null
);
}
@@ -124,7 +170,7 @@ public class AgentRuntimeCommandProducer {
*/
public void sendCancelAgent(String targetNodeId, String agentId, String reason) {
sendAndWait(
targetNodeId, null, null, agentId,
targetNodeId, null, null, agentId, null,
AgentRuntimeCommandAction.CANCEL_AGENT, reason, null, null
);
}
@@ -136,6 +182,7 @@ public class AgentRuntimeCommandProducer {
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
* @param agentId Agent ID
* @param approvalId AG-UI 不透明审批 ID
* @param action 命令动作
* @param reason 操作原因
* @param operatorId 操作人 ID
@@ -146,6 +193,7 @@ public class AgentRuntimeCommandProducer {
String requestId,
String resumeToken,
String agentId,
String approvalId,
AgentRuntimeCommandAction action,
String reason,
BigInteger operatorId,
@@ -158,6 +206,7 @@ public class AgentRuntimeCommandProducer {
command.setRequestId(requestId);
command.setResumeToken(resumeToken);
command.setAgentId(agentId);
command.setApprovalId(approvalId);
command.setAction(action);
command.setReason(reason);
command.setOperatorId(operatorId);

View File

@@ -23,6 +23,7 @@ public class AgentRuntimeRouteRegistry {
private static final String REQUEST_ROUTE_PREFIX = "easyflow:agent:runtime:request:";
private static final String TOKEN_ROUTE_PREFIX = "easyflow:agent:runtime:resume-token:";
private static final String APPROVAL_ROUTE_PREFIX = "easyflow:agent:runtime:approval:";
private static final String NODE_HEARTBEAT_PREFIX = "easyflow:agent:runtime:node:";
private static final String AGENT_RUNS_PREFIX = "easyflow:agent:runtime:agent:";
@@ -89,6 +90,30 @@ public class AgentRuntimeRouteRegistry {
stringRedisTemplate.opsForValue().set(tokenKey(resumeToken), requestId, properties.getRouteTtl());
}
/**
* 注册不透明审批 ID 与内部恢复目标的关系。
*
* @param approvalId 公开审批 ID
* @param requestId 内部请求 ID
* @param resumeToken 内部恢复令牌
*/
public void registerApproval(String approvalId, String requestId, String resumeToken) {
if (approvalId == null || approvalId.isBlank()
|| requestId == null || requestId.isBlank()
|| resumeToken == null || resumeToken.isBlank()) {
return;
}
AgentApprovalRoute route = new AgentApprovalRoute();
route.setRequestId(requestId);
route.setResumeToken(resumeToken);
try {
stringRedisTemplate.opsForValue().set(
approvalKey(approvalId), objectMapper.writeValueAsString(route), properties.getRouteTtl());
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Agent 审批路由序列化失败", exception);
}
}
/**
* 查询请求 ID 所属节点。
*
@@ -130,6 +155,27 @@ public class AgentRuntimeRouteRegistry {
return stringRedisTemplate.opsForValue().get(tokenKey(resumeToken));
}
/**
* 根据公开审批 ID 查询内部恢复目标。
*
* @param approvalId 公开审批 ID
* @return 审批恢复目标,不存在时返回 null
*/
public AgentApprovalRoute findApproval(String approvalId) {
if (approvalId == null || approvalId.isBlank()) {
return null;
}
String value = stringRedisTemplate.opsForValue().get(approvalKey(approvalId));
if (value == null || value.isBlank()) {
return null;
}
try {
return objectMapper.readValue(value, AgentApprovalRoute.class);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Agent 审批路由反序列化失败", exception);
}
}
/**
* 查询指定 Agent 当前活跃运行所在的节点。
*
@@ -192,6 +238,18 @@ public class AgentRuntimeRouteRegistry {
deleteQuietly(tokenKey(resumeToken));
}
/**
* 删除公开审批 ID 的内部路由。
*
* @param approvalId 公开审批 ID
*/
public void removeApproval(String approvalId) {
if (approvalId == null || approvalId.isBlank()) {
return;
}
deleteQuietly(approvalKey(approvalId));
}
/**
* 获取当前节点 ID。
*
@@ -241,6 +299,10 @@ public class AgentRuntimeRouteRegistry {
return TOKEN_ROUTE_PREFIX + resumeToken;
}
private String approvalKey(String approvalId) {
return APPROVAL_ROUTE_PREFIX + approvalId;
}
private String nodeKey(String nodeId) {
return NODE_HEARTBEAT_PREFIX + nodeId;
}

View File

@@ -71,6 +71,8 @@ public class Agent extends DateEntity implements VisibilityResource, Serializabl
private List<AgentToolBinding> toolBindings;
@Column(ignore = true)
private List<AgentKnowledgeBinding> knowledgeBindings;
@Column(ignore = true)
private List<AgentSkillBinding> skillBindings;
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
@@ -144,4 +146,8 @@ public class Agent extends DateEntity implements VisibilityResource, Serializabl
public void setToolBindings(List<AgentToolBinding> toolBindings) { this.toolBindings = toolBindings; }
public List<AgentKnowledgeBinding> getKnowledgeBindings() { return knowledgeBindings; }
public void setKnowledgeBindings(List<AgentKnowledgeBinding> knowledgeBindings) { this.knowledgeBindings = knowledgeBindings; }
/** @return Skill 绑定 */
public List<AgentSkillBinding> getSkillBindings() { return skillBindings; }
/** @param skillBindings Skill 绑定 */
public void setSkillBindings(List<AgentSkillBinding> skillBindings) { this.skillBindings = skillBindings; }
}

View File

@@ -0,0 +1,168 @@
package tech.easyflow.agent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import tech.easyflow.common.entity.DateEntity;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
/**
* Agent 正式产物的对象存储状态账本。
*/
@Table("tb_agent_artifact")
public class AgentArtifact extends DateEntity implements Serializable {
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
private BigInteger id;
private String artifactId;
@Column(tenantId = true)
private BigInteger tenantId;
private BigInteger agentId;
private BigInteger ownerUserId;
private String chatMode;
private BigInteger chatSessionId;
private String runtimeSessionId;
private String requestId;
private BigInteger roundId;
private Integer variantIndex;
private String toolCallId;
private String fileName;
private String mimeType;
private Long sizeBytes;
private String sha256;
private String storagePlatform;
private String objectKey;
private String storageEtag;
private String status;
private Date expiresAt;
private Integer retryCount;
private Date nextRetryAt;
private String lastErrorCode;
private Date created;
private BigInteger createdBy;
private Date modified;
private BigInteger modifiedBy;
@Column(isLogicDelete = true)
private Integer isDeleted;
/** @return 内部主键 */
public BigInteger getId() { return id; }
/** @param id 内部主键 */
public void setId(BigInteger id) { this.id = id; }
/** @return 对外稳定产物 ID */
public String getArtifactId() { return artifactId; }
/** @param artifactId 对外稳定产物 ID */
public void setArtifactId(String artifactId) { this.artifactId = artifactId; }
/** @return 租户 ID */
public BigInteger getTenantId() { return tenantId; }
/** @param tenantId 租户 ID */
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
/** @return Agent ID */
public BigInteger getAgentId() { return agentId; }
/** @param agentId Agent ID */
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
/** @return 所有者用户 ID */
public BigInteger getOwnerUserId() { return ownerUserId; }
/** @param ownerUserId 所有者用户 ID */
public void setOwnerUserId(BigInteger ownerUserId) { this.ownerUserId = ownerUserId; }
/** @return 聊天模式 */
public String getChatMode() { return chatMode; }
/** @param chatMode 聊天模式 */
public void setChatMode(String chatMode) { this.chatMode = chatMode; }
/** @return 正式聊天会话 ID */
public BigInteger getChatSessionId() { return chatSessionId; }
/** @param chatSessionId 正式聊天会话 ID */
public void setChatSessionId(BigInteger chatSessionId) { this.chatSessionId = chatSessionId; }
/** @return Runtime 会话 ID */
public String getRuntimeSessionId() { return runtimeSessionId; }
/** @param runtimeSessionId Runtime 会话 ID */
public void setRuntimeSessionId(String runtimeSessionId) { this.runtimeSessionId = runtimeSessionId; }
/** @return 运行请求 ID */
public String getRequestId() { return requestId; }
/** @param requestId 运行请求 ID */
public void setRequestId(String requestId) { this.requestId = requestId; }
/** @return 聊天轮次 ID */
public BigInteger getRoundId() { return roundId; }
/** @param roundId 聊天轮次 ID */
public void setRoundId(BigInteger roundId) { this.roundId = roundId; }
/** @return 正式聊天答案版本序号 */
public Integer getVariantIndex() { return variantIndex; }
/** @param variantIndex 正式聊天答案版本序号 */
public void setVariantIndex(Integer variantIndex) { this.variantIndex = variantIndex; }
/** @return 工具调用 ID */
public String getToolCallId() { return toolCallId; }
/** @param toolCallId 工具调用 ID */
public void setToolCallId(String toolCallId) { this.toolCallId = toolCallId; }
/** @return 安全展示文件名 */
public String getFileName() { return fileName; }
/** @param fileName 安全展示文件名 */
public void setFileName(String fileName) { this.fileName = fileName; }
/** @return MIME 类型 */
public String getMimeType() { return mimeType; }
/** @param mimeType MIME 类型 */
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
/** @return 字节数 */
public Long getSizeBytes() { return sizeBytes; }
/** @param sizeBytes 字节数 */
public void setSizeBytes(Long sizeBytes) { this.sizeBytes = sizeBytes; }
/** @return SHA-256 */
public String getSha256() { return sha256; }
/** @param sha256 SHA-256 */
public void setSha256(String sha256) { this.sha256 = sha256; }
/** @return 内部存储平台 */
public String getStoragePlatform() { return storagePlatform; }
/** @param storagePlatform 内部存储平台 */
public void setStoragePlatform(String storagePlatform) { this.storagePlatform = storagePlatform; }
/** @return 内部对象键 */
public String getObjectKey() { return objectKey; }
/** @param objectKey 内部对象键 */
public void setObjectKey(String objectKey) { this.objectKey = objectKey; }
/** @return 对象 ETag */
public String getStorageEtag() { return storageEtag; }
/** @param storageEtag 对象 ETag */
public void setStorageEtag(String storageEtag) { this.storageEtag = storageEtag; }
/** @return 账本状态 */
public String getStatus() { return status; }
/** @param status 账本状态 */
public void setStatus(String status) { this.status = status; }
/** @return 过期时间 */
public Date getExpiresAt() { return expiresAt; }
/** @param expiresAt 过期时间 */
public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; }
/** @return 重试次数 */
public Integer getRetryCount() { return retryCount; }
/** @param retryCount 重试次数 */
public void setRetryCount(Integer retryCount) { this.retryCount = retryCount; }
/** @return 下次重试时间 */
public Date getNextRetryAt() { return nextRetryAt; }
/** @param nextRetryAt 下次重试时间 */
public void setNextRetryAt(Date nextRetryAt) { this.nextRetryAt = nextRetryAt; }
/** @return 最近错误码 */
public String getLastErrorCode() { return lastErrorCode; }
/** @param lastErrorCode 最近错误码 */
public void setLastErrorCode(String lastErrorCode) { this.lastErrorCode = lastErrorCode; }
/** @return 创建时间 */
@Override public Date getCreated() { return created; }
/** @param created 创建时间 */
@Override public void setCreated(Date created) { this.created = created; }
/** @return 创建人 */
public BigInteger getCreatedBy() { return createdBy; }
/** @param createdBy 创建人 */
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
/** @return 修改时间 */
@Override public Date getModified() { return modified; }
/** @param modified 修改时间 */
@Override public void setModified(Date modified) { this.modified = modified; }
/** @return 修改人 */
public BigInteger getModifiedBy() { return modifiedBy; }
/** @param modifiedBy 修改人 */
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
/** @return 逻辑删除标记 */
public Integer getIsDeleted() { return isDeleted; }
/** @param isDeleted 逻辑删除标记 */
public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; }
}

View File

@@ -0,0 +1,88 @@
package tech.easyflow.agent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.handler.FastjsonTypeHandler;
import tech.easyflow.common.entity.DateEntity;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Agent 与已发布 Skill 的原子草稿绑定。
*/
@Table("tb_agent_skill_binding")
public class AgentSkillBinding extends DateEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
private BigInteger id;
@Column(tenantId = true)
private BigInteger tenantId;
private BigInteger agentId;
private BigInteger skillId;
private Integer sortNo;
private Date created;
private BigInteger createdBy;
private Date modified;
private BigInteger modifiedBy;
@Column(ignore = true, typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> resourceSnapshot = new LinkedHashMap<>();
@Column(ignore = true, typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> resourceSummary = new LinkedHashMap<>();
/** @return 绑定 ID */
public BigInteger getId() { return id; }
/** @param id 绑定 ID */
public void setId(BigInteger id) { this.id = id; }
/** @return 租户 ID */
public BigInteger getTenantId() { return tenantId; }
/** @param tenantId 租户 ID */
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
/** @return Agent ID */
public BigInteger getAgentId() { return agentId; }
/** @param agentId Agent ID */
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
/** @return Skill ID */
public BigInteger getSkillId() { return skillId; }
/** @param skillId Skill ID */
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
/** @return 排序号 */
public Integer getSortNo() { return sortNo; }
/** @param sortNo 排序号 */
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
/** @return 创建时间 */
@Override public Date getCreated() { return created; }
/** @param created 创建时间 */
@Override public void setCreated(Date created) { this.created = created; }
/** @return 创建人 */
public BigInteger getCreatedBy() { return createdBy; }
/** @param createdBy 创建人 */
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
/** @return 修改时间 */
@Override public Date getModified() { return modified; }
/** @param modified 修改时间 */
@Override public void setModified(Date modified) { this.modified = modified; }
/** @return 修改人 */
public BigInteger getModifiedBy() { return modifiedBy; }
/** @param modifiedBy 修改人 */
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
/** @return Agent 内部冻结 Skill 运行快照 */
public Map<String, Object> getResourceSnapshot() { return resourceSnapshot; }
/** @param resourceSnapshot Agent 内部冻结 Skill 运行快照 */
public void setResourceSnapshot(Map<String, Object> resourceSnapshot) {
this.resourceSnapshot = resourceSnapshot == null ? new LinkedHashMap<>() : resourceSnapshot;
}
/** @return 脱敏 Skill 摘要 */
public Map<String, Object> getResourceSummary() { return resourceSummary; }
/** @param resourceSummary 脱敏 Skill 摘要 */
public void setResourceSummary(Map<String, Object> resourceSummary) {
this.resourceSummary = resourceSummary == null ? new LinkedHashMap<>() : resourceSummary;
}
}

View File

@@ -0,0 +1,41 @@
package tech.easyflow.agent.mapper;
import com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import tech.easyflow.agent.entity.AgentArtifact;
import java.util.List;
/**
* Agent Artifact 状态账本 Mapper。
*/
public interface AgentArtifactMapper extends BaseMapper<AgentArtifact> {
/**
* 有界查询正式会话已删除、缺失或归属不一致的 Artifact。
*
* @param limit 最大返回数量
* @return 待补偿删除的 Artifact
*/
@Select("""
SELECT artifact.*
FROM tb_agent_artifact artifact
WHERE artifact.is_deleted = 0
AND artifact.chat_mode = 'FORMAL'
AND artifact.status IN ('PUBLISHING', 'AVAILABLE', 'FAILED', 'DELETE_FAILED')
AND NOT EXISTS (
SELECT 1
FROM chat_session chat
WHERE chat.id = artifact.chat_session_id
AND chat.is_deleted = 0
AND chat.assistant_code = 'AGENT'
AND chat.tenant_id = artifact.tenant_id
AND chat.user_id = artifact.owner_user_id
AND chat.assistant_id = artifact.agent_id
)
ORDER BY artifact.id
LIMIT #{limit}
""")
List<AgentArtifact> selectOrphanedFormalArtifacts(@Param("limit") int limit);
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.agent.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.agent.entity.AgentSkillBinding;
/**
* Agent Skill 绑定 Mapper。
*/
public interface AgentSkillBindingMapper extends BaseMapper<AgentSkillBinding> {
}

View File

@@ -9,11 +9,13 @@ import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.entity.AgentSkillBinding;
import tech.easyflow.agent.runtime.AgentRunRegistry;
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
import tech.easyflow.agent.service.AgentService;
import tech.easyflow.agent.service.AgentToolBindingService;
import tech.easyflow.agent.service.AgentSkillBindingService;
import tech.easyflow.agent.support.AgentBindingLockExecutor;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.publish.AbstractAiResourceLifecycleHandler;
@@ -38,6 +40,7 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
private final AgentService agentService;
private final AgentToolBindingService agentToolBindingService;
private final AgentKnowledgeBindingService agentKnowledgeBindingService;
private final AgentSkillBindingService agentSkillBindingService;
private final ResourceAccessService resourceAccessService;
private final AgentBindingLockExecutor agentBindingLockExecutor;
private final AgentRunRegistry agentRunRegistry;
@@ -53,6 +56,7 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
* @param agentService Agent 服务
* @param agentToolBindingService Agent 工具绑定服务
* @param agentKnowledgeBindingService Agent 知识库绑定服务
* @param agentSkillBindingService Agent Skill 绑定服务
* @param resourceAccessService 资源访问服务
* @param agentBindingLockExecutor Agent 配置锁执行器
* @param agentRunRegistry Agent 运行态注册表
@@ -65,6 +69,7 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
AgentService agentService,
AgentToolBindingService agentToolBindingService,
AgentKnowledgeBindingService agentKnowledgeBindingService,
AgentSkillBindingService agentSkillBindingService,
ResourceAccessService resourceAccessService,
AgentBindingLockExecutor agentBindingLockExecutor,
AgentRunRegistry agentRunRegistry,
@@ -75,6 +80,7 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
this.agentService = agentService;
this.agentToolBindingService = agentToolBindingService;
this.agentKnowledgeBindingService = agentKnowledgeBindingService;
this.agentSkillBindingService = agentSkillBindingService;
this.resourceAccessService = resourceAccessService;
this.agentBindingLockExecutor = agentBindingLockExecutor;
this.agentRunRegistry = agentRunRegistry;
@@ -196,6 +202,8 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
QueryWrapper.create().eq(AgentToolBinding::getAgentId, resourceId));
agentKnowledgeBindingService.remove(
QueryWrapper.create().eq(AgentKnowledgeBinding::getAgentId, resourceId));
agentSkillBindingService.remove(
QueryWrapper.create().eq(AgentSkillBinding::getAgentId, resourceId));
agentService.removeById(resourceId);
return null;
});

View File

@@ -3,6 +3,7 @@ package tech.easyflow.agent.runtime;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.entity.AgentSkillBinding;
import java.util.List;
import java.util.ArrayList;
@@ -15,6 +16,7 @@ public class AgentDraftChatRequest {
private Agent agent;
private List<AgentToolBinding> toolBindings;
private List<AgentKnowledgeBinding> knowledgeBindings;
private List<AgentSkillBinding> skillBindings;
private String sessionId;
private String prompt;
private List<String> imageUploadIds = new ArrayList<>();
@@ -74,6 +76,24 @@ public class AgentDraftChatRequest {
this.knowledgeBindings = knowledgeBindings;
}
/**
* 获取 Skill 绑定快照。
*
* @return Skill 绑定快照
*/
public List<AgentSkillBinding> getSkillBindings() {
return skillBindings;
}
/**
* 设置 Skill 绑定快照。
*
* @param skillBindings Skill 绑定快照
*/
public void setSkillBindings(List<AgentSkillBinding> skillBindings) {
this.skillBindings = skillBindings;
}
/**
* 获取草稿试运行会话 ID。
*

View File

@@ -10,11 +10,14 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import reactor.core.Disposable;
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
import tech.easyflow.agent.distributed.AgentApprovalRoute;
import tech.easyflow.agent.runtime.lock.AgentRunLock;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
import tech.easyflow.agent.runtime.output.AgentRunOutput;
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
import tech.easyflow.core.runtime.ChatRuntimeContext;
import tech.easyflow.core.chat.protocol.ChatDomain;
import tech.easyflow.core.chat.protocol.ChatType;
import java.util.ArrayList;
import java.util.Map;
@@ -23,6 +26,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.UUID;
/**
* Agent 运行态注册表。
@@ -36,6 +40,8 @@ public class AgentRunRegistry {
private final Map<String, String> sessionRuns = new ConcurrentHashMap<>();
private final Map<String, String> resumeTokenIndex = new ConcurrentHashMap<>();
private final Map<String, Set<String>> requestTokens = new ConcurrentHashMap<>();
private final Map<String, ApprovalTarget> approvalTargets = new ConcurrentHashMap<>();
private final Map<String, Set<String>> requestApprovals = new ConcurrentHashMap<>();
private final Map<String, RunOwner> owners = new ConcurrentHashMap<>();
private AgentRuntimeRouteRegistry routeRegistry;
@@ -102,6 +108,16 @@ public class AgentRunRegistry {
return requestId == null ? null : runs.get(requestId);
}
/**
* 判断指定 Runtime 会话当前是否仍有活动运行。
*
* @param sessionId Runtime 会话 ID
* @return 有活动运行时为 true
*/
public boolean hasActiveSession(String sessionId) {
return sessionId != null && sessionRuns.containsKey(sessionId);
}
/**
* 取消并移除指定会话当前活跃运行。
*
@@ -174,6 +190,121 @@ public class AgentRunRegistry {
}
}
/**
* 为内部恢复目标注册随机且不可推测的公开审批 ID。
*
* @param requestId 内部请求 ID
* @param resumeToken 内部恢复令牌
* @return 公开审批 ID
*/
public String registerApproval(String requestId, String resumeToken) {
if (requestId == null || requestId.isBlank() || resumeToken == null || resumeToken.isBlank()) {
throw new BusinessException("Agent 审批恢复目标不能为空");
}
String approvalId = "approval_" + UUID.randomUUID();
ApprovalTarget target = new ApprovalTarget(requestId, resumeToken);
approvalTargets.put(approvalId, target);
requestApprovals.computeIfAbsent(requestId, ignored -> ConcurrentHashMap.newKeySet()).add(approvalId);
if (routeRegistry != null) {
routeRegistry.registerApproval(approvalId, requestId, resumeToken);
}
return approvalId;
}
/**
* 解析公开审批 ID 对应的内部恢复目标。
*
* @param approvalId 公开审批 ID
* @return 内部恢复目标
*/
public ApprovalTarget resolveApproval(String approvalId) {
if (approvalId == null || approvalId.isBlank()) {
throw new BusinessException("Agent 审批 ID 不能为空");
}
ApprovalTarget local = approvalTargets.get(approvalId);
if (local != null) {
return local;
}
AgentApprovalRoute route = routeRegistry == null ? null : routeRegistry.findApproval(approvalId);
if (route == null || route.getRequestId() == null || route.getResumeToken() == null) {
throw new BusinessException("Agent 审批请求不存在或已失效");
}
return new ApprovalTarget(route.getRequestId(), route.getResumeToken());
}
/**
* 根据当前节点的内部恢复目标查询公开审批 ID。
*
* @param requestId 内部请求 ID
* @param resumeToken 内部恢复令牌
* @return 公开审批 ID不存在时返回 null
*/
public String findApprovalId(String requestId, String resumeToken) {
Set<String> approvals = requestApprovals.get(requestId);
if (approvals == null || approvals.isEmpty()) {
return null;
}
for (String approvalId : approvals) {
ApprovalTarget target = approvalTargets.get(approvalId);
if (target != null && java.util.Objects.equals(resumeToken, target.resumeToken())) {
return approvalId;
}
}
return null;
}
/**
* 校验本节点审批目标归属。
*
* @param approvalId 公开审批 ID
* @param userId 当前用户 ID
*/
public void assertApprovalOwner(String approvalId, String userId) {
ApprovalTarget target = resolveApproval(approvalId);
if (runs.containsKey(target.requestId())) {
assertOwner(target.requestId(), userId);
}
}
/**
* 清理已经消费的公开审批 ID。
*
* @param approvalId 公开审批 ID
*/
public void removeApproval(String approvalId) {
ApprovalTarget target = approvalTargets.remove(approvalId);
if (target != null) {
Set<String> approvals = requestApprovals.get(target.requestId());
if (approvals != null) {
approvals.remove(approvalId);
}
}
if (routeRegistry != null) {
routeRegistry.removeApproval(approvalId);
}
}
/**
* 在恢复 Runtime 前向原连接发送审批决议。
*
* @param approvalId 公开审批 ID
* @param status 决议状态
* @param reason 拒绝原因
* @return 本节点存在连接且发送成功时为 true
*/
public boolean emitApprovalResolved(String approvalId, String status, String reason) {
ApprovalTarget target = resolveApproval(approvalId);
AgentRunContext context = runs.get(target.requestId());
if (context == null) {
return false;
}
Map<String, Object> payload = new java.util.LinkedHashMap<>();
payload.put("approvalId", approvalId);
payload.put("status", status);
payload.put("reason", reason);
return context.runOutput().emitViewEvent(ChatDomain.TOOL, ChatType.FORM_CANCEL, payload);
}
/**
* 运行结束后移除运行态。
*
@@ -199,6 +330,15 @@ public class AgentRunRegistry {
}
});
}
Set<String> approvals = requestApprovals.remove(requestId);
if (approvals != null) {
approvals.forEach(approvalId -> {
approvalTargets.remove(approvalId);
if (routeRegistry != null) {
routeRegistry.removeApproval(approvalId);
}
});
}
if (routeRegistry != null) {
routeRegistry.removeRun(requestId);
}
@@ -368,6 +508,15 @@ public class AgentRunRegistry {
public record RunOwner(String agentId, String sessionId, String userId) {
}
/**
* 公开审批 ID 解析后的内部恢复目标。
*
* @param requestId 内部请求 ID
* @param resumeToken 内部恢复令牌
*/
public record ApprovalTarget(String requestId, String resumeToken) {
}
/**
* 单机内存运行态。
*
@@ -377,7 +526,7 @@ public class AgentRunRegistry {
private final String requestId;
private final String sessionId;
private final AgentRuntime runtime;
private final ChatSseEmitter chatSseEmitter;
private final AgentRunOutput runOutput;
private final ChatRuntimeContext chatContext;
private final StringBuilder answer;
private final ChatAssistantAccumulator assistantAccumulator;
@@ -397,7 +546,7 @@ public class AgentRunRegistry {
* @param requestId 请求 ID
* @param sessionId 会话 ID
* @param runtime 有状态运行时
* @param chatSseEmitter SSE 连接
* @param runOutput SSE 连接
* @param chatContext 聊天上下文
* @param answer 助手正文累计缓冲
* @param assistantAccumulator 助手结构化累计器
@@ -411,7 +560,7 @@ public class AgentRunRegistry {
public AgentRunContext(String requestId,
String sessionId,
AgentRuntime runtime,
ChatSseEmitter chatSseEmitter,
AgentRunOutput runOutput,
ChatRuntimeContext chatContext,
StringBuilder answer,
ChatAssistantAccumulator assistantAccumulator,
@@ -425,7 +574,7 @@ public class AgentRunRegistry {
this.requestId = requestId;
this.sessionId = sessionId;
this.runtime = runtime;
this.chatSseEmitter = chatSseEmitter;
this.runOutput = runOutput;
this.chatContext = chatContext;
this.answer = answer;
this.assistantAccumulator = assistantAccumulator;
@@ -465,6 +614,15 @@ public class AgentRunRegistry {
return owner;
}
/**
* 获取协议无关运行输出。
*
* @return 运行输出
*/
public AgentRunOutput runOutput() {
return runOutput;
}
/**
* 获取运行事件处理器。
*
@@ -550,8 +708,8 @@ public class AgentRunRegistry {
*/
public void cancelAndComplete() {
cancel();
if (finished.compareAndSet(false, true) && chatSseEmitter != null) {
chatSseEmitter.complete();
if (finished.compareAndSet(false, true) && runOutput != null) {
runOutput.complete();
}
}

View File

@@ -2,7 +2,9 @@ package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.AgentDefinition;
import com.easyagents.agent.runtime.AgentExecutionOptions;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
import com.easyagents.agent.runtime.AgentRuntimeContext;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgePolicy;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
@@ -11,28 +13,39 @@ import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter;
import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
import com.easyagents.agent.runtime.memory.AgentMemoryType;
import com.easyagents.agent.runtime.mcp.McpSpec;
import com.easyagents.agent.runtime.mcp.McpTransportType;
import com.easyagents.agent.runtime.mcp.McpToolManifestEntry;
import com.easyagents.agent.runtime.model.AgentGenerationOptions;
import com.easyagents.agent.runtime.model.AgentModelSpec;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import com.easyagents.agent.runtime.tool.AgentToolCategory;
import com.easyagents.agent.runtime.tool.AgentToolResult;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import com.easyagents.agent.runtime.tool.AgentToolVisibility;
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolAdapter;
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolSpec;
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolType;
import com.easyagents.agent.runtime.tool.operate.ControlledShellTool;
import com.easyagents.agent.runtime.tool.operate.WorkspaceQuotaLimits;
import com.easyagents.agent.runtime.tool.operate.WorkspaceQuotaHook;
import com.easyagents.core.document.Document;
import com.easyagents.core.model.chat.tool.Parameter;
import com.easyagents.core.model.chat.tool.Tool;
import io.agentscope.core.tool.Toolkit;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.enums.AgentToolType;
import tech.easyflow.agent.config.AgentBuiltinToolsConfig;
import tech.easyflow.agent.config.AgentBuiltinToolsConfigResolver;
import tech.easyflow.agent.config.AgentShellProperties;
import tech.easyflow.agent.config.AgentWorkspaceProperties;
import tech.easyflow.agent.runtime.artifact.AgentArtifactOperationException;
import tech.easyflow.agent.runtime.artifact.AgentArtifactService;
import tech.easyflow.agent.runtime.artifact.AgentArtifactView;
import tech.easyflow.agent.runtime.workspace.AgentWorkspaceResolver;
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompilation;
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
import tech.easyflow.ai.easyagents.tool.ChatToolNameHelper;
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompilation;
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompiler;
import tech.easyflow.ai.entity.*;
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
@@ -41,9 +54,8 @@ import tech.easyflow.common.web.exceptions.BusinessException;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.Duration;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.*;
/**
@@ -59,22 +71,29 @@ public class AgentRuntimeCompiler {
* EasyFlow 仅按 Token 阈值触发压缩,消息数阈值固定为不可达上限。
*/
private static final int DISABLED_MESSAGE_COMPRESSION_THRESHOLD = Integer.MAX_VALUE;
private static final Pattern MCP_INPUT_PATTERN = Pattern.compile("\\$\\{input:([A-Za-z0-9_.-]+)}");
private static final int MAX_RUNTIME_TOOL_COUNT = 128;
private static final long MAX_RUNTIME_SCHEMA_BYTES = 2L * 1024L * 1024L;
@Resource
private ModelService modelService;
@Resource
private WorkflowService workflowService;
@Resource
private PluginItemService pluginItemService;
@Resource
private McpService mcpService;
@Resource
private DocumentCollectionService documentCollectionService;
@Resource
private ObjectMapper objectMapper;
@Resource
private AgentToolRuntimeCompiler agentToolRuntimeCompiler;
@Resource
private AgentSkillRuntimeCompiler agentSkillRuntimeCompiler;
@Resource
private AgentBuiltinToolsConfigResolver agentBuiltinToolsConfigResolver;
@Resource
private AgentWorkspaceResolver agentWorkspaceResolver;
@Resource
private AgentWorkspaceProperties agentWorkspaceProperties;
@Resource
private AgentShellProperties agentShellProperties;
@Resource
private AgentArtifactService agentArtifactService;
/**
* 编译 Agent 运行时定义和调用器。
@@ -99,10 +118,39 @@ public class AgentRuntimeCompiler {
bundle.setDefinition(definition);
compileTools(agent, definition, bundle);
if (agentBuiltinToolsConfigResolver != null) {
validateBuiltinTools(definition,
agentBuiltinToolsConfigResolver.resolvePublishedRuntime(agent.getExecutionConfigJson()));
}
compileKnowledge(agent, definition, bundle);
return bundle;
}
/**
* 为真实运行会话编译并附加会话隔离的内置工具。
*
* <p>该重载先复用发布校验编译,再使用可信 RuntimeContext 创建当前会话工作区。</p>
*
* @param agent Agent 运行视图
* @param runtimeContext 可信运行上下文
* @param draftMode 是否为草稿试运行
* @return 带会话操作工具及 Artifact 调用器的运行时编译结果
*/
public AgentRuntimeBundle compile(Agent agent,
AgentRuntimeContext runtimeContext,
boolean draftMode) {
AgentRuntimeBundle bundle = compile(agent);
// 仅兼容未经过 Spring 装配的历史单元测试桩;生产 Bean 必须完整注入以下依赖。
if (agentBuiltinToolsConfigResolver == null) {
return bundle;
}
AgentBuiltinToolsConfig config = draftMode
? agentBuiltinToolsConfigResolver.resolveDraftRuntime(agent.getExecutionConfigJson())
: agentBuiltinToolsConfigResolver.resolvePublishedRuntime(agent.getExecutionConfigJson());
attachBuiltinTools(agent, runtimeContext, draftMode, config, bundle);
return bundle;
}
private AgentModelSpec buildModelSpec(Agent agent) {
Model model = modelService.getModelInstance(agent.getModelId());
if (model == null) {
@@ -211,172 +259,336 @@ public class AgentRuntimeCompiler {
}
private void compileTools(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) {
AgentToolRuntimeCompilation compilation = agentToolRuntimeCompiler.compile(agent);
definition.setToolSpecs(compilation.getToolSpecs());
definition.setMcpSpecs(compilation.getMcpSpecs());
bundle.setToolInvokers(compilation.getToolInvokers());
}
private Tool buildTool(AgentToolBinding binding) {
AgentToolType type = AgentToolType.from(binding.getToolType());
if (type == AgentToolType.WORKFLOW) {
Workflow workflow = snapshotOrPublishedWorkflow(binding);
if (workflow == null) {
throw new BusinessException("绑定工作流不存在");
AgentToolRuntimeCompilation direct = agentToolRuntimeCompiler.compile(agent);
AgentSkillRuntimeCompilation skills = agentSkillRuntimeCompiler.compile(agent);
List<AgentToolSpec> toolSpecs = new ArrayList<>(direct.getToolSpecs());
Set<String> names = new LinkedHashSet<>();
direct.getToolSpecs().forEach(spec -> names.add(spec.getName()));
for (AgentToolSpec spec : skills.getToolSpecs()) {
if (!names.add(spec.getName())) {
throw new BusinessException("Agent Tool 运行名冲突:" + spec.getName());
}
return new WorkflowTool(
workflow,
true,
PublishedWorkflowDefinitionIds.published(String.valueOf(workflow.getId()))
);
toolSpecs.add(spec);
}
if (type == AgentToolType.PLUGIN) {
PluginItem pluginItem = snapshotOrCurrentPlugin(binding);
if (pluginItem == null) {
throw new BusinessException("绑定插件不存在");
List<McpSpec> mcpSpecs = new ArrayList<>(direct.getMcpSpecs());
mcpSpecs.addAll(skills.getMcpSpecs());
assertToolBudget(toolSpecs, mcpSpecs);
Map<String, com.easyagents.agent.runtime.tool.AgentToolInvoker> invokers =
new LinkedHashMap<>(direct.getToolInvokers());
skills.getToolInvokers().forEach((name, invoker) -> {
if (invokers.putIfAbsent(name, invoker) != null) {
throw new BusinessException("Agent Tool 运行名冲突:" + name);
}
return pluginItem.toFunction();
}
throw new BusinessException("不支持的 Agent 工具类型:" + type.name());
});
definition.setToolSpecs(toolSpecs);
definition.setMcpSpecs(mcpSpecs);
definition.setSkillBoxSpec(skills.getSkillBoxSpec());
bundle.setToolInvokers(invokers);
}
private McpSpec buildMcpSpec(AgentToolBinding binding) {
Mcp mcp = snapshotOrCurrentMcp(binding);
if (mcp == null) {
throw new BusinessException("绑定 MCP 不存在");
private void validateBuiltinTools(AgentDefinition definition, AgentBuiltinToolsConfig config) {
Set<String> builtinNames = builtinToolNames(config);
assertNoBuiltinNameConflict(definition, builtinNames);
List<AgentToolSpec> specs = new ArrayList<>(definition.getToolSpecs());
specs.addAll(buildOperateBudgetSpecs(config));
if (config.artifactPublish().enabled()) {
specs.add(buildArtifactPublishSpec(config.artifactPublish()));
}
Map.Entry<String, Map<String, Object>> server = firstMcpServer(mcp);
Map<String, Object> serverConfig = server.getValue();
McpTransportType transportType = parseMcpTransportType(mcp, serverConfig);
McpSpec spec = new McpSpec();
spec.setName(mcpRuntimeName(mcp));
spec.setDescription(firstNonBlank(mcp.getDescription(), mcp.getTitle()));
spec.setTransportType(transportType);
spec.setCommand(resolveMcpInput(stringValue(serverConfig, "command", null)));
spec.setArgs(resolveMcpInputs(stringListValue(serverConfig, "args")));
spec.setEnv(resolveMcpInputMap(stringMapValue(serverConfig, "env")));
spec.setUrl(resolveMcpInput(stringValue(serverConfig, "url", null)));
spec.setHeaders(resolveMcpInputMap(stringMapValue(serverConfig, "headers")));
spec.setQueryParams(resolveMcpInputMap(stringMapValue(serverConfig, "queryParams")));
Duration timeout = durationValue(serverConfig, "timeout");
if (timeout != null) {
spec.setTimeout(timeout);
}
Duration initializationTimeout = durationValue(serverConfig, "initializationTimeout");
if (initializationTimeout != null) {
spec.setInitializationTimeout(initializationTimeout);
}
spec.setGroupName(mcpRuntimeName(mcp));
spec.setApprovalRequired(Boolean.TRUE.equals(mcp.getApprovalRequired()));
spec.setApprovalRequest(buildMcpApprovalRequest(mcp));
spec.setToolNamePrefix(mcpRuntimeToolPrefix(mcp.getId()));
spec.getMetadata().put("toolType", AgentToolType.MCP.name());
spec.getMetadata().put("mcpId", String.valueOf(mcp.getId()));
spec.getMetadata().put("mcpTitle", mcp.getTitle());
spec.getMetadata().put("serverName", server.getKey());
return spec;
assertToolBudget(specs, definition.getMcpSpecs());
}
private void applyMcpToolBinding(McpSpec spec, AgentToolBinding binding) {
if (Boolean.TRUE.equals(binding.getHitlEnabled())) {
spec.setApprovalRequired(true);
spec.setApprovalRequest(buildBindingApprovalRequest(binding));
private void attachBuiltinTools(Agent agent,
AgentRuntimeContext runtimeContext,
boolean draftMode,
AgentBuiltinToolsConfig config,
AgentRuntimeBundle bundle) {
if (runtimeContext == null || runtimeContext.getTenantId() == null
|| runtimeContext.getSessionId() == null) {
throw new BusinessException("Agent 内置工具运行上下文不完整");
}
}
private AgentToolApprovalRequest buildMcpApprovalRequest(Mcp mcp) {
AgentToolApprovalRequest request = new AgentToolApprovalRequest();
request.setApprovalPrompt("是否批准执行 MCP 工具:" + firstNonBlank(mcp.getTitle(), mcpRuntimeName(mcp)));
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("toolType", AgentToolType.MCP.name());
metadata.put("mcpId", String.valueOf(mcp.getId()));
metadata.put("mcpTitle", mcp.getTitle());
request.setMetadata(metadata);
return request;
}
private AgentToolApprovalRequest buildBindingApprovalRequest(AgentToolBinding binding) {
AgentToolApprovalRequest request = new AgentToolApprovalRequest();
request.setApprovalPrompt(stringValue(binding.getHitlConfigJson(), "prompt", "是否批准执行 MCP 工具"));
Map<String, Object> metadata = sanitizedHitlMetadata(binding.getHitlConfigJson());
metadata.put("toolType", binding.getToolType());
metadata.put("bindingId", binding.getId());
metadata.put("targetId", binding.getTargetId());
request.setMetadata(metadata);
return request;
}
private AgentToolSpec toToolSpec(Tool tool, AgentToolBinding binding) {
AgentToolSpec spec = new AgentToolSpec();
String name = resolveRuntimeToolName(tool, binding);
spec.setName(name);
spec.setDescription(safeDescription(tool == null ? null : tool.getDescription()));
spec.setCategory(AgentToolCategory.valueOf(AgentToolType.from(binding.getToolType()).name()));
spec.setParametersSchema(toSchema(tool == null ? null : tool.getParameters()));
spec.setApprovalRequired(Boolean.TRUE.equals(binding.getHitlEnabled()));
if (Boolean.TRUE.equals(binding.getHitlEnabled())) {
AgentToolApprovalRequest request = new AgentToolApprovalRequest();
request.setApprovalPrompt(stringValue(binding.getHitlConfigJson(), "prompt", "是否批准执行工具:" + name));
Map<String, Object> metadata = sanitizedHitlMetadata(binding.getHitlConfigJson());
metadata.put("toolType", binding.getToolType());
metadata.put("bindingId", binding.getId());
metadata.put("targetId", binding.getTargetId());
request.setMetadata(metadata);
spec.setApprovalRequest(request);
validateBuiltinTools(bundle.getDefinition(), config);
if (builtinToolNames(config).isEmpty()) {
return;
}
spec.getMetadata().put("bindingId", binding.getId());
spec.getMetadata().put("targetId", binding.getTargetId());
return spec;
}
private Map<String, Object> sanitizedHitlMetadata(Map<String, Object> config) {
Map<String, Object> metadata = new LinkedHashMap<>();
if (config != null) {
config.forEach((key, value) -> {
if (!isHitlPromptKey(key)) {
metadata.put(key, value);
}
});
}
return metadata;
}
private boolean isHitlPromptKey(String key) {
if (key == null) {
return false;
}
String normalized = key.trim();
return "prompt".equalsIgnoreCase(normalized)
|| "question".equalsIgnoreCase(normalized)
|| "approvalPrompt".equalsIgnoreCase(normalized);
}
private AgentToolResult invokeTool(Tool tool, Map<String, Object> arguments) {
String toolName = tool == null ? null : tool.getName();
LOG.info("Agent tool invoke started, toolName={}, arguments={}", toolName, arguments);
Path workspace;
try {
Object result = tool.invoke(arguments == null ? Map.of() : arguments);
String resultText = result == null ? "" : String.valueOf(result);
LOG.info("Agent tool invoke completed, toolName={}, result={}", toolName, truncate(resultText));
return AgentToolResult.success(resultText);
} catch (Exception e) {
LOG.error("Agent tool invoke failed, toolName={}, message={}", toolName, e.getMessage(), e);
return AgentToolResult.failure(e.getMessage() == null ? "工具执行失败" : e.getMessage());
workspace = agentWorkspaceResolver.resolve(
new BigInteger(runtimeContext.getTenantId()), agent.getId(), runtimeContext.getSessionId());
} catch (NumberFormatException error) {
throw new BusinessException("Agent 内置工具租户标识不合法");
}
WorkspaceQuotaLimits quota = workspaceQuota();
List<AgentOperateToolSpec> operateSpecs = new ArrayList<>();
addOperateSpec(operateSpecs, AgentOperateToolType.READ_FILE, config.read(), workspace, quota);
addOperateSpec(operateSpecs, AgentOperateToolType.WRITE_FILE, config.write(), workspace, quota);
addOperateSpec(operateSpecs, AgentOperateToolType.PATCH, config.patch(), workspace, quota);
addOperateSpec(operateSpecs, AgentOperateToolType.SHELL, config.shell(), workspace, quota);
bundle.getDefinition().setOperateToolSpecs(operateSpecs);
if (config.artifactPublish().enabled()) {
AgentToolSpec spec = buildArtifactPublishSpec(config.artifactPublish());
bundle.getDefinition().getToolSpecs().add(spec);
if (bundle.getToolInvokers().putIfAbsent(spec.getName(),
(arguments, context) -> publishArtifact(arguments, context, workspace, draftMode)) != null) {
throw new BusinessException("Agent Tool 运行名冲突:" + spec.getName());
}
}
}
private String resolveRuntimeToolName(Tool tool, AgentToolBinding binding) {
String bindingName = binding == null ? null : binding.getToolName();
if (ChatToolNameHelper.isSafeToolName(bindingName)) {
return bindingName;
private List<AgentToolSpec> buildOperateBudgetSpecs(AgentBuiltinToolsConfig config) {
if (agentWorkspaceResolver == null || agentWorkspaceResolver.getRealRoot() == null) {
throw new BusinessException("Agent 工作区尚未初始化");
}
String toolName = tool == null ? null : tool.getName();
if (ChatToolNameHelper.isSafeToolName(toolName)) {
return toolName;
List<AgentOperateToolSpec> operateSpecs = new ArrayList<>();
Path root = agentWorkspaceResolver.getRealRoot();
WorkspaceQuotaLimits quota = workspaceQuota();
addOperateSpec(operateSpecs, AgentOperateToolType.READ_FILE, config.read(), root, quota);
addOperateSpec(operateSpecs, AgentOperateToolType.WRITE_FILE, config.write(), root, quota);
addOperateSpec(operateSpecs, AgentOperateToolType.PATCH, config.patch(), root, quota);
addOperateSpec(operateSpecs, AgentOperateToolType.SHELL, config.shell(), root, quota);
Toolkit toolkit = new Toolkit();
List<AgentToolSpec> specs = new AgentOperateToolAdapter().register(operateSpecs, toolkit);
for (AgentToolSpec spec : specs) {
io.agentscope.core.tool.AgentTool tool = toolkit.getTool(spec.getName());
if (tool == null) {
throw new BusinessException("Agent 内置工具 Schema 生成失败:" + spec.getName());
}
spec.setParametersSchema(tool.getParameters());
spec.setOutputSchema(tool.getOutputSchema());
}
return specs;
}
private WorkspaceQuotaLimits workspaceQuota() {
return new WorkspaceQuotaLimits(
agentWorkspaceProperties.getMaxTotalSize().toBytes(),
agentWorkspaceProperties.getMaxSingleFileSize().toBytes(),
agentWorkspaceProperties.getMaxFileCount(),
agentWorkspaceProperties.getMaxReadSize().toBytes());
}
private void addOperateSpec(List<AgentOperateToolSpec> target,
AgentOperateToolType type,
AgentBuiltinToolsConfig.ToolSwitch toolSwitch,
Path workspace,
WorkspaceQuotaLimits quota) {
if (!toolSwitch.enabled()) {
return;
}
AgentOperateToolSpec spec = new AgentOperateToolSpec();
spec.setType(type);
spec.setBaseDir(workspace.toString());
spec.setApprovalRequired(toolSwitch.approvalRequired());
spec.setWorkspaceQuotaLimits(quota);
spec.setWorkspaceQuotaHook(new WorkspaceQuotaHook() {
@Override
public void beforeRead(Path workspaceRoot, Path target, long requestedBytes) {
agentWorkspaceResolver.touch(workspaceRoot);
}
@Override
public void beforeWrite(Path workspaceRoot, Path target, long previousBytes, long resultingBytes) {
agentWorkspaceResolver.touch(workspaceRoot);
}
});
if (type == AgentOperateToolType.PATCH) {
spec.setPatchMaxSize(agentWorkspaceProperties.getMaxReadSize().toBytes());
spec.setPatchMaxFiles(agentWorkspaceProperties.getMaxFileCount());
spec.setPatchMaxAffectedBytes(agentWorkspaceProperties.getMaxTotalSize().toBytes());
}
if (type == AgentOperateToolType.SHELL) {
spec.setShellAllowedCommands(ControlledShellTool.DEFAULT_ALLOWED_COMMANDS);
spec.setShellDefaultTimeout(agentShellProperties.getDefaultTimeout());
spec.setShellMaxTimeout(agentShellProperties.getMaxTimeout());
spec.setShellMaxCommandLength(agentShellProperties.getMaxCommandLength());
spec.setShellMaxOutputSize(agentShellProperties.getMaxOutputSize().toBytes());
spec.setShellMaxConcurrency(agentShellProperties.getMaxConcurrentPerInstance());
}
target.add(spec);
}
private AgentToolSpec buildArtifactPublishSpec(AgentBuiltinToolsConfig.ToolSwitch toolSwitch) {
AgentToolSpec spec = new AgentToolSpec();
spec.setName("artifact_publish");
spec.setDescription("Publish a completed user-facing file from the current Agent workspace as a private "
+ "downloadable artifact. You MUST call this tool after creating or updating any final file that "
+ "the user expects to receive or download, including DOCX, XLSX, PPTX, PDF, CSV, images, archives, "
+ "or source files. Do not finish with only a workspace path. Publish each final deliverable after "
+ "validation, use a clear download filename, and publish the updated version again if the file "
+ "changes. Do not publish temporary files, intermediate scripts, caches, previews, or internal "
+ "working files. If publishing fails, report the failure clearly to the user.");
spec.setCategory(AgentToolCategory.CUSTOM);
spec.setVisibility(AgentToolVisibility.VISIBLE);
spec.setApprovalRequired(toolSwitch.approvalRequired());
spec.setParametersSchema(Map.of(
"type", "object",
"properties", Map.of(
"path", Map.of("type", "string", "description",
"Workspace-relative path to the completed final file; directories and temporary or intermediate files are not allowed."),
"fileName", Map.of("type", "string", "description",
"Optional user-facing download name with the correct file extension.")),
"required", List.of("path"),
"additionalProperties", false));
spec.setOutputSchema(Map.of(
"type", "object",
"properties", Map.of(
"schemaVersion", Map.of("type", "integer"),
"artifactId", Map.of("type", "string"),
"fileName", Map.of("type", "string"),
"mimeType", Map.of("type", "string"),
"size", Map.of("type", "integer"),
"sha256", Map.of("type", "string"),
"downloadUrl", Map.of("type", "string"),
"status", Map.of("type", "string")),
"required", List.of("schemaVersion", "artifactId", "fileName", "mimeType", "size",
"sha256", "downloadUrl", "status"),
"additionalProperties", false));
return spec;
}
private AgentToolResult publishArtifact(Map<String, Object> arguments,
com.easyagents.agent.runtime.tool.AgentToolContext context,
Path workspace,
boolean draftMode) {
try {
String path = stringValue(arguments, "path", null);
String fileName = stringValue(arguments, "fileName", null);
AgentArtifactView artifact = agentArtifactService.publish(
workspace, path, fileName,
draftMode ? AgentArtifactService.MODE_DRAFT : AgentArtifactService.MODE_FORMAL,
context);
Map<String, Object> safe = artifact.toMap();
AgentRuntimeEvent projection = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_RESULT);
projection.setTraceId(context.getTraceId());
projection.setSessionId(context.getSessionId());
projection.setAgentId(context.getAgentId());
projection.setToolCallId(context.getToolCallId());
projection.getPayload().put("artifactProjectionOnly", true);
projection.getPayload().put("toolName", "artifact_publish");
projection.getPayload().put("artifactPublished", safe);
context.emitEvent(projection);
String json = objectMapper.writeValueAsString(safe);
AgentToolResult result = AgentToolResult.success(json);
result.setDisplayContent(safe);
return result;
} catch (AgentArtifactOperationException error) {
return artifactFailure(error.getCode(), error.getMessage(), error.isRetryable());
} catch (Exception error) {
LOG.error("Agent artifact_publish tool failed", error);
return artifactFailure("ARTIFACT_PUBLISH_FAILED", "产物发布失败", true);
}
}
private AgentToolResult artifactFailure(String code, String message, boolean retryable) {
try {
return AgentToolResult.failure(objectMapper.writeValueAsString(Map.of(
"code", code, "message", message, "retryable", retryable)));
} catch (Exception error) {
LOG.error("Serialize Agent artifact failure payload failed, code={}", code, error);
return AgentToolResult.failure("{\"code\":\"ARTIFACT_PUBLISH_FAILED\",\"message\":\"产物发布失败\",\"retryable\":true}");
}
}
private Set<String> builtinToolNames(AgentBuiltinToolsConfig config) {
Set<String> names = new LinkedHashSet<>();
if (config.read().enabled()) {
names.add(AgentOperateToolAdapter.VIEW_TEXT_FILE_TOOL);
names.add(AgentOperateToolAdapter.LIST_DIRECTORY_TOOL);
}
if (config.write().enabled()) {
names.add(AgentOperateToolAdapter.WRITE_TEXT_FILE_TOOL);
names.add(AgentOperateToolAdapter.INSERT_TEXT_FILE_TOOL);
}
if (config.patch().enabled()) {
names.add(AgentOperateToolAdapter.APPLY_PATCH_TOOL);
}
if (config.shell().enabled()) {
names.add(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL);
}
if (config.artifactPublish().enabled()) {
names.add("artifact_publish");
}
return names;
}
private void assertNoBuiltinNameConflict(AgentDefinition definition, Set<String> builtinNames) {
Set<String> existing = new LinkedHashSet<>();
for (AgentToolSpec spec : definition.getToolSpecs()) {
existing.add(spec.getName());
}
for (McpSpec mcp : definition.getMcpSpecs()) {
if (mcp.getFrozenToolManifest() != null) {
mcp.getFrozenToolManifest().forEach(entry -> existing.add(entry.getName()));
}
if (mcp.getEnableTools() != null) {
existing.addAll(mcp.getEnableTools());
}
}
for (String name : builtinNames) {
if (existing.contains(name)) {
throw new BusinessException("Agent Tool 运行名冲突:" + name);
}
}
}
/**
* 对最终合并后的直接 Tool、Skill Tool 与冻结 MCP 清单执行统一预算校验。
*
* @param toolSpecs 静态 Tool 声明
* @param mcpSpecs MCP 声明
*/
private void assertToolBudget(List<AgentToolSpec> toolSpecs, List<McpSpec> mcpSpecs) {
assertToolBudget(toolSpecs, mcpSpecs, 0);
}
private void assertToolBudget(List<AgentToolSpec> toolSpecs,
List<McpSpec> mcpSpecs,
int additionalToolCount) {
int toolCount = toolSpecs == null ? 0 : toolSpecs.size();
toolCount = Math.addExact(toolCount, additionalToolCount);
long schemaBytes = 0L;
if (toolSpecs != null) {
for (AgentToolSpec spec : toolSpecs) {
schemaBytes = addSchemaBytes(schemaBytes, spec.getParametersSchema());
schemaBytes = addSchemaBytes(schemaBytes, spec.getOutputSchema());
}
}
if (mcpSpecs != null) {
for (McpSpec spec : mcpSpecs) {
List<McpToolManifestEntry> manifest = spec.getFrozenToolManifest();
if (manifest != null && !manifest.isEmpty()) {
toolCount = Math.addExact(toolCount, manifest.size());
for (McpToolManifestEntry entry : manifest) {
schemaBytes = addSchemaBytes(schemaBytes, entry.getInputSchema());
schemaBytes = addSchemaBytes(schemaBytes, entry.getOutputSchema());
}
} else if (spec.getEnableTools() != null && !spec.getEnableTools().isEmpty()) {
toolCount = Math.addExact(toolCount, spec.getEnableTools().size());
} else {
// 历史直接 MCP 尚无冻结清单时至少计为一个动态工具;新 Skill MCP 均必须有清单。
toolCount = Math.addExact(toolCount, 1);
}
}
}
if (toolCount > MAX_RUNTIME_TOOL_COUNT) {
throw new BusinessException("Agent Runtime Tool 数量超过 128 个,请减少直接工具或 Skill 绑定");
}
if (schemaBytes > MAX_RUNTIME_SCHEMA_BYTES) {
throw new BusinessException("Agent Runtime Tool Schema 超过 2 MiB请减少工具或精简 Schema");
}
}
private long addSchemaBytes(long current, Object schema) {
try {
long total = Math.addExact(current, objectMapper.writeValueAsBytes(schema == null ? Map.of() : schema).length);
if (total > MAX_RUNTIME_SCHEMA_BYTES) {
throw new BusinessException("Agent Runtime Tool Schema 超过 2 MiB请减少工具或精简 Schema");
}
return total;
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
throw new BusinessException(500, 500, "计算 Agent Tool Schema 预算失败", exception);
}
BigInteger targetId = binding == null ? null : binding.getTargetId();
return ChatToolNameHelper.buildFallbackName("tool", targetId);
}
private void compileKnowledge(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) {
@@ -502,165 +714,6 @@ public class AgentRuntimeCompiler {
return text.substring(0, LOG_TEXT_MAX_LENGTH) + "...";
}
private Workflow snapshotOrPublishedWorkflow(AgentToolBinding binding) {
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
Workflow workflow = objectMapper.convertValue(binding.getResourceSnapshot(), Workflow.class);
workflow.setId(firstNonNull(workflow.getId(), binding.getTargetId()));
return workflow;
}
return workflowService.getPublishedById(binding.getTargetId());
}
private PluginItem snapshotOrCurrentPlugin(AgentToolBinding binding) {
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
PluginItem pluginItem = objectMapper.convertValue(binding.getResourceSnapshot(), PluginItem.class);
pluginItem.setId(firstNonNull(pluginItem.getId(), binding.getTargetId()));
return pluginItem;
}
return pluginItemService.getById(binding.getTargetId());
}
private Mcp snapshotOrCurrentMcp(AgentToolBinding binding) {
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
Mcp mcp = objectMapper.convertValue(binding.getResourceSnapshot(), Mcp.class);
mcp.setId(firstNonNull(mcp.getId(), binding.getTargetId()));
return mcp;
}
return mcpService.getById(binding.getTargetId());
}
private Map.Entry<String, Map<String, Object>> firstMcpServer(Mcp mcp) {
Map<String, Object> config = parseMcpConfig(mcp);
Map<String, Object> servers = mapValue(config, "mcpServers");
if (servers.isEmpty()) {
throw new BusinessException("MCP 配置 JSON 中没有找到任何 MCP 服务名称");
}
Map.Entry<String, Object> first = servers.entrySet().iterator().next();
if (!(first.getValue() instanceof Map<?, ?> rawServer)) {
throw new BusinessException("MCP 服务配置必须是对象:" + first.getKey());
}
Map<String, Object> serverConfig = new LinkedHashMap<>();
rawServer.forEach((key, value) -> serverConfig.put(String.valueOf(key), value));
return Map.entry(first.getKey(), serverConfig);
}
private Map<String, Object> parseMcpConfig(Mcp mcp) {
String configJson = mcp == null ? null : mcp.getConfigJson();
if (configJson == null || configJson.isBlank()) {
throw new BusinessException("MCP 配置 JSON 不能为空");
}
try {
return objectMapper.readValue(configJson, new com.fasterxml.jackson.core.type.TypeReference<>() {});
} catch (Exception e) {
throw new BusinessException("MCP 配置 JSON 格式错误");
}
}
private McpTransportType parseMcpTransportType(Mcp mcp, Map<String, Object> serverConfig) {
String transport = firstNonBlank(
mcp == null ? null : mcp.getTransportType(),
stringValue(serverConfig, "transport", null)
);
return McpTransportType.from(transport);
}
private String mcpRuntimeName(Mcp mcp) {
BigInteger id = mcp == null ? null : mcp.getId();
return "mcp_" + safeToolNameSegment(id == null ? "unknown" : String.valueOf(id));
}
private String mcpRuntimeToolPrefix(BigInteger mcpId) {
return "mcp_" + safeToolNameSegment(String.valueOf(mcpId)) + "_";
}
private String safeToolNameSegment(String value) {
String normalized = String.valueOf(value == null ? "" : value).trim()
.replaceAll("[^A-Za-z0-9_-]", "_")
.replaceAll("_+", "_");
if (normalized.isBlank()) {
return "tool";
}
return normalized;
}
private List<String> stringListValue(Map<String, Object> map, String key) {
Object value = map == null ? null : map.get(key);
if (value == null) {
return new ArrayList<>();
}
if (value instanceof Collection<?> collection) {
List<String> result = new ArrayList<>();
for (Object item : collection) {
if (item != null) {
result.add(String.valueOf(item));
}
}
return result;
}
throw new BusinessException("Agent 配置字段必须是数组:" + key);
}
private Duration durationValue(Map<String, Object> map, String key) {
Object value = map == null ? null : map.get(key);
if (value == null) {
return null;
}
if (value instanceof Number number) {
return Duration.ofSeconds(number.longValue());
}
String text = String.valueOf(value).trim();
if (text.isEmpty()) {
return null;
}
try {
return Duration.parse(text);
} catch (Exception ignored) {
try {
return Duration.ofSeconds(Long.parseLong(text));
} catch (NumberFormatException e) {
throw new BusinessException("Agent 配置字段必须是秒数或 Duration" + key);
}
}
}
private List<String> resolveMcpInputs(List<String> values) {
if (values == null || values.isEmpty()) {
return new ArrayList<>();
}
List<String> result = new ArrayList<>(values.size());
for (String value : values) {
result.add(resolveMcpInput(value));
}
return result;
}
private Map<String, String> resolveMcpInputMap(Map<String, String> values) {
if (values == null || values.isEmpty()) {
return new LinkedHashMap<>();
}
Map<String, String> result = new LinkedHashMap<>();
values.forEach((key, value) -> result.put(key, resolveMcpInput(value)));
return result;
}
private String resolveMcpInput(String value) {
if (value == null || value.isBlank()) {
return value;
}
Matcher matcher = MCP_INPUT_PATTERN.matcher(value);
StringBuffer resolved = new StringBuffer();
while (matcher.find()) {
String inputKey = matcher.group(1);
String resolvedValue = System.getProperty("mcp.input." + inputKey);
if (resolvedValue == null || resolvedValue.isBlank()) {
throw new BusinessException("MCP 输入变量未解析:" + inputKey);
}
matcher.appendReplacement(resolved, Matcher.quoteReplacement(resolvedValue));
}
matcher.appendTail(resolved);
return resolved.toString();
}
private DocumentCollection snapshotOrPublishedKnowledge(AgentKnowledgeBinding binding) {
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
DocumentCollection knowledge = objectMapper.convertValue(binding.getResourceSnapshot(), DocumentCollection.class);
@@ -683,74 +736,6 @@ public class AgentRuntimeCompiler {
return value == null ? null : String.valueOf(value);
}
private Map<String, Object> toSchema(Parameter[] parameters) {
Map<String, Object> schema = new LinkedHashMap<>();
Map<String, Object> properties = new LinkedHashMap<>();
List<String> required = new ArrayList<>();
if (parameters != null) {
for (Parameter parameter : parameters) {
properties.put(parameter.getName(), parameterSchema(parameter));
if (parameter.isRequired()) {
required.add(parameter.getName());
}
}
}
schema.put("type", "object");
schema.put("properties", properties);
schema.put("required", required);
return schema;
}
private Map<String, Object> parameterSchema(Parameter parameter) {
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", parameter.getType() == null ? "string" : parameter.getType());
putOptionalString(schema, "description", parameter.getDescription());
if (parameter.getChildren() != null && !parameter.getChildren().isEmpty()) {
Map<String, Object> children = new LinkedHashMap<>();
for (Parameter child : parameter.getChildren()) {
if (child != null && child.getName() != null && !child.getName().isBlank()) {
children.put(child.getName(), parameterSchema(child));
}
}
if ("array".equalsIgnoreCase(parameter.getType())) {
schema.put("items", firstArrayItemSchema(parameter.getChildren()));
} else {
schema.put("properties", children);
}
}
return schema;
}
private Map<String, Object> firstArrayItemSchema(List<Parameter> children) {
return children.stream()
.filter(Objects::nonNull)
.findFirst()
.map(this::parameterSchema)
.orElse(Map.of("type", "string"));
}
/**
* 写入非空字符串字段,避免向模型 function schema 输出 null。
*
* @param target 目标 schema
* @param key 字段名
* @param value 字段值
*/
private void putOptionalString(Map<String, Object> target, String key, String value) {
if (value != null && !value.isBlank()) {
target.put(key, value);
}
}
/**
* 将工具描述规整为模型协议可接受的字符串。
*
* @param description 原始描述
* @return 非 null 描述
*/
private String safeDescription(String description) {
return description == null ? "" : description;
}
private AgentMemoryType memoryTypeValue(Map<String, Object> map, String key) {
String value = stringValue(map, key, AgentMemoryType.AUTO_CONTEXT.name());

View File

@@ -9,7 +9,7 @@ import java.util.Map;
public class AgentToolHitlPayload {
private String requestId;
private String resumeToken;
private String approvalId;
private String sessionId;
private String agentId;
private String toolCallId;
@@ -39,21 +39,21 @@ public class AgentToolHitlPayload {
}
/**
* 获取恢复令牌
* 获取公开审批 ID
*
* @return 恢复令牌
* @return 不暴露内部恢复令牌的审批 ID
*/
public String getResumeToken() {
return resumeToken;
public String getApprovalId() {
return approvalId;
}
/**
* 设置恢复令牌
* 设置公开审批 ID
*
* @param resumeToken 恢复令牌
* @param approvalId 公开审批 ID
*/
public void setResumeToken(String resumeToken) {
this.resumeToken = resumeToken;
public void setApprovalId(String approvalId) {
this.approvalId = approvalId;
}
/**

View File

@@ -0,0 +1,41 @@
package tech.easyflow.agent.runtime.agui;
/**
* AG-UI 自定义 HITL 兼容桥的审批请求。
*/
public class AgentAguiHitlResolveRequest {
private String approvalId;
private String decision;
private String reason;
/** @return 公开审批 ID */
public String getApprovalId() {
return approvalId;
}
/** @param approvalId 公开审批 ID */
public void setApprovalId(String approvalId) {
this.approvalId = approvalId;
}
/** @return APPROVE 或 REJECT */
public String getDecision() {
return decision;
}
/** @param decision APPROVE 或 REJECT */
public void setDecision(String decision) {
this.decision = decision;
}
/** @return 拒绝原因 */
public String getReason() {
return reason;
}
/** @param reason 拒绝原因 */
public void setReason(String reason) {
this.reason = reason;
}
}

View File

@@ -0,0 +1,400 @@
package tech.easyflow.agent.runtime.agui;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.agui.model.AguiMessage;
import io.agentscope.core.agui.model.RunAgentInput;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentSkillBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.runtime.AgentChatCapability;
import tech.easyflow.agent.runtime.AgentChatRequest;
import tech.easyflow.agent.runtime.AgentDraftChatRequest;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 将受控 AG-UI RunAgentInput 映射为现有 Agent 业务请求。
*
* <p>客户端历史、工具、上下文和 state 均不进入 Runtime 权限或会话恢复逻辑。</p>
*/
@Component
public class AgentAguiRunInputMapper {
private static final Logger LOG = LoggerFactory.getLogger(AgentAguiRunInputMapper.class);
private static final int MAX_ATTACHMENTS_PER_TYPE = 32;
private static final int MAX_BINDINGS_PER_TYPE = 256;
private static final int MAX_SKILL_BINDINGS = 20;
private static final int MAX_CAPABILITIES = 32;
private static final int MAX_CAPABILITY_RESOURCE_IDS = 256;
private static final int MAX_FORWARDED_PROPS_BYTES = 1_048_576;
private static final int MAX_IDENTIFIER_LENGTH = 128;
private static final int MAX_PROMPT_LENGTH = 65_536;
private static final Set<String> FORMAL_EASYFLOW_KEYS = Set.of("input");
private static final Set<String> DRAFT_EASYFLOW_KEYS = Set.of("draft", "input");
private static final Set<String> INPUT_KEYS = Set.of(
"capabilities", "documentUploadIds", "imageUploadIds");
private static final Set<String> DRAFT_KEYS = Set.of(
"agent", "knowledgeBindings", "toolBindings", "skillBindings");
private static final Set<String> SKILL_BINDING_KEYS = Set.of("skillId", "sortNo");
private final ObjectMapper objectMapper;
/**
* 创建输入映射器。
*
* @param objectMapper Jackson 映射器
*/
public AgentAguiRunInputMapper(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 映射正式聊天请求。
*
* @param agentId URL 中的可信 Agent ID
* @param input AG-UI 运行输入
* @return 现有正式聊天请求
*/
public AgentChatRequest toFormalRequest(BigInteger agentId, RunAgentInput input) {
ValidatedInput validated = validate(input, false);
AgentChatRequest request = new AgentChatRequest();
request.setAgentId(agentId);
request.setSessionId(parseFormalThreadId(validated.threadId()));
request.setPrompt(validated.userMessage().getContent());
Map<String, Object> inputProps = nestedMap(easyflowProps(input), "input");
request.setImageUploadIds(stringList(
inputProps.get("imageUploadIds"), "imageUploadIds", MAX_ATTACHMENTS_PER_TYPE));
request.setDocumentUploadIds(stringList(
inputProps.get("documentUploadIds"), "documentUploadIds", MAX_ATTACHMENTS_PER_TYPE));
List<AgentChatCapability> capabilities = convertList(
inputProps.get("capabilities"), AgentChatCapability.class,
"capabilities", MAX_CAPABILITIES);
validateCapabilities(capabilities);
request.setCapabilities(capabilities);
return request;
}
/**
* 映射草稿试用请求。
*
* @param input AG-UI 运行输入
* @return 现有草稿试用请求
*/
public AgentDraftChatRequest toDraftRequest(RunAgentInput input) {
ValidatedInput validated = validate(input, true);
Map<String, Object> easyflow = easyflowProps(input);
Map<String, Object> inputProps = nestedMap(easyflow, "input");
Map<String, Object> draftProps = nestedMap(easyflow, "draft");
AgentDraftChatRequest request = new AgentDraftChatRequest();
request.setSessionId(validated.threadId());
request.setPrompt(validated.userMessage().getContent());
request.setImageUploadIds(stringList(
inputProps.get("imageUploadIds"), "imageUploadIds", MAX_ATTACHMENTS_PER_TYPE));
request.setDocumentUploadIds(stringList(
inputProps.get("documentUploadIds"), "documentUploadIds", MAX_ATTACHMENTS_PER_TYPE));
request.setAgent(convertRequired(draftProps.get("agent"), Agent.class, "Agent 草稿不能为空"));
request.setToolBindings(convertList(
draftProps.get("toolBindings"), AgentToolBinding.class,
"toolBindings", MAX_BINDINGS_PER_TYPE));
request.setKnowledgeBindings(convertList(
draftProps.get("knowledgeBindings"), AgentKnowledgeBinding.class,
"knowledgeBindings", MAX_BINDINGS_PER_TYPE));
request.setSkillBindings(convertSkillBindings(draftProps.get("skillBindings")));
return request;
}
/**
* 获取 AG-UI wire 上下文。
*
* @param input AG-UI 运行输入
* @return wire 上下文
*/
public AgentAguiWireContext wireContext(RunAgentInput input) {
ValidatedInput validated = validateCommon(input);
return new AgentAguiWireContext(
validated.threadId(),
input.getRunId(),
validated.userMessage().getId(),
validated.userMessage().getContent());
}
private ValidatedInput validate(RunAgentInput input, boolean draft) {
ValidatedInput validated = validateCommon(input);
validateForwardedProps(input, draft);
return validated;
}
private ValidatedInput validateCommon(RunAgentInput input) {
if (input == null) {
throw new BusinessException("AG-UI 运行输入不能为空");
}
requireIdentifier(input.getThreadId(), "threadId");
requireIdentifier(input.getRunId(), "runId");
if (input.getMessages() == null || input.getMessages().size() != 1) {
throw new BusinessException("AG-UI 入口每轮只接受一条用户消息");
}
if (input.getTools() != null && !input.getTools().isEmpty()) {
throw new BusinessException("当前 Agent 入口不接受客户端工具");
}
if (input.getContext() != null && !input.getContext().isEmpty()) {
throw new BusinessException("当前 Agent 入口不接受客户端上下文");
}
if (input.getState() != null && !input.getState().isEmpty()) {
throw new BusinessException("当前 Agent 入口不接受客户端 state");
}
AguiMessage userMessage = input.getMessages().get(0);
if (userMessage == null || userMessage.getContent() == null) {
throw new BusinessException("AG-UI 输入缺少本轮用户消息");
}
if (!userMessage.isUserMessage()) {
throw new BusinessException("AG-UI 入口只接受用户消息");
}
requireIdentifier(userMessage.getId(), "messageId");
if (userMessage.hasToolCalls()
|| (userMessage.getToolCallId() != null && !userMessage.getToolCallId().isBlank())) {
throw new BusinessException("AG-UI 用户消息不能携带工具调用");
}
if (userMessage.getContent().length() > MAX_PROMPT_LENGTH) {
throw new BusinessException("Agent 输入内容过长");
}
return new ValidatedInput(input.getThreadId(), userMessage);
}
private void validateForwardedProps(RunAgentInput input, boolean draft) {
Map<String, Object> forwardedProps = input.getForwardedProps() == null
? Map.of()
: input.getForwardedProps();
rejectUnknownKeys(forwardedProps, Set.of("easyflow"), "forwardedProps");
validateSerializedSize(forwardedProps);
Map<String, Object> easyflow = easyflowProps(input);
rejectUnknownKeys(easyflow, draft ? DRAFT_EASYFLOW_KEYS : FORMAL_EASYFLOW_KEYS, "easyflow");
Map<String, Object> inputProps = nestedMap(easyflow, "input");
rejectUnknownKeys(inputProps, INPUT_KEYS, "easyflow.input");
if (draft) {
Map<String, Object> draftProps = nestedMap(easyflow, "draft");
rejectUnknownKeys(draftProps, DRAFT_KEYS, "easyflow.draft");
}
}
private void validateSerializedSize(Map<String, Object> forwardedProps) {
try {
if (objectMapper.writeValueAsBytes(forwardedProps).length > MAX_FORWARDED_PROPS_BYTES) {
throw new BusinessException("AG-UI forwardedProps 内容过大");
}
} catch (JsonProcessingException exception) {
throw new BusinessException("AG-UI forwardedProps 格式不合法");
}
}
private BigInteger parseFormalThreadId(String threadId) {
try {
BigInteger value = new BigInteger(threadId);
if (value.signum() <= 0) {
throw new NumberFormatException("non-positive");
}
return value;
} catch (NumberFormatException exception) {
throw new BusinessException("正式 Agent threadId 必须是有效会话 ID");
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> easyflowProps(RunAgentInput input) {
Object value = input.getForwardedProps() == null
? null
: input.getForwardedProps().get("easyflow");
if (value == null) {
return Map.of();
}
if (!(value instanceof Map<?, ?> map)) {
throw new BusinessException("AG-UI easyflow 扩展格式不合法");
}
return (Map<String, Object>) map;
}
@SuppressWarnings("unchecked")
private Map<String, Object> nestedMap(Map<String, Object> source, String key) {
Object value = source.get(key);
if (value == null) {
return Map.of();
}
if (!(value instanceof Map<?, ?> map)) {
throw new BusinessException("AG-UI " + key + " 扩展格式不合法");
}
return (Map<String, Object>) map;
}
private List<String> stringList(Object value, String name, int maximumSize) {
if (value == null) {
return List.of();
}
if (!(value instanceof List<?> list) || list.size() > maximumSize) {
throw new BusinessException("AG-UI " + name + " 数量不合法");
}
List<String> result = new ArrayList<>(list.size());
for (Object item : list) {
if (!(item instanceof String text) || text.isBlank()
|| text.length() > MAX_IDENTIFIER_LENGTH) {
throw new BusinessException("AG-UI " + name + " 内容不合法");
}
result.add(text);
}
return result;
}
private <T> List<T> convertList(Object value,
Class<T> targetType,
String name,
int maximumSize) {
if (value == null) {
return List.of();
}
if (!(value instanceof List<?> list) || list.size() > maximumSize) {
throw new BusinessException("AG-UI " + name + " 数量不合法");
}
List<T> result = new ArrayList<>(list.size());
try {
for (Object item : list) {
result.add(objectMapper.convertValue(item, targetType));
}
} catch (IllegalArgumentException exception) {
throw new BusinessException("AG-UI " + name + " 内容不合法");
}
return result;
}
/**
* 将客户端 Skill 引用转换为最小领域绑定,服务端快照与摘要字段不会进入 Runtime。
*
* @param value 客户端 Skill 引用列表
* @return 仅包含 Skill ID 与排序号的绑定
*/
private List<AgentSkillBinding> convertSkillBindings(Object value) {
if (value == null) {
return List.of();
}
if (!(value instanceof List<?> list) || list.size() > MAX_SKILL_BINDINGS) {
throw new BusinessException("AG-UI skillBindings 数量不合法");
}
List<AgentSkillBinding> result = new ArrayList<>(list.size());
for (Object item : list) {
if (!(item instanceof Map<?, ?> raw)) {
throw new BusinessException("AG-UI skillBindings 内容不合法");
}
Map<String, Object> binding = stringKeyMap(raw, "skillBindings");
rejectUnknownKeys(binding, SKILL_BINDING_KEYS, "easyflow.draft.skillBindings");
AgentSkillBinding converted = new AgentSkillBinding();
converted.setSkillId(positiveBigInteger(binding.get("skillId"), "skillId"));
converted.setSortNo(optionalInteger(binding.get("sortNo"), "sortNo"));
result.add(converted);
}
return result;
}
/**
* 将任意 Map 规范为字符串键 Map。
*
* @param source 原始 Map
* @param name 字段名称
* @return 字符串键 Map
*/
private Map<String, Object> stringKeyMap(Map<?, ?> source, String name) {
Map<String, Object> result = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : source.entrySet()) {
if (!(entry.getKey() instanceof String key)) {
throw new BusinessException("AG-UI " + name + " 内容不合法");
}
result.put(key, entry.getValue());
}
return result;
}
/**
* 解析正整数 ID。
*
* @param value 原始值
* @param name 字段名称
* @return 正整数 ID
*/
private BigInteger positiveBigInteger(Object value, String name) {
if (value == null) {
throw new BusinessException("AG-UI " + name + " 不能为空");
}
try {
BigInteger result = new BigInteger(String.valueOf(value));
if (result.signum() <= 0) {
throw new NumberFormatException("non-positive");
}
return result;
} catch (NumberFormatException exception) {
throw new BusinessException("AG-UI " + name + " 不合法");
}
}
/**
* 解析可选整数。
*
* @param value 原始值
* @param name 字段名称
* @return 整数或 null
*/
private Integer optionalInteger(Object value, String name) {
if (value == null) {
return null;
}
try {
return Integer.valueOf(String.valueOf(value));
} catch (NumberFormatException exception) {
throw new BusinessException("AG-UI " + name + " 不合法");
}
}
private void validateCapabilities(List<AgentChatCapability> capabilities) {
for (AgentChatCapability capability : capabilities) {
if (capability == null || capability.getType() == null
|| capability.getType().isBlank() || capability.getType().length() > 64
|| capability.getResourceIds().size() > MAX_CAPABILITY_RESOURCE_IDS) {
throw new BusinessException("AG-UI capabilities 内容不合法");
}
}
}
private <T> T convertRequired(Object value, Class<T> targetType, String message) {
if (value == null) {
throw new BusinessException(message);
}
try {
return objectMapper.convertValue(value, targetType);
} catch (IllegalArgumentException exception) {
throw new BusinessException(message);
}
}
private void requireIdentifier(String value, String name) {
if (value == null || value.isBlank() || value.length() > MAX_IDENTIFIER_LENGTH
|| !value.matches("[A-Za-z0-9._:-]+")) {
throw new BusinessException("AG-UI " + name + " 不合法");
}
}
private void rejectUnknownKeys(Map<String, Object> source, Set<String> allowedKeys, String name) {
if (!allowedKeys.containsAll(source.keySet())) {
LOG.debug("Reject unsupported AG-UI keys, namespace={}, keys={}", name, source.keySet());
throw new BusinessException("AG-UI " + name + " 包含不支持的字段");
}
}
private record ValidatedInput(String threadId, AguiMessage userMessage) {
}
}

View File

@@ -0,0 +1,16 @@
package tech.easyflow.agent.runtime.agui;
/**
* 单次 AG-UI 连接的客户端 wire 标识。
*
* @param threadId 客户端 thread ID
* @param runId 客户端 run ID仅用于协议输出
* @param userMessageId 客户端本轮用户消息 ID
* @param userMessageContent 客户端本轮用户消息正文
*/
public record AgentAguiWireContext(
String threadId,
String runId,
String userMessageId,
String userMessageContent) {
}

View File

@@ -0,0 +1,69 @@
package tech.easyflow.agent.runtime.artifact;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.runtime.AgentRuntimeStateCleanupService;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.service.ChatSessionExtension;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.List;
import java.util.Objects;
/**
* Agent 会话的 Artifact 生命周期与历史安全投影扩展。
*/
@Component
public class AgentArtifactChatSessionExtension implements ChatSessionExtension {
private static final String AGENT_ASSISTANT_CODE = "AGENT";
private final AgentRuntimeStateCleanupService runtimeStateCleanupService;
private final AgentArtifactService artifactService;
/**
* 创建 Agent 会话扩展。
*
* @param runtimeStateCleanupService Agent 运行态清理服务
* @param artifactService Artifact 服务
*/
public AgentArtifactChatSessionExtension(AgentRuntimeStateCleanupService runtimeStateCleanupService,
AgentArtifactService artifactService) {
this.runtimeStateCleanupService = runtimeStateCleanupService;
this.artifactService = artifactService;
}
@Override
public boolean supports(ChatSessionSummary summary) {
return summary != null && AGENT_ASSISTANT_CODE.equals(summary.getAssistantCode());
}
@Override
public void beforeDelete(ChatSessionSummary summary, BigInteger userId, BigInteger operatorId) {
requireIdentity(summary, userId);
runtimeStateCleanupService.clearChatSession(summary.getId(), userId);
}
@Override
public void afterDelete(ChatSessionSummary summary, BigInteger userId, BigInteger operatorId) {
requireIdentity(summary, userId);
artifactService.markSessionDeletePending(
summary.getTenantId(), userId, summary.getAssistantId(), summary.getId());
}
@Override
public void projectMessages(ChatSessionSummary summary, List<ChatMessageRecord> records) {
requireIdentity(summary, summary.getUserId());
artifactService.projectHistoryArtifacts(records,
summary.getTenantId(), summary.getUserId(), summary.getAssistantId(), summary.getId());
}
private void requireIdentity(ChatSessionSummary summary, BigInteger userId) {
if (summary == null || summary.getId() == null || summary.getTenantId() == null
|| summary.getAssistantId() == null || summary.getUserId() == null
|| !Objects.equals(summary.getUserId(), userId)) {
throw new BusinessException("Agent 会话归属不完整");
}
}
}

View File

@@ -0,0 +1,136 @@
package tech.easyflow.agent.runtime.artifact;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.tenant.TenantManager;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.entity.AgentArtifact;
import tech.easyflow.agent.mapper.AgentArtifactMapper;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 有界领取并补偿清理过期、待删除和删除失败 Artifact。
*/
@Component
public class AgentArtifactCleanupScheduler {
private static final int BATCH_SIZE = 100;
private static final String PUBLISH_TIMEOUT_ERROR = "ARTIFACT_PUBLISH_TIMEOUT";
private static final String SESSION_UNAVAILABLE_ERROR = "ARTIFACT_SESSION_UNAVAILABLE";
private final AgentArtifactMapper mapper;
private final AgentArtifactService artifactService;
/**
* 创建清理任务。
*
* @param mapper Artifact Mapper
* @param artifactService Artifact 服务
*/
public AgentArtifactCleanupScheduler(AgentArtifactMapper mapper,
AgentArtifactService artifactService) {
this.mapper = mapper;
this.artifactService = artifactService;
}
/**
* 周期性清理,单次最多处理一百条,避免全表扫描和长时间占用调度线程。
*/
@Scheduled(fixedDelayString = "${easyflow.agent.workspace.cleanup-interval:30m}")
public void cleanup() {
TenantManager.withoutTenantCondition(() -> {
cleanupWithoutTenantCondition();
return null;
});
}
/**
* 在关闭 ORM 当前租户条件的作用域中执行全租户清理。
*/
private void cleanupWithoutTenantCondition() {
Date now = new Date();
List<AgentArtifact> expired = mapper.selectListByQuery(QueryWrapper.create()
.eq(AgentArtifact::getChatMode, AgentArtifactService.MODE_DRAFT)
.eq(AgentArtifact::getStatus, AgentArtifactStatus.AVAILABLE.name())
.le(AgentArtifact::getExpiresAt, now)
.orderBy(AgentArtifact::getId, true)
.limit(BATCH_SIZE));
for (AgentArtifact artifact : expired) {
AgentArtifact update = new AgentArtifact();
update.setStatus(AgentArtifactStatus.DELETE_PENDING.name());
update.setNextRetryAt(now);
update.setModified(now);
mapper.updateByQuery(update, QueryWrapper.create()
.eq(AgentArtifact::getId, artifact.getId())
.eq(AgentArtifact::getStatus, AgentArtifactStatus.AVAILABLE.name()));
}
Map<java.math.BigInteger, AgentArtifact> candidates = new LinkedHashMap<>();
List<AgentArtifact> abandonedPublishing = mapper.selectListByQuery(QueryWrapper.create()
.eq(AgentArtifact::getStatus, AgentArtifactStatus.PUBLISHING.name())
.le(AgentArtifact::getNextRetryAt, now)
.orderBy(AgentArtifact::getId, true)
.limit(BATCH_SIZE));
for (AgentArtifact artifact : abandonedPublishing) {
AgentArtifact update = new AgentArtifact();
update.setStatus(AgentArtifactStatus.DELETE_PENDING.name());
update.setNextRetryAt(now);
update.setLastErrorCode(PUBLISH_TIMEOUT_ERROR);
update.setModified(now);
int changed = mapper.updateByQuery(update, QueryWrapper.create()
.eq(AgentArtifact::getId, artifact.getId())
.eq(AgentArtifact::getStatus, AgentArtifactStatus.PUBLISHING.name())
.le(AgentArtifact::getNextRetryAt, now));
if (changed == 1) {
artifact.setStatus(AgentArtifactStatus.DELETE_PENDING.name());
artifact.setNextRetryAt(now);
artifact.setLastErrorCode(PUBLISH_TIMEOUT_ERROR);
candidates.put(artifact.getId(), artifact);
}
}
if (candidates.size() < BATCH_SIZE) {
List<AgentArtifact> orphans = mapper.selectOrphanedFormalArtifacts(BATCH_SIZE - candidates.size());
for (AgentArtifact artifact : orphans) {
AgentArtifact update = new AgentArtifact();
update.setStatus(AgentArtifactStatus.DELETE_PENDING.name());
update.setNextRetryAt(now);
update.setLastErrorCode(SESSION_UNAVAILABLE_ERROR);
update.setModified(now);
int changed = mapper.updateByQuery(update, QueryWrapper.create()
.eq(AgentArtifact::getId, artifact.getId())
.eq(AgentArtifact::getStatus, artifact.getStatus()));
if (changed == 1) {
artifact.setStatus(AgentArtifactStatus.DELETE_PENDING.name());
artifact.setNextRetryAt(now);
artifact.setLastErrorCode(SESSION_UNAVAILABLE_ERROR);
candidates.put(artifact.getId(), artifact);
}
}
}
if (candidates.size() < BATCH_SIZE) {
add(candidates, mapper.selectListByQuery(QueryWrapper.create()
.eq(AgentArtifact::getStatus, AgentArtifactStatus.DELETE_PENDING.name())
.orderBy(AgentArtifact::getId, true)
.limit(BATCH_SIZE - candidates.size())));
}
if (candidates.size() < BATCH_SIZE) {
add(candidates, mapper.selectListByQuery(QueryWrapper.create()
.eq(AgentArtifact::getStatus, AgentArtifactStatus.DELETE_FAILED.name())
.le(AgentArtifact::getNextRetryAt, now)
.orderBy(AgentArtifact::getId, true)
.limit(BATCH_SIZE - candidates.size())));
}
new ArrayList<>(candidates.values()).forEach(artifactService::deleteObject);
}
private void add(Map<java.math.BigInteger, AgentArtifact> target, List<AgentArtifact> artifacts) {
for (AgentArtifact artifact : artifacts) {
target.putIfAbsent(artifact.getId(), artifact);
}
}
}

View File

@@ -0,0 +1,173 @@
package tech.easyflow.agent.runtime.artifact;
import io.minio.GetObjectArgs;
import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs;
import io.minio.StatObjectArgs;
import io.minio.StatObjectResponse;
import io.minio.errors.ErrorResponseException;
import org.dromara.x.file.storage.core.FileStorageService;
import org.dromara.x.file.storage.core.platform.MinioFileStorage;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.io.InputStream;
/**
* 复用 x-file-storage 中固定私有平台的 Agent Artifact 对象存储适配器。
*/
@Component
public class AgentArtifactObjectStorage {
/** 固定私有产物平台,不允许配置回退。 */
public static final String PLATFORM = "minio-agent-artifacts";
private final FileStorageService fileStorageService;
/**
* 创建对象存储适配器。
*
* @param fileStorageService x-file-storage 聚合服务
*/
public AgentArtifactObjectStorage(FileStorageService fileStorageService) {
this.fileStorageService = fileStorageService;
}
/**
* 应用就绪时校验固定私有平台,缺失或配置公开域名时 fail-fast。
*/
@EventListener(ApplicationReadyEvent.class)
public void validatePlatform() {
MinioFileStorage storage = storage();
if (StringUtils.hasText(storage.getDomain())) {
throw new IllegalStateException("Agent Artifact 存储必须使用无公开域名的私有 MinIO 平台");
}
try {
boolean exists = storage.getClient().bucketExists(
io.minio.BucketExistsArgs.builder().bucket(storage.getBucketName()).build());
if (!exists) {
throw new IllegalStateException("Agent Artifact 私有 MinIO bucket 不存在");
}
} catch (IllegalStateException error) {
throw error;
} catch (Exception error) {
throw new IllegalStateException("校验 Agent Artifact 私有 MinIO bucket 失败", error);
}
}
/**
* 流式写入对象。
*
* @param objectKey 业务对象键
* @param input 输入流,由调用方关闭
* @param size 已校验字节数
* @param mimeType MIME 类型
* @return 对象 ETag
*/
public String put(String objectKey, InputStream input, long size, String mimeType) {
try {
MinioFileStorage storage = storage();
return storage.getClient().putObject(PutObjectArgs.builder()
.bucket(storage.getBucketName())
.object(fullKey(storage, objectKey))
.stream(input, size, -1)
.contentType(mimeType)
.build()).etag();
} catch (Exception error) {
throw new AgentArtifactOperationException(
"ARTIFACT_STORAGE_UNAVAILABLE", "产物存储暂时不可用", true, error);
}
}
/**
* 查询私有对象实际元数据。
*
* @param objectKey 业务对象键
* @return 对象实际大小与 ETag
*/
public StoredObjectMetadata stat(String objectKey) {
try {
MinioFileStorage storage = storage();
StatObjectResponse response = storage.getClient().statObject(StatObjectArgs.builder()
.bucket(storage.getBucketName())
.object(fullKey(storage, objectKey))
.build());
return new StoredObjectMetadata(response.size(), response.etag());
} catch (Exception error) {
throw new AgentArtifactOperationException(
"ARTIFACT_STORAGE_UNAVAILABLE", "读取产物对象元数据失败", true, error);
}
}
/**
* 打开私有对象读取流。
*
* @param objectKey 业务对象键
* @return MinIO 输入流,由调用方关闭
* @throws IOException 对象读取失败
*/
public InputStream open(String objectKey) throws IOException {
try {
MinioFileStorage storage = storage();
return storage.getClient().getObject(GetObjectArgs.builder()
.bucket(storage.getBucketName())
.object(fullKey(storage, objectKey))
.build());
} catch (Exception error) {
throw new IOException("读取 Agent Artifact 对象失败", error);
}
}
/**
* 幂等删除一个私有对象。
*
* @param objectKey 业务对象键
*/
public void delete(String objectKey) {
try {
MinioFileStorage storage = storage();
storage.getClient().removeObject(RemoveObjectArgs.builder()
.bucket(storage.getBucketName())
.object(fullKey(storage, objectKey))
.build());
} catch (ErrorResponseException error) {
String code = error.errorResponse() == null ? null : error.errorResponse().code();
if ("NoSuchKey".equals(code) || "NoSuchObject".equals(code)) {
return;
}
throw new AgentArtifactOperationException(
"ARTIFACT_STORAGE_UNAVAILABLE", "产物对象删除失败", true, error);
} catch (Exception error) {
throw new AgentArtifactOperationException(
"ARTIFACT_STORAGE_UNAVAILABLE", "产物对象删除失败", true, error);
}
}
private MinioFileStorage storage() {
MinioFileStorage storage = fileStorageService.getFileStorage(PLATFORM);
if (storage == null) {
throw new IllegalStateException("缺少固定 x-file-storage 平台: " + PLATFORM);
}
return storage;
}
private String fullKey(MinioFileStorage storage, String objectKey) {
String basePath = storage.getBasePath();
if (!StringUtils.hasText(basePath)) {
return objectKey;
}
return basePath.replaceAll("/+$", "") + "/" + objectKey.replaceAll("^/+", "");
}
/**
* 私有对象存储返回的可信元数据。
*
* @param size 实际对象字节数
* @param etag 对象 ETag
*/
public record StoredObjectMetadata(long size, String etag) {
}
}

View File

@@ -0,0 +1,42 @@
package tech.easyflow.agent.runtime.artifact;
/**
* Artifact Tool 可安全返回给模型的稳定业务异常。
*/
public class AgentArtifactOperationException extends RuntimeException {
private final String code;
private final boolean retryable;
/**
* 创建 Artifact 业务异常。
*
* @param code 稳定错误码
* @param message 脱敏错误消息
* @param retryable 是否可重试
*/
public AgentArtifactOperationException(String code, String message, boolean retryable) {
super(message);
this.code = code;
this.retryable = retryable;
}
/**
* 创建保留内部原因的 Artifact 业务异常。
*
* @param code 稳定错误码
* @param message 脱敏错误消息
* @param retryable 是否可重试
* @param cause 内部异常原因,仅用于服务端日志
*/
public AgentArtifactOperationException(String code, String message, boolean retryable, Throwable cause) {
super(message, cause);
this.code = code;
this.retryable = retryable;
}
/** @return 稳定错误码 */
public String getCode() { return code; }
/** @return 是否可重试 */
public boolean isRetryable() { return retryable; }
}

View File

@@ -0,0 +1,996 @@
package tech.easyflow.agent.runtime.artifact;
import com.easyagents.agent.runtime.tool.AgentToolContext;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.update.UpdateChain;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import tech.easyflow.agent.config.AgentWorkspaceProperties;
import tech.easyflow.agent.entity.AgentArtifact;
import tech.easyflow.agent.mapper.AgentArtifactMapper;
import tech.easyflow.agent.runtime.workspace.AgentWorkspaceResolver;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.service.ChatSessionQueryService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.nio.file.StandardOpenOption;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Date;
import java.util.Enumeration;
import java.util.HexFormat;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
* Agent Artifact 发布、归属校验和生命周期服务。
*/
@Service
public class AgentArtifactService {
/** 草稿产物模式。 */
public static final String MODE_DRAFT = "DRAFT";
/** 正式聊天产物模式。 */
public static final String MODE_FORMAL = "FORMAL";
private static final Logger LOG = LoggerFactory.getLogger(AgentArtifactService.class);
private static final int MAX_FILE_NAME_LENGTH = 255;
private static final int MAX_ZIP_ENTRIES = 256;
private static final int MAX_ZIP_ENTRY_NAME_LENGTH = 1024;
private static final long MAX_ZIP_CENTRAL_DIRECTORY_SIZE = 1024L * 1024;
private static final long MAX_ZIP_DECLARED_SIZE = 512L * 1024 * 1024;
private static final long MAX_ZIP_COMPRESSION_RATIO = 200L;
private static final int ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014B50;
private static final int ZIP_CENTRAL_DIRECTORY_HEADER_SIZE = 46;
private static final int ZIP_EOCD_SIGNATURE = 0x06054B50;
private static final int ZIP_EOCD_MIN_SIZE = 22;
private static final int ZIP_EOCD_MAX_SIZE = 65_557;
private static final int ZIP_UINT16_MAX = 0xFFFF;
private static final long ZIP_UINT32_MAX = 0xFFFF_FFFFL;
private static final long PUBLISH_RECOVERY_TIMEOUT_SECONDS = 10 * 60L;
private static final String UNAVAILABLE_STATUS = "UNAVAILABLE";
private final AgentArtifactMapper mapper;
private final AgentArtifactObjectStorage objectStorage;
private final AgentWorkspaceResolver workspaceResolver;
private final AgentWorkspaceProperties workspaceProperties;
private ChatSessionQueryService chatSessionQueryService;
/**
* 创建 Artifact 服务。
*
* @param mapper 状态账本 Mapper
* @param objectStorage 私有对象存储
* @param workspaceResolver 工作区解析器
* @param workspaceProperties 工作区限制
*/
public AgentArtifactService(AgentArtifactMapper mapper,
AgentArtifactObjectStorage objectStorage,
AgentWorkspaceResolver workspaceResolver,
AgentWorkspaceProperties workspaceProperties) {
this.mapper = mapper;
this.objectStorage = objectStorage;
this.workspaceResolver = workspaceResolver;
this.workspaceProperties = workspaceProperties;
}
/**
* 延迟注入会话查询服务,避免会话投影扩展初始化形成依赖环。
*
* @param chatSessionQueryService 会话查询服务
*/
@Autowired
@Lazy
public void setChatSessionQueryService(ChatSessionQueryService chatSessionQueryService) {
this.chatSessionQueryService = chatSessionQueryService;
}
/**
* 将当前会话工作区中的普通文件发布为私有 Artifact。
*
* @param workspace 当前会话绝对工作区
* @param relativePath 工作区相对文件路径
* @param requestedFileName 可选展示文件名
* @param mode DRAFT 或 FORMAL
* @param context 可信 Tool 调用上下文
* @return 安全产物视图
*/
public AgentArtifactView publish(Path workspace,
String relativePath,
String requestedFileName,
String mode,
AgentToolContext context) {
ToolIdentity identity = requireIdentity(context, mode);
Path file = workspaceResolver.resolveExistingFile(workspace, relativePath);
long size = fileSize(file);
if (size > workspaceProperties.getMaxSingleFileSize().toBytes()) {
throw new AgentArtifactOperationException(
"WORKSPACE_QUOTA_EXCEEDED", "文件超过允许发布的单文件大小", false);
}
String fileName = safeFileName(requestedFileName, file.getFileName().toString());
String mimeType = mimeType(file);
String artifactId = opaqueId();
String objectKey = "artifacts/%s/%s/%s/content%s".formatted(
identity.tenantId(), identity.agentId(), artifactId, safeExtension(fileName));
AgentArtifact artifact = publishingRecord(
artifactId, fileName, mimeType, size, objectKey, identity, context);
mapper.insert(artifact);
boolean uploadAttempted = false;
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
String etag;
try (InputStream raw = Files.newInputStream(file);
DigestInputStream input = new DigestInputStream(raw, digest)) {
uploadAttempted = true;
etag = objectStorage.put(objectKey, input, size, mimeType);
}
String sha256 = HexFormat.of().formatHex(digest.digest());
verifyStoredObject(objectKey, size, sha256);
boolean changed = UpdateChain.of(new AgentArtifact(), mapper)
.set(AgentArtifact::getSha256, sha256)
.set(AgentArtifact::getStorageEtag, etag)
.set(AgentArtifact::getStatus, AgentArtifactStatus.AVAILABLE.name())
.set(AgentArtifact::getNextRetryAt, null)
.set(AgentArtifact::getLastErrorCode, null)
.set(AgentArtifact::getModified, new Date())
.set(AgentArtifact::getModifiedBy, identity.userId())
.eq(AgentArtifact::getId, artifact.getId())
.eq(AgentArtifact::getStatus, AgentArtifactStatus.PUBLISHING.name())
.update();
if (!changed) {
throw new AgentArtifactOperationException(
"ARTIFACT_PUBLISH_FAILED", "提交产物发布状态失败", true);
}
artifact.setSha256(sha256);
artifact.setStorageEtag(etag);
artifact.setStatus(AgentArtifactStatus.AVAILABLE.name());
workspaceResolver.touch(workspace);
return toView(artifact);
} catch (AgentArtifactOperationException error) {
compensatePublishFailure(artifact, uploadAttempted, error.getCode(), error);
throw error;
} catch (NoSuchAlgorithmException | IOException error) {
compensatePublishFailure(artifact, uploadAttempted, "ARTIFACT_PUBLISH_FAILED", error);
throw new AgentArtifactOperationException(
"ARTIFACT_PUBLISH_FAILED", "读取并发布工作区文件失败", true);
} catch (RuntimeException error) {
compensatePublishFailure(artifact, uploadAttempted, "ARTIFACT_PUBLISH_FAILED", error);
throw new AgentArtifactOperationException(
"ARTIFACT_PUBLISH_FAILED", "产物发布失败", true);
}
}
/**
* 校验当前登录用户并返回可下载账本。
*
* @param artifactId 对外 Artifact ID
* @param account 当前账号
* @param expectedAgentId 当前页面 Agent ID
* @param expectedMode 当前页面聊天模式
* @param expectedSessionId 当前页面正式会话 ID
* @param expectedRuntimeSessionId 当前页面草稿 Runtime 会话 ID
* @return 可下载记录
*/
public AgentArtifact requireDownload(String artifactId,
LoginAccount account,
BigInteger expectedAgentId,
String expectedMode,
BigInteger expectedSessionId,
String expectedRuntimeSessionId) {
if (!StringUtils.hasText(artifactId) || account == null
|| account.getId() == null || account.getTenantId() == null
|| expectedAgentId == null || expectedAgentId.signum() <= 0
|| !StringUtils.hasText(expectedMode)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "产物不存在");
}
AgentArtifact artifact = mapper.selectOneByQuery(QueryWrapper.create()
.eq(AgentArtifact::getTenantId, account.getTenantId())
.eq(AgentArtifact::getArtifactId, artifactId));
if (artifact == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "产物不存在");
}
if (!account.getId().equals(artifact.getOwnerUserId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权下载该产物");
}
validateDownloadScope(
artifact, expectedAgentId, expectedMode, expectedSessionId, expectedRuntimeSessionId);
if (artifact.getExpiresAt() != null && artifact.getExpiresAt().before(new Date())) {
markDeletePending(artifact);
throw new ResponseStatusException(HttpStatus.GONE, "产物已过期");
}
if (!AgentArtifactStatus.AVAILABLE.name().equals(artifact.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "产物当前不可下载");
}
return artifact;
}
/**
* 打开已鉴权账本对应的对象流。
*
* @param artifact 已鉴权记录
* @return 对象输入流
* @throws IOException 对象读取失败
*/
public InputStream openDownload(AgentArtifact artifact) throws IOException {
if (artifact == null || !AgentArtifactObjectStorage.PLATFORM.equals(artifact.getStoragePlatform())) {
throw new IOException("Artifact 存储平台不匹配");
}
return objectStorage.open(artifact.getObjectKey());
}
/**
* 用当前 Artifact 账本批量覆盖会话历史中的安全产物投影。
*
* @param messages 同一正式会话的一页或全部消息
* @param tenantId 当前登录租户 ID
* @param ownerUserId 当前登录用户 ID
* @param chatSessionId 已鉴权的聊天会话 ID
* @throws IllegalArgumentException 可信归属不完整时抛出
*/
public void projectHistoryArtifacts(List<ChatMessageRecord> messages,
BigInteger tenantId,
BigInteger ownerUserId,
BigInteger agentId,
BigInteger chatSessionId) {
requirePositive(tenantId, "tenantId");
requirePositive(ownerUserId, "ownerUserId");
requirePositive(agentId, "agentId");
requirePositive(chatSessionId, "chatSessionId");
if (messages == null || messages.isEmpty()) {
return;
}
Set<BigInteger> roundIds = collectHistoryRoundIds(messages, chatSessionId);
if (roundIds.isEmpty()) {
return;
}
List<AgentArtifact> artifacts = mapper.selectListByQuery(QueryWrapper.create()
.eq(AgentArtifact::getTenantId, tenantId)
.eq(AgentArtifact::getOwnerUserId, ownerUserId)
.eq(AgentArtifact::getAgentId, agentId)
.eq(AgentArtifact::getChatMode, MODE_FORMAL)
.eq(AgentArtifact::getChatSessionId, chatSessionId)
.in(AgentArtifact::getRoundId, roundIds)
.orderBy(AgentArtifact::getId, true));
Map<String, AgentArtifact> ledgerById = new LinkedHashMap<>();
Map<BigInteger, List<AgentArtifact>> ledgerByRound = new LinkedHashMap<>();
for (AgentArtifact artifact : artifacts) {
if (artifact != null && StringUtils.hasText(artifact.getArtifactId())) {
ledgerById.put(artifact.getArtifactId(), artifact);
ledgerByRound.computeIfAbsent(artifact.getRoundId(), ignored -> new ArrayList<>()).add(artifact);
}
}
for (ChatMessageRecord message : messages) {
projectMessageArtifacts(message, chatSessionId, ledgerById, ledgerByRound);
}
}
/**
* 将正式聊天会话的全部产物标记为待删除。
*
* @param chatSessionId 聊天会话 ID
*/
public void markSessionDeletePending(BigInteger tenantId,
BigInteger ownerUserId,
BigInteger agentId,
BigInteger chatSessionId) {
requirePositive(tenantId, "tenantId");
requirePositive(ownerUserId, "ownerUserId");
requirePositive(agentId, "agentId");
requirePositive(chatSessionId, "chatSessionId");
AgentArtifact update = new AgentArtifact();
update.setStatus(AgentArtifactStatus.DELETE_PENDING.name());
update.setNextRetryAt(new Date());
update.setModified(new Date());
mapper.updateByQuery(update, QueryWrapper.create()
.eq(AgentArtifact::getTenantId, tenantId)
.eq(AgentArtifact::getOwnerUserId, ownerUserId)
.eq(AgentArtifact::getAgentId, agentId)
.eq(AgentArtifact::getChatMode, MODE_FORMAL)
.eq(AgentArtifact::getChatSessionId, chatSessionId)
.in(AgentArtifact::getStatus,
AgentArtifactStatus.PUBLISHING.name(),
AgentArtifactStatus.AVAILABLE.name(),
AgentArtifactStatus.FAILED.name(),
AgentArtifactStatus.DELETE_FAILED.name()));
}
/**
* 将指定草稿 Runtime 会话的产物标记为待删除。
*
* @param runtimeSessionId 草稿会话 ID
* @param tenantId 租户 ID
* @param ownerUserId 所有者用户 ID
*/
public void markDraftSessionDeletePending(String runtimeSessionId,
BigInteger tenantId,
BigInteger ownerUserId) {
if (!StringUtils.hasText(runtimeSessionId) || tenantId == null || ownerUserId == null) {
return;
}
AgentArtifact update = new AgentArtifact();
update.setStatus(AgentArtifactStatus.DELETE_PENDING.name());
update.setNextRetryAt(new Date());
update.setModified(new Date());
mapper.updateByQuery(update, QueryWrapper.create()
.eq(AgentArtifact::getTenantId, tenantId)
.eq(AgentArtifact::getChatMode, MODE_DRAFT)
.eq(AgentArtifact::getRuntimeSessionId, runtimeSessionId)
.eq(AgentArtifact::getOwnerUserId, ownerUserId)
.in(AgentArtifact::getStatus,
AgentArtifactStatus.PUBLISHING.name(),
AgentArtifactStatus.AVAILABLE.name(),
AgentArtifactStatus.FAILED.name(),
AgentArtifactStatus.DELETE_FAILED.name()));
}
/**
* 删除一条待清理对象并更新终态。
*
* @param artifact 待清理记录
*/
public void deleteObject(AgentArtifact artifact) {
if (artifact == null) {
return;
}
try {
objectStorage.delete(artifact.getObjectKey());
AgentArtifact update = new AgentArtifact();
update.setStatus(AgentArtifactStatus.DELETED.name());
update.setNextRetryAt(null);
update.setLastErrorCode(null);
update.setModified(new Date());
mapper.updateByQuery(update, QueryWrapper.create().eq(AgentArtifact::getId, artifact.getId()));
} catch (RuntimeException error) {
int retries = artifact.getRetryCount() == null ? 1 : artifact.getRetryCount() + 1;
AgentArtifact update = new AgentArtifact();
update.setStatus(AgentArtifactStatus.DELETE_FAILED.name());
update.setRetryCount(retries);
update.setNextRetryAt(Date.from(Instant.now().plusSeconds(Math.min(3_600L, 60L << Math.min(retries, 5)))));
update.setLastErrorCode("ARTIFACT_STORAGE_UNAVAILABLE");
update.setModified(new Date());
mapper.updateByQuery(update, QueryWrapper.create().eq(AgentArtifact::getId, artifact.getId()));
LOG.error("Agent Artifact object cleanup failed, artifactId={}", artifact.getArtifactId(), error);
}
}
/**
* 转换数据库记录为安全视图。
*
* @param artifact 账本记录
* @return 安全视图
*/
public AgentArtifactView toView(AgentArtifact artifact) {
String downloadUrl = AgentArtifactStatus.AVAILABLE.name().equals(artifact.getStatus())
? downloadUrl(artifact)
: null;
return new AgentArtifactView(
1, artifact.getArtifactId(), artifact.getFileName(), artifact.getMimeType(),
artifact.getSizeBytes() == null ? 0L : artifact.getSizeBytes(), artifact.getSha256(),
downloadUrl, artifact.getStatus());
}
private ToolIdentity requireIdentity(AgentToolContext context, String mode) {
if (context == null || context.getRuntimeContext() == null) {
throw new AgentArtifactOperationException("ARTIFACT_ACCESS_DENIED", "产物调用上下文缺失", false);
}
try {
BigInteger tenantId = positiveId(context.getRuntimeContext().getTenantId());
BigInteger userId = positiveId(context.getRuntimeContext().getUserId());
BigInteger agentId = positiveId(context.getAgentId());
String sessionId = context.getSessionId();
if (!StringUtils.hasText(sessionId) || !StringUtils.hasText(context.getRequestId())
|| !StringUtils.hasText(context.getToolCallId())) {
throw new IllegalArgumentException();
}
String safeMode = MODE_DRAFT.equals(mode) ? MODE_DRAFT : MODE_FORMAL;
BigInteger chatSessionId = MODE_FORMAL.equals(safeMode) ? positiveId(sessionId) : null;
BigInteger roundId = MODE_FORMAL.equals(safeMode)
? positiveId(String.valueOf(context.getRuntimeContext().getMetadata()
.get(ChatRuntimeExtKeys.CURRENT_ROUND_ID))) : null;
Integer variantIndex = MODE_FORMAL.equals(safeMode)
? positiveInteger(context.getRuntimeContext().getMetadata()
.get(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX)) : null;
return new ToolIdentity(
tenantId, userId, agentId, sessionId, chatSessionId, roundId, variantIndex, safeMode);
} catch (RuntimeException error) {
throw new AgentArtifactOperationException("ARTIFACT_ACCESS_DENIED", "产物调用归属不完整", false);
}
}
private AgentArtifact publishingRecord(String artifactId,
String fileName,
String mimeType,
long size,
String objectKey,
ToolIdentity identity,
AgentToolContext context) {
Date now = new Date();
AgentArtifact artifact = new AgentArtifact();
artifact.setArtifactId(artifactId);
artifact.setTenantId(identity.tenantId());
artifact.setAgentId(identity.agentId());
artifact.setOwnerUserId(identity.userId());
artifact.setChatMode(identity.mode());
artifact.setChatSessionId(identity.chatSessionId());
artifact.setRoundId(identity.roundId());
artifact.setVariantIndex(identity.variantIndex());
artifact.setRuntimeSessionId(identity.runtimeSessionId());
artifact.setRequestId(context.getRequestId());
artifact.setToolCallId(context.getToolCallId());
artifact.setFileName(fileName);
artifact.setMimeType(mimeType);
artifact.setSizeBytes(size);
artifact.setStoragePlatform(AgentArtifactObjectStorage.PLATFORM);
artifact.setObjectKey(objectKey);
artifact.setStatus(AgentArtifactStatus.PUBLISHING.name());
artifact.setNextRetryAt(Date.from(Instant.now().plusSeconds(PUBLISH_RECOVERY_TIMEOUT_SECONDS)));
artifact.setExpiresAt(MODE_DRAFT.equals(identity.mode())
? Date.from(Instant.now().plusSeconds(24 * 60 * 60L)) : null);
artifact.setRetryCount(0);
artifact.setCreated(now);
artifact.setCreatedBy(identity.userId());
artifact.setModified(now);
artifact.setModifiedBy(identity.userId());
artifact.setIsDeleted(0);
return artifact;
}
private void validateDownloadScope(AgentArtifact artifact,
BigInteger expectedAgentId,
String expectedMode,
BigInteger expectedSessionId,
String expectedRuntimeSessionId) {
if (!Objects.equals(artifact.getAgentId(), expectedAgentId)
|| !artifact.getChatMode().equalsIgnoreCase(expectedMode)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "产物不属于当前 Agent 会话");
}
if (MODE_FORMAL.equals(artifact.getChatMode())) {
if (expectedSessionId == null || expectedSessionId.signum() <= 0
|| StringUtils.hasText(expectedRuntimeSessionId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "产物不属于当前正式会话");
}
if (!Objects.equals(artifact.getChatSessionId(), expectedSessionId)
|| chatSessionQueryService == null || artifact.getChatSessionId() == null) {
throw new ResponseStatusException(HttpStatus.GONE, "产物所属会话已失效");
}
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(artifact.getChatSessionId());
boolean valid = summary != null
&& !Integer.valueOf(1).equals(summary.getIsDeleted())
&& "AGENT".equals(summary.getAssistantCode())
&& Objects.equals(summary.getTenantId(), artifact.getTenantId())
&& Objects.equals(summary.getUserId(), artifact.getOwnerUserId())
&& Objects.equals(summary.getAssistantId(), artifact.getAgentId());
if (!valid) {
throw new ResponseStatusException(HttpStatus.GONE, "产物所属会话已失效");
}
return;
}
boolean validDraft = MODE_DRAFT.equals(artifact.getChatMode())
&& expectedSessionId == null
&& artifact.getAgentId() != null && artifact.getAgentId().signum() > 0
&& StringUtils.hasText(artifact.getRuntimeSessionId())
&& Objects.equals(artifact.getRuntimeSessionId(), expectedRuntimeSessionId)
&& artifact.getChatSessionId() == null;
if (!validDraft) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "产物归属记录不完整");
}
}
private String downloadUrl(AgentArtifact artifact) {
String base = "/api/v1/agent/artifacts/%s/content?agentId=%s&mode=%s".formatted(
artifact.getArtifactId(), artifact.getAgentId(), artifact.getChatMode());
if (MODE_FORMAL.equals(artifact.getChatMode())) {
return base + "&sessionId=" + artifact.getChatSessionId();
}
return base + "&runtimeSessionId=" + artifact.getRuntimeSessionId();
}
private Set<BigInteger> collectHistoryRoundIds(List<ChatMessageRecord> messages,
BigInteger chatSessionId) {
Set<BigInteger> roundIds = new LinkedHashSet<>();
for (ChatMessageRecord message : messages) {
if (message != null && Objects.equals(message.getSessionId(), chatSessionId)
&& message.getRoundId() != null) {
roundIds.add(message.getRoundId());
}
}
return roundIds;
}
private void projectMessageArtifacts(ChatMessageRecord message,
BigInteger chatSessionId,
Map<String, AgentArtifact> ledgerById,
Map<BigInteger, List<AgentArtifact>> ledgerByRound) {
if (message == null || !Objects.equals(message.getSessionId(), chatSessionId)) {
return;
}
Map<String, Object> originalPayload = message.getContentPayload();
Object rawArtifacts = originalPayload == null ? null : originalPayload.get("artifacts");
List<?> list = rawArtifacts instanceof List<?> values ? values : List.of();
boolean assistantMessage = "assistant".equalsIgnoreCase(message.getSenderRole());
List<AgentArtifact> variantArtifacts = ledgerByRound.getOrDefault(message.getRoundId(), List.of())
.stream()
.filter(ledger -> Objects.equals(ledger.getVariantIndex(), message.getVariantIndex()))
.toList();
if (list.isEmpty() && (!assistantMessage || variantArtifacts.isEmpty())) {
return;
}
List<Map<String, Object>> projected = new ArrayList<>(list.size());
Set<String> projectedIds = new LinkedHashSet<>();
for (Object item : list) {
if (!(item instanceof Map<?, ?> oldView)) {
continue;
}
String artifactId = safeString(oldView.get("artifactId"));
AgentArtifact ledger = ledgerById.get(artifactId);
boolean sameRound = ledger != null
&& Objects.equals(ledger.getRoundId(), message.getRoundId())
&& Objects.equals(ledger.getVariantIndex(), message.getVariantIndex())
&& Objects.equals(message.getSessionId(), chatSessionId);
projected.add(sameRound ? toView(ledger).toMap() : unavailableView(oldView, artifactId));
if (StringUtils.hasText(artifactId)) {
projectedIds.add(artifactId);
}
}
if (assistantMessage) {
for (AgentArtifact ledger : variantArtifacts) {
if (projectedIds.add(ledger.getArtifactId())) {
projected.add(toView(ledger).toMap());
}
}
}
Map<String, Object> payload = originalPayload == null
? new LinkedHashMap<>() : new LinkedHashMap<>(originalPayload);
payload.put("artifacts", projected);
message.setContentPayload(payload);
}
private Map<String, Object> unavailableView(Map<?, ?> oldView, String artifactId) {
Object sizeValue = oldView.get("size");
long size = sizeValue instanceof Number number ? Math.max(0L, number.longValue()) : 0L;
return new AgentArtifactView(
1,
artifactId,
safeString(oldView.get("fileName")),
safeString(oldView.get("mimeType")),
size,
safeString(oldView.get("sha256")),
null,
UNAVAILABLE_STATUS).toMap();
}
private String safeString(Object value) {
return value instanceof String text ? text : null;
}
private void requirePositive(BigInteger value, String field) {
if (value == null || value.signum() <= 0) {
throw new IllegalArgumentException(field + " must be positive");
}
}
private void compensatePublishFailure(AgentArtifact artifact,
boolean uploadAttempted,
String code,
Throwable error) {
boolean cleanupFailed = false;
if (uploadAttempted) {
try {
objectStorage.delete(artifact.getObjectKey());
} catch (RuntimeException cleanupError) {
cleanupFailed = true;
error.addSuppressed(cleanupError);
}
}
AgentArtifact update = new AgentArtifact();
update.setStatus(cleanupFailed
? AgentArtifactStatus.DELETE_FAILED.name() : AgentArtifactStatus.FAILED.name());
update.setLastErrorCode(code);
update.setRetryCount(cleanupFailed ? 1 : 0);
update.setNextRetryAt(cleanupFailed ? new Date() : null);
update.setModified(new Date());
QueryWrapper condition = QueryWrapper.create()
.eq(AgentArtifact::getId, artifact.getId());
if (cleanupFailed) {
condition.in(AgentArtifact::getStatus,
AgentArtifactStatus.PUBLISHING.name(),
AgentArtifactStatus.DELETE_PENDING.name());
} else {
// 会话删除已把记录置为 DELETE_PENDING 时,保留删除意图交给调度器幂等收口。
condition.eq(AgentArtifact::getStatus, AgentArtifactStatus.PUBLISHING.name());
}
mapper.updateByQuery(update, condition);
LOG.error("Agent Artifact publish failed, artifactId={}", artifact.getArtifactId(), error);
}
private void markDeletePending(AgentArtifact artifact) {
AgentArtifact update = new AgentArtifact();
update.setStatus(AgentArtifactStatus.DELETE_PENDING.name());
update.setNextRetryAt(new Date());
update.setModified(new Date());
mapper.updateByQuery(update, QueryWrapper.create()
.eq(AgentArtifact::getId, artifact.getId())
.eq(AgentArtifact::getStatus, AgentArtifactStatus.AVAILABLE.name()));
}
private long fileSize(Path file) {
try {
return Files.size(file);
} catch (IOException error) {
throw new AgentArtifactOperationException(
"WORKSPACE_FILE_NOT_FOUND", "读取工作区文件大小失败", true, error);
}
}
private String mimeType(Path file) {
byte[] header = new byte[512];
int length;
try (InputStream input = Files.newInputStream(file)) {
length = input.read(header);
} catch (IOException error) {
throw new AgentArtifactOperationException(
"WORKSPACE_FILE_NOT_FOUND", "读取工作区文件类型失败", true, error);
}
if (length < 0) {
return "application/octet-stream";
}
if (startsWith(header, length, new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A})) {
return "image/png";
}
if (startsWith(header, length, new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF})) {
return "image/jpeg";
}
if (startsWith(header, length, "GIF87a".getBytes(StandardCharsets.US_ASCII))
|| startsWith(header, length, "GIF89a".getBytes(StandardCharsets.US_ASCII))) {
return "image/gif";
}
if (startsWith(header, length, "%PDF-".getBytes(StandardCharsets.US_ASCII))) {
return "application/pdf";
}
if (startsWith(header, length, new byte[]{'P', 'K', 0x03, 0x04})
|| startsWith(header, length, new byte[]{'P', 'K', 0x05, 0x06})
|| startsWith(header, length, new byte[]{'P', 'K', 0x07, 0x08})) {
return officeOpenXmlMime(file);
}
if (isSafeUtf8Text(header, length)) {
return "text/plain";
}
return "application/octet-stream";
}
private String officeOpenXmlMime(Path file) {
if (!hasBoundedClassicZipDirectory(file)) {
return "application/zip";
}
boolean contentTypes = false;
String documentType = null;
long declaredSize = 0L;
try (ZipFile zipFile = new ZipFile(file.toFile())) {
if (zipFile.size() > MAX_ZIP_ENTRIES) {
return "application/zip";
}
Enumeration<? extends ZipEntry> entries = zipFile.entries();
int inspected = 0;
while (entries.hasMoreElements() && inspected++ < MAX_ZIP_ENTRIES) {
ZipEntry entry = entries.nextElement();
if (isSuspiciousZipEntry(entry, declaredSize)) {
return "application/zip";
}
declaredSize += Math.max(0L, entry.getSize());
String name = entry.getName();
contentTypes |= "[Content_Types].xml".equals(name);
if (name.startsWith("word/")) {
documentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
} else if (name.startsWith("xl/")) {
documentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
} else if (name.startsWith("ppt/")) {
documentType = "application/vnd.openxmlformats-officedocument.presentationml.presentation";
}
}
} catch (IOException ignored) {
return "application/zip";
}
return contentTypes && documentType != null ? documentType : "application/zip";
}
/**
* 在构造 {@link ZipFile} 前以常量内存校验经典 ZIP 的中央目录边界和实际条目数。
*
* @param file 待识别 ZIP 文件
* @return 中央目录可安全交给 ZipFile 解析时为 true
*/
private boolean hasBoundedClassicZipDirectory(Path file) {
try (SeekableByteChannel channel = Files.newByteChannel(file, StandardOpenOption.READ)) {
long archiveSize = channel.size();
if (archiveSize < ZIP_EOCD_MIN_SIZE) {
return false;
}
int tailSize = (int) Math.min(archiveSize, ZIP_EOCD_MAX_SIZE);
long tailOffset = archiveSize - tailSize;
ByteBuffer tail = readZipAt(channel, tailOffset, tailSize);
int eocdIndex = findZipEocd(tail);
if (eocdIndex < 0) {
return false;
}
int diskNumber = unsignedZipShort(tail, eocdIndex + 4);
int directoryDisk = unsignedZipShort(tail, eocdIndex + 6);
int entriesOnDisk = unsignedZipShort(tail, eocdIndex + 8);
int totalEntries = unsignedZipShort(tail, eocdIndex + 10);
long directorySize = unsignedZipInt(tail, eocdIndex + 12);
long directoryOffset = unsignedZipInt(tail, eocdIndex + 16);
if (diskNumber == ZIP_UINT16_MAX || directoryDisk == ZIP_UINT16_MAX
|| entriesOnDisk == ZIP_UINT16_MAX || totalEntries == ZIP_UINT16_MAX
|| directorySize == ZIP_UINT32_MAX || directoryOffset == ZIP_UINT32_MAX
|| diskNumber != 0 || directoryDisk != 0 || entriesOnDisk != totalEntries
|| totalEntries > MAX_ZIP_ENTRIES
|| directorySize > MAX_ZIP_CENTRAL_DIRECTORY_SIZE) {
return false;
}
long eocdOffset = tailOffset + eocdIndex;
long directoryEnd = Math.addExact(directoryOffset, directorySize);
if (directoryEnd != eocdOffset || directoryEnd > archiveSize) {
return false;
}
return hasExpectedCentralDirectoryEntries(
channel, directoryOffset, directoryEnd, totalEntries);
} catch (IOException | ArithmeticException ignored) {
return false;
}
}
/**
* 有界扫描中央目录头,防止伪造较小 EOCD 条目数绕过预检。
*
* @param channel ZIP 文件通道
* @param position 中央目录起点
* @param end 中央目录终点
* @param expectedEntries EOCD 声明条目数
* @return 实际结构和数量一致时为 true
* @throws IOException 读取失败或文件截断时抛出
*/
private boolean hasExpectedCentralDirectoryEntries(SeekableByteChannel channel,
long position,
long end,
int expectedEntries) throws IOException {
int actualEntries = 0;
while (position < end) {
if (end - position < ZIP_CENTRAL_DIRECTORY_HEADER_SIZE) {
return false;
}
ByteBuffer header = readZipAt(channel, position, ZIP_CENTRAL_DIRECTORY_HEADER_SIZE);
if (header.getInt(0) != ZIP_CENTRAL_DIRECTORY_SIGNATURE) {
return false;
}
long variableSize = (long) unsignedZipShort(header, 28)
+ unsignedZipShort(header, 30)
+ unsignedZipShort(header, 32);
position = Math.addExact(position,
Math.addExact((long) ZIP_CENTRAL_DIRECTORY_HEADER_SIZE, variableSize));
if (position > end || ++actualEntries > MAX_ZIP_ENTRIES) {
return false;
}
}
return position == end && actualEntries == expectedEntries;
}
/**
* 在文件尾缓冲区中定位与注释长度一致的 EOCD。
*
* @param tail ZIP 文件尾缓冲区
* @return EOCD 相对偏移,未找到时返回 -1
*/
private int findZipEocd(ByteBuffer tail) {
for (int index = tail.limit() - ZIP_EOCD_MIN_SIZE; index >= 0; index--) {
if (tail.getInt(index) == ZIP_EOCD_SIGNATURE) {
int commentLength = unsignedZipShort(tail, index + 20);
if (index + ZIP_EOCD_MIN_SIZE + commentLength == tail.limit()) {
return index;
}
}
}
return -1;
}
/**
* 从通道指定位置完整读取固定长度的小端序数据。
*
* @param channel ZIP 文件通道
* @param position 起始偏移
* @param length 读取长度
* @return 已翻转的小端序缓冲区
* @throws IOException 读取失败或文件截断时抛出
*/
private ByteBuffer readZipAt(SeekableByteChannel channel, long position, int length) throws IOException {
if (position < 0L || length < 0 || position > channel.size() - length) {
throw new IOException("ZIP record exceeds archive bounds");
}
ByteBuffer buffer = ByteBuffer.allocate(length).order(ByteOrder.LITTLE_ENDIAN);
channel.position(position);
while (buffer.hasRemaining()) {
if (channel.read(buffer) <= 0) {
throw new IOException("ZIP record is truncated");
}
}
buffer.flip();
return buffer;
}
/**
* 读取小端序无符号 16 位整数。
*
* @param buffer 来源缓冲区
* @param offset 字段偏移
* @return 无符号整数值
*/
private int unsignedZipShort(ByteBuffer buffer, int offset) {
return Short.toUnsignedInt(buffer.getShort(offset));
}
/**
* 读取小端序无符号 32 位整数。
*
* @param buffer 来源缓冲区
* @param offset 字段偏移
* @return 无符号长整数值
*/
private long unsignedZipInt(ByteBuffer buffer, int offset) {
return Integer.toUnsignedLong(buffer.getInt(offset));
}
/**
* 仅依据 central directory 元数据识别可能造成过量展开的 ZIP 条目。
*
* @param entry ZIP 条目元数据
* @param accumulatedSize 已累计声明展开大小
* @return 条目超出结构探测安全边界时为 true
*/
private boolean isSuspiciousZipEntry(ZipEntry entry, long accumulatedSize) {
String name = entry.getName();
long size = entry.getSize();
long compressedSize = entry.getCompressedSize();
if (name == null || name.length() > MAX_ZIP_ENTRY_NAME_LENGTH
|| size < 0L || compressedSize < 0L
|| size > MAX_ZIP_DECLARED_SIZE - accumulatedSize) {
return true;
}
if (size == 0L) {
return false;
}
return compressedSize == 0L
|| (double) size / (double) compressedSize > MAX_ZIP_COMPRESSION_RATIO;
}
private void verifyStoredObject(String objectKey, long expectedSize, String expectedSha256) throws IOException {
AgentArtifactObjectStorage.StoredObjectMetadata metadata = objectStorage.stat(objectKey);
if (metadata == null || metadata.size() != expectedSize) {
throw new AgentArtifactOperationException(
"ARTIFACT_STORAGE_VERIFY_FAILED", "产物对象大小校验失败", true);
}
MessageDigest digest;
try {
digest = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException error) {
throw new IllegalStateException("SHA-256 算法不可用", error);
}
long actualSize = 0L;
byte[] buffer = new byte[8192];
try (InputStream raw = objectStorage.open(objectKey);
DigestInputStream input = new DigestInputStream(raw, digest)) {
int read;
while ((read = input.read(buffer)) != -1) {
if (read > 0) {
actualSize += read;
}
}
}
String actualSha256 = HexFormat.of().formatHex(digest.digest());
if (actualSize != expectedSize || !expectedSha256.equals(actualSha256)) {
throw new AgentArtifactOperationException(
"ARTIFACT_STORAGE_VERIFY_FAILED", "产物对象内容校验失败", true);
}
}
private boolean startsWith(byte[] source, int length, byte[] prefix) {
if (length < prefix.length) {
return false;
}
for (int index = 0; index < prefix.length; index++) {
if (source[index] != prefix[index]) {
return false;
}
}
return true;
}
private boolean isSafeUtf8Text(byte[] source, int length) {
for (int index = 0; index < length; index++) {
int value = source[index] & 0xFF;
if (value == 0 || value < 0x09 || value > 0x0D && value < 0x20) {
return false;
}
}
String decoded = new String(source, 0, length, StandardCharsets.UTF_8);
return !decoded.contains("\uFFFD");
}
private String safeFileName(String requested, String fallback) {
String value = StringUtils.hasText(requested) ? requested.trim() : fallback;
value = value.replaceAll("[\\r\\n\\u0000-\\u001f\\u007f]", "_");
if (value.contains("/") || value.contains("\\\\") || ".".equals(value) || "..".equals(value)) {
throw new AgentArtifactOperationException("ARTIFACT_PUBLISH_FAILED", "产物文件名不合法", false);
}
if (value.length() > MAX_FILE_NAME_LENGTH) {
value = value.substring(0, MAX_FILE_NAME_LENGTH);
}
return value;
}
private String safeExtension(String fileName) {
int dot = fileName.lastIndexOf('.');
if (dot < 0 || dot == fileName.length() - 1) {
return "";
}
String extension = fileName.substring(dot).toLowerCase(Locale.ROOT);
return extension.matches("\\.[a-z0-9]{1,16}") ? extension : "";
}
private BigInteger positiveId(String value) {
BigInteger id = new BigInteger(value);
if (id.signum() <= 0) {
throw new IllegalArgumentException();
}
return id;
}
private Integer positiveInteger(Object value) {
int number = value instanceof Number numeric
? numeric.intValue() : Integer.parseInt(String.valueOf(value));
if (number <= 0) {
throw new IllegalArgumentException();
}
return number;
}
private String opaqueId() {
return UUID.randomUUID().toString().replace("-", "");
}
private record ToolIdentity(BigInteger tenantId,
BigInteger userId,
BigInteger agentId,
String runtimeSessionId,
BigInteger chatSessionId,
BigInteger roundId,
Integer variantIndex,
String mode) {
}
}

View File

@@ -0,0 +1,19 @@
package tech.easyflow.agent.runtime.artifact;
/**
* Agent Artifact 跨数据库与对象存储的状态。
*/
public enum AgentArtifactStatus {
/** 正在上传。 */
PUBLISHING,
/** 可下载。 */
AVAILABLE,
/** 发布失败。 */
FAILED,
/** 等待删除。 */
DELETE_PENDING,
/** 删除失败且等待重试。 */
DELETE_FAILED,
/** 已删除。 */
DELETED
}

View File

@@ -0,0 +1,44 @@
package tech.easyflow.agent.runtime.artifact;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Artifact Tool、AG-UI 与管理端共用的安全视图。
*
* @param schemaVersion 结构版本
* @param artifactId 稳定产物 ID
* @param fileName 安全文件名
* @param mimeType MIME 类型
* @param size 文件字节数
* @param sha256 文件 SHA-256
* @param downloadUrl 鉴权下载地址
* @param status 可公开状态
*/
public record AgentArtifactView(int schemaVersion,
String artifactId,
String fileName,
String mimeType,
long size,
String sha256,
String downloadUrl,
String status) {
/**
* 转换为稳定字段顺序的安全 Map。
*
* @return 不含对象存储定位信息的 Map
*/
public Map<String, Object> toMap() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("schemaVersion", schemaVersion);
result.put("artifactId", artifactId);
result.put("fileName", fileName);
result.put("mimeType", mimeType);
result.put("size", size);
result.put("sha256", sha256);
result.put("downloadUrl", downloadUrl);
result.put("status", status);
return result;
}
}

View File

@@ -4,6 +4,7 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import tech.easyflow.agent.enums.AgentToolType;
import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult;
import tech.easyflow.agent.runtime.tool.PluginToolExecutor;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import java.util.Map;
@@ -14,6 +15,7 @@ import java.util.Map;
public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools {
private final PluginItem pluginItem;
private final Plugin plugin;
private final String toolName;
private final String displayName;
private final PluginToolExecutor pluginToolExecutor;
@@ -22,6 +24,7 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools {
* 创建 Plugin 异步工具子能力。
*
* @param pluginItem 插件工具快照
* @param plugin 父插件调用配置快照
* @param toolName runtime 工具名
* @param displayName 用户可见名称
* @param pluginToolExecutor Plugin 执行器
@@ -29,6 +32,7 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools {
* @param taskExecutor 后台执行器
*/
public PluginAsyncSubTools(PluginItem pluginItem,
Plugin plugin,
String toolName,
String displayName,
PluginToolExecutor pluginToolExecutor,
@@ -36,6 +40,7 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools {
ThreadPoolTaskExecutor taskExecutor) {
super(taskStore, taskExecutor);
this.pluginItem = pluginItem;
this.plugin = plugin;
this.toolName = toolName;
this.displayName = displayName;
this.pluginToolExecutor = pluginToolExecutor;
@@ -78,6 +83,6 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools {
*/
@Override
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
return pluginToolExecutor.execute(pluginItem, arguments);
return pluginToolExecutor.execute(pluginItem, plugin, arguments);
}
}

View File

@@ -58,6 +58,7 @@ public class MySqlAgentRunEventRecorder implements AgentRunEventRecorder {
private boolean shouldPersist(AgentRuntimeEventType type) {
return type != AgentRuntimeEventType.MESSAGE_DELTA
&& type != AgentRuntimeEventType.REASONING_DELTA
&& type != AgentRuntimeEventType.SKILL_STEP
&& type != AgentRuntimeEventType.STARTED
&& type != AgentRuntimeEventType.COMPLETED;
}

View File

@@ -65,7 +65,8 @@ public class AgentHitlPendingServiceImpl implements AgentHitlPendingService {
pending.setRequestId(requestId);
pending.setToolCallId(firstText(event.getToolCallId(), stringValue(event.getPayload().get("toolCallId"))));
pending.setToolName(stringValue(event.getPayload().get("toolName")));
pending.setToolInputJson(mapValue(firstNonNull(event.getPayload().get("toolInput"), event.getPayload().get("input"))));
pending.setToolInputJson(ToolApprovalInputProjection.project(
firstNonNull(event.getPayload().get("toolInput"), event.getPayload().get("input"))));
pending.setStatus(AgentHitlPendingStatus.PENDING.name());
pending.setExpiresAt(resolveExpiresAt(event));
pending.setMetadataJson(metadata(event));
@@ -272,17 +273,7 @@ public class AgentHitlPendingServiceImpl implements AgentHitlPendingService {
if (approvalMetadata instanceof Map<?, ?> map) {
map.forEach((key, value) -> metadata.put(String.valueOf(key), value));
}
return metadata;
}
@SuppressWarnings("unchecked")
private Map<String, Object> mapValue(Object value) {
if (value instanceof Map<?, ?> map) {
Map<String, Object> result = new LinkedHashMap<>();
map.forEach((key, item) -> result.put(String.valueOf(key), item));
return result;
}
return new LinkedHashMap<>();
return ToolApprovalInputProjection.project(metadata);
}
private Date dateValue(Object value) {

View File

@@ -0,0 +1,99 @@
package tech.easyflow.agent.runtime.hitl;
import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 将工具审批输入转换为可展示、可持久化的脱敏副本。
*/
public final class ToolApprovalInputProjection {
private static final String REDACTED = "[已隐藏]";
private static final Pattern SENSITIVE_KEY = Pattern.compile(
".*(token|secret|password|passwd|apikey|authorization|auth|cookie|credential|privatekey|accesskey|header|environment|env).*",
Pattern.CASE_INSENSITIVE);
private static final Pattern SENSITIVE_QUERY = Pattern.compile(
"(?i)([?&](?:token|secret|password|passwd|api[_-]?key|authorization|access[_-]?key)=)[^&#\\s]*");
private static final Pattern URL_USER_INFO = Pattern.compile(
"(?i)([a-z][a-z0-9+.-]*://)[^/@\\s]+:[^/@\\s]+@");
private ToolApprovalInputProjection() {
}
/**
* 投影工具输入,保留普通业务参数并递归遮蔽敏感字段。
*
* @param value 原始工具输入
* @return 不修改原对象的脱敏 Map输入不是 Map 时返回空 Map
*/
public static Map<String, Object> project(Object value) {
if (!(value instanceof Map<?, ?> source)) {
return Map.of();
}
return projectMap(source);
}
/**
* 递归投影 Map。
*
* @param source 原始 Map
* @return 保持字段顺序的脱敏 Map
*/
private static Map<String, Object> projectMap(Map<?, ?> source) {
Map<String, Object> projected = new LinkedHashMap<>();
source.forEach((rawKey, rawValue) -> {
String key = String.valueOf(rawKey);
projected.put(key, isSensitiveKey(key) ? REDACTED : projectValue(rawValue));
});
return projected;
}
/**
* 递归投影集合、数组、Map 与字符串值。
*
* @param value 原始值
* @return 脱敏副本
*/
private static Object projectValue(Object value) {
if (value instanceof Map<?, ?> map) {
return projectMap(map);
}
if (value instanceof Collection<?> collection) {
List<Object> projected = new ArrayList<>(collection.size());
collection.forEach(item -> projected.add(projectValue(item)));
return projected;
}
if (value != null && value.getClass().isArray()) {
int length = Array.getLength(value);
List<Object> projected = new ArrayList<>(length);
for (int index = 0; index < length; index++) {
projected.add(projectValue(Array.get(value, index)));
}
return projected;
}
if (value instanceof String text) {
String withoutUserInfo = URL_USER_INFO.matcher(text).replaceAll("$1" + REDACTED + "@");
return SENSITIVE_QUERY.matcher(withoutUserInfo).replaceAll("$1" + REDACTED);
}
return value;
}
/**
* 判断字段名是否表达凭据、认证头或环境配置。
*
* @param key 原始字段名
* @return 需要整值遮蔽时为 true
*/
private static boolean isSensitiveKey(String key) {
String normalized = key == null ? "" : key
.replaceAll("[^A-Za-z0-9]", "")
.toLowerCase(Locale.ROOT);
return SENSITIVE_KEY.matcher(normalized).matches();
}
}

View File

@@ -16,6 +16,17 @@ public interface AgentRunLock {
*/
Handle acquire(BigInteger agentId, String sessionId);
/**
* 无等待尝试获取指定 Agent 会话的运行锁。
*
* @param agentId Agent ID
* @param sessionId 运行时会话 ID
* @return 获取成功时返回锁句柄,锁已被占用时返回 null
*/
default Handle tryAcquire(BigInteger agentId, String sessionId) {
return null;
}
/**
* Agent 运行锁句柄。
*/

View File

@@ -6,6 +6,7 @@ import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
@@ -52,6 +53,13 @@ public class RedisAgentRunLock implements AgentRunLock {
}
}
@Override
public Handle tryAcquire(BigInteger agentId, String sessionId) {
RedisLockExecutor.LockHandle handle = redisLockExecutor.tryAcquire(
lockKey(agentId, sessionId), Duration.ZERO, properties.getLockLeaseTimeout());
return handle == null ? null : new RedisHandle(handle, scheduleRenew(handle));
}
private ScheduledFuture<?> scheduleRenew(RedisLockExecutor.LockHandle handle) {
long intervalMillis = Math.max(1000L, properties.getLockRenewInterval().toMillis());
return RENEW_EXECUTOR.scheduleAtFixedRate(handle::renew, intervalMillis, intervalMillis, TimeUnit.MILLISECONDS);

View File

@@ -0,0 +1,75 @@
package tech.easyflow.agent.runtime.output;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.core.chat.protocol.ChatDomain;
import tech.easyflow.core.chat.protocol.ChatType;
/**
* Agent 单次运行的协议无关输出边界。
*/
public interface AgentRunOutput {
/**
* 获取底层 SSE 连接。
*
* @return SSE Emitter
*/
SseEmitter emitter();
/**
* 接收一条规范化运行时事件。
*
* @param event 运行时事件
* @return 发送成功时为 true
*/
boolean emitRuntimeEvent(AgentRuntimeEvent event);
/**
* 发送现有展示语义事件。
*
* @param domain 事件域
* @param type 展示事件类型
* @param payload 展示载荷
* @return 发送成功时为 true
*/
boolean emitViewEvent(ChatDomain domain, ChatType type, Object payload);
/**
* 判断当前协议输出是否已经收到可用于成功收口的运行时终态。
*
* <p>旧协议允许自然 EOF 兼容收口;要求显式终态的协议实现应覆盖此方法。</p>
*
* @return 可以按成功状态持久化并结束时为 true
*/
default boolean canFinishSuccessfully() {
return true;
}
/**
* 发送协议终态并关闭连接。
*
* @param finalText 服务端权威最终正文,可为空
* @return 发送成功时为 true
*/
boolean finish(String finalText);
/**
* 正常关闭连接。
*/
void complete();
/**
* 以异常关闭连接。
*
* @param error 异常
*/
void completeWithError(Throwable error);
/**
* 判断连接是否关闭。
*
* @return 已关闭时为 true
*/
boolean isClosed();
}

View File

@@ -0,0 +1,583 @@
package tech.easyflow.agent.runtime.output;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agui.AguiExtendedEvent;
import com.easyagents.agui.AguiProtocolEventEncoder;
import com.easyagents.agui.AguiRuntimeEventProjector;
import io.agentscope.core.agui.event.AguiEvent;
import io.agentscope.core.agui.model.AguiMessage;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.agent.runtime.hitl.ToolApprovalInputProjection;
import tech.easyflow.core.chat.protocol.ChatDomain;
import tech.easyflow.core.chat.protocol.ChatType;
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 将同一 Agent 业务运行投影为原生 AG-UI SSE 的输出实现。
*
* <p>所有公开发送方法串行化,避免运行线程与 HITL 恢复线程交错破坏事件顺序。</p>
*/
public final class AguiAgentRunOutput implements AgentRunOutput {
private static final String ASSISTANT_ROLE = "assistant";
private static final String REASONING_ROLE = "reasoning";
private final String threadId;
private final String runId;
private final String clientUserMessageId;
private final String clientUserMessageContent;
private final ChatSseEmitter delegate;
private final AguiRuntimeEventProjector projector;
private final AguiProtocolEventEncoder encoder = new AguiProtocolEventEncoder();
private long customSequence;
private long assistantMessageSequence;
private long reasoningMessageSequence;
private String assistantMessageId;
private String lastAssistantMessageId;
private String reasoningMessageId;
private final StringBuilder assistantText = new StringBuilder();
private final Map<String, Map<String, Object>> activeSkillInvocations = new LinkedHashMap<>();
private AgentRuntimeEvent pendingCompletedEvent;
/**
* 创建 AG-UI 输出。
*
* @param threadId 客户端 thread ID
* @param runId 客户端 run ID
* @param clientUserMessageId 本轮客户端用户消息 ID
*/
public AguiAgentRunOutput(String threadId, String runId, String clientUserMessageId) {
this(threadId, runId, clientUserMessageId, null, new ChatSseEmitter());
}
/**
* 创建包含本轮用户消息快照信息的 AG-UI 输出。
*
* @param threadId 客户端 thread ID
* @param runId 客户端 run ID
* @param clientUserMessageId 本轮客户端用户消息 ID
* @param clientUserMessageContent 本轮客户端用户消息正文
*/
public AguiAgentRunOutput(
String threadId,
String runId,
String clientUserMessageId,
String clientUserMessageContent) {
this(threadId, runId, clientUserMessageId, clientUserMessageContent, new ChatSseEmitter());
}
/**
* 使用指定 SSE 发射器创建 AG-UI 输出,供受控装配和测试使用。
*
* @param threadId 客户端 thread ID
* @param runId 客户端 run ID
* @param clientUserMessageId 本轮客户端用户消息 ID
* @param delegate SSE 发射器
*/
public AguiAgentRunOutput(
String threadId,
String runId,
String clientUserMessageId,
ChatSseEmitter delegate) {
this(threadId, runId, clientUserMessageId, null, delegate);
}
/**
* 使用指定 SSE 发射器和用户消息快照信息创建 AG-UI 输出。
*
* @param threadId 客户端 thread ID
* @param runId 客户端 run ID
* @param clientUserMessageId 本轮客户端用户消息 ID
* @param clientUserMessageContent 本轮客户端用户消息正文
* @param delegate SSE 发射器
*/
public AguiAgentRunOutput(
String threadId,
String runId,
String clientUserMessageId,
String clientUserMessageContent,
ChatSseEmitter delegate) {
this.threadId = requireText(threadId, "threadId");
this.runId = requireText(runId, "runId");
this.clientUserMessageId = clientUserMessageId;
this.clientUserMessageContent = clientUserMessageContent;
this.delegate = java.util.Objects.requireNonNull(delegate, "delegate cannot be null");
this.projector = new AguiRuntimeEventProjector(threadId, runId);
}
@Override
public SseEmitter emitter() {
return delegate.getEmitter();
}
@Override
public synchronized boolean emitRuntimeEvent(AgentRuntimeEvent event) {
if (event == null || event.getEventType() == null || delegate.isClosed()) {
return !delegate.isClosed();
}
AgentRuntimeEventType type = event.getEventType();
if (type == AgentRuntimeEventType.MESSAGE_DELTA
|| type == AgentRuntimeEventType.REASONING_STARTED
|| type == AgentRuntimeEventType.REASONING_DELTA
|| type == AgentRuntimeEventType.REASONING_COMPLETED) {
// EasyFlow 的跨 delta <think> 归一化结果通过 emitViewEvent 输出,避免重复和标签泄漏。
return true;
}
if (type == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) {
return emitToolApproval(event);
}
if (type == AgentRuntimeEventType.SKILL_CALL
|| type == AgentRuntimeEventType.SKILL_RESULT
|| type == AgentRuntimeEventType.SKILL_FAILED) {
return emitSkillInvocation(event);
}
if (type == AgentRuntimeEventType.SKILL_STEP) {
return true;
}
if (isAsyncToolEvent(type)) {
return true;
}
if (type == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL) {
return true;
}
if (type == AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED
|| type == AgentRuntimeEventType.MEMORY_COMPRESSION_COMPLETED) {
return true;
}
if (type == AgentRuntimeEventType.SUSPENDED) {
return true;
}
if (type == AgentRuntimeEventType.COMPLETED) {
pendingCompletedEvent = event;
return true;
}
if (type == AgentRuntimeEventType.CANCELLED) {
if (!closeActiveSkillInvocations("CANCELLED")) {
return false;
}
} else if (type == AgentRuntimeEventType.FAILED) {
if (!closeActiveSkillInvocations("INCOMPLETE")) {
return false;
}
}
if (type == AgentRuntimeEventType.TOOL_CALL
|| type == AgentRuntimeEventType.FAILED
|| type == AgentRuntimeEventType.CANCELLED) {
if (!closeOpenMessages()) {
return false;
}
}
for (Object protocolEvent : projector.project(event)) {
if (!send(protocolEvent)) {
return false;
}
}
if (type == AgentRuntimeEventType.TOOL_CALL) {
return emitToolMetadata(event);
}
return true;
}
@Override
public synchronized boolean emitViewEvent(ChatDomain domain, ChatType type, Object payload) {
if (delegate.isClosed()) {
return false;
}
if (type == ChatType.MESSAGE) {
return emitAssistantDelta(stringValue(payload, "delta"));
}
if (type == ChatType.THINKING) {
return emitReasoningDelta(firstText(
stringValue(payload, "delta"), stringValue(payload, "reasoning")));
}
if (type == ChatType.INPUT_ACCEPTED) {
Map<String, Object> value = copyMap(payload);
value.put("clientMessageId", clientUserMessageId);
value.put("serverMessageId", value.get("messageId"));
return emitCustom("easyflow.input.accepted", value, clientUserMessageId);
}
if (type == ChatType.CITATIONS) {
return emitCustom("easyflow.knowledge.citations", copyMap(payload), lastAssistantMessageId);
}
if ((type == ChatType.TOOL_CALL || type == ChatType.TOOL_RESULT)
&& Boolean.TRUE.equals(copyMap(payload).get("asyncTool"))) {
return emitCustom("easyflow.async_tool.status",
selectPayload(payload,
"asyncTool", "asyncToolName", "input", "label", "name", "output",
"phase", "result", "sourceToolCallId", "status", "statusKey", "summary",
"taskId", "text", "toolCallId", "toolDisplayName", "toolInput", "toolName"),
null);
}
if (type == ChatType.STATUS) {
String statusKey = stringValue(payload, "statusKey");
if ("artifact-published".equals(statusKey)) {
return emitCustom("easyflow.artifact.published",
selectPayload(payload, "schemaVersion", "artifactId", "fileName", "mimeType",
"size", "sha256", "downloadUrl", "status"),
lastAssistantMessageId);
}
if ("knowledge-retrieval".equals(statusKey)) {
return emitCustom("easyflow.knowledge.retrieval_status",
selectPayload(payload, "label", "status", "statusKey"), null);
}
if ("memory-compression".equals(statusKey)) {
return emitCustom("easyflow.runtime.context_status",
selectPayload(payload, "compressed", "label", "phase", "status", "statusKey"), null);
}
}
if (type == ChatType.FORM_CANCEL) {
return emitCustom("easyflow.hitl.tool_approval_resolved", copyMap(payload), null);
}
if (type == ChatType.ERROR) {
return fail(firstText(stringValue(payload, "message"), "Agent runtime failed."), "AGENT_RUNTIME_FAILED");
}
// 工具标准事件和业务状态已从 AgentRuntimeEvent 投影;其余 Legacy 展示事件不进入 AG-UI wire。
return true;
}
@Override
public synchronized boolean canFinishSuccessfully() {
return pendingCompletedEvent != null;
}
@Override
public synchronized boolean finish(String finalText) {
if (delegate.isClosed()) {
return false;
}
if (!reconcileFinalText(finalText)) {
return false;
}
if (pendingCompletedEvent != null && !projector.isTerminated()) {
if (!closeActiveSkillInvocations("INCOMPLETE")) {
return false;
}
if (!closeOpenMessages()) {
return false;
}
for (Object protocolEvent : projector.project(pendingCompletedEvent)) {
if (!send(protocolEvent)) {
return false;
}
}
pendingCompletedEvent = null;
}
if (!projector.isTerminated()) {
if (!fail("Agent stream ended without a terminal event.", "MISSING_TERMINAL_EVENT")) {
return false;
}
}
delegate.complete();
return true;
}
@Override
public synchronized void complete() {
delegate.complete();
}
@Override
public synchronized void completeWithError(Throwable error) {
if (!projector.isTerminated() && !delegate.isClosed()) {
closeActiveSkillInvocations("INCOMPLETE");
fail(error == null || error.getMessage() == null
? "Agent runtime failed."
: error.getMessage(), "AGENT_RUNTIME_FAILED");
}
delegate.complete();
}
@Override
public boolean isClosed() {
return delegate.isClosed();
}
private boolean emitAssistantDelta(String delta) {
if (delta == null || delta.isEmpty()) {
return true;
}
if (!ensureRunStarted()) {
return false;
}
if (reasoningMessageId != null && !closeReasoning()) {
return false;
}
if (assistantMessageId == null) {
assistantMessageId = runId + "-assistant-" + (++assistantMessageSequence);
lastAssistantMessageId = assistantMessageId;
if (!send(new AguiEvent.TextMessageStart(
threadId, runId, assistantMessageId, ASSISTANT_ROLE))) {
return false;
}
}
assistantText.append(delta);
return send(new AguiEvent.TextMessageContent(threadId, runId, assistantMessageId, delta));
}
private boolean emitReasoningDelta(String delta) {
if (delta == null || delta.isEmpty()) {
return true;
}
if (!ensureRunStarted()) {
return false;
}
if (assistantMessageId != null && !closeAssistant()) {
return false;
}
if (reasoningMessageId == null) {
reasoningMessageId = runId + "-reasoning-" + (++reasoningMessageSequence);
if (!send(new AguiEvent.ReasoningMessageStart(
threadId, runId, reasoningMessageId, REASONING_ROLE))) {
return false;
}
}
return send(new AguiEvent.ReasoningMessageContent(threadId, runId, reasoningMessageId, delta));
}
private boolean emitToolApproval(AgentRuntimeEvent event) {
Map<String, Object> source = event.getPayload() == null ? Map.of() : event.getPayload();
Map<String, Object> value = new LinkedHashMap<>();
value.put("approvalId", event.getMetadata().get("approvalId"));
value.put("toolCallId", firstText(event.getToolCallId(), stringValue(source, "toolCallId")));
value.put("toolName", stringValue(source, "toolName"));
value.put("toolDisplayName", firstText(
stringValue(source, "toolDisplayName"), stringValue(source, "toolName")));
value.put("input", ToolApprovalInputProjection.project(
firstNonNull(source.get("toolInput"), source.get("input"))));
value.put("expiresAt", source.get("expiresAt"));
return emitCustom("easyflow.hitl.tool_approval_required", value, event.getMessageId());
}
private boolean emitSkillInvocation(AgentRuntimeEvent event) {
Map<String, Object> value = selectPayload(event.getPayload(),
"statusKey", "status", "skillId", "skillName", "skillDisplayName",
"toolCallId", "message");
String statusKey = stringValue(value, "statusKey");
if (statusKey == null) {
return true;
}
String status = stringValue(value, "status");
if ("RUNNING".equals(status)) {
activeSkillInvocations.put(statusKey, new LinkedHashMap<>(value));
} else {
activeSkillInvocations.remove(statusKey);
}
return emitCustom("easyflow.skill.invocation_status", value, event.getMessageId());
}
private boolean closeActiveSkillInvocations(String status) {
if (activeSkillInvocations.isEmpty()) {
return true;
}
List<Map<String, Object>> pending = new ArrayList<>(activeSkillInvocations.values());
activeSkillInvocations.clear();
for (Map<String, Object> value : pending) {
Map<String, Object> terminal = new LinkedHashMap<>(value);
terminal.put("status", status);
terminal.remove("message");
if (!emitCustom("easyflow.skill.invocation_status", terminal, null)) {
return false;
}
}
return true;
}
private boolean emitToolMetadata(AgentRuntimeEvent event) {
Map<String, Object> source = event.getPayload() == null ? Map.of() : event.getPayload();
String toolCallId = firstText(event.getToolCallId(), stringValue(source, "toolCallId"));
String toolName = firstText(stringValue(source, "toolName"), stringValue(source, "name"));
String toolDisplayName = stringValue(source, "toolDisplayName");
if (toolCallId == null || toolDisplayName == null
|| toolDisplayName.equals(toolName) || isHiddenToolName(toolName)) {
return true;
}
Map<String, Object> value = new LinkedHashMap<>();
value.put("toolCallId", toolCallId);
value.put("toolName", toolName);
value.put("toolDisplayName", toolDisplayName);
return emitCustom("easyflow.tool.metadata", value, event.getMessageId());
}
private boolean emitCustom(String name, Map<String, Object> payload, String messageId) {
if (!ensureRunStarted()) {
return false;
}
Map<String, Object> value = new LinkedHashMap<>();
if (payload != null) {
value.putAll(payload);
}
// 协议保留字段由服务端最终写入,避免业务 payload 覆盖运行边界信息。
value.put("schemaVersion", 1);
value.put("id", "evt_" + UUID.randomUUID());
value.put("runId", runId);
value.put("threadId", threadId);
value.put("messageId", messageId);
value.put("sequence", ++customSequence);
value.put("timestamp", Instant.now().toString());
return send(new AguiEvent.Custom(threadId, runId, name, value));
}
private boolean fail(String message, String code) {
if (projector.isTerminated()) {
return true;
}
if (!closeOpenMessages()) {
return false;
}
AgentRuntimeEvent failed = AgentRuntimeEvent.of(AgentRuntimeEventType.FAILED);
failed.getPayload().put("message", message);
String resolvedCode = code == null || code.isBlank() ? "AGENT_RUNTIME_FAILED" : code;
for (Object protocolEvent : projector.project(failed)) {
Object output = protocolEvent instanceof AguiExtendedEvent.RunError runError
? new AguiExtendedEvent.RunError(
runError.threadId(), runError.runId(), runError.message(), resolvedCode)
: protocolEvent;
if (!send(output)) {
return false;
}
}
return true;
}
private boolean closeOpenMessages() {
return closeReasoning() && closeAssistant();
}
private boolean closeReasoning() {
if (reasoningMessageId == null) {
return true;
}
String messageId = reasoningMessageId;
reasoningMessageId = null;
return send(new AguiEvent.ReasoningMessageEnd(threadId, runId, messageId));
}
private boolean closeAssistant() {
if (assistantMessageId == null) {
return true;
}
String messageId = assistantMessageId;
assistantMessageId = null;
return send(new AguiEvent.TextMessageEnd(threadId, runId, messageId));
}
private boolean reconcileFinalText(String finalText) {
if (finalText == null || finalText.contentEquals(assistantText)) {
return true;
}
String streamedText = assistantText.toString();
if (finalText.startsWith(streamedText)) {
return emitAssistantDelta(finalText.substring(streamedText.length()));
}
if (!closeOpenMessages()) {
return false;
}
String resolvedAssistantMessageId = lastAssistantMessageId;
if (resolvedAssistantMessageId == null) {
resolvedAssistantMessageId = runId + "-assistant-" + (++assistantMessageSequence);
lastAssistantMessageId = resolvedAssistantMessageId;
}
List<AguiMessage> messages = new ArrayList<>(2);
if (clientUserMessageId != null && clientUserMessageContent != null) {
messages.add(AguiMessage.userMessage(clientUserMessageId, clientUserMessageContent));
}
messages.add(AguiMessage.assistantMessage(resolvedAssistantMessageId, finalText));
assistantText.setLength(0);
assistantText.append(finalText);
return send(new AguiExtendedEvent.MessagesSnapshot(threadId, runId, messages));
}
private boolean send(Object event) {
return delegate.sendData(encoder.encodeToJson(event));
}
private boolean ensureRunStarted() {
if (projector.isTerminated()) {
return false;
}
AgentRuntimeEvent started = AgentRuntimeEvent.of(AgentRuntimeEventType.STARTED);
for (Object protocolEvent : projector.project(started)) {
if (!send(protocolEvent)) {
return false;
}
}
return true;
}
private static boolean isAsyncToolEvent(AgentRuntimeEventType type) {
return type == AgentRuntimeEventType.ASYNC_TOOL_SUBMITTED
|| type == AgentRuntimeEventType.ASYNC_TOOL_OBSERVED
|| type == AgentRuntimeEventType.ASYNC_TOOL_RESULT
|| type == AgentRuntimeEventType.ASYNC_TOOL_CANCELLED
|| type == AgentRuntimeEventType.ASYNC_TOOL_LISTED
|| type == AgentRuntimeEventType.ASYNC_TOOL_FAILED;
}
private static boolean isHiddenToolName(String toolName) {
return "retrieve_knowledge".equalsIgnoreCase(toolName)
|| "context_reload".equalsIgnoreCase(toolName)
|| "__fragment__".equalsIgnoreCase(toolName);
}
@SuppressWarnings("unchecked")
private static Map<String, Object> copyMap(Object payload) {
return payload instanceof Map<?, ?> map
? new LinkedHashMap<>((Map<String, Object>) map)
: new LinkedHashMap<>();
}
/**
* 选取允许进入 AG-UI CUSTOM 的公开字段。
*
* @param payload 服务层展示载荷
* @param allowedKeys 允许字段
* @return 公开载荷
*/
private static Map<String, Object> selectPayload(Object payload, String... allowedKeys) {
Map<String, Object> source = copyMap(payload);
Map<String, Object> selected = new LinkedHashMap<>();
for (String key : allowedKeys) {
if (source.containsKey(key)) {
selected.put(key, source.get(key));
}
}
return selected;
}
private static String stringValue(Object payload, String key) {
if (!(payload instanceof Map<?, ?> map)) {
return null;
}
Object value = map.get(key);
return value instanceof String text ? text : null;
}
private static Object firstNonNull(Object first, Object second) {
return first == null ? second : first;
}
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;
}
}

View File

@@ -0,0 +1,80 @@
package tech.easyflow.agent.runtime.output;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.core.chat.protocol.ChatDomain;
import tech.easyflow.core.chat.protocol.ChatEnvelope;
import tech.easyflow.core.chat.protocol.ChatType;
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
import java.util.Map;
import java.util.Objects;
/**
* 保持现有 EasyFlow ChatEnvelope 行为的运行输出。
*/
public final class LegacyAgentRunOutput implements AgentRunOutput {
private final ChatSseEmitter delegate;
/**
* 创建 Legacy 输出。
*/
public LegacyAgentRunOutput() {
this(new ChatSseEmitter());
}
/**
* 使用指定 SSE 发射器创建 Legacy 输出。
*
* @param delegate SSE 发射器
*/
public LegacyAgentRunOutput(ChatSseEmitter delegate) {
this.delegate = Objects.requireNonNull(delegate, "delegate cannot be null");
}
@Override
public SseEmitter emitter() {
return delegate.getEmitter();
}
@Override
public boolean emitRuntimeEvent(AgentRuntimeEvent event) {
return !delegate.isClosed();
}
@Override
public boolean emitViewEvent(ChatDomain domain, ChatType type, Object payload) {
ChatEnvelope<Object> envelope = new ChatEnvelope<>();
envelope.setDomain(domain);
envelope.setType(type);
envelope.setPayload(payload);
return delegate.send(envelope);
}
@Override
public boolean finish(String finalText) {
ChatEnvelope<Map<String, Object>> envelope = new ChatEnvelope<>();
envelope.setDomain(ChatDomain.SYSTEM);
envelope.setType(ChatType.DONE);
if (finalText != null) {
envelope.setPayload(Map.of("finalText", finalText));
}
return delegate.sendDone(envelope);
}
@Override
public void complete() {
delegate.complete();
}
@Override
public void completeWithError(Throwable error) {
delegate.completeWithError(error);
}
@Override
public boolean isClosed() {
return delegate.isClosed();
}
}

View File

@@ -0,0 +1,45 @@
package tech.easyflow.agent.runtime.skill;
import com.easyagents.agent.runtime.mcp.McpSpec;
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Agent Skill 运行时编译结果。
*/
public class AgentSkillRuntimeCompilation {
private AgentSkillBoxSpec skillBoxSpec;
private List<AgentToolSpec> toolSpecs = new ArrayList<>();
private List<McpSpec> mcpSpecs = new ArrayList<>();
private Map<String, AgentToolInvoker> toolInvokers = new LinkedHashMap<>();
/** @return SkillBox 声明 */
public AgentSkillBoxSpec getSkillBoxSpec() { return skillBoxSpec; }
/** @param skillBoxSpec SkillBox 声明 */
public void setSkillBoxSpec(AgentSkillBoxSpec skillBoxSpec) { this.skillBoxSpec = skillBoxSpec; }
/** @return 静态 Tool 声明 */
public List<AgentToolSpec> getToolSpecs() { return toolSpecs; }
/** @param toolSpecs 静态 Tool 声明 */
public void setToolSpecs(List<AgentToolSpec> toolSpecs) {
this.toolSpecs = toolSpecs == null ? new ArrayList<>() : new ArrayList<>(toolSpecs);
}
/** @return MCP 声明 */
public List<McpSpec> getMcpSpecs() { return mcpSpecs; }
/** @param mcpSpecs MCP 声明 */
public void setMcpSpecs(List<McpSpec> mcpSpecs) {
this.mcpSpecs = mcpSpecs == null ? new ArrayList<>() : new ArrayList<>(mcpSpecs);
}
/** @return Tool 调用器 */
public Map<String, AgentToolInvoker> getToolInvokers() { return toolInvokers; }
/** @param toolInvokers Tool 调用器 */
public void setToolInvokers(Map<String, AgentToolInvoker> toolInvokers) {
this.toolInvokers = toolInvokers == null ? new LinkedHashMap<>() : new LinkedHashMap<>(toolInvokers);
}
}

View File

@@ -0,0 +1,338 @@
package tech.easyflow.agent.runtime.skill;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
import com.easyagents.agent.runtime.mcp.McpSpec;
import com.easyagents.agent.runtime.mcp.McpToolManifestEntry;
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
import com.easyagents.agent.runtime.skill.AgentSkillSpec;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import com.easyagents.skill.util.SkillHashes;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentSkillBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompilation;
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* 将 Agent 内部冻结的 Skill 投影编译为一个 SkillBox 及其延迟激活 Tool。
*/
@Component
public class AgentSkillRuntimeCompiler {
private static final TypeReference<List<McpToolManifestEntry>> MCP_MANIFEST_TYPE = new TypeReference<>() { };
private static final TypeReference<Map<String, String>> STRING_MAP_TYPE = new TypeReference<>() { };
private final AgentSkillRuntimeProjector runtimeProjector;
private final AgentToolRuntimeCompiler toolRuntimeCompiler;
private final ObjectMapper objectMapper;
/**
* 创建 Skill 运行时编译器。
*
* @param runtimeProjector Skill 冻结投影器
* @param toolRuntimeCompiler 公共 Tool 编译器
* @param objectMapper JSON 映射器
*/
public AgentSkillRuntimeCompiler(AgentSkillRuntimeProjector runtimeProjector,
AgentToolRuntimeCompiler toolRuntimeCompiler,
ObjectMapper objectMapper) {
this.runtimeProjector = runtimeProjector;
this.toolRuntimeCompiler = toolRuntimeCompiler;
this.objectMapper = objectMapper;
}
/**
* 编译 Agent 的全部 Skill。
*
* <p>正式 Agent 直接消费冻结投影;草稿试用只有引用时才读取当前已发布 Skill 构建临时投影。</p>
*
* @param agent Agent 运行定义
* @return Skill 运行时编译结果
*/
public AgentSkillRuntimeCompilation compile(Agent agent) {
AgentSkillRuntimeCompilation result = new AgentSkillRuntimeCompilation();
List<AgentSkillBinding> bindings = agent == null ? null : agent.getSkillBindings();
if (bindings == null || bindings.isEmpty()) {
return result;
}
if (!hasCompleteSnapshots(bindings)) {
throw new BusinessException("Agent Skill 运行快照缺失,请重新保存或发布 Agent");
}
List<AgentSkillBinding> effectiveBindings = bindings;
runtimeProjector.assertFrozenBindings(effectiveBindings);
AgentSkillBoxSpec box = new AgentSkillBoxSpec();
box.setSkillBoxId("skill-box");
List<AgentSkillSpec> skills = new ArrayList<>();
Map<String, List<String>> toolBindings = new LinkedHashMap<>();
List<AgentToolSpec> toolSpecs = new ArrayList<>();
List<McpSpec> mcpSpecs = new ArrayList<>();
Map<String, com.easyagents.agent.runtime.tool.AgentToolInvoker> invokers = new LinkedHashMap<>();
Map<String, String> targetOwners = directTargetOwners(agent);
Set<String> runtimeNames = new HashSet<>();
for (AgentSkillBinding binding : effectiveBindings) {
Map<String, Object> snapshot = binding.getResourceSnapshot();
String skillId = requiredText(snapshot, "skillId", "Skill 运行快照缺少 ID");
String displayName = firstText(text(snapshot.get("displayName")), text(snapshot.get("name")));
AgentSkillSpec skillSpec = toSkillSpec(snapshot, skillId, displayName);
skills.add(skillSpec);
List<AgentToolBinding> syntheticBindings = new ArrayList<>();
Map<BigInteger, Map<String, Object>> mcpSnapshots = new LinkedHashMap<>();
for (Map<String, Object> item : bindingSnapshots(snapshot)) {
AgentToolBinding synthetic = toSyntheticBinding(skillId, displayName, item);
assertUniqueTarget(targetOwners, synthetic, displayName);
syntheticBindings.add(synthetic);
if ("MCP".equals(synthetic.getToolType())) {
mcpSnapshots.put(synthetic.getTargetId(), item);
}
}
AgentToolRuntimeCompilation compiled = toolRuntimeCompiler.compileBindings(syntheticBindings);
List<String> ownedNames = new ArrayList<>();
for (AgentToolSpec spec : compiled.getToolSpecs()) {
assertRuntimeName(runtimeNames, spec.getName());
attachSkillMetadata(spec, skillId, displayName);
toolSpecs.add(spec);
ownedNames.add(spec.getName());
}
compiled.getToolInvokers().forEach((name, invoker) -> {
if (invokers.putIfAbsent(name, invoker) != null) {
throw new BusinessException("Agent Skill Tool 运行名冲突:" + name);
}
});
for (McpSpec spec : compiled.getMcpSpecs()) {
BigInteger targetId = new BigInteger(String.valueOf(spec.getMetadata().get("mcpId")));
Map<String, Object> item = mcpSnapshots.get(targetId);
configureSkillMcp(spec, item, skillId, displayName, runtimeNames, ownedNames);
mcpSpecs.add(spec);
}
toolBindings.put(skillId, ownedNames);
}
box.setSkills(skills);
box.setToolBindings(toolBindings);
result.setSkillBoxSpec(box);
result.setToolSpecs(toolSpecs);
result.setMcpSpecs(mcpSpecs);
result.setToolInvokers(invokers);
return result;
}
/**
* 将冻结投影转成 AgentSkillSpec。
*/
private AgentSkillSpec toSkillSpec(Map<String, Object> snapshot, String skillId, String displayName) {
AgentSkillSpec spec = new AgentSkillSpec();
spec.setSkillId(skillId);
spec.setName(requiredText(snapshot, "name", "Skill 运行快照缺少名称"));
spec.setDescription(requiredText(snapshot, "description", "Skill 运行快照缺少描述"));
spec.setSkillContent(requiredText(snapshot, "skillContent", "Skill 运行快照缺少指令"));
spec.setSource(requiredText(snapshot, "source", "Skill 运行快照缺少来源"));
Object resources = snapshot.get("resources");
spec.setResources(resources instanceof Map<?, ?> ? objectMapper.convertValue(resources, STRING_MAP_TYPE) : Map.of());
spec.getMetadata().put("displayName", displayName);
spec.getMetadata().put("skillSnapshotHash", snapshot.get("skillSnapshotHash"));
spec.getMetadata().put("skillRuntimeSnapshotHash", snapshot.get("skillRuntimeSnapshotHash"));
return spec;
}
/**
* 构建可复用公共 Tool 编译器的服务端绑定。
*/
private AgentToolBinding toSyntheticBinding(String skillId,
String displayName,
Map<String, Object> item) {
String type = requiredText(item, "toolType", "Skill Tool 快照缺少类型").toUpperCase(Locale.ROOT);
if (!Set.of("WORKFLOW", "PLUGIN", "MCP").contains(type)) {
throw new BusinessException("Skill Tool 快照类型不支持:" + type);
}
BigInteger targetId = bigInteger(item.get("targetId"), "Skill Tool 快照缺少目标 ID");
AgentToolBinding binding = new AgentToolBinding();
binding.setToolType(type);
binding.setTargetId(targetId);
binding.setEnabled(true);
binding.setHitlEnabled(Boolean.TRUE.equals(item.get("hitlEnabled")));
binding.setSortNo(number(item.get("sortNo"), 0));
Object resource = item.get("resourceSnapshot");
if (!(resource instanceof Map<?, ?>)) {
throw new BusinessException("Skill Tool 冻结资源快照缺失:" + displayName);
}
binding.setResourceSnapshot(toStringMap(resource));
if (!"MCP".equals(type)) {
binding.setToolName("skill_" + safeSegment(skillId) + "_"
+ type.toLowerCase(Locale.ROOT) + "_" + targetId);
}
return binding;
}
/**
* 为 Skill MCP 写入冻结白名单、稳定别名和可信归属。
*/
private void configureSkillMcp(McpSpec spec,
Map<String, Object> item,
String skillId,
String displayName,
Set<String> runtimeNames,
List<String> ownedNames) {
if (item == null) {
throw new BusinessException("Skill MCP 冻结快照缺失:" + displayName);
}
List<McpToolManifestEntry> manifest = objectMapper.convertValue(
item.get("mcpToolManifest"), MCP_MANIFEST_TYPE);
if (manifest == null || manifest.isEmpty()) {
throw new BusinessException("Skill MCP 冻结 Tool 清单为空:" + displayName);
}
String manifestHash = requiredText(item, "mcpToolManifestHash", "Skill MCP 冻结清单 hash 缺失");
String mcpId = String.valueOf(item.get("targetId"));
Map<String, String> aliases = new LinkedHashMap<>();
manifest.stream().sorted(java.util.Comparator.comparing(McpToolManifestEntry::getName))
.forEach(entry -> {
String rawName = entry.getName();
String alias = "skill_" + safeSegment(skillId) + "_mcp_" + safeSegment(mcpId)
+ "_" + safeSegment(rawName) + "_" + shortHash(rawName);
assertRuntimeName(runtimeNames, alias);
aliases.put(rawName, alias);
ownedNames.add(alias);
});
spec.setName("skill_" + safeSegment(skillId) + "_mcp_" + safeSegment(mcpId));
spec.setGroupName(spec.getName());
spec.setSkillId(skillId);
spec.setFrozenToolManifest(manifest);
spec.setFrozenToolManifestHash(manifestHash);
spec.setEnableTools(manifest.stream().map(McpToolManifestEntry::getName).toList());
spec.setToolAliases(aliases);
spec.setToolNamePrefix(null);
spec.getMetadata().put("skillId", skillId);
spec.getMetadata().put("skillDisplayName", displayName);
attachApprovalMetadata(spec.getApprovalRequest(), skillId, displayName);
}
private void attachSkillMetadata(AgentToolSpec spec, String skillId, String displayName) {
spec.getMetadata().put("skillId", skillId);
spec.getMetadata().put("skillDisplayName", displayName);
attachApprovalMetadata(spec.getApprovalRequest(), skillId, displayName);
}
private void attachApprovalMetadata(AgentToolApprovalRequest request, String skillId, String displayName) {
if (request == null) {
return;
}
request.getMetadata().put("skillId", skillId);
request.getMetadata().put("skillDisplayName", displayName);
}
private Map<String, String> directTargetOwners(Agent agent) {
Map<String, String> owners = new HashMap<>();
if (agent == null || agent.getToolBindings() == null) {
return owners;
}
for (AgentToolBinding binding : agent.getToolBindings()) {
if (binding == null || !Boolean.TRUE.equals(binding.getEnabled()) || binding.getTargetId() == null) {
continue;
}
owners.put(binding.getToolType().toUpperCase(Locale.ROOT) + ":" + binding.getTargetId(), "Agent 直接工具");
}
return owners;
}
private void assertUniqueTarget(Map<String, String> owners,
AgentToolBinding binding,
String displayName) {
String key = binding.getToolType().toUpperCase(Locale.ROOT) + ":" + binding.getTargetId();
String existing = owners.putIfAbsent(key, displayName);
if (existing != null) {
throw new BusinessException("工具资源重复:" + existing + "" + displayName + " 引用了 " + key);
}
}
private List<Map<String, Object>> bindingSnapshots(Map<String, Object> snapshot) {
Object value = snapshot.get("toolBindings");
if (!(value instanceof List<?> list)) {
return List.of();
}
List<Map<String, Object>> result = new ArrayList<>();
for (Object item : list) {
if (!(item instanceof Map<?, ?>)) {
throw new BusinessException("Skill Tool 运行快照格式错误");
}
result.add(toStringMap(item));
}
return result;
}
private boolean hasCompleteSnapshots(List<AgentSkillBinding> bindings) {
return bindings.stream().allMatch(binding -> binding != null
&& binding.getResourceSnapshot() != null
&& !binding.getResourceSnapshot().isEmpty());
}
private void assertRuntimeName(Set<String> names, String name) {
if (name == null || name.isBlank() || !names.add(name)) {
throw new BusinessException("Agent Skill Tool 运行名冲突:" + name);
}
}
private Map<String, Object> toStringMap(Object value) {
Map<String, Object> result = new LinkedHashMap<>();
((Map<?, ?>) value).forEach((key, item) -> result.put(String.valueOf(key), item));
return result;
}
private String requiredText(Map<String, Object> source, String key, String message) {
String value = text(source.get(key));
if (value == null || value.isBlank()) {
throw new BusinessException(message);
}
return value;
}
private String text(Object value) { return value == null ? null : String.valueOf(value); }
private String firstText(String first, String second) {
return first == null || first.isBlank() ? second : first;
}
private BigInteger bigInteger(Object value, String message) {
if (value == null) {
throw new BusinessException(message);
}
try {
return new BigInteger(String.valueOf(value));
} catch (NumberFormatException exception) {
throw new BusinessException(message);
}
}
private Integer number(Object value, int fallback) {
return value instanceof Number number ? number.intValue() : fallback;
}
private String safeSegment(String value) {
String normalized = String.valueOf(value == null ? "" : value).trim()
.replaceAll("[^A-Za-z0-9_-]", "_").replaceAll("_+", "_");
if (normalized.length() > 28) {
normalized = normalized.substring(0, 28);
}
return normalized.isBlank() ? "tool" : normalized;
}
private String shortHash(String value) {
return SkillHashes.sha256Hex(String.valueOf(value).getBytes(StandardCharsets.UTF_8)).substring(0, 8);
}
}

View File

@@ -0,0 +1,404 @@
package tech.easyflow.agent.runtime.skill;
import com.easyagents.skill.util.SkillHashes;
import com.easyagents.skill.util.SkillPaths;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentSkillBinding;
import tech.easyflow.agent.service.AgentDependencyAccessService;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.service.SkillService;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/**
* 将已发布 Skill 内容与平台 Tool 快照投影为 Agent 内部运行快照。
*/
@Component
public class AgentSkillRuntimeProjector {
/** 单个 Agent 的 Skill 原始文本投影上限。 */
public static final long MAX_TEXT_BYTES = 8L * 1024L * 1024L;
/** 单个 Agent 的 Skill 绑定数量上限。 */
public static final int MAX_SKILL_COUNT = 20;
private final AgentDependencyAccessService dependencyAccessService;
private final SkillService skillService;
private final ObjectMapper objectMapper;
/**
* 创建 Agent Skill 运行投影器。
*
* @param dependencyAccessService Agent 依赖权限服务
* @param skillService Skill 服务
* @param objectMapper JSON 映射器
*/
public AgentSkillRuntimeProjector(AgentDependencyAccessService dependencyAccessService,
SkillService skillService,
ObjectMapper objectMapper) {
this.dependencyAccessService = dependencyAccessService;
this.skillService = skillService;
this.objectMapper = objectMapper;
}
/**
* 校验当前绑定并构建 Agent 发布用冻结 Skill 运行投影。
*
* @param agent Agent
* @param bindings Skill 草稿绑定
* @return 带内部运行快照及脱敏摘要的绑定副本
*/
public List<AgentSkillBinding> projectCurrentBindings(Agent agent,
List<AgentSkillBinding> bindings) {
if (bindings == null || bindings.isEmpty()) {
return List.of();
}
if (bindings.size() > MAX_SKILL_COUNT) {
throw new BusinessException(409, 4092, "单个 Agent 最多可绑定 20 个 Skill");
}
Map<BigInteger, Skill> skills = loadSkillsInStableLockOrder(agent, bindings);
Set<BigInteger> unique = new HashSet<>();
List<AgentSkillBinding> projected = new ArrayList<>();
long totalBytes = 0L;
for (AgentSkillBinding binding : bindings) {
if (binding == null || binding.getSkillId() == null || !unique.add(binding.getSkillId())) {
throw new BusinessException(409, 4092, "同一 Skill 不能重复绑定");
}
Skill skill = skills.get(binding.getSkillId());
Projection projection = project(skill);
totalBytes = Math.addExact(totalBytes, projection.textBytes());
if (totalBytes > MAX_TEXT_BYTES) {
throw new BusinessException(409, 4092,
"Agent Skill 文本投影超过 8 MiB请减少绑定或精简文本资源");
}
AgentSkillBinding copy = copyBinding(binding);
copy.setResourceSnapshot(projection.runtimeSnapshot());
copy.setResourceSummary(projection.summary());
projected.add(copy);
}
return projected;
}
/**
* 为详情页构建单个当前 Skill 的脱敏摘要。
*
* @param skill 已发布 Skill
* @param publishedRuntimeHash Agent 线上冻结的组合 hash可为空
* @return 脱敏摘要
*/
public Map<String, Object> currentSummary(Skill skill, String publishedRuntimeHash) {
Projection projection = project(skill);
Map<String, Object> summary = new LinkedHashMap<>(projection.summary());
summary.put("hasUpdate", publishedRuntimeHash != null
&& !publishedRuntimeHash.equals(summary.get("skillRuntimeSnapshotHash")));
return summary;
}
/**
* 校验 Agent 快照中已经冻结的 Skill 文本投影。
*
* @param bindings 冻结绑定
*/
public void assertFrozenBindings(List<AgentSkillBinding> bindings) {
if (bindings == null || bindings.isEmpty()) {
return;
}
if (bindings.size() > MAX_SKILL_COUNT) {
throw new BusinessException("Agent 发布快照中的 Skill 数量超过 20 个");
}
long totalBytes = 0L;
Set<String> ids = new HashSet<>();
for (AgentSkillBinding binding : bindings) {
Map<String, Object> snapshot = binding == null ? null : binding.getResourceSnapshot();
if (snapshot == null || snapshot.isEmpty()) {
throw new BusinessException("Agent Skill 运行快照为空");
}
String skillId = text(snapshot.get("skillId"));
if (skillId == null || !ids.add(skillId)) {
throw new BusinessException("Agent Skill 运行快照包含重复或空 Skill ID");
}
String declaredHash = text(snapshot.get("skillRuntimeSnapshotHash"));
Map<String, Object> canonical = new LinkedHashMap<>(snapshot);
canonical.remove("skillRuntimeSnapshotHash");
if (declaredHash == null || !declaredHash.equals(hash(canonical))) {
throw new BusinessException("Agent Skill 运行快照 hash 校验失败:" + skillId);
}
totalBytes = Math.addExact(totalBytes, frozenTextBytes(snapshot));
if (totalBytes > MAX_TEXT_BYTES) {
throw new BusinessException("Agent 发布快照中的 Skill 文本投影超过 8 MiB");
}
}
}
/**
* 按 Skill ID 锁顺序加载并复核权限,降低并发死锁概率。
*
* @param agent Agent
* @param bindings Skill 绑定
* @return Skill ID 到实体的映射
*/
private Map<BigInteger, Skill> loadSkillsInStableLockOrder(Agent agent,
List<AgentSkillBinding> bindings) {
List<BigInteger> ids = bindings.stream()
.filter(binding -> binding != null && binding.getSkillId() != null)
.map(AgentSkillBinding::getSkillId)
.distinct()
.sorted()
.toList();
Map<BigInteger, Skill> result = new LinkedHashMap<>();
for (BigInteger id : ids) {
result.put(id, dependencyAccessService.requireSkill(agent, id));
}
return result;
}
/**
* 构建单个 Skill 运行投影。
*
* @param skill 已发布 Skill
* @return 运行投影与摘要
*/
private Projection project(Skill skill) {
Map<String, Object> content = skill.getPublishedSnapshotJson();
skillService.assertPublishedAggregateHash(skill);
String skillContent = text(content.get("skillContent"));
Map<String, String> textResources = new TreeMap<>();
int binaryCount = 0;
long textBytes = utf8Length(skillContent);
Set<String> paths = new HashSet<>();
Object rawResources = content.get("resources");
if (rawResources instanceof List<?> resources) {
for (Object raw : resources) {
if (!(raw instanceof Map<?, ?> item)) {
throw new BusinessException("Skill 发布快照资源格式错误:" + skill.getName());
}
String path = normalizePath(text(item.get("path")));
if (!paths.add(path.toLowerCase(java.util.Locale.ROOT))) {
throw new BusinessException("Skill 发布快照资源路径重复:" + path);
}
if (!Boolean.TRUE.equals(item.get("text"))) {
binaryCount++;
continue;
}
String value = text(item.get("textContent"));
if (value == null) {
throw new BusinessException("Skill 文本资源正文缺失:" + path);
}
textResources.put(path, value);
textBytes = Math.addExact(textBytes, utf8Length(value));
}
}
Map<String, Object> toolSnapshot = skill.getPublishedToolBindingsJson() == null
? Map.of() : skill.getPublishedToolBindingsJson();
String contentHash = text(content.get("snapshotHash"));
String toolHash = text(toolSnapshot.get("snapshotHash"));
// 新版发布快照冻结展示字段;历史快照显式回退当前行以保持兼容。
String displayName = firstText(text(content.get("displayName")),
firstText(skill.getDisplayName(), skill.getName()));
String visibilityScope = firstText(text(content.get("visibilityScope")),
skill.getVisibilityScope());
Map<String, Object> runtime = new LinkedHashMap<>();
runtime.put("schemaVersion", 1);
runtime.put("skillId", skill.getId().toString());
runtime.put("name", content.get("name"));
runtime.put("displayName", displayName);
runtime.put("description", content.get("description"));
runtime.put("skillContent", skillContent);
runtime.put("packageHash", content.get("packageHash"));
runtime.put("skillSnapshotHash", contentHash);
runtime.put("toolBindingsHash", toolHash == null ? "" : toolHash);
runtime.put("resources", textResources);
runtime.put("toolBindings", toolBindings(toolSnapshot));
runtime.put("source", "easyflow://skill/" + skill.getId());
String runtimeHash = hash(runtime);
runtime.put("skillRuntimeSnapshotHash", runtimeHash);
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("skillId", skill.getId());
summary.put("displayName", displayName);
summary.put("description", content.get("description"));
summary.put("visibilityScope", visibilityScope);
summary.put("skillSnapshotHash", contentHash);
summary.put("toolBindingsHash", toolHash == null ? "" : toolHash);
summary.put("skillRuntimeSnapshotHash", runtimeHash);
summary.put("textBytes", textBytes);
summary.put("textResourceCount", textResources.size());
summary.put("binaryExcludedCount", binaryCount);
summary.put("toolCount", toolCount(toolSnapshot));
return new Projection(runtime, summary, textBytes);
}
/**
* 从平台 Tool 快照提取冻结绑定数组。
*
* @param toolSnapshot 平台 Tool 快照
* @return Tool 绑定数组
*/
private List<?> toolBindings(Map<String, Object> toolSnapshot) {
Object value = toolSnapshot.get("bindings");
return value instanceof List<?> list ? list : List.of();
}
/**
* 汇总实际 Tool 数。
*
* @param toolSnapshot 平台 Tool 快照
* @return Tool 数量
*/
private int toolCount(Map<String, Object> toolSnapshot) {
int count = 0;
for (Object raw : toolBindings(toolSnapshot)) {
if (raw instanceof Map<?, ?> item && item.get("toolCount") instanceof Number number) {
count += number.intValue();
}
}
return count;
}
/**
* 计算冻结快照文本字节数。
*
* @param snapshot Skill 运行快照
* @return UTF-8 字节数
*/
private long frozenTextBytes(Map<String, Object> snapshot) {
long total = utf8Length(text(snapshot.get("skillContent")));
Object resources = snapshot.get("resources");
if (resources instanceof Map<?, ?> map) {
for (Object value : map.values()) {
total = Math.addExact(total, utf8Length(text(value)));
}
}
return total;
}
/**
* 创建无内部快照副作用的绑定副本。
*
* @param source 原绑定
* @return 绑定副本
*/
private AgentSkillBinding copyBinding(AgentSkillBinding source) {
AgentSkillBinding copy = new AgentSkillBinding();
copy.setId(source.getId());
copy.setTenantId(source.getTenantId());
copy.setAgentId(source.getAgentId());
copy.setSkillId(source.getSkillId());
copy.setSortNo(source.getSortNo());
copy.setCreated(source.getCreated());
copy.setCreatedBy(source.getCreatedBy());
copy.setModified(source.getModified());
copy.setModifiedBy(source.getModifiedBy());
return copy;
}
/**
* 计算内容与 Tool 的组合运行 hash。
*
* @param contentHash 内容快照 hash
* @param toolHash Tool 快照 hash
* @return 组合 SHA-256
*/
private String hash(Map<String, Object> value) {
try {
return SkillHashes.sha256Hex(objectMapper.writeValueAsBytes(canonicalizeJson(value)));
} catch (JsonProcessingException exception) {
throw new BusinessException("Agent Skill 运行快照序列化失败");
}
}
/**
* 将运行快照转换为稳定 JSON 结构,确保发布前 POJO 与落库后的 Map 产生相同 hash。
*
* @param value 原始快照值
* @return 按键排序且仅包含 JSON 基础类型的值
*/
private Object canonicalizeJson(Object value) {
if (value instanceof Map<?, ?> map) {
Map<String, Object> sorted = new TreeMap<>();
map.forEach((key, item) -> sorted.put(String.valueOf(key), canonicalizeJson(item)));
return sorted;
}
if (value instanceof List<?> list) {
return list.stream().map(this::canonicalizeJson).toList();
}
if (value == null || value instanceof String || value instanceof Number
|| value instanceof Boolean) {
return value;
}
return canonicalizeJson(objectMapper.convertValue(value, Object.class));
}
/**
* 规范资源路径。
*
* @param path 原始路径
* @return 规范路径
*/
private String normalizePath(String path) {
if (path == null || path.isBlank()) {
throw new BusinessException("Skill 发布快照资源路径不能为空");
}
try {
return SkillPaths.normalize(path);
} catch (RuntimeException exception) {
throw new BusinessException("Skill 发布快照资源路径不合法:" + path);
}
}
/**
* 读取文本。
*
* @param value 原值
* @return 文本或 null
*/
private String text(Object value) {
return value == null ? null : String.valueOf(value);
}
/**
* 获取首个非空文本。
*
* @param first 首选值
* @param second 备选值
* @return 非空文本
*/
private String firstText(String first, String second) {
return first == null || first.isBlank() ? second : first;
}
/**
* 计算 UTF-8 字节数。
*
* @param value 文本
* @return 字节数
*/
private long utf8Length(String value) {
return (value == null ? "" : value).getBytes(StandardCharsets.UTF_8).length;
}
/**
* 单个 Skill 的运行投影结果。
*
* @param runtimeSnapshot 内部运行快照
* @param summary 脱敏摘要
* @param textBytes 文本 UTF-8 字节数
*/
private record Projection(Map<String, Object> runtimeSnapshot,
Map<String, Object> summary,
long textBytes) {
}
}

View File

@@ -9,6 +9,8 @@ import com.easyagents.agent.runtime.tool.asynctool.AsyncToolSpecExpander;
import com.easyagents.core.model.chat.tool.Parameter;
import com.easyagents.core.model.chat.tool.Tool;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.entity.Agent;
@@ -19,10 +21,12 @@ import tech.easyflow.agent.runtime.asynctool.PluginAsyncSubTools;
import tech.easyflow.agent.runtime.asynctool.WorkflowAsyncSubTools;
import tech.easyflow.ai.easyagents.tool.ChatToolNameHelper;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.service.McpService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.web.exceptions.BusinessException;
@@ -39,6 +43,8 @@ import java.util.regex.Pattern;
@Component
public class AgentToolRuntimeCompiler {
private static final Logger LOG = LoggerFactory.getLogger(AgentToolRuntimeCompiler.class);
private static final String TOOL_FAILURE_MESSAGE = "工具执行失败,请稍后重试";
private static final Pattern MCP_INPUT_PATTERN = Pattern.compile("\\$\\{input:([A-Za-z0-9_.-]+)}");
private static final Pattern ASYNC_SAFE_NAME = Pattern.compile("^[a-z][a-z0-9_]*$");
@@ -47,6 +53,8 @@ public class AgentToolRuntimeCompiler {
@Resource
private PluginItemService pluginItemService;
@Resource
private PluginService pluginService;
@Resource
private McpService mcpService;
@Resource
private ObjectMapper objectMapper;
@@ -66,8 +74,21 @@ public class AgentToolRuntimeCompiler {
* @return 工具编译结果
*/
public AgentToolRuntimeCompilation compile(Agent agent) {
return compileBindings(agent == null ? null : agent.getToolBindings());
}
/**
* 编译一组服务端已规范化的工具绑定。
*
* <p>Agent 直接工具和 Skill 冻结工具共用该入口,避免 Workflow、Plugin、MCP
* 的快照解析、调用器与 HITL 规则形成两套实现。</p>
*
* @param bindings 工具绑定
* @return 工具编译结果
*/
public AgentToolRuntimeCompilation compileBindings(List<AgentToolBinding> bindings) {
AgentToolRuntimeCompilation compilation = new AgentToolRuntimeCompilation();
if (agent == null || agent.getToolBindings() == null) {
if (bindings == null) {
return compilation;
}
List<AgentToolSpec> specs = new ArrayList<>();
@@ -76,7 +97,7 @@ public class AgentToolRuntimeCompiler {
Map<BigInteger, McpSpec> mcpSpecMap = new LinkedHashMap<>();
Set<String> compiledToolNames = new LinkedHashSet<>();
AsyncToolSpecExpander asyncExpander = new AsyncToolSpecExpander();
for (AgentToolBinding binding : agent.getToolBindings()) {
for (AgentToolBinding binding : bindings) {
if (!Boolean.TRUE.equals(binding.getEnabled())) {
continue;
}
@@ -139,16 +160,17 @@ public class AgentToolRuntimeCompiler {
Workflow workflow = requireWorkflow(binding);
Tool tool = workflowToolExecutor.buildTool(workflow);
AgentToolSpec spec = toToolSpec(tool, binding);
AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(),
AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(), binding, context,
() -> workflowToolExecutor.execute(workflow, arguments).getResult());
return new CompiledSyncTool(spec, invoker);
}
if (type == AgentToolType.PLUGIN) {
PluginItem pluginItem = requirePlugin(binding);
Tool tool = pluginToolExecutor.buildTool(pluginItem);
PluginRuntimeResource plugin = requirePlugin(binding);
PluginItem pluginItem = plugin.pluginItem();
Tool tool = pluginToolExecutor.buildTool(pluginItem, plugin.plugin());
AgentToolSpec spec = toToolSpec(tool, binding);
AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(),
() -> pluginToolExecutor.execute(pluginItem, arguments).getResult());
AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(), binding, context,
() -> pluginToolExecutor.execute(pluginItem, plugin.plugin(), arguments).getResult());
return new CompiledSyncTool(spec, invoker);
}
throw new BusinessException("不支持的 Agent 工具类型:" + type.name());
@@ -166,12 +188,13 @@ public class AgentToolRuntimeCompiler {
return spec;
}
if (type == AgentToolType.PLUGIN) {
PluginItem pluginItem = requirePlugin(binding);
Tool tool = pluginToolExecutor.buildTool(pluginItem);
PluginRuntimeResource plugin = requirePlugin(binding);
PluginItem pluginItem = plugin.pluginItem();
Tool tool = pluginToolExecutor.buildTool(pluginItem, plugin.plugin());
String asyncName = asyncToolName(tool, binding, "plugin");
String toolDisplayName = displayName(tool, pluginItem.getName());
AsyncToolSpec spec = baseAsyncSpec(asyncName, tool, binding, toolDisplayName);
spec.setSubTools(new PluginAsyncSubTools(pluginItem, asyncName, toolDisplayName,
spec.setSubTools(new PluginAsyncSubTools(pluginItem, plugin.plugin(), asyncName, toolDisplayName,
pluginToolExecutor, asyncToolTaskStore, agentAsyncToolExecutor));
return spec;
}
@@ -195,12 +218,27 @@ public class AgentToolRuntimeCompiler {
return spec;
}
private AgentToolResult invokeSafely(String toolName, ToolCall call) {
private AgentToolResult invokeSafely(String toolName,
AgentToolBinding binding,
AgentToolContext context,
ToolCall call) {
try {
Object result = call.invoke();
return AgentToolResult.success(result == null ? "" : String.valueOf(result));
} catch (Exception e) {
return AgentToolResult.failure(e.getMessage() == null ? "工具执行失败" : e.getMessage());
LOG.error("Agent Tool execution failed: toolName={}, toolType={}, targetId={}, bindingId={}, "
+ "agentId={}, sessionId={}, requestId={}, traceId={}, toolCallId={}",
toolName,
binding == null ? null : binding.getToolType(),
binding == null ? null : binding.getTargetId(),
binding == null ? null : binding.getId(),
context == null ? null : context.getAgentId(),
context == null ? null : context.getSessionId(),
context == null ? null : context.getRequestId(),
context == null ? null : context.getTraceId(),
context == null ? null : context.getToolCallId(),
e);
return AgentToolResult.failure(TOOL_FAILURE_MESSAGE);
}
}
@@ -217,12 +255,12 @@ public class AgentToolRuntimeCompiler {
return workflow;
}
private PluginItem requirePlugin(AgentToolBinding binding) {
PluginItem pluginItem = snapshotOrCurrentPlugin(binding);
if (pluginItem == null) {
private PluginRuntimeResource requirePlugin(AgentToolBinding binding) {
PluginRuntimeResource plugin = snapshotOrCurrentPlugin(binding);
if (plugin == null || plugin.pluginItem() == null || plugin.plugin() == null) {
throw new BusinessException("绑定插件不存在");
}
return pluginItem;
return plugin;
}
private AgentToolSpec toToolSpec(Tool tool, AgentToolBinding binding) {
@@ -320,13 +358,20 @@ public class AgentToolRuntimeCompiler {
return workflowService.getPublishedById(binding.getTargetId());
}
private PluginItem snapshotOrCurrentPlugin(AgentToolBinding binding) {
private PluginRuntimeResource snapshotOrCurrentPlugin(AgentToolBinding binding) {
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
PluginItem pluginItem = objectMapper.convertValue(binding.getResourceSnapshot(), PluginItem.class);
Map<String, Object> snapshot = binding.getResourceSnapshot();
Object itemValue = snapshot.containsKey("pluginItem") ? snapshot.get("pluginItem") : snapshot;
PluginItem pluginItem = objectMapper.convertValue(itemValue, PluginItem.class);
pluginItem.setId(firstNonNull(pluginItem.getId(), binding.getTargetId()));
return pluginItem;
Plugin plugin = snapshot.get("plugin") == null
? pluginService.getById(pluginItem.getPluginId())
: objectMapper.convertValue(snapshot.get("plugin"), Plugin.class);
return new PluginRuntimeResource(pluginItem, plugin);
}
return pluginItemService.getById(binding.getTargetId());
PluginItem pluginItem = pluginItemService.getById(binding.getTargetId());
Plugin plugin = pluginItem == null ? null : pluginService.getById(pluginItem.getPluginId());
return pluginItem == null ? null : new PluginRuntimeResource(pluginItem, plugin);
}
private Mcp snapshotOrCurrentMcp(AgentToolBinding binding) {
@@ -615,6 +660,10 @@ public class AgentToolRuntimeCompiler {
private record CompiledSyncTool(AgentToolSpec spec, AgentToolInvoker invoker) {
}
/** 冻结插件工具与父插件调用配置。 */
private record PluginRuntimeResource(PluginItem pluginItem, Plugin plugin) {
}
private interface ToolCall {
/**

View File

@@ -2,6 +2,7 @@ package tech.easyflow.agent.runtime.tool;
import com.easyagents.core.model.chat.tool.Tool;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import java.util.Map;
@@ -22,6 +23,17 @@ public class PluginToolExecutor {
return pluginItem.toFunction();
}
/**
* 使用冻结的父插件配置构建工具声明和执行对象。
*
* @param pluginItem 插件工具快照
* @param plugin 父插件调用配置快照
* @return 工具声明来源
*/
public Tool buildTool(PluginItem pluginItem, Plugin plugin) {
return pluginItem.toFunction(plugin);
}
/**
* 执行 Plugin 工具。
*
@@ -33,4 +45,19 @@ public class PluginToolExecutor {
Object result = buildTool(pluginItem).invoke(arguments == null ? Map.of() : arguments);
return new AgentToolExecutionResult(result, null);
}
/**
* 使用冻结父插件配置执行插件工具。
*
* @param pluginItem 插件工具快照
* @param plugin 父插件调用配置快照
* @param arguments 调用参数
* @return 执行结果
*/
public AgentToolExecutionResult execute(PluginItem pluginItem,
Plugin plugin,
Map<String, Object> arguments) {
Object result = buildTool(pluginItem, plugin).invoke(arguments == null ? Map.of() : arguments);
return new AgentToolExecutionResult(result, null);
}
}

View File

@@ -2,8 +2,10 @@ package tech.easyflow.agent.runtime.tool;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.easyagents.core.model.chat.tool.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
import tech.easyflow.ai.easyagentsflow.repository.FrozenWorkflowDefinitionRegistry;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Workflow;
@@ -16,14 +18,27 @@ import java.util.Map;
public class WorkflowToolExecutor {
private final ChainExecutor chainExecutor;
private final FrozenWorkflowDefinitionRegistry frozenDefinitionRegistry;
/**
* 创建 Workflow 工具执行器。
*
* @param chainExecutor 工作流执行器
*/
public WorkflowToolExecutor(ChainExecutor chainExecutor) {
@Autowired
public WorkflowToolExecutor(ChainExecutor chainExecutor,
FrozenWorkflowDefinitionRegistry frozenDefinitionRegistry) {
this.chainExecutor = chainExecutor;
this.frozenDefinitionRegistry = frozenDefinitionRegistry;
}
/**
* 创建仅供测试替身继承的执行器。
*
* @param chainExecutor 工作流执行器
*/
protected WorkflowToolExecutor(ChainExecutor chainExecutor) {
this(chainExecutor, null);
}
/**
@@ -44,11 +59,16 @@ public class WorkflowToolExecutor {
* @return 执行结果
*/
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
Object result = chainExecutor.execute(definitionId(workflow), arguments == null ? Map.of() : arguments);
Object result = chainExecutor.executeWithoutSuspension(
definitionId(workflow), arguments == null ? Map.of() : arguments);
return new AgentToolExecutionResult(result, resolveBusinessExecutionId(result));
}
private String definitionId(Workflow workflow) {
if (frozenDefinitionRegistry != null && workflow != null
&& workflow.getContent() != null && !workflow.getContent().isBlank()) {
return frozenDefinitionRegistry.register(workflow);
}
return PublishedWorkflowDefinitionIds.published(String.valueOf(workflow == null ? null : workflow.getId()));
}

View File

@@ -0,0 +1,151 @@
package tech.easyflow.agent.runtime.workspace;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.config.AgentWorkspaceProperties;
import tech.easyflow.agent.runtime.AgentRunRegistry;
import tech.easyflow.agent.runtime.lock.AgentRunLock;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.math.BigInteger;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 清理过期且没有活动运行保护的本机会话工作区。
*/
@Component
public class AgentWorkspaceCleanupService {
private static final Logger LOG = LoggerFactory.getLogger(AgentWorkspaceCleanupService.class);
private static final int MAX_DELETIONS_PER_RUN = 50;
private final AgentWorkspaceResolver resolver;
private final AgentWorkspaceProperties properties;
private final AgentRunRegistry runRegistry;
private final AgentRunLock agentRunLock;
/**
* 创建工作区清理服务。
*
* @param resolver 工作区解析器
* @param properties 工作区配置
* @param runRegistry 活动运行注册表
* @param agentRunLock 会话级分布式运行锁
*/
public AgentWorkspaceCleanupService(AgentWorkspaceResolver resolver,
AgentWorkspaceProperties properties,
AgentRunRegistry runRegistry,
AgentRunLock agentRunLock) {
this.resolver = resolver;
this.properties = properties;
this.runRegistry = runRegistry;
this.agentRunLock = agentRunLock;
}
/**
* 按固定深度扫描会话目录并执行有界清理。
*/
@Scheduled(fixedDelayString = "${easyflow.agent.workspace.cleanup-interval:30m}")
public void cleanup() {
Instant threshold = Instant.now().minus(properties.getRetention());
AtomicInteger deleted = new AtomicInteger();
try (var tenants = Files.list(resolver.getRealRoot())) {
tenants.filter(this::businessDirectory).forEach(tenant -> scanAgents(tenant, threshold, deleted));
} catch (IOException error) {
LOG.error("Scan Agent workspace root failed", error);
}
}
private void scanAgents(Path tenant, Instant threshold, AtomicInteger deleted) {
if (deleted.get() >= MAX_DELETIONS_PER_RUN) {
return;
}
try (var agents = Files.list(tenant)) {
agents.filter(this::businessDirectory).forEach(agent -> scanSessions(agent, threshold, deleted));
} catch (IOException error) {
LOG.error("Scan Agent workspace tenant directory failed", error);
}
}
private void scanSessions(Path agent, Instant threshold, AtomicInteger deleted) {
if (deleted.get() >= MAX_DELETIONS_PER_RUN) {
return;
}
try (var sessions = Files.list(agent)) {
sessions.filter(this::businessDirectory).forEach(session -> {
if (deleted.get() >= MAX_DELETIONS_PER_RUN || runRegistry.hasActiveSession(session.getFileName().toString())) {
return;
}
AgentRunLock.Handle lockHandle = tryAcquireSessionLock(agent, session);
if (lockHandle == null) {
return;
}
try (lockHandle) {
if (runRegistry.hasActiveSession(session.getFileName().toString())) {
return;
}
Path activity = resolver.activityFile(session);
Instant lastActive = Files.exists(activity)
? Files.getLastModifiedTime(activity).toInstant()
: Files.getLastModifiedTime(session).toInstant();
if (lastActive.isAfter(threshold)) {
return;
}
if (runRegistry.hasActiveSession(session.getFileName().toString())) {
return;
}
deleteTree(session);
Files.deleteIfExists(activity);
deleted.incrementAndGet();
} catch (IOException error) {
LOG.error("Clean expired Agent workspace failed", error);
}
});
} catch (IOException error) {
LOG.error("Scan Agent workspace session directory failed", error);
}
}
private AgentRunLock.Handle tryAcquireSessionLock(Path agent, Path session) {
try {
return agentRunLock.tryAcquire(
new BigInteger(agent.getFileName().toString()), session.getFileName().toString());
} catch (RuntimeException error) {
LOG.error("Acquire Agent workspace cleanup lock failed", error);
return null;
}
}
private boolean businessDirectory(Path path) {
return Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)
&& !Files.isSymbolicLink(path)
&& !path.equals(resolver.getActivityRoot());
}
private void deleteTree(Path root) throws IOException {
Files.walkFileTree(root, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path directory, IOException error) throws IOException {
if (error != null) {
throw error;
}
Files.delete(directory);
return FileVisitResult.CONTINUE;
}
});
}
}

View File

@@ -0,0 +1,227 @@
package tech.easyflow.agent.runtime.workspace;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.config.AgentWorkspaceProperties;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.HexFormat;
import java.util.regex.Pattern;
/**
* 按租户、Agent 和运行会话分配本地工作区,并维护服务端活动标记。
*/
@Component
public class AgentWorkspaceResolver {
private static final Pattern SESSION_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}");
private static final String ACTIVITY_DIRECTORY = ".easyflow-activity";
private final AgentWorkspaceProperties properties;
private Path realRoot;
private Path activityRoot;
/**
* 创建工作区解析器。
*
* @param properties 工作区配置
*/
public AgentWorkspaceResolver(AgentWorkspaceProperties properties) {
this.properties = properties;
}
/**
* 初始化并校验工作区根目录。
*/
@PostConstruct
public void initialize() {
try {
Path configured = Path.of(properties.getRoot()).toAbsolutePath().normalize();
Files.createDirectories(configured);
if (Files.isSymbolicLink(configured)) {
throw new IllegalStateException("Agent 工作区根目录不能是符号链接");
}
realRoot = configured.toRealPath(LinkOption.NOFOLLOW_LINKS);
activityRoot = realRoot.resolve(ACTIVITY_DIRECTORY);
if (Files.exists(activityRoot, LinkOption.NOFOLLOW_LINKS) && Files.isSymbolicLink(activityRoot)) {
throw new IllegalStateException("Agent 工作区活动目录不能是符号链接");
}
Files.createDirectories(activityRoot);
if (Files.isSymbolicLink(activityRoot)) {
throw new IllegalStateException("Agent 工作区活动目录不能是符号链接");
}
} catch (IOException error) {
throw new IllegalStateException("初始化 Agent 工作区根目录失败", error);
}
}
/**
* 解析并创建一个隔离的会话工作区。
*
* @param tenantId 租户 ID
* @param agentId Agent ID
* @param runtimeSessionId Runtime 会话 ID
* @return 经过真实路径校验的绝对工作区
*/
public Path resolve(BigInteger tenantId, BigInteger agentId, String runtimeSessionId) {
if (tenantId == null || tenantId.signum() <= 0 || agentId == null || agentId.signum() <= 0) {
throw new BusinessException("Agent 工作区租户和 Agent 标识不完整");
}
if (runtimeSessionId == null || !SESSION_ID.matcher(runtimeSessionId).matches()) {
throw new BusinessException("Agent 工作区会话标识不合法");
}
Path target = realRoot.resolve(tenantId.toString()).resolve(agentId.toString())
.resolve(runtimeSessionId).normalize();
if (!target.startsWith(realRoot)) {
throw new BusinessException("Agent 工作区路径越界");
}
try {
createSafeDirectories(target);
Path realTarget = target.toRealPath(LinkOption.NOFOLLOW_LINKS);
if (!realTarget.startsWith(realRoot) || Files.isSymbolicLink(realTarget)) {
throw new BusinessException("Agent 工作区路径越界");
}
touch(realTarget);
return realTarget;
} catch (IOException error) {
throw new BusinessException(500, 500, "创建 Agent 会话工作区失败", error);
}
}
/**
* 安全解析工作区内一个已经存在的普通文件。
*
* @param workspace 会话工作区
* @param relativePath 模型提交的相对路径
* @return 文件真实绝对路径
*/
public Path resolveExistingFile(Path workspace, String relativePath) {
if (workspace == null || relativePath == null || relativePath.isBlank()
|| relativePath.indexOf('\0') >= 0 || relativePath.startsWith("~")) {
throw new BusinessException("WORKSPACE_PATH_FORBIDDEN: 文件路径必须是工作区相对路径");
}
Path raw;
try {
raw = Path.of(relativePath);
} catch (InvalidPathException error) {
throw new BusinessException("WORKSPACE_PATH_FORBIDDEN: 文件路径格式不合法");
}
if (raw.isAbsolute()) {
throw new BusinessException("WORKSPACE_PATH_FORBIDDEN: 禁止绝对路径");
}
for (Path segment : raw) {
if ("..".equals(segment.toString())) {
throw new BusinessException("WORKSPACE_PATH_FORBIDDEN: 禁止路径回退");
}
}
try {
Path realWorkspace = workspace.toRealPath(LinkOption.NOFOLLOW_LINKS);
Path candidate = realWorkspace.resolve(raw).normalize();
if (!candidate.startsWith(realWorkspace) || !Files.exists(candidate, LinkOption.NOFOLLOW_LINKS)) {
throw new BusinessException("WORKSPACE_FILE_NOT_FOUND: 工作区文件不存在");
}
rejectSymlinkChain(realWorkspace, candidate);
Path realFile = candidate.toRealPath(LinkOption.NOFOLLOW_LINKS);
if (!realFile.startsWith(realWorkspace) || !Files.isRegularFile(realFile, LinkOption.NOFOLLOW_LINKS)) {
throw new BusinessException("WORKSPACE_FILE_TYPE_UNSUPPORTED: 只允许普通文件");
}
rejectUnixHardlink(realFile);
touch(realWorkspace);
return realFile;
} catch (BusinessException error) {
throw error;
} catch (IOException error) {
throw new BusinessException(500, 500, "解析工作区文件失败", error);
}
}
/**
* 更新工作区的可信活动时间。
*
* @param workspace 会话工作区
*/
public void touch(Path workspace) {
try {
Files.writeString(activityFile(workspace), Instant.now().toString(),
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
} catch (IOException error) {
throw new BusinessException(500, 500, "更新 Agent 工作区活动状态失败", error);
}
}
/** @return 工作区真实根目录 */
public Path getRealRoot() { return realRoot; }
/** @return 活动标记目录 */
public Path getActivityRoot() { return activityRoot; }
/**
* 取得指定工作区的活动标记文件。
*
* @param workspace 会话工作区
* @return 位于模型工作区之外的标记文件
*/
public Path activityFile(Path workspace) {
Path relative = realRoot.relativize(workspace.toAbsolutePath().normalize());
String digest = sha256(relative.toString());
return activityRoot.resolve(digest + ".activity");
}
private void createSafeDirectories(Path target) throws IOException {
Path current = realRoot;
for (Path segment : realRoot.relativize(target)) {
current = current.resolve(segment);
if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(current) || !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
throw new BusinessException("Agent 工作区路径链不是安全目录");
}
continue;
}
try {
Files.createDirectory(current);
} catch (java.nio.file.FileAlreadyExistsException ignored) {
if (Files.isSymbolicLink(current) || !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
throw new BusinessException("Agent 工作区路径链不是安全目录");
}
}
}
}
private void rejectSymlinkChain(Path root, Path target) {
Path current = root;
for (Path segment : root.relativize(target)) {
current = current.resolve(segment);
if (Files.isSymbolicLink(current)) {
throw new BusinessException("WORKSPACE_PATH_FORBIDDEN: 路径链包含符号链接");
}
}
}
private void rejectUnixHardlink(Path file) throws IOException {
try {
Object value = Files.getAttribute(file, "unix:nlink", LinkOption.NOFOLLOW_LINKS);
if (value instanceof Number number && number.longValue() > 1L) {
throw new BusinessException("WORKSPACE_FILE_TYPE_UNSUPPORTED: 禁止发布硬链接文件");
}
} catch (UnsupportedOperationException ignored) {
// 非 Unix 文件系统没有 nlink 属性,仍保留普通文件与符号链接校验。
}
}
private String sha256(String value) {
try {
return HexFormat.of().formatHex(
java.security.MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (java.security.NoSuchAlgorithmException error) {
throw new IllegalStateException("当前 JVM 不支持 SHA-256", error);
}
}
}

View File

@@ -26,6 +26,8 @@ import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.service.SkillService;
import java.math.BigInteger;
import java.util.Objects;
@@ -46,6 +48,7 @@ public class AgentDependencyAccessService {
private final AgentCategoryService agentCategoryService;
private final CategoryPermissionService categoryPermissionService;
private final ResourceAccessService resourceAccessService;
private final SkillService skillService;
/**
* 创建 Agent 依赖资源校验服务。
@@ -60,6 +63,7 @@ public class AgentDependencyAccessService {
* @param agentCategoryService Agent 分类服务
* @param categoryPermissionService 分类权限服务
* @param resourceAccessService 资源权限服务
* @param skillService Skill 服务
*/
public AgentDependencyAccessService(ModelService modelService,
WorkflowService workflowService,
@@ -70,7 +74,8 @@ public class AgentDependencyAccessService {
DocumentCollectionService documentCollectionService,
AgentCategoryService agentCategoryService,
CategoryPermissionService categoryPermissionService,
ResourceAccessService resourceAccessService) {
ResourceAccessService resourceAccessService,
SkillService skillService) {
this.modelService = modelService;
this.workflowService = workflowService;
this.pluginItemService = pluginItemService;
@@ -81,6 +86,7 @@ public class AgentDependencyAccessService {
this.agentCategoryService = agentCategoryService;
this.categoryPermissionService = categoryPermissionService;
this.resourceAccessService = resourceAccessService;
this.skillService = skillService;
}
/**
@@ -135,6 +141,17 @@ public class AgentDependencyAccessService {
* @return 插件工具
*/
public PluginItem requirePluginItem(Agent agent, BigInteger pluginItemId) {
return requirePluginResource(agent, pluginItemId).pluginItem();
}
/**
* 校验并锁定插件工具及其父插件,返回同一事务中的完整调用资源。
*
* @param agent Agent
* @param pluginItemId 插件工具 ID
* @return 插件项与父插件
*/
public PluginResource requirePluginResource(Agent agent, BigInteger pluginItemId) {
PluginItem current = pluginItemService.getById(pluginItemId);
if (current == null || current.getPluginId() == null) {
throw new BusinessException("绑定插件不存在");
@@ -153,7 +170,7 @@ public class AgentDependencyAccessService {
}
assertSameTenant(agent, plugin.getTenantId(), "无权限绑定该插件");
pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限绑定该插件");
return pluginItem;
return new PluginResource(pluginItem, plugin);
}
/**
@@ -174,6 +191,30 @@ public class AgentDependencyAccessService {
return mcp;
}
/**
* 校验并锁定 Agent 可使用的已发布 Skill。
*
* <p>Skill 发布阶段已经完成底层 Tool 权限与 MCP 清单检测。Agent 保存阶段只消费冻结快照,
* 避免在数据库事务中执行外部 MCP I/O快照内容及组合 hash 由运行投影器继续校验。</p>
*
* @param agent Agent
* @param skillId Skill ID
* @return 已发布 Skill
*/
public Skill requireSkill(Agent agent, BigInteger skillId) {
Skill skill = skillService.getOne(QueryWrapper.create()
.eq(Skill::getId, skillId)
.forUpdate());
if (skill == null || PublishStatus.from(skill.getPublishStatus()) != PublishStatus.PUBLISHED
|| skill.getPublishedSnapshotJson() == null || skill.getPublishedSnapshotJson().isEmpty()) {
throw new BusinessException("绑定 Skill 不存在或未发布");
}
assertSameTenant(agent, skill.getTenantId(), "无权限绑定该 Skill");
resourceAccessService.assertAccess(
CategoryResourceType.SKILL, skill, ResourceAction.USE, "无权限绑定该 Skill");
return skill;
}
/**
* 校验并锁定知识库。
*
@@ -233,4 +274,13 @@ public class AgentDependencyAccessService {
throw new BusinessException(message);
}
}
/**
* 插件运行依赖聚合。
*
* @param pluginItem 插件工具
* @param plugin 父插件调用配置
*/
public record PluginResource(PluginItem pluginItem, Plugin plugin) {
}
}

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.security.AgentVisibilityQueryHelper;
import tech.easyflow.agent.vo.AgentOptionView;
@@ -28,8 +29,12 @@ import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.service.SkillService;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
@@ -54,9 +59,11 @@ public class AgentOptionQueryService {
private final PluginItemService pluginItemService;
private final PluginVisibilityService pluginVisibilityService;
private final McpService mcpService;
private final SkillService skillService;
private final AgentVisibilityQueryHelper agentVisibilityQueryHelper;
private final ResourceAccessService resourceAccessService;
private final ObjectMapper objectMapper;
private CategoryPermissionService categoryPermissionService;
/**
* 创建 Agent 安全选项查询服务。
@@ -69,6 +76,7 @@ public class AgentOptionQueryService {
* @param pluginItemService 插件工具服务
* @param pluginVisibilityService 插件可见性服务
* @param mcpService MCP 服务
* @param skillService Skill 服务
* @param agentVisibilityQueryHelper Agent 可见性查询助手
* @param resourceAccessService 资源访问服务
* @param objectMapper JSON 映射器
@@ -81,6 +89,7 @@ public class AgentOptionQueryService {
PluginItemService pluginItemService,
PluginVisibilityService pluginVisibilityService,
McpService mcpService,
SkillService skillService,
AgentVisibilityQueryHelper agentVisibilityQueryHelper,
ResourceAccessService resourceAccessService,
ObjectMapper objectMapper) {
@@ -92,6 +101,7 @@ public class AgentOptionQueryService {
this.pluginItemService = pluginItemService;
this.pluginVisibilityService = pluginVisibilityService;
this.mcpService = mcpService;
this.skillService = skillService;
this.agentVisibilityQueryHelper = agentVisibilityQueryHelper;
this.resourceAccessService = resourceAccessService;
this.objectMapper = objectMapper;
@@ -133,12 +143,113 @@ public class AgentOptionQueryService {
return new AgentResourceOptionsView(
listModelOptions(account),
listKnowledgeOptions(account),
listSkillOptions(account),
listWorkflowOptions(account),
listPluginToolOptions(account),
listMcpOptions(account)
listMcpOptions(account),
new AgentResourceOptionsView.Capabilities(
categoryPermissionService != null && categoryPermissionService.isSuperAdmin(account))
);
}
/**
* 注入平台超级管理员判定服务。
*
* @param categoryPermissionService 分类权限服务
*/
@Autowired
public void setCategoryPermissionService(CategoryPermissionService categoryPermissionService) {
this.categoryPermissionService = categoryPermissionService;
}
/**
* 查询当前账号可使用的已发布 Skill 安全选项。
*
* @param account 当前登录账号
* @return Skill 选项
*/
private List<AgentResourceOptionsView.SkillOption> listSkillOptions(LoginAccount account) {
return skillService.list(QueryWrapper.create()
.eq(Skill::getTenantId, account.getTenantId())
.eq(Skill::getPublishStatus, PublishStatus.PUBLISHED.getCode())
.orderBy(Skill::getModified, false)
.orderBy(Skill::getDisplayName, true))
.stream()
.filter(skill -> resourceAccessService.canAccess(
CategoryResourceType.SKILL, skill, ResourceAction.USE))
.map(this::toSkillOption)
.toList();
}
/**
* 将 Skill 发布数据投影为不含正文、资源内容和连接配置的选择项。
*
* @param skill Skill
* @return 安全选择项
*/
private AgentResourceOptionsView.SkillOption toSkillOption(Skill skill) {
Map<String, Object> publishedSnapshot = skill.getPublishedSnapshotJson();
List<?> resources = listValue(publishedSnapshot, "resources");
int textCount = 0;
int binaryCount = 0;
long textBytes = utf8Length(textValue(publishedSnapshot, "skillContent"));
for (Object raw : resources) {
if (!(raw instanceof Map<?, ?> resource)) {
continue;
}
if (Boolean.TRUE.equals(resource.get("text"))) {
textCount++;
textBytes = Math.addExact(textBytes,
utf8Length(resource.get("textContent") == null
? null : String.valueOf(resource.get("textContent"))));
} else {
binaryCount++;
}
}
int toolCount = 0;
for (Object raw : listValue(skill.getPublishedToolBindingsJson(), "bindings")) {
if (raw instanceof Map<?, ?> binding && binding.get("toolCount") instanceof Number number) {
toolCount += Math.max(0, number.intValue());
}
}
return new AgentResourceOptionsView.SkillOption(
skill.getId(),
publishedText(publishedSnapshot, "displayName", skill.getDisplayName()),
publishedText(publishedSnapshot, "description", skill.getDescription()),
publishedText(publishedSnapshot, "visibilityScope", skill.getVisibilityScope()),
skill.getSnapshotHash(), toolCount, textBytes, textCount, binaryCount);
}
/**
* 读取发布快照中的展示字段,旧快照缺少字段时兼容当前行。
*
* @param snapshot 发布内容快照
* @param key 字段名
* @param legacyFallback 历史快照回退值
* @return 冻结展示值
*/
private String publishedText(Map<String, Object> snapshot, String key, String legacyFallback) {
if (snapshot == null || !snapshot.containsKey(key)) {
return legacyFallback;
}
Object value = snapshot.get(key);
return value == null ? null : String.valueOf(value);
}
private String textValue(Map<String, Object> source, String key) {
Object value = source == null ? null : source.get(key);
return value == null ? null : String.valueOf(value);
}
private long utf8Length(String value) {
return value == null ? 0L : value.getBytes(StandardCharsets.UTF_8).length;
}
private List<?> listValue(Map<String, Object> source, String key) {
Object value = source == null ? null : source.get(key);
return value instanceof List<?> list ? list : List.of();
}
/**
* 查询当前账号可用于 Agent 会话的知识库安全选项。
*
@@ -228,7 +339,6 @@ public class AgentOptionQueryService {
return workflowService.list(QueryWrapper.create()
.eq(Workflow::getTenantId, account.getTenantId())
.eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode())
.eq(Workflow::getStatus, 1)
.orderBy(Workflow::getModified, false))
.stream()
.filter(item -> resourceAccessService.canAccess(

View File

@@ -2,8 +2,12 @@ package tech.easyflow.agent.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentSkillBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
/**
@@ -35,6 +39,26 @@ public interface AgentService extends IService<Agent> {
*/
Agent updateDraft(Agent agent);
/**
* 在一个事务中保存 Agent 草稿及发生变化的资源绑定。
*
* @param agent Agent 草稿
* @param toolBindings 工具绑定
* @param replaceToolBindings 是否替换工具绑定
* @param knowledgeBindings 知识库绑定
* @param replaceKnowledgeBindings 是否替换知识库绑定
* @param skillBindings Skill 绑定
* @param replaceSkillBindings 是否替换 Skill 绑定
* @return 保存后的 Agent 与本次替换的绑定
*/
Agent saveDraftGraph(Agent agent,
List<AgentToolBinding> toolBindings,
boolean replaceToolBindings,
List<AgentKnowledgeBinding> knowledgeBindings,
boolean replaceKnowledgeBindings,
List<AgentSkillBinding> skillBindings,
boolean replaceSkillBindings);
/**
* 更新 Agent 的可见范围。
*

View File

@@ -0,0 +1,38 @@
package tech.easyflow.agent.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.agent.entity.AgentSkillBinding;
import java.math.BigInteger;
import java.util.List;
/**
* Agent Skill 绑定服务。
*/
public interface AgentSkillBindingService extends IService<AgentSkillBinding> {
/**
* 原子替换 Agent 的全部 Skill 绑定。
*
* @param agentId Agent ID
* @param bindings Skill 引用列表
* @return 规范化后的脱敏绑定摘要
*/
List<AgentSkillBinding> replaceBindings(BigInteger agentId, List<AgentSkillBinding> bindings);
/**
* 查询 Agent 的 Skill 草稿绑定。
*
* @param agentId Agent ID
* @return 稳定排序的绑定
*/
List<AgentSkillBinding> listBindings(BigInteger agentId);
/**
* 查询 Agent 的 Skill 脱敏绑定摘要。
*
* @param agentId Agent ID
* @return 稳定排序的绑定摘要
*/
List<AgentSkillBinding> listSummaries(BigInteger agentId);
}

View File

@@ -0,0 +1,142 @@
package tech.easyflow.agent.service.impl;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentSkillBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.enums.AgentToolType;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 比较 Agent 绑定的可持久化业务字段,忽略 ID、审计字段与展示摘要。
*/
final class AgentBindingSemanticComparator {
private static final String DEFAULT_RETRIEVAL_MODE = "HYBRID";
private AgentBindingSemanticComparator() {
}
/**
* 判断工具绑定整组替换是否会产生业务变化。
*
* @param current 当前持久化绑定
* @param requested 客户端请求绑定
* @return 业务字段完全一致时返回 {@code true}
*/
static boolean sameTools(List<AgentToolBinding> current, List<AgentToolBinding> requested) {
List<AgentToolBinding> left = safeList(current);
List<AgentToolBinding> right = safeList(requested);
if (left.size() != right.size()) {
return false;
}
for (int index = 0; index < left.size(); index++) {
AgentToolBinding persisted = left.get(index);
AgentToolBinding incoming = right.get(index);
if (persisted == null || incoming == null
|| !Objects.equals(normalizeToolType(persisted.getToolType()), normalizeToolType(incoming.getToolType()))
|| !Objects.equals(persisted.getTargetId(), incoming.getTargetId())
|| !Objects.equals(text(persisted.getToolName()), text(incoming.getToolName()))
|| !Objects.equals(enabled(persisted.getEnabled()), enabled(incoming.getEnabled()))
|| !Objects.equals(Boolean.TRUE.equals(persisted.getHitlEnabled()),
Boolean.TRUE.equals(incoming.getHitlEnabled()))
|| !Objects.equals(map(persisted.getHitlConfigJson()), map(incoming.getHitlConfigJson()))
|| !Objects.equals(map(persisted.getOptionsJson()), map(incoming.getOptionsJson()))
|| !Objects.equals(persisted.getSortNo(), sortNo(incoming.getSortNo(), index))) {
return false;
}
}
return true;
}
/**
* 判断知识库绑定整组替换是否会产生业务变化。
*
* @param current 当前持久化绑定
* @param requested 客户端请求绑定
* @return 业务字段完全一致时返回 {@code true}
*/
static boolean sameKnowledges(List<AgentKnowledgeBinding> current,
List<AgentKnowledgeBinding> requested) {
List<AgentKnowledgeBinding> left = safeList(current);
List<AgentKnowledgeBinding> right = safeList(requested);
if (left.size() != right.size()) {
return false;
}
for (int index = 0; index < left.size(); index++) {
AgentKnowledgeBinding persisted = left.get(index);
AgentKnowledgeBinding incoming = right.get(index);
if (persisted == null || incoming == null
|| !Objects.equals(persisted.getKnowledgeId(), incoming.getKnowledgeId())
|| !Objects.equals(retrievalMode(persisted.getRetrievalMode()),
retrievalMode(incoming.getRetrievalMode()))
|| !Objects.equals(enabled(persisted.getEnabled()), enabled(incoming.getEnabled()))
|| !Objects.equals(map(persisted.getOptionsJson()), map(incoming.getOptionsJson()))
|| !Objects.equals(persisted.getSortNo(), sortNo(incoming.getSortNo(), index))) {
return false;
}
}
return true;
}
/**
* 判断 Skill 绑定顺序是否发生变化。
*
* @param current 当前持久化绑定
* @param requested 客户端请求绑定
* @return Skill ID 与稳定顺序完全一致时返回 {@code true}
*/
static boolean sameSkills(List<AgentSkillBinding> current, List<AgentSkillBinding> requested) {
List<AgentSkillBinding> left = safeList(current);
List<AgentSkillBinding> right = safeList(requested);
if (left.size() != right.size()) {
return false;
}
for (int index = 0; index < left.size(); index++) {
AgentSkillBinding persisted = left.get(index);
AgentSkillBinding incoming = right.get(index);
if (persisted == null || incoming == null
|| !Objects.equals(persisted.getSkillId(), incoming.getSkillId())
|| !Objects.equals(persisted.getSortNo(), index)) {
return false;
}
}
return true;
}
private static String normalizeToolType(String value) {
try {
return AgentToolType.from(value).name();
} catch (RuntimeException ignored) {
return value;
}
}
private static String retrievalMode(String value) {
return value == null || value.isBlank()
? DEFAULT_RETRIEVAL_MODE : value.trim().toUpperCase(java.util.Locale.ROOT);
}
private static String text(String value) {
return value == null ? "" : value;
}
private static Boolean enabled(Boolean value) {
return value == null || value;
}
private static Integer sortNo(Integer value, int index) {
return value == null ? index : value;
}
private static Map<String, Object> map(Map<String, Object> value) {
return value == null ? Collections.emptyMap() : value;
}
private static <T> List<T> safeList(List<T> value) {
return value == null ? Collections.emptyList() : value;
}
}

View File

@@ -57,6 +57,10 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl<AgentKnowledge
Agent agent = requireAgentForUpdate(agentId);
resourceAccessService.assertAccess(
CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent");
List<AgentKnowledgeBinding> current = listAll(agentId);
if (AgentBindingSemanticComparator.sameKnowledges(current, bindings)) {
return enabledBindings(current);
}
validateBindings(agent, bindings);
remove(QueryWrapper.create().where("agent_id = ?", agentId));
if (bindings == null || bindings.isEmpty()) {
@@ -66,7 +70,7 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl<AgentKnowledge
applyBindingDefaults(agent, bindings.get(i), i);
}
saveBatch(bindings);
return listEnabled(agentId);
return enabledBindings(bindings);
});
}
@@ -81,6 +85,30 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl<AgentKnowledge
.orderBy("sort_no asc, id asc"));
}
/**
* 查询 Agent 的全部知识库绑定,用于整组语义比较。
*
* @param agentId Agent ID
* @return 稳定排序的全部绑定
*/
private List<AgentKnowledgeBinding> listAll(BigInteger agentId) {
return list(QueryWrapper.create()
.where("agent_id = ?", agentId)
.orderBy("sort_no asc, id asc"));
}
/**
* 从已加载或已写入的绑定中筛选启用项,避免替换后再次查询。
*
* @param bindings 知识库绑定
* @return 启用绑定
*/
private List<AgentKnowledgeBinding> enabledBindings(List<AgentKnowledgeBinding> bindings) {
return bindings.stream()
.filter(binding -> binding != null && binding.getEnabled() != Boolean.FALSE)
.toList();
}
/**
* 锁定并加载待修改的 Agent。
*

View File

@@ -255,7 +255,29 @@ public class AgentResourceBindingProviderImpl implements AgentResourceBindingPro
AgentToolType toolType,
BigInteger resourceId) {
return snapshotListContains(snapshot, "toolBindings", resourceId, toolType.name())
|| snapshotListContains(snapshot, "toolSummaries", resourceId, toolType.name());
|| snapshotListContains(snapshot, "toolSummaries", resourceId, toolType.name())
|| nestedSkillBindingsContain(snapshot, toolType, resourceId);
}
private boolean nestedSkillBindingsContain(Map<String, Object> snapshot,
AgentToolType toolType,
BigInteger resourceId) {
Object rawBindings = snapshot == null ? null : snapshot.get("skillBindings");
if (!(rawBindings instanceof List<?> bindings)) {
return false;
}
for (Object raw : bindings) {
if (!(raw instanceof Map<?, ?> binding)
|| !(binding.get("resourceSnapshot") instanceof Map<?, ?> resourceSnapshot)) {
continue;
}
Object rawTools = resourceSnapshot.get("toolBindings");
if (rawTools instanceof List<?> tools
&& tools.stream().anyMatch(item -> matchesResourceBinding(item, resourceId, toolType.name()))) {
return true;
}
}
return false;
}
/**

View File

@@ -7,18 +7,26 @@ import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.agent.config.AgentInteractionConfigSupport;
import tech.easyflow.agent.config.AgentBuiltinToolsConfigResolver;
import tech.easyflow.agent.config.AgentBuiltinToolsConfig;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.entity.AgentSkillBinding;
import tech.easyflow.agent.mapper.AgentMapper;
import tech.easyflow.agent.runtime.AgentRuntimeCompiler;
import tech.easyflow.agent.service.AgentDependencyAccessService;
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
import tech.easyflow.agent.service.AgentService;
import tech.easyflow.agent.service.AgentToolBindingService;
import tech.easyflow.agent.service.AgentSkillBindingService;
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeProjector;
import tech.easyflow.agent.support.AgentBindingLockExecutor;
import tech.easyflow.ai.entity.*;
import tech.easyflow.ai.easyagentsflow.repository.AgentWorkflowSnapshotFactory;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.mcp.McpConnectionSnapshotFactory;
import tech.easyflow.ai.plugin.PluginConnectionSnapshotFactory;
import tech.easyflow.ai.service.*;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
@@ -26,7 +34,9 @@ import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.enums.VisibilityScope;
import tech.easyflow.system.entity.SysLog;
import tech.easyflow.system.service.ResourceAccessService;
import tech.easyflow.system.service.SysLogService;
import javax.annotation.Resource;
import java.math.BigInteger;
@@ -43,12 +53,15 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
private static final TypeReference<List<AgentToolBinding>> TOOL_BINDING_LIST_TYPE = new TypeReference<>() {};
private static final TypeReference<List<AgentKnowledgeBinding>> KNOWLEDGE_BINDING_LIST_TYPE = new TypeReference<>() {};
private static final TypeReference<List<AgentSkillBinding>> SKILL_BINDING_LIST_TYPE = new TypeReference<>() {};
@Resource
private AgentToolBindingService agentToolBindingService;
@Resource
private AgentKnowledgeBindingService agentKnowledgeBindingService;
@Resource
private AgentSkillBindingService agentSkillBindingService;
@Resource
private ModelService modelService;
@Resource
private WorkflowService workflowService;
@@ -57,6 +70,10 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
@Resource
private McpService mcpService;
@Resource
private McpConnectionSnapshotFactory mcpConnectionSnapshotFactory;
@Resource
private PluginConnectionSnapshotFactory pluginConnectionSnapshotFactory;
@Resource
private DocumentCollectionService documentCollectionService;
@Resource
private ResourceAccessService resourceAccessService;
@@ -68,6 +85,14 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
private AgentBindingLockExecutor agentBindingLockExecutor;
@Resource
private AgentRuntimeCompiler agentRuntimeCompiler;
@Resource
private AgentSkillRuntimeProjector agentSkillRuntimeProjector;
@Resource
private AgentWorkflowSnapshotFactory agentWorkflowSnapshotFactory;
@Resource
private AgentBuiltinToolsConfigResolver agentBuiltinToolsConfigResolver;
@Resource
private SysLogService sysLogService;
/**
* {@inheritDoc}
@@ -76,8 +101,11 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
public Agent getDetail(BigInteger id) {
Agent agent = requireAgent(id);
resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.READ, "无权限查看该 Agent");
agent.setExecutionConfigJson(
agentBuiltinToolsConfigResolver.normalizeForDraftRead(agent.getExecutionConfigJson()));
agent.setToolBindings(agentToolBindingService.listEnabled(id));
agent.setKnowledgeBindings(agentKnowledgeBindingService.listEnabled(id));
agent.setSkillBindings(agentSkillBindingService.listSummaries(id));
return agent;
}
@@ -88,9 +116,15 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
@Transactional(rollbackFor = Exception.class)
public Agent saveDraft(Agent agent) {
applyDraftDefaults(agent);
validateDraft(agent);
validateDraft(agent, null);
boolean shellApprovalDisabled = agentBuiltinToolsConfigResolver
.isShellApprovalDisableTransition(agent.getExecutionConfigJson(), null);
save(agent);
return getDetail(agent.getId());
if (shellApprovalDisabled) {
recordShellApprovalDisabled(agent.getId(), "saveDraft",
AgentBuiltinToolsConfig.newAgentDefaults().shell());
}
return agent;
}
/**
@@ -107,13 +141,48 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
resourceAccessService.assertAccess(
CategoryResourceType.AGENT, existing, ResourceAction.MANAGE, "无权限管理该 Agent");
agent.setTenantId(existing.getTenantId());
validateDraft(agent);
Map<String, Object> existingExecutionConfig = existing.getExecutionConfigJson();
validateDraft(agent, existingExecutionConfig);
boolean shellApprovalDisabled = agentBuiltinToolsConfigResolver
.isShellApprovalDisableTransition(agent.getExecutionConfigJson(), existingExecutionConfig);
AgentBuiltinToolsConfig.ToolSwitch previousShell = agentBuiltinToolsConfigResolver
.resolveDraftRuntime(existingExecutionConfig).shell();
applyDraftUpdate(existing, agent);
updateById(existing);
return getDetail(existing.getId());
if (shellApprovalDisabled) {
recordShellApprovalDisabled(existing.getId(), "updateDraft", previousShell);
}
return existing;
});
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Agent saveDraftGraph(Agent agent,
List<AgentToolBinding> toolBindings,
boolean replaceToolBindings,
List<AgentKnowledgeBinding> knowledgeBindings,
boolean replaceKnowledgeBindings,
List<AgentSkillBinding> skillBindings,
boolean replaceSkillBindings) {
Agent saved = agent != null && agent.getId() != null
? updateDraft(agent) : saveDraft(agent);
BigInteger agentId = saved.getId();
if (replaceToolBindings) {
saved.setToolBindings(agentToolBindingService.replaceBindings(agentId, toolBindings));
}
if (replaceKnowledgeBindings) {
saved.setKnowledgeBindings(agentKnowledgeBindingService.replaceBindings(agentId, knowledgeBindings));
}
if (replaceSkillBindings) {
saved.setSkillBindings(agentSkillBindingService.replaceBindings(agentId, skillBindings));
}
return saved;
}
/**
* {@inheritDoc}
*/
@@ -174,7 +243,10 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
CategoryResourceType.AGENT, detail, ResourceAction.MANAGE, "无权限管理该 Agent");
detail.setToolBindings(agentToolBindingService.listEnabled(agentId));
detail.setKnowledgeBindings(agentKnowledgeBindingService.listEnabled(agentId));
validateDraft(detail);
detail.setSkillBindings(agentSkillBindingService.listBindings(agentId));
validateDraft(detail, detail.getExecutionConfigJson());
List<AgentSkillBinding> projectedSkillBindings =
agentSkillRuntimeProjector.projectCurrentBindings(detail, detail.getSkillBindings());
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("id", detail.getId());
snapshot.put("tenantId", detail.getTenantId());
@@ -194,12 +266,15 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
snapshot.put("visibilityScope", detail.getVisibilityScope());
snapshot.put("toolBindings", snapshotToolBindings(detail, detail.getToolBindings()));
snapshot.put("knowledgeBindings", snapshotKnowledgeBindings(detail, detail.getKnowledgeBindings()));
snapshot.put("skillBindings", projectedSkillBindings);
snapshot.put("basicSummary", basicSummary(detail));
snapshot.put("modelSummary", modelSummary(detail.getModelId()));
snapshot.put("parameterSummary", parameterSummary(detail));
snapshot.put("promptSummary", promptSummary(detail));
snapshot.put("toolSummaries", toolSummaries(detail.getToolBindings()));
snapshot.put("knowledgeSummaries", knowledgeSummaries(detail.getKnowledgeBindings()));
snapshot.put("skillSummaries", projectedSkillBindings.stream()
.map(AgentSkillBinding::getResourceSummary).toList());
snapshot.put("snapshotAt", new Date());
// 发布前完整编译一次,提前暴露工具运行名冲突和运行定义错误。
agentRuntimeCompiler.compile(fromSnapshot(snapshot));
@@ -222,10 +297,14 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
agent.setModelId(toBigInteger(snapshot.get("modelId")));
agent.setCategoryId(toBigInteger(snapshot.get("categoryId")));
agent.setPublishStatus(PublishStatus.PUBLISHED.getCode());
agent.setExecutionConfigJson(
agentBuiltinToolsConfigResolver.normalizeForPublishedRuntime(agent.getExecutionConfigJson()));
agent.setInteractionConfigJson(AgentInteractionConfigSupport.normalize(agent.getInteractionConfigJson()));
agent.setPublishedSnapshotJson(snapshot);
agent.setToolBindings(objectMapper.convertValue(snapshot.get("toolBindings"), TOOL_BINDING_LIST_TYPE));
agent.setKnowledgeBindings(objectMapper.convertValue(snapshot.get("knowledgeBindings"), KNOWLEDGE_BINDING_LIST_TYPE));
agent.setSkillBindings(snapshot.get("skillBindings") == null ? List.of()
: objectMapper.convertValue(snapshot.get("skillBindings"), SKILL_BINDING_LIST_TYPE));
return agent;
}
@@ -253,7 +332,7 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
return agent;
}
private void validateDraft(Agent agent) {
private void validateDraft(Agent agent, Map<String, Object> existingExecutionConfig) {
if (agent == null) {
throw new BusinessException("Agent 不能为空");
}
@@ -264,7 +343,9 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
agentDependencyAccessService.validateCategory(agent);
agent.setVisibilityScope(VisibilityScope.fromOrDefault(agent.getVisibilityScope(), VisibilityScope.PRIVATE).name());
agent.setInteractionConfigJson(AgentInteractionConfigSupport.normalize(agent.getInteractionConfigJson()));
agent.setExecutionConfigJson(normalizeExecutionConfig(agent.getExecutionConfigJson()));
Map<String, Object> executionConfig = normalizeExecutionConfig(agent.getExecutionConfigJson());
agent.setExecutionConfigJson(agentBuiltinToolsConfigResolver.normalizeForDraftSave(
executionConfig, existingExecutionConfig, requireCurrentLoginAccount()));
}
/**
@@ -356,6 +437,33 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
existing.setModifiedBy(account.getId());
}
/**
* 持久化 Shell 审批关闭这一高风险配置变更的专用审计记录。
*
* @param agentId Agent ID
* @param actionMethod 触发变更的服务方法
* @param previousShell 变更前 Shell 配置
*/
private void recordShellApprovalDisabled(BigInteger agentId,
String actionMethod,
AgentBuiltinToolsConfig.ToolSwitch previousShell) {
LoginAccount account = requireCurrentLoginAccount();
SysLog log = new SysLog();
log.setAccountId(account.getId());
log.setActionName("关闭 Agent Shell 调用审批");
log.setActionType("SECURITY_CONFIG_CHANGE");
log.setActionClass(AgentServiceImpl.class.getName());
log.setActionMethod(actionMethod);
log.setActionUrl("/api/v1/agent/" + ("saveDraft".equals(actionMethod) ? "save" : "update"));
log.setActionBody("{\"agentId\":\"" + agentId
+ "\",\"setting\":\"shell\",\"before\":{\"enabled\":"
+ previousShell.enabled() + ",\"approvalRequired\":" + previousShell.approvalRequired()
+ "},\"after\":{\"enabled\":true,\"approvalRequired\":false}}");
log.setStatus(1);
log.setCreated(new Date());
sysLogService.save(log);
}
private Map<String, Object> modelSummary(BigInteger modelId) {
Model model = modelService.getModelInstance(modelId);
Map<String, Object> summary = new LinkedHashMap<>();
@@ -432,14 +540,19 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
private Map<String, Object> toolResourceSnapshot(Agent agent, AgentToolBinding binding) {
if ("WORKFLOW".equalsIgnoreCase(binding.getToolType())) {
Workflow workflow = agentDependencyAccessService.requireWorkflow(agent, binding.getTargetId());
return objectMapper.convertValue(workflow, new TypeReference<Map<String, Object>>() {});
return agentWorkflowSnapshotFactory.snapshot(workflow);
}
if ("PLUGIN".equalsIgnoreCase(binding.getToolType())) {
PluginItem pluginItem = agentDependencyAccessService.requirePluginItem(agent, binding.getTargetId());
return objectMapper.convertValue(pluginItem, new TypeReference<Map<String, Object>>() {});
AgentDependencyAccessService.PluginResource resource =
agentDependencyAccessService.requirePluginResource(agent, binding.getTargetId());
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("pluginItem", objectMapper.convertValue(
resource.pluginItem(), new TypeReference<Map<String, Object>>() {}));
snapshot.put("plugin", pluginConnectionSnapshotFactory.snapshot(resource.plugin()));
return snapshot;
}
Mcp mcp = agentDependencyAccessService.requireMcp(agent, binding.getTargetId());
return objectMapper.convertValue(mcp, new TypeReference<Map<String, Object>>() {});
return mcpConnectionSnapshotFactory.snapshot(mcp);
}
private List<AgentKnowledgeBinding> snapshotKnowledgeBindings(

View File

@@ -0,0 +1,233 @@
package tech.easyflow.agent.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentSkillBinding;
import tech.easyflow.agent.mapper.AgentMapper;
import tech.easyflow.agent.mapper.AgentSkillBindingMapper;
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeProjector;
import tech.easyflow.agent.service.AgentSkillBindingService;
import tech.easyflow.agent.support.AgentBindingLockExecutor;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Agent Skill 绑定服务实现。
*/
@Service
public class AgentSkillBindingServiceImpl
extends ServiceImpl<AgentSkillBindingMapper, AgentSkillBinding>
implements AgentSkillBindingService {
private final AgentMapper agentMapper;
private final AgentBindingLockExecutor bindingLockExecutor;
private final AgentSkillRuntimeProjector runtimeProjector;
private final SkillService skillService;
private final ResourceAccessService resourceAccessService;
/**
* 创建 Agent Skill 绑定服务。
*
* @param agentMapper Agent Mapper
* @param bindingLockExecutor Agent 绑定锁执行器
* @param runtimeProjector Skill 运行投影器
* @param skillService Skill 服务
* @param resourceAccessService 资源权限服务
*/
public AgentSkillBindingServiceImpl(AgentMapper agentMapper,
AgentBindingLockExecutor bindingLockExecutor,
AgentSkillRuntimeProjector runtimeProjector,
SkillService skillService,
ResourceAccessService resourceAccessService) {
this.agentMapper = agentMapper;
this.bindingLockExecutor = bindingLockExecutor;
this.runtimeProjector = runtimeProjector;
this.skillService = skillService;
this.resourceAccessService = resourceAccessService;
}
/** {@inheritDoc} */
@Override
@Transactional(rollbackFor = Exception.class)
public List<AgentSkillBinding> replaceBindings(BigInteger agentId,
List<AgentSkillBinding> bindings) {
return bindingLockExecutor.execute(agentId, () -> {
Agent agent = requireAgentForUpdate(agentId);
resourceAccessService.assertAccess(
CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent");
List<AgentSkillBinding> current = listBindings(agentId);
if (AgentBindingSemanticComparator.sameSkills(current, bindings)) {
return listSummaries(agentId);
}
List<AgentSkillBinding> normalized = normalize(agent, bindings);
// 在删除旧绑定前完成权限、包完整性、Tool 与 8 MiB 预算校验,失败时保留旧组。
List<AgentSkillBinding> projected = runtimeProjector.projectCurrentBindings(agent, normalized);
Map<BigInteger, Map<String, Object>> summaries = new HashMap<>();
for (AgentSkillBinding binding : projected) {
summaries.put(binding.getSkillId(), binding.getResourceSummary());
}
normalized.forEach(binding -> binding.setResourceSummary(summaries.get(binding.getSkillId())));
remove(QueryWrapper.create()
.eq(AgentSkillBinding::getTenantId, agent.getTenantId())
.eq(AgentSkillBinding::getAgentId, agentId));
if (!normalized.isEmpty()) {
saveBatch(normalized);
}
return normalized;
});
}
/** {@inheritDoc} */
@Override
public List<AgentSkillBinding> listBindings(BigInteger agentId) {
if (agentId == null) {
return Collections.emptyList();
}
return list(QueryWrapper.create()
.eq(AgentSkillBinding::getAgentId, agentId)
.orderBy(AgentSkillBinding::getSortNo, true)
.orderBy(AgentSkillBinding::getId, true));
}
/** {@inheritDoc} */
@Override
public List<AgentSkillBinding> listSummaries(BigInteger agentId) {
List<AgentSkillBinding> bindings = listBindings(agentId);
Agent agent = agentMapper.selectOneById(agentId);
Map<BigInteger, String> publishedHashes = publishedRuntimeHashes(agent);
for (AgentSkillBinding binding : bindings) {
Skill skill = skillService.getById(binding.getSkillId());
if (skill == null) {
binding.setResourceSummary(Map.of(
"skillId", binding.getSkillId(),
"displayName", "已失效技能",
"available", false));
continue;
}
binding.setResourceSummary(runtimeProjector.currentSummary(
skill, publishedHashes.get(binding.getSkillId())));
binding.getResourceSummary().put("available", true);
}
return bindings;
}
/**
* 规范客户端绑定并写入服务端归属、排序和审计字段。
*
* @param agent Agent
* @param bindings 客户端绑定
* @return 规范绑定
*/
private List<AgentSkillBinding> normalize(Agent agent, List<AgentSkillBinding> bindings) {
if (bindings == null || bindings.isEmpty()) {
return List.of();
}
if (bindings.size() > AgentSkillRuntimeProjector.MAX_SKILL_COUNT) {
throw new BusinessException(409, 4092, "单个 Agent 最多可绑定 20 个 Skill");
}
Set<BigInteger> unique = new HashSet<>();
List<AgentSkillBinding> result = new ArrayList<>();
LoginAccount account = requireAccount();
Date now = new Date();
for (int index = 0; index < bindings.size(); index++) {
AgentSkillBinding source = bindings.get(index);
if (source == null || source.getSkillId() == null) {
throw new BusinessException("Agent Skill 绑定参数不完整");
}
if (!unique.add(source.getSkillId())) {
throw new BusinessException(409, 4092, "同一 Skill 不能重复绑定");
}
AgentSkillBinding binding = new AgentSkillBinding();
binding.setTenantId(agent.getTenantId());
binding.setAgentId(agent.getId());
binding.setSkillId(source.getSkillId());
binding.setSortNo(index);
binding.setCreated(now);
binding.setCreatedBy(account.getId());
binding.setModified(now);
binding.setModifiedBy(account.getId());
result.add(binding);
}
return result;
}
/**
* 查询并锁定 Agent。
*
* @param agentId Agent ID
* @return Agent
*/
private Agent requireAgentForUpdate(BigInteger agentId) {
if (agentId == null) {
throw new BusinessException("Agent ID 不能为空");
}
Agent agent = agentMapper.selectOneByQuery(QueryWrapper.create()
.eq(Agent::getId, agentId)
.forUpdate());
if (agent == null) {
throw new BusinessException(404, 404, "Agent 不存在");
}
return agent;
}
/**
* 获取 Agent 当前线上冻结的 Skill 组合 hash。
*
* @param agentId Agent ID
* @param skillId Skill ID
* @return 组合 hash 或 null
*/
private Map<BigInteger, String> publishedRuntimeHashes(Agent agent) {
Map<BigInteger, String> hashes = new HashMap<>();
Map<String, Object> snapshot = agent == null ? null : agent.getPublishedSnapshotJson();
Object rawBindings = snapshot == null ? null : snapshot.get("skillBindings");
if (!(rawBindings instanceof List<?> items)) {
return hashes;
}
for (Object raw : items) {
if (!(raw instanceof Map<?, ?> item) || item.get("skillId") == null) {
continue;
}
Object resource = item.get("resourceSnapshot");
if (resource instanceof Map<?, ?> resourceMap) {
Object hash = resourceMap.get("skillRuntimeSnapshotHash");
if (hash != null) {
hashes.put(new BigInteger(String.valueOf(item.get("skillId"))), String.valueOf(hash));
}
}
}
return hashes;
}
/**
* 获取当前登录账号。
*
* @return 登录账号
*/
private LoginAccount requireAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null) {
throw new BusinessException(401, 401, "当前登录状态失效,请重新登录后再试");
}
return account;
}
}

View File

@@ -0,0 +1,69 @@
package tech.easyflow.agent.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentSkillBinding;
import tech.easyflow.agent.service.AgentService;
import tech.easyflow.agent.service.AgentSkillBindingService;
import tech.easyflow.skill.service.SkillReferenceProvider;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Agent 草稿和有效发布快照中的 Skill 引用提供者。
*/
@Component
public class AgentSkillReferenceProvider implements SkillReferenceProvider {
private final AgentService agentService;
private final AgentSkillBindingService bindingService;
/**
* 创建引用提供者。
*
* @param agentService Agent 服务
* @param bindingService Agent Skill 绑定服务
*/
public AgentSkillReferenceProvider(AgentService agentService,
AgentSkillBindingService bindingService) {
this.agentService = agentService;
this.bindingService = bindingService;
}
/** {@inheritDoc} */
@Override
public List<String> listReferences(BigInteger skillId) {
Set<BigInteger> ids = new LinkedHashSet<>();
for (AgentSkillBinding binding : bindingService.list(QueryWrapper.create()
.eq(AgentSkillBinding::getSkillId, skillId))) {
ids.add(binding.getAgentId());
}
for (Agent agent : agentService.list(QueryWrapper.create()
.select(Agent::getId, Agent::getPublishedSnapshotJson)
.isNotNull(Agent::getPublishedSnapshotJson))) {
if (containsSkill(agent.getPublishedSnapshotJson(), skillId)) {
ids.add(agent.getId());
}
}
List<String> result = new ArrayList<>();
for (Agent agent : agentService.listByIds(ids)) {
result.add("智能体“" + (agent.getName() == null ? "未命名智能体" : agent.getName()) + "");
}
return result;
}
private boolean containsSkill(Map<String, Object> snapshot, BigInteger skillId) {
Object raw = snapshot == null ? null : snapshot.get("skillBindings");
if (!(raw instanceof List<?> bindings)) {
return false;
}
return bindings.stream().anyMatch(item -> item instanceof Map<?, ?> binding
&& skillId.toString().equals(String.valueOf(binding.get("skillId"))));
}
}

View File

@@ -55,6 +55,10 @@ public class AgentToolBindingServiceImpl extends ServiceImpl<AgentToolBindingMap
Agent agent = requireAgentForUpdate(agentId);
resourceAccessService.assertAccess(
CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent");
List<AgentToolBinding> current = listAll(agentId);
if (AgentBindingSemanticComparator.sameTools(current, bindings)) {
return enabledBindings(current);
}
validateBindings(agent, bindings);
remove(QueryWrapper.create().where("agent_id = ?", agentId));
if (bindings == null || bindings.isEmpty()) {
@@ -64,7 +68,7 @@ public class AgentToolBindingServiceImpl extends ServiceImpl<AgentToolBindingMap
applyBindingDefaults(agent, bindings.get(i), i);
}
saveBatch(bindings);
return listEnabled(agentId);
return enabledBindings(bindings);
});
}
@@ -79,6 +83,30 @@ public class AgentToolBindingServiceImpl extends ServiceImpl<AgentToolBindingMap
.orderBy("sort_no asc, id asc"));
}
/**
* 查询 Agent 的全部工具绑定,用于整组语义比较。
*
* @param agentId Agent ID
* @return 稳定排序的全部绑定
*/
private List<AgentToolBinding> listAll(BigInteger agentId) {
return list(QueryWrapper.create()
.where("agent_id = ?", agentId)
.orderBy("sort_no asc, id asc"));
}
/**
* 从已加载或已写入的绑定中筛选启用项,避免替换后再次查询。
*
* @param bindings 工具绑定
* @return 启用绑定
*/
private List<AgentToolBinding> enabledBindings(List<AgentToolBinding> bindings) {
return bindings.stream()
.filter(binding -> binding != null && binding.getEnabled() != Boolean.FALSE)
.toList();
}
/**
* 锁定并加载待修改的 Agent。
*

View File

@@ -8,18 +8,56 @@ import java.util.List;
*
* @param models 模型选项
* @param knowledges 知识库选项
* @param skills Skill 选项
* @param workflows 工作流选项
* @param pluginTools 插件工具选项
* @param mcps MCP 选项
* @param capabilities 当前账号的 Agent 设计能力
*/
public record AgentResourceOptionsView(
List<ModelOption> models,
List<ResourceOption> knowledges,
List<SkillOption> skills,
List<ResourceOption> workflows,
List<PluginToolOption> pluginTools,
List<McpOption> mcps
List<McpOption> mcps,
Capabilities capabilities
) {
/**
* Agent 设计器的服务端权限能力。
*
* @param canDisableShellApproval 是否允许关闭 Shell 调用前审批
*/
public record Capabilities(boolean canDisableShellApproval) {
}
/**
* 已发布 Skill 安全选择项。
*
* @param id Skill ID
* @param displayName 展示名称
* @param description 用途描述
* @param visibilityScope 使用范围
* @param snapshotHash 发布组合快照 hash
* @param toolCount 冻结 Tool 数量
* @param textBytes Skill 文本投影 UTF-8 字节数
* @param textResourceCount 文本资源数量
* @param binaryResourceCount 二进制资源数量
*/
public record SkillOption(
BigInteger id,
String displayName,
String description,
String visibilityScope,
String snapshotHash,
int toolCount,
long textBytes,
int textResourceCount,
int binaryResourceCount
) {
}
/**
* 模型安全选择项。
*